From 298375cbdd5128d185a7485a67cf91481bedcbe7 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 15:23:32 -0400 Subject: [PATCH] refactor(mobile): move the account domain onto the host lane accounts.list, the three selection methods and accounts.subscribe now reach the desktop through the generic lane, and the page parses the snapshot with its own schema. The shell keeps only the reset-credit arms, which mint a native idempotency key and so cannot be a plain forward. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-web-account-capability.ts | 26 +- .../mobile-web-account-operations.test.ts | 241 ------------------ .../mobile-web-account-operations.ts | 44 +--- .../mobile-web-account-presentation.ts | 229 ----------------- ...ile-web-account-rate-limit-presentation.ts | 151 ----------- .../mobile-web-account-roundtrip.test.ts | 103 -------- .../mobile-web-account-subscriptions.ts | 40 --- .../mobile-web-capability-broker.ts | 1 - ...ile-web-capability-dispatch-census.test.ts | 2 +- .../mobile-web-capability-execution-arms.ts | 1 - ...e-web-capability-execution-dependencies.ts | 2 - .../mobile-web-capability-subscriptions.ts | 5 +- .../mobile-web-document-replacement.test.tsx | 6 +- .../mobile-web-host-rpc-error-code.test.ts | 2 +- ...eb-mutation-reauthorization-census.test.ts | 2 +- .../mobile-web-production-grants.ts | 7 +- .../mobile-web-subscription-capacity.test.ts | 2 +- ...le-web-subscription-closure-frames.test.ts | 15 +- ...mobile-web-subscription-error-code.test.ts | 20 +- ...-web-subscription-ledger-lifecycle.test.ts | 59 +++-- .../use-mobile-web-capability-broker.test.ts | 22 +- .../mobile-web-account-host-requests.test.ts | 107 ++++++++ .../src/mobile-web-account-request-client.ts | 69 +++-- ...e-web-bridge-grant-operation-scope.test.ts | 11 +- .../mobile-web/account-operation-contract.ts | 232 ++++++++--------- .../bridge-operation-registry-census.test.ts | 4 +- .../mobile-web/bridge-operation-registry.ts | 5 +- 27 files changed, 376 insertions(+), 1032 deletions(-) delete mode 100644 mobile/src/mobile-web/mobile-web-account-operations.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-account-presentation.ts delete mode 100644 mobile/src/mobile-web/mobile-web-account-rate-limit-presentation.ts delete mode 100644 mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-account-subscriptions.ts create mode 100644 src/mobile-web/src/mobile-web-account-host-requests.test.ts diff --git a/mobile/src/mobile-web/mobile-web-account-capability.ts b/mobile/src/mobile-web/mobile-web-account-capability.ts index a3e96e772f4..d59d7c116ff 100644 --- a/mobile/src/mobile-web/mobile-web-account-capability.ts +++ b/mobile/src/mobile-web/mobile-web-account-capability.ts @@ -1,27 +1,13 @@ -import { MobileWebAccountSubscribePayloadSchema } from '../../../src/shared/mobile-web/account-operation-contract' import { executeMobileWebAccountOperation } from './mobile-web-account-operations' import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies' export async function executeMobileWebAccountCapability( args: MobileWebCapabilityExecutionDependencies ): Promise { - const { request } = args - if (request.mode === 'subscription' && request.operation === 'subscribe') { - MobileWebAccountSubscribePayloadSchema.parse(request.payload) - args.accountSubscriptions.start({ - requestId: request.requestId, - subscriptionId: request.subscriptionId, - client: args.connectedClient() - }) - return null - } - if (request.mode === 'once') { - return executeMobileWebAccountOperation({ - operation: request.operation, - payload: request.payload, - client: args.connectedClient(), - nativeAuthority: args.nativeAuthority - }) - } - throw new Error('unsupported_account_request') + return executeMobileWebAccountOperation({ + operation: args.request.operation, + payload: args.request.payload, + client: args.connectedClient(), + nativeAuthority: args.nativeAuthority + }) } diff --git a/mobile/src/mobile-web/mobile-web-account-operations.test.ts b/mobile/src/mobile-web/mobile-web-account-operations.test.ts deleted file mode 100644 index d3aac626ee4..00000000000 --- a/mobile/src/mobile-web/mobile-web-account-operations.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' -import type { RpcClient } from '../transport/rpc-client' -import { - createMobileWebBrokerFixture, - mobileWebBridgeRequestMessage -} from './mobile-web-bridge-roundtrip-fixture' - -describe('mobile web account operations', () => { - it('sanitizes snapshots and switches accounts through the host', async () => { - const harness = createHarness() - - await harness.broker.handle(request('A', 'snapshot', {})) - await harness.broker.handle( - request('B', 'select', { provider: 'claude', accountId: 'claude-1' }) - ) - - expect(successPayload(harness.messages, 'A')).toEqual(snapshotPresentation()) - expect(JSON.stringify(harness.messages)).not.toContain('/private/claude/auth.json') - expect(JSON.stringify(harness.messages)).not.toContain('provider-account-secret') - expect(harness.sendRequest).toHaveBeenCalledWith('accounts.selectClaude', { - accountId: 'claude-1' - }) - expect(successPayload(harness.messages, 'B')).toBeNull() - }) - - it('forwards bounded snapshot events and retires the host stream on client replacement', async () => { - const harness = createHarness() - await harness.broker.handle(subscriptionRequest()) - harness.subscriptionListener?.({ type: 'ready', snapshot: hostSnapshot() }) - - await vi.waitFor(() => { - expect(harness.messages.some((message) => message.type === 'event')).toBe(true) - }) - expect(harness.messages.find((message) => message.type === 'event')).toMatchObject({ - type: 'event', - sequence: 0, - payload: { type: 'ready', snapshot: snapshotPresentation() } - }) - - harness.broker.replaceClient(null) - expect(harness.hostUnsubscribe).toHaveBeenCalledOnce() - }) - - it('keeps reset identity and idempotency in the native shell', async () => { - const harness = createHarness() - const expectedScope = { - target: { runtime: 'host' as const, wslDistro: null }, - accountId: 'codex-1', - accountRevision: 900, - offerRevision: 'v1:offer' - } - - await harness.broker.handle(request('F', 'resetCreditCapability', {})) - expect(successPayload(harness.messages, 'F')).toBe(true) - - await harness.broker.handle(request('H', 'consumeResetCredit', { expectedScope })) - expect(harness.codexResetCreditConsume).toHaveBeenCalledWith(expect.anything(), expectedScope) - expect(successPayload(harness.messages, 'H')).toMatchObject({ - outcome: 'reset', - scope: expectedScope, - attemptJournalRetained: false - }) - expect(JSON.stringify(harness.messages)).not.toContain('host-pairing-identity') - }) -}) - -function createHarness() { - let subscriptionListener: ((event: unknown) => void) | null = null - const hostUnsubscribe = vi.fn() - const codexResetCreditCapability = vi.fn(async () => true) - const codexResetCreditConsume = vi.fn(async (_client, expectedScope) => ({ - outcome: 'reset' as const, - scope: expectedScope, - snapshot: hostSnapshot(), - attemptJournalRetained: false - })) - const sendRequest = vi.fn(async (method: string) => { - if (method === 'accounts.list') { - return { ok: true, result: hostSnapshot() } - } - return { ok: true, result: {} } - }) - const client = { - sendRequest, - subscribe: vi.fn((_method, _params, listener) => { - subscriptionListener = listener - return hostUnsubscribe - }) - } as unknown as RpcClient - const { broker, messages } = createMobileWebBrokerFixture({ - getClient: () => client, - nativeAuthority: { codexResetCreditCapability, codexResetCreditConsume }, - navigationAuthority: { - route: vi.fn(), - reconnect: vi.fn(), - removeHost: vi.fn() - } - }) - return { - broker, - messages, - sendRequest, - hostUnsubscribe, - codexResetCreditConsume, - get subscriptionListener() { - return subscriptionListener - } - } -} - -function hostSnapshot() { - return { - claude: { - accounts: [ - { - id: 'claude-1', - email: 'claude@example.com', - organizationName: 'Orca', - managedAuthPath: '/private/claude/auth.json' - } - ], - activeAccountId: 'claude-1' - }, - codex: { - accounts: [ - { - id: 'codex-1', - email: 'codex@example.com', - workspaceLabel: 'Personal', - updatedAt: 900, - providerAccountId: 'provider-account-secret' - } - ], - activeAccountId: null - }, - rateLimits: { - claude: rateLimits('claude', 24), - codex: rateLimits('codex', 12), - claudeTarget: { runtime: 'host', wslDistro: null }, - codexTarget: { runtime: 'host', wslDistro: null }, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] - } - } -} - -function rateLimits(provider: 'claude' | 'codex', usedPercent: number) { - return { - provider, - session: { - usedPercent, - windowMinutes: 300, - resetsAt: 2_000, - resetDescription: 'Later' - }, - weekly: null, - updatedAt: 1_000, - error: null, - status: 'ok', - usageMetadata: { credentialSource: 'must-not-cross' } - } -} - -function snapshotPresentation() { - return { - claude: { - accounts: [{ id: 'claude-1', email: 'claude@example.com', organizationName: 'Orca' }], - activeAccountId: 'claude-1' - }, - codex: { - accounts: [ - { - id: 'codex-1', - email: 'codex@example.com', - workspaceLabel: 'Personal', - updatedAt: 900 - } - ], - activeAccountId: null - }, - rateLimits: { - claude: rateLimitsPresentation('claude', 24), - codex: rateLimitsPresentation('codex', 12), - claudeTarget: { runtime: 'host', wslDistro: null }, - codexTarget: { runtime: 'host', wslDistro: null }, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] - } - } -} - -function rateLimitsPresentation(provider: 'claude' | 'codex', usedPercent: number) { - return { - provider, - session: { - usedPercent, - windowMinutes: 300, - resetsAt: 2_000, - resetDescription: 'Later' - }, - weekly: null, - updatedAt: 1_000, - error: null, - status: 'ok' - } -} - -function request( - id: string, - operation: 'snapshot' | 'select' | 'resetCreditCapability' | 'consumeResetCredit', - payload: unknown -): Extract { - return mobileWebBridgeRequestMessage({ - requestId: id.repeat(22), - capability: 'account', - operation, - payload - }) -} - -function subscriptionRequest(): Extract { - return { - ...request('D', 'snapshot', {}), - mode: 'subscription', - subscriptionId: 'E'.repeat(22), - operation: 'subscribe' - } -} - -function successPayload(messages: readonly MobileWebBridgeShellMessage[], id: string): unknown { - const message = messages.find( - (candidate) => - candidate.type === 'response' && - candidate.requestId === id.repeat(22) && - candidate.status === 'success' - ) - return message && message.type === 'response' && message.status === 'success' - ? message.payload - : undefined -} diff --git a/mobile/src/mobile-web/mobile-web-account-operations.ts b/mobile/src/mobile-web/mobile-web-account-operations.ts index 98297e14bef..1de84e5ce88 100644 --- a/mobile/src/mobile-web/mobile-web-account-operations.ts +++ b/mobile/src/mobile-web/mobile-web-account-operations.ts @@ -3,42 +3,20 @@ import { MobileWebAccountConsumeResetResultSchema, MobileWebAccountResetCapabilityPayloadSchema, MobileWebAccountResetCapabilityResultSchema, - MobileWebAccountSelectPayloadSchema, - MobileWebAccountSelectResultSchema, - MobileWebAccountSnapshotPayloadSchema + MobileWebAccountsSnapshotSchema } from '../../../src/shared/mobile-web/account-operation-contract' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' -import { mobileWebAccountsSnapshot } from './mobile-web-account-presentation' +import { MobileWebBrokerError } from './mobile-web-broker-error' import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority' +/** Only the reset-credit arms are left: they mint a native idempotency key and carry the shell's + * host identity, so they cannot be a plain desktop forward. */ export async function executeMobileWebAccountOperation(args: { operation: string payload: unknown client: RpcClient nativeAuthority: MobileWebNativeCapabilityAuthority }): Promise { - if (args.operation === 'snapshot') { - MobileWebAccountSnapshotPayloadSchema.parse(args.payload) - const response = await args.client.sendRequest('accounts.list') - requireSuccess(response) - return mobileWebAccountsSnapshot(response.result) - } - if (args.operation === 'select') { - const payload = MobileWebAccountSelectPayloadSchema.parse(args.payload) - const method = - payload.provider === 'claude' - ? 'accounts.selectClaude' - : payload.codexTarget?.runtime === 'wsl' - ? 'accounts.selectCodexForTarget' - : 'accounts.selectCodex' - const params = - method === 'accounts.selectCodexForTarget' - ? { accountId: payload.accountId, target: payload.codexTarget } - : { accountId: payload.accountId } - requireSuccess(await args.client.sendRequest(method, params)) - return MobileWebAccountSelectResultSchema.parse(null) - } if (args.operation === 'resetCreditCapability') { MobileWebAccountResetCapabilityPayloadSchema.parse(args.payload) const capability = args.nativeAuthority.codexResetCreditCapability @@ -55,20 +33,8 @@ export async function executeMobileWebAccountOperation(args: { const result = await consume(args.client, payload.expectedScope) return MobileWebAccountConsumeResetResultSchema.parse({ ...result, - snapshot: mobileWebAccountsSnapshot(result.snapshot) + snapshot: MobileWebAccountsSnapshotSchema.parse(result.snapshot) }) } throw new MobileWebBrokerError('unsupported_capability') } - -function requireSuccess(response: { - ok: boolean - error?: { code?: unknown } -}): asserts response is { - ok: true - result: unknown -} { - if (!response.ok) { - throw mobileWebBrokerHostRpcError(response.error ?? {}) - } -} diff --git a/mobile/src/mobile-web/mobile-web-account-presentation.ts b/mobile/src/mobile-web/mobile-web-account-presentation.ts deleted file mode 100644 index 0076a80fe79..00000000000 --- a/mobile/src/mobile-web/mobile-web-account-presentation.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - MOBILE_WEB_ACCOUNT_LIMIT, - MobileWebAccountEventSchema, - MobileWebAccountsSnapshotSchema, - type MobileWebAccountEvent, - type MobileWebAccountsSnapshot -} from '../../../src/shared/mobile-web/account-operation-contract' -import { - mobileWebInactiveAccountUsage, - mobileWebProviderRateLimits -} from './mobile-web-account-rate-limit-presentation' - -type Provider = 'claude' | 'codex' -type RuntimeTarget = { runtime: 'host' | 'wsl'; wslDistro: string | null } - -const HOST_TARGET: RuntimeTarget = { runtime: 'host', wslDistro: null } - -export function mobileWebAccountsSnapshot(value: unknown): MobileWebAccountsSnapshot { - if (!isRecord(value) || !isRecord(value.claude) || !isRecord(value.codex)) { - throw new Error('mobile_web_accounts_snapshot_invalid') - } - const rateLimits = isRecord(value.rateLimits) ? value.rateLimits : {} - const claudeTarget = runtimeTarget(rateLimits.claudeTarget) - const codexTarget = runtimeTarget(rateLimits.codexTarget) - return MobileWebAccountsSnapshotSchema.parse({ - claude: accountsState(value.claude, 'claude', claudeTarget), - codex: accountsState(value.codex, 'codex', codexTarget), - rateLimits: { - claude: mobileWebProviderRateLimits(rateLimits.claude, 'claude'), - codex: mobileWebProviderRateLimits(rateLimits.codex, 'codex'), - claudeTarget, - codexTarget, - inactiveClaudeAccounts: mobileWebInactiveAccountUsage( - rateLimits.inactiveClaudeAccounts, - 'claude' - ), - inactiveCodexAccounts: mobileWebInactiveAccountUsage( - rateLimits.inactiveCodexAccounts, - 'codex' - ) - } - }) -} - -export function mobileWebAccountEvent(value: unknown): MobileWebAccountEvent | null { - if (!isRecord(value) || typeof value.type !== 'string') { - return null - } - if (value.type === 'end' || value.type === 'error') { - return MobileWebAccountEventSchema.parse({ type: value.type }) - } - if (value.type !== 'ready' && value.type !== 'snapshot') { - return null - } - try { - return MobileWebAccountEventSchema.parse({ - type: value.type, - snapshot: mobileWebAccountsSnapshot(value.snapshot) - }) - } catch { - return null - } -} - -function accountsState(value: Record, provider: Provider, target: RuntimeTarget) { - const activeAccountId = boundedRequiredText(value.activeAccountId, 256) - const activeAccountIdsByRuntime = runtimeSelection(value.activeAccountIdsByRuntime) - const targetAccountId = - target.runtime === 'host' - ? activeAccountIdsByRuntime?.host - : target.wslDistro - ? activeAccountIdsByRuntime?.wsl[target.wslDistro] - : null - const source = Array.isArray(value.accounts) ? value.accounts : [] - const accounts = source.flatMap((account) => accountPresentation(account, provider)) - const bounded = boundedAccounts(accounts, [activeAccountId, targetAccountId]) - const visibleIds = new Set(bounded.map((account) => account.id)) - return { - accounts: bounded, - activeAccountId: activeAccountId && visibleIds.has(activeAccountId) ? activeAccountId : null, - ...(activeAccountIdsByRuntime - ? { - activeAccountIdsByRuntime: visibleRuntimeSelection(activeAccountIdsByRuntime, visibleIds) - } - : {}) - } -} - -function boundedAccounts( - accounts: readonly T[], - priorityIds: readonly (string | null | undefined)[] -): T[] { - const bounded = accounts.slice(0, MOBILE_WEB_ACCOUNT_LIMIT) - const priorities = new Set(priorityIds.filter((id): id is string => Boolean(id))) - for (const id of priorities) { - if (bounded.some((account) => account.id === id)) { - continue - } - const account = accounts.find((candidate) => candidate.id === id) - if (!account) { - continue - } - const replaceIndex = bounded.findLastIndex((candidate) => !priorities.has(candidate.id)) - if (replaceIndex !== -1) { - bounded[replaceIndex] = account - } - } - return bounded -} - -function accountPresentation(value: unknown, provider: Provider) { - if (!isRecord(value)) { - return [] - } - const id = boundedRequiredText(value.id, 256) - const email = boundedRequiredText(value.email, 320) - if (!id || !email) { - return [] - } - if (provider === 'codex') { - const updatedAt = boundedTimestampOrNull(value.updatedAt) - if (updatedAt === null) { - return [] - } - return [ - { - id, - email, - updatedAt, - ...optionalEnumField('managedHomeRuntime', value.managedHomeRuntime, ['host', 'wsl']), - ...optionalNullableTextField('wslDistro', value.wslDistro, 255), - ...optionalNullableTextField('workspaceLabel', value.workspaceLabel, 240), - ...optionalNullableTextField('workspaceAccountId', value.workspaceAccountId, 256), - ...optionalTimestampField('createdAt', value.createdAt), - ...optionalTimestampField('lastAuthenticatedAt', value.lastAuthenticatedAt) - } - ] - } - return [ - { - id, - email, - ...optionalEnumField('managedAuthRuntime', value.managedAuthRuntime, ['host', 'wsl']), - ...optionalNullableTextField('wslDistro', value.wslDistro, 255), - ...optionalEnumField('authMethod', value.authMethod, ['subscription-oauth', 'unknown']), - ...optionalNullableTextField('organizationUuid', value.organizationUuid, 256), - ...optionalNullableTextField('organizationName', value.organizationName, 240), - ...optionalTimestampField('createdAt', value.createdAt), - ...optionalTimestampField('updatedAt', value.updatedAt), - ...optionalTimestampField('lastAuthenticatedAt', value.lastAuthenticatedAt) - } - ] -} - -function runtimeSelection(value: unknown) { - if (!isRecord(value)) { - return undefined - } - const wsl = isRecord(value.wsl) - ? Object.fromEntries( - Object.entries(value.wsl) - .filter( - ([distro]) => distro.length > 0 && distro.length <= 255 && distro.trim() === distro - ) - .slice(0, MOBILE_WEB_ACCOUNT_LIMIT) - .map(([distro, accountId]) => [distro, boundedRequiredText(accountId, 256)]) - ) - : {} - return { - host: boundedRequiredText(value.host, 256), - wsl - } -} - -function visibleRuntimeSelection( - selection: { host: string | null; wsl: Record }, - visibleIds: ReadonlySet -) { - return { - host: selection.host && visibleIds.has(selection.host) ? selection.host : null, - wsl: Object.fromEntries( - Object.entries(selection.wsl).map(([distro, accountId]) => [ - distro, - accountId && visibleIds.has(accountId) ? accountId : null - ]) - ) - } -} - -function runtimeTarget(value: unknown): RuntimeTarget { - if (!isRecord(value) || value.runtime !== 'wsl') { - return HOST_TARGET - } - const distro = boundedRequiredText(value.wslDistro, 255) - return distro && distro.trim() === distro ? { runtime: 'wsl', wslDistro: distro } : HOST_TARGET -} - -function optionalEnumField( - name: string, - value: unknown, - allowed: readonly T[] -) { - return typeof value === 'string' && allowed.includes(value as T) ? { [name]: value as T } : {} -} - -function optionalNullableTextField(name: string, value: unknown, maximum: number) { - return value === undefined ? {} : { [name]: boundedNullableText(value, maximum) } -} - -function optionalTimestampField(name: string, value: unknown) { - const timestamp = boundedTimestampOrNull(value) - return timestamp === null ? {} : { [name]: timestamp } -} - -function boundedTimestampOrNull(value: unknown): number | null { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null -} - -function boundedRequiredText(value: unknown, maximum: number): string | null { - return typeof value === 'string' && value.length > 0 ? value.slice(0, maximum) : null -} - -function boundedNullableText(value: unknown, maximum: number): string | null { - return typeof value === 'string' ? value.slice(0, maximum) : null -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/mobile/src/mobile-web/mobile-web-account-rate-limit-presentation.ts b/mobile/src/mobile-web/mobile-web-account-rate-limit-presentation.ts deleted file mode 100644 index 24d2f1f8b8a..00000000000 --- a/mobile/src/mobile-web/mobile-web-account-rate-limit-presentation.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { MOBILE_WEB_ACCOUNT_LIMIT } from '../../../src/shared/mobile-web/account-operation-contract' - -type Provider = 'claude' | 'codex' - -export function mobileWebInactiveAccountUsage(value: unknown, provider: Provider) { - if (!Array.isArray(value)) { - return [] - } - return value.slice(0, MOBILE_WEB_ACCOUNT_LIMIT).flatMap((entry) => { - if (!isRecord(entry)) { - return [] - } - const accountId = boundedRequiredText(entry.accountId, 256) - if (!accountId) { - return [] - } - return [ - { - accountId, - rateLimits: mobileWebProviderRateLimits(entry.rateLimits, provider), - updatedAt: boundedTimestamp(entry.updatedAt), - isFetching: entry.isFetching === true - } - ] - }) -} - -export function mobileWebProviderRateLimits(value: unknown, provider: Provider) { - if (!isRecord(value)) { - return null - } - return { - provider, - session: rateLimitWindow(value.session), - weekly: rateLimitWindow(value.weekly), - ...optionalWindowField('fableWeekly', value.fableWeekly), - ...optionalWindowField('monthly', value.monthly), - ...optionalBucketsField(value.buckets), - ...optionalResetCreditsField(value.rateLimitResetCredits), - updatedAt: boundedTimestamp(value.updatedAt), - error: boundedNullableText(value.error, 512), - status: rateLimitStatus(value.status) - } -} - -function rateLimitWindow(value: unknown) { - if (!isRecord(value) || !Number.isFinite(value.usedPercent)) { - return null - } - return { - usedPercent: Math.min(100, Math.max(0, Number(value.usedPercent))), - windowMinutes: boundedInteger(value.windowMinutes, 1_000_000), - resetsAt: value.resetsAt === null ? null : boundedTimestamp(value.resetsAt), - resetDescription: boundedNullableText(value.resetDescription, 240) - } -} - -function optionalWindowField(name: string, value: unknown) { - return value === undefined ? {} : { [name]: rateLimitWindow(value) } -} - -function optionalBucketsField(value: unknown) { - if (!Array.isArray(value)) { - return {} - } - const buckets = value.slice(0, MOBILE_WEB_ACCOUNT_LIMIT).flatMap((bucket) => { - if (!isRecord(bucket)) { - return [] - } - const name = boundedRequiredText(bucket.name, 240) - const window = rateLimitWindow(bucket) - return name && window ? [{ ...window, name }] : [] - }) - return { buckets } -} - -function optionalResetCreditsField(value: unknown) { - if (value === undefined) { - return {} - } - if (!isRecord(value)) { - return { rateLimitResetCredits: null } - } - const credits = Array.isArray(value.credits) - ? value.credits.slice(0, MOBILE_WEB_ACCOUNT_LIMIT).flatMap((credit) => { - if (!isRecord(credit)) { - return [] - } - const status = boundedRequiredText(credit.status, 64) - return status - ? [ - { - status, - expiresAt: nullableTimestamp(credit.expiresAt), - grantedAt: nullableTimestamp(credit.grantedAt) - } - ] - : [] - }) - : undefined - return { - rateLimitResetCredits: { - availableCount: boundedInteger(value.availableCount, Number.MAX_SAFE_INTEGER), - ...(value.totalEarnedCount === undefined - ? {} - : { - totalEarnedCount: boundedInteger(value.totalEarnedCount, Number.MAX_SAFE_INTEGER) - }), - ...(value.nextExpiresAt === undefined - ? {} - : { nextExpiresAt: nullableTimestamp(value.nextExpiresAt) }), - ...(credits ? { credits } : {}) - } - } -} - -function nullableTimestamp(value: unknown): number | null { - return value === null ? null : boundedTimestamp(value) -} - -function rateLimitStatus(value: unknown) { - return value === 'idle' || - value === 'fetching' || - value === 'ok' || - value === 'error' || - value === 'unavailable' - ? value - : 'unavailable' -} - -function boundedInteger(value: unknown, maximum: number): number { - return typeof value === 'number' && Number.isFinite(value) - ? Math.min(maximum, Math.max(0, Math.round(value))) - : 0 -} - -function boundedTimestamp(value: unknown): number { - return boundedInteger(value, Number.MAX_SAFE_INTEGER) -} - -function boundedRequiredText(value: unknown, maximum: number): string | null { - return typeof value === 'string' && value.length > 0 ? value.slice(0, maximum) : null -} - -function boundedNullableText(value: unknown, maximum: number): string | null { - return typeof value === 'string' ? value.slice(0, maximum) : null -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts deleted file mode 100644 index f1a6de96bb5..00000000000 --- a/mobile/src/mobile-web/mobile-web-account-roundtrip.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' - -it('round trips typed account reads, selection, and snapshots through the production bridge', async () => { - let hostListener: ((event: unknown) => void) | null = null - const hostUnsubscribe = vi.fn() - const sendRequest = vi.fn(async (method: string) => { - if (method === 'accounts.list') { - return { ok: true, result: hostSnapshot() } - } - return { ok: true, result: {} } - }) - const rpcClient = { - sendRequest, - subscribe: vi.fn((_method, _params, listener) => { - hostListener = listener - return hostUnsubscribe - }) - } as unknown as RpcClient - const requestIds = ['A', 'B', 'C', 'D', 'E'] - let requestIndex = 0 - const { client, pageMessages, shellMessages } = createMobileWebBridgeRoundtripFixture({ - grants: [accountGrant('snapshot'), accountGrant('select'), accountGrant('subscribe')], - rpcClient, - createRequestId: () => requestIds[requestIndex++]!.repeat(22), - navigationAuthority: { - route: vi.fn(), - reconnect: vi.fn(), - removeHost: vi.fn() - } - }) - - await expect(client.account.snapshot()).resolves.toMatchObject({ - claude: { - accounts: [{ id: 'claude-1', email: 'claude@example.com' }], - activeAccountId: 'claude-1' - } - }) - expect(pageMessages[0]).toMatchObject({ - type: 'request', - requestId: 'A'.repeat(22), - capability: 'account', - operation: 'snapshot' - }) - expect(shellMessages[0]).toMatchObject({ - type: 'response', - requestId: 'A'.repeat(22), - status: 'success' - }) - await expect( - client.account.select({ provider: 'codex', accountId: 'codex-1' }) - ).resolves.toBeNull() - expect(sendRequest).toHaveBeenCalledWith('accounts.selectCodex', { - accountId: 'codex-1' - }) - - const onEvent = vi.fn() - const subscription = client.account.subscribe(onEvent, vi.fn()) - await subscription.ready - hostListener?.({ type: 'snapshot', snapshot: hostSnapshot() }) - await vi.waitFor(() => expect(onEvent).toHaveBeenCalledOnce()) - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'snapshot', - snapshot: expect.objectContaining({ - codex: { accounts: [], activeAccountId: null } - }) - }) - ) - subscription.unsubscribe() - expect(hostUnsubscribe).toHaveBeenCalledOnce() -}) - -function accountGrant(operation: 'snapshot' | 'select' | 'subscribe') { - return { - capability: 'account' as const, - operation, - limits: { - maxRequestBytes: 1024, - maxResponseBytes: 96 * 1024, - maxConcurrent: 2, - rateCapacity: 8, - rateRefillPerSecond: 8 - } - } -} - -function hostSnapshot() { - return { - claude: { - accounts: [{ id: 'claude-1', email: 'claude@example.com' }], - activeAccountId: 'claude-1' - }, - codex: { accounts: [], activeAccountId: null }, - rateLimits: { - claude: null, - codex: null, - inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] - } - } -} diff --git a/mobile/src/mobile-web/mobile-web-account-subscriptions.ts b/mobile/src/mobile-web/mobile-web-account-subscriptions.ts deleted file mode 100644 index bb98780ce2c..00000000000 --- a/mobile/src/mobile-web/mobile-web-account-subscriptions.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - MobileWebSubscriptionLedger, - type MobileWebSubscriptionLedgerConfig, - type MobileWebSubscriptionRecord -} from './mobile-web-subscription-ledger' -import type { MobileWebAccountEvent } from '../../../src/shared/mobile-web/account-operation-contract' -import type { RpcClient } from '../transport/rpc-client' -import { mobileWebAccountEvent } from './mobile-web-account-presentation' - -export class MobileWebAccountSubscriptions extends MobileWebSubscriptionLedger { - constructor(config: MobileWebSubscriptionLedgerConfig) { - super({ ...config, operationKey: 'account.subscribe' }) - } - - start(args: { requestId: string; subscriptionId: string; client: RpcClient }): void { - this.admit(args.subscriptionId) - const record = this.newRecord(args.requestId) - this.open(args.subscriptionId, record, () => - args.client.subscribe('accounts.subscribe', null, (event) => - this.receive(args.subscriptionId, record, event) - ) - ) - } - - private receive( - subscriptionId: string, - record: MobileWebSubscriptionRecord, - value: unknown - ): void { - if (!this.isCurrent(subscriptionId, record)) { - return - } - const event = mobileWebAccountEvent(value) - if (!event) { - this.cancel(subscriptionId, { code: 'invalid_message', retryable: false }) - return - } - this.enqueue(subscriptionId, record, event, event.type === 'end' || event.type === 'error') - } -} diff --git a/mobile/src/mobile-web/mobile-web-capability-broker.ts b/mobile/src/mobile-web/mobile-web-capability-broker.ts index 6d789dfe7cb..c8c6152ed43 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker.ts @@ -243,7 +243,6 @@ export class MobileWebCapabilityBroker { agentHistoryAuthority: this.authorities.agentHistory, agentHistoryPager: this.authorities.agentHistoryPager, agentHistoryResume: this.authorities.agentHistoryResume, - accountSubscriptions: this.subscriptions.account, browserStreams: this.subscriptions.browser, sourceControlBranchCompare: this.authorities.sourceControlBranchCompare, speechAuthority: this.speechAuthority, diff --git a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts index 3a3036f33d7..0a3ea7b9091 100644 --- a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts @@ -49,7 +49,7 @@ describe('mobile web capability dispatch census', () => { }) expect(unresolved.map(({ capability, operation }) => `${capability}.${operation}`)).toEqual([]) - expect(registeredOperations()).toHaveLength(192) + expect(registeredOperations()).toHaveLength(189) }) it('carries a dispatch arm for exactly the capabilities that own operations of that mode', () => { diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts index 1a7fa9ccf9f..8a09f38fa88 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts @@ -218,7 +218,6 @@ async function subscribeSpeech(args: Deps, request: SubscriptionRequest): Promis export const MOBILE_WEB_SUBSCRIPTION_CAPABILITY_ARMS: Partial< Record > = { - account: (args) => executeMobileWebAccountCapability(args), browser: subscribeBrowser, workspace: subscribeWorkspace, terminal: subscribeTerminal, diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts index 5a514fecb44..60c65519aa5 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts @@ -1,7 +1,6 @@ import type { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { RpcClient } from '../transport/rpc-client' -import type { MobileWebAccountSubscriptions } from './mobile-web-account-subscriptions' import type { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-authority' import type { MobileWebAgentHistoryPager } from './mobile-web-agent-history-pager' import type { MobileWebAgentHistoryResume } from './mobile-web-agent-history-resume' @@ -31,7 +30,6 @@ export type MobileWebCapabilityExecutionDependencies = { agentHistoryPager: MobileWebAgentHistoryPager agentHistoryResume: MobileWebAgentHistoryResume hostSubscriptions: MobileWebHostSubscriptions - accountSubscriptions: MobileWebAccountSubscriptions browserStreams: MobileWebBrowserStreams sourceControlBranchCompare: MobileWebSourceControlBranchComparePager speechAuthority: MobileWebSpeechAuthority diff --git a/mobile/src/mobile-web/mobile-web-capability-subscriptions.ts b/mobile/src/mobile-web/mobile-web-capability-subscriptions.ts index 728cb966d70..8f796e46a95 100644 --- a/mobile/src/mobile-web/mobile-web-capability-subscriptions.ts +++ b/mobile/src/mobile-web/mobile-web-capability-subscriptions.ts @@ -1,5 +1,4 @@ import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' -import { MobileWebAccountSubscriptions } from './mobile-web-account-subscriptions' import type { MobileWebSubscriptionClosure } from './mobile-web-subscription-closure' import type { MobileWebSubscriptionLedgerConfig, @@ -10,7 +9,6 @@ import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authori export class MobileWebCapabilitySubscriptions { readonly host: MobileWebHostSubscriptions - readonly account: MobileWebAccountSubscriptions readonly browser: MobileWebBrowserStreams private readonly ledgers: MobileWebSubscriptionLedgerHandle[] @@ -28,12 +26,11 @@ export class MobileWebCapabilitySubscriptions { ...shared, workspaceAuthority: args.workspaceAuthority }) - this.account = new MobileWebAccountSubscriptions(shared) this.browser = new MobileWebBrowserStreams({ ...shared, workspaceAuthority: args.workspaceAuthority }) - this.ledgers = [this.host, this.account, this.browser] + this.ledgers = [this.host, this.browser] } countForOperation(operationKey: string): number { diff --git a/mobile/src/mobile-web/mobile-web-document-replacement.test.tsx b/mobile/src/mobile-web/mobile-web-document-replacement.test.tsx index 6fb276e0ff9..8181d84c94f 100644 --- a/mobile/src/mobile-web/mobile-web-document-replacement.test.tsx +++ b/mobile/src/mobile-web/mobile-web-document-replacement.test.tsx @@ -264,9 +264,9 @@ function subscribeRequest( mode: 'subscription', requestId: requestId.repeat(22), subscriptionId: subscriptionId.repeat(22), - capability: 'account', - operation: 'subscribe', - payload: {} + capability: 'workspace', + operation: 'hostSubscribe', + payload: { method: 'mobileWeb.workspace.subscribe', params: {} } } } diff --git a/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts b/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts index 50e6097e556..e4572cc4333 100644 --- a/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-rpc-error-code.test.ts @@ -69,7 +69,7 @@ describe('host RPC error codes', () => { ['runtime_error', 'host_error', true] ])('reports %s to the page as %s', async (code, expected, retryable) => { const { client } = createMobileWebBridgeRoundtripFixture({ - grants: [{ capability: 'account', operation: 'snapshot', limits: GRANT_LIMITS }], + grants: [{ capability: 'workspace', operation: 'hostRequest', limits: GRANT_LIMITS }], rpcClient: failingClient(code) }) diff --git a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts index e3fa68e287a..21d3303270d 100644 --- a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts @@ -163,7 +163,7 @@ describe('mobile web mutation reauthorization census', () => { } expect(unaccounted).toEqual([]) - expect(mutations()).toHaveLength(109) + expect(mutations()).toHaveLength(108) }) it('exempts only registered mutations', () => { diff --git a/mobile/src/mobile-web/mobile-web-production-grants.ts b/mobile/src/mobile-web/mobile-web-production-grants.ts index 0b023b99e71..a587bffb9b9 100644 --- a/mobile/src/mobile-web/mobile-web-production-grants.ts +++ b/mobile/src/mobile-web/mobile-web-production-grants.ts @@ -19,15 +19,10 @@ export const MOBILE_WEB_PRODUCTION_GRANTS = [ hostRequest: grantLimits(600 * 1024, 600 * 1024, 16, 32, 4), snapshot: grantLimits(1 * 1024, 128 * 1024, 2, 4, 1) }), - ...capabilityGrants('account', { - snapshot: grantLimits(256, 96 * 1024, 2, 6, 1) - }), ...MOBILE_WEB_PRODUCTION_TASK_GRANTS, ...capabilityGrants('account', { - select: grantLimits(1 * 1024, 256, 1, 4, 1), resetCreditCapability: grantLimits(256, 256, 2, 4, 1), - consumeResetCredit: grantLimits(8 * 1024, 96 * 1024, 1, 2, 0.25), - subscribe: grantLimits(256, 96 * 1024, 1, 4, 1) + consumeResetCredit: grantLimits(8 * 1024, 96 * 1024, 1, 2, 0.25) }), ...MOBILE_WEB_PRODUCTION_SESSION_GRANTS, ...MOBILE_WEB_PRODUCTION_TERMINAL_GRANTS, diff --git a/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts b/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts index 0464e3ea48f..c477145a725 100644 --- a/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts +++ b/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts @@ -18,7 +18,7 @@ describe('aggregate subscription admission', () => { ledgers: [ { countForOperation: (key: string) => - key === 'account.subscribe' + key === 'browser.subscribe' ? MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS - 2 : key === 'terminal.subscribe' ? 1 diff --git a/mobile/src/mobile-web/mobile-web-subscription-closure-frames.test.ts b/mobile/src/mobile-web/mobile-web-subscription-closure-frames.test.ts index 66b516f6279..564101ce8fa 100644 --- a/mobile/src/mobile-web/mobile-web-subscription-closure-frames.test.ts +++ b/mobile/src/mobile-web/mobile-web-subscription-closure-frames.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { MobileWebSubscriptionClosure } from './mobile-web-subscription-closure' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebAccountSubscriptions } from './mobile-web-account-subscriptions' +import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import { MobileWebBrowserStreams } from './mobile-web-browser-streams' import { MobileWebSpeechSubscriptions } from './mobile-web-speech-subscriptions' import type { MobileWebSpeechEvent } from '../../../src/shared/mobile-web/speech-operation-contract' @@ -61,16 +61,19 @@ function pageWorkspace(): { authority: MobileWebWorkspaceAuthority; pageWorkspac const LEDGER_CASES: LedgerCase[] = [ { - name: 'account', - invalidCode: 'invalid_message', - invalid: { type: 'bogus' }, + name: 'host', + invalidCode: null, + invalid: undefined, valid: { type: 'end' }, open: async (posts) => { const host = hostClient() - new MobileWebAccountSubscriptions(posts).start({ + const { authority } = pageWorkspace() + new MobileWebHostSubscriptions({ ...posts, workspaceAuthority: authority }).start({ requestId: 'request-1', subscriptionId: SUBSCRIPTION_ID, - client: host.client + payload: { method: 'accounts.subscribe', params: {} }, + client: host.client, + isActive: () => true }) return host.emit } diff --git a/mobile/src/mobile-web/mobile-web-subscription-error-code.test.ts b/mobile/src/mobile-web/mobile-web-subscription-error-code.test.ts index 23a7ae37f1a..d164ec6dff5 100644 --- a/mobile/src/mobile-web/mobile-web-subscription-error-code.test.ts +++ b/mobile/src/mobile-web/mobile-web-subscription-error-code.test.ts @@ -5,7 +5,7 @@ import type { MobileWebBridgeShellMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebAccountSubscriptions } from './mobile-web-account-subscriptions' +import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import { MobileWebBrowserStreams } from './mobile-web-browser-streams' import { isRetryableMobileWebBridgeError, @@ -34,7 +34,12 @@ function ledgerStarters(): { name: string; start: (subscriptionId: string) => vo const pageId = 'raw-page' const postClosed = (): void => {} - const account = new MobileWebAccountSubscriptions({ isActive, postEvent, postClosed }) + const hostFeed = new MobileWebHostSubscriptions({ + isActive, + workspaceAuthority, + postEvent, + postClosed + }) const browser = new MobileWebBrowserStreams({ isActive, workspaceAuthority, @@ -43,8 +48,15 @@ function ledgerStarters(): { name: string; start: (subscriptionId: string) => vo }) return [ { - name: 'account', - start: (subscriptionId) => account.start({ requestId: 'r', subscriptionId, client }) + name: 'host', + start: (subscriptionId) => + hostFeed.start({ + requestId: 'r', + subscriptionId, + payload: { method: 'accounts.subscribe', params: {} }, + client, + isActive + }) }, { name: 'browser', diff --git a/mobile/src/mobile-web/mobile-web-subscription-ledger-lifecycle.test.ts b/mobile/src/mobile-web/mobile-web-subscription-ledger-lifecycle.test.ts index fbcd1bcbd3b..d4ef968bca5 100644 --- a/mobile/src/mobile-web/mobile-web-subscription-ledger-lifecycle.test.ts +++ b/mobile/src/mobile-web/mobile-web-subscription-ledger-lifecycle.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { MobileWebBridgeShellMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { MobileWebSubscriptionClosure } from './mobile-web-subscription-closure' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebAccountSubscriptions } from './mobile-web-account-subscriptions' +import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import { MobileWebCapabilitySubscriptions } from './mobile-web-capability-subscriptions' import { MobileWebBrokerMessageSender } from './mobile-web-broker-message-sender' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' @@ -28,7 +28,8 @@ describe('subscription ledger teardown', () => { it('retires every live subscription without a closure when the whole shell goes away', () => { const unsubscribe = vi.fn() const postClosed = vi.fn() - const ledger = new MobileWebAccountSubscriptions({ + const ledger = new MobileWebHostSubscriptions({ + workspaceAuthority: new MobileWebWorkspaceAuthority(randomBytes), isActive: () => true, postEvent: async () => {}, postClosed @@ -36,7 +37,9 @@ describe('subscription ledger teardown', () => { ledger.start({ requestId: 'request-1', subscriptionId: 'subscription-1', - client: stubClient(unsubscribe) + payload: { method: 'accounts.subscribe', params: {} }, + client: stubClient(unsubscribe), + isActive: () => true }) ledger.dispose() @@ -45,19 +48,26 @@ describe('subscription ledger teardown', () => { expect(postClosed).not.toHaveBeenCalled() expect(unsubscribe).toHaveBeenCalledOnce() expect(ledger.cancel('subscription-1')).toBeNull() - expect(ledger.countForOperation('account.subscribe')).toBe(0) + expect(ledger.countForOperation('workspace.hostSubscribe')).toBe(0) }) it('tells the page why each subscription ended when only the host feed goes away', () => { const unsubscribe = vi.fn() const closures: [string, MobileWebSubscriptionClosure][] = [] - const ledger = new MobileWebAccountSubscriptions({ + const ledger = new MobileWebHostSubscriptions({ + workspaceAuthority: new MobileWebWorkspaceAuthority(randomBytes), isActive: () => true, postEvent: async () => {}, postClosed: (subscriptionId, closure) => closures.push([subscriptionId, closure]) }) for (const subscriptionId of ['subscription-1', 'subscription-2']) { - ledger.start({ requestId: subscriptionId, subscriptionId, client: stubClient(unsubscribe) }) + ledger.start({ + requestId: subscriptionId, + subscriptionId, + payload: { method: 'accounts.subscribe', params: {} }, + client: stubClient(unsubscribe), + isActive: () => true + }) } ledger.closeAll({ code: 'unavailable', retryable: true }) @@ -67,7 +77,7 @@ describe('subscription ledger teardown', () => { ['subscription-2', { code: 'unavailable', retryable: true }] ]) expect(unsubscribe).toHaveBeenCalledTimes(2) - expect(ledger.countForOperation('account.subscribe')).toBe(0) + expect(ledger.countForOperation('workspace.hostSubscribe')).toBe(0) }) it('fans closeAll out across every capability ledger', () => { @@ -79,12 +89,28 @@ describe('subscription ledger teardown', () => { messages.push(message) } }) + const workspaceAuthority = new MobileWebWorkspaceAuthority(randomBytes) + workspaceAuthority.synchronize(['host-workspace']) const subscriptions = new MobileWebCapabilitySubscriptions({ ...sender.subscriptionPosts(), - workspaceAuthority: new MobileWebWorkspaceAuthority(randomBytes) + workspaceAuthority }) const client = stubClient(() => {}) - subscriptions.account.start({ requestId: 'r1', subscriptionId: 'account-1', client }) + subscriptions.browser.start({ + requestId: 'r1', + subscriptionId: 'browser-1', + payload: { + workspaceId: workspaceAuthority.pageWorkspaceId('host-workspace'), + pageId: 'page', + format: 'jpeg', + quality: 72, + maxWidth: 800, + maxHeight: 600, + everyNthFrame: 1, + minFrameIntervalMs: 100 + }, + client + }) subscriptions.host.start({ requestId: 'r2', subscriptionId: 'host-1', @@ -99,13 +125,14 @@ describe('subscription ledger teardown', () => { 'subscriptionClosed', 'subscriptionClosed' ]) - expect(subscriptions.cancel('account-1')).toBeNull() + expect(subscriptions.cancel('browser-1')).toBeNull() expect(subscriptions.cancel('host-1')).toBeNull() }) it('keeps a bare ledger cancel silent so a page-driven cancel gets no closure echo', () => { const postClosed = vi.fn() - const ledger = new MobileWebAccountSubscriptions({ + const ledger = new MobileWebHostSubscriptions({ + workspaceAuthority: new MobileWebWorkspaceAuthority(randomBytes), isActive: () => true, postEvent: async () => {}, postClosed @@ -113,7 +140,9 @@ describe('subscription ledger teardown', () => { ledger.start({ requestId: 'request-1', subscriptionId: 'subscription-1', - client: stubClient(() => {}) + payload: { method: 'accounts.subscribe', params: {} }, + client: stubClient(() => {}), + isActive: () => true }) expect(ledger.cancel('subscription-1')).toBe('request-1') @@ -155,8 +184,8 @@ function accountSubscribe(): ReturnType { return mobileWebBridgeRequestMessage({ requestId: bridgeId(1), subscriptionId: bridgeId(2), - capability: 'account', - operation: 'subscribe', - payload: {} + capability: 'workspace', + operation: 'hostSubscribe', + payload: { method: 'accounts.subscribe', params: {} } }) } diff --git a/mobile/src/mobile-web/use-mobile-web-capability-broker.test.ts b/mobile/src/mobile-web/use-mobile-web-capability-broker.test.ts index eb05c07c7a5..5774a082073 100644 --- a/mobile/src/mobile-web/use-mobile-web-capability-broker.test.ts +++ b/mobile/src/mobile-web/use-mobile-web-capability-broker.test.ts @@ -8,12 +8,14 @@ import { } from '../../../src/shared/mobile-web/bridge-contract' import type { RpcClient } from '../transport/rpc-client' import { MobileWebCapabilityBroker } from './mobile-web-capability-broker' +import { MOBILE_WEB_PRODUCTION_GRANT_INDEX } from './mobile-web-production-grants' import { useMobileWebCapabilityBroker, type MobileWebBrokerPageIdentity } from './use-mobile-web-capability-broker' const CONTEXT = { shellSessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) } +const ID_ALPHABET = 'ABCDEFGHIJKLMNOP'.split('') describe('useMobileWebCapabilityBroker', () => { let renderer: ReactTestRenderer | null = null @@ -28,14 +30,18 @@ describe('useMobileWebCapabilityBroker', () => { renderer = null }) - it('caps a single page at one concurrent account subscription', async () => { + it('caps a single page at the granted concurrent host stream count', async () => { await mount(0) + const ceiling = + MOBILE_WEB_PRODUCTION_GRANT_INDEX.get('workspace.hostSubscribe')!.limits.maxConcurrent + for (let index = 0; index < ceiling; index += 1) { + await handle(subscribeRequest(ID_ALPHABET[index]!, ID_ALPHABET[index]!.toLowerCase())) + } - await handle(subscribeRequest('A', 'Z')) - await handle(subscribeRequest('B', 'Y')) + await handle(subscribeRequest('Y', 'y')) - expect(harness.subscribe).toHaveBeenCalledOnce() - expect(errorFor(harness.messages, 'B')).toEqual([{ code: 'rate_limited', retryable: true }]) + expect(harness.subscribe).toHaveBeenCalledTimes(ceiling) + expect(errorFor(harness.messages, 'Y')).toEqual([{ code: 'rate_limited', retryable: true }]) }) it('retires the previous page subscriptions when the view epoch restarts the document', async () => { @@ -170,9 +176,9 @@ function subscribeRequest( mode: 'subscription', requestId: requestId.repeat(22), subscriptionId: subscriptionId.repeat(22), - capability: 'account', - operation: 'subscribe', - payload: {} + capability: 'workspace', + operation: 'hostSubscribe', + payload: { method: 'mobileWeb.workspace.subscribe', params: {} } } } diff --git a/src/mobile-web/src/mobile-web-account-host-requests.test.ts b/src/mobile-web/src/mobile-web-account-host-requests.test.ts new file mode 100644 index 00000000000..7af755cc01b --- /dev/null +++ b/src/mobile-web/src/mobile-web-account-host-requests.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileWebAccountRequestClient } from './mobile-web-account-request-client' +import type { MobileWebBridgeSubscriptionClient } from './mobile-web-bridge-subscription-client' +import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' + +const SNAPSHOT = { + claude: { accounts: [], activeAccountId: null }, + codex: { accounts: [], activeAccountId: null }, + rateLimits: { + claude: null, + codex: null, + claudeTarget: { runtime: 'host', wslDistro: null }, + codexTarget: { runtime: 'host', wslDistro: null }, + inactiveClaudeAccounts: [], + inactiveCodexAccounts: [] + } +} + +function fixture(result: unknown) { + const request = vi.fn().mockResolvedValue(result) + const subscribeHost = vi.fn().mockReturnValue({ ready: Promise.resolve(), unsubscribe() {} }) + return { + request, + subscribeHost, + client: new MobileWebAccountRequestClient( + { request } as unknown as MobileWebOneShotRequestClient, + { subscribeHost } as unknown as MobileWebBridgeSubscriptionClient + ) + } +} + +function hostCall(method: string, params: unknown) { + return [ + 'workspace', + 'hostRequest', + { method, params }, + expect.anything(), + expect.anything(), + undefined + ] +} + +describe('host-forwarded account requests', () => { + it('reads accounts.list and drops the desktop fields the page has no schema for', async () => { + const f = fixture({ + ...SNAPSHOT, + gemini: { accounts: [] }, + minimaxCookieConfigured: true, + claude: { + accounts: [{ id: 'a', email: 'user@example.com', credentialSource: '/private/creds' }], + activeAccountId: 'a' + } + }) + + const snapshot = await f.client.snapshot() + expect(snapshot.claude.accounts).toEqual([{ id: 'a', email: 'user@example.com' }]) + expect(JSON.stringify(snapshot)).not.toContain('private') + expect(f.request).toHaveBeenCalledWith(...hostCall('accounts.list', { refreshUsage: true })) + }) + + it('rejects a snapshot the page schema cannot read', async () => { + await expect(fixture({ claude: {} }).client.snapshot()).rejects.toMatchObject({ + code: 'invalid_message' + }) + }) + + it.each([ + [{ provider: 'claude' as const, accountId: 'a' }, 'accounts.selectClaude', { accountId: 'a' }], + [{ provider: 'codex' as const, accountId: 'a' }, 'accounts.selectCodex', { accountId: 'a' }], + [ + { + provider: 'codex' as const, + accountId: 'a', + codexTarget: { runtime: 'wsl' as const, wslDistro: 'Ubuntu' } + }, + 'accounts.selectCodexForTarget', + { accountId: 'a', target: { runtime: 'wsl', wslDistro: 'Ubuntu' } } + ] + ])('routes %j to %s', async (payload, method, params) => { + const f = fixture({}) + await expect(f.client.select(payload)).resolves.toBeNull() + expect(f.request).toHaveBeenCalledWith(...hostCall(method, params)) + }) + + it('parses each accounts.subscribe frame and reports one it cannot read', () => { + const f = fixture({}) + const events: unknown[] = [] + const errors: unknown[] = [] + f.client.subscribe( + (event) => events.push(event), + (error) => errors.push(error) + ) + const onEvent = f.subscribeHost.mock.calls[0]![1] as (event: unknown) => void + + expect(f.subscribeHost.mock.calls[0]![0]).toEqual({ + method: 'accounts.subscribe', + params: {} + }) + onEvent({ type: 'ready', subscriptionId: 'accounts-private-1', snapshot: SNAPSHOT }) + onEvent({ type: 'snapshot', snapshot: { claude: {} } }) + onEvent({ type: 'end' }) + + expect(events).toEqual([{ type: 'ready', snapshot: SNAPSHOT }, { type: 'end' }]) + expect(errors).toMatchObject([{ code: 'invalid_message' }]) + expect(JSON.stringify(events)).not.toContain('accounts-private-1') + }) +}) diff --git a/src/mobile-web/src/mobile-web-account-request-client.ts b/src/mobile-web/src/mobile-web-account-request-client.ts index 5bd2833a85f..35194ff791c 100644 --- a/src/mobile-web/src/mobile-web-account-request-client.ts +++ b/src/mobile-web/src/mobile-web-account-request-client.ts @@ -4,8 +4,6 @@ import { MobileWebAccountResetCapabilityPayloadSchema, MobileWebAccountResetCapabilityResultSchema, MobileWebAccountSelectPayloadSchema, - MobileWebAccountSelectResultSchema, - MobileWebAccountSnapshotPayloadSchema, MobileWebAccountsSnapshotSchema, type MobileWebAccountConsumeResetPayload, type MobileWebAccountConsumeResetResult, @@ -16,8 +14,28 @@ import { import type { MobileWebBridgeSubscription } from './mobile-web-bridge-subscription' import type { MobileWebBridgeSubscriptionClient } from './mobile-web-bridge-subscription-client' import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' +import { requestMobileWebHost } from './mobile-web-host-request-client' import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' +function parseSnapshot(result: unknown): MobileWebAccountsSnapshot { + const parsed = MobileWebAccountsSnapshotSchema.safeParse(result) + if (!parsed.success) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + return parsed.data +} + +/** A WSL target needs the distro-aware method: an older host silently drops the target field from + * `accounts.selectCodex` and would clear the host slot instead. */ +function selectMethod(payload: MobileWebAccountSelectPayload): string { + if (payload.provider === 'claude') { + return 'accounts.selectClaude' + } + return payload.codexTarget?.runtime === 'wsl' + ? 'accounts.selectCodexForTarget' + : 'accounts.selectCodex' +} + export class MobileWebAccountRequestClient { constructor( private readonly requests: MobileWebOneShotRequestClient, @@ -25,23 +43,20 @@ export class MobileWebAccountRequestClient { ) {} snapshot(): Promise { - return this.requests.request( - 'account', - 'snapshot', - {}, - MobileWebAccountSnapshotPayloadSchema, - MobileWebAccountsSnapshotSchema - ) + return requestMobileWebHost(this.requests, 'accounts.list', undefined, { + refreshUsage: true + }).then(parseSnapshot) } select(payload: MobileWebAccountSelectPayload): Promise { - return this.requests.request( - 'account', - 'select', - payload, - MobileWebAccountSelectPayloadSchema, - MobileWebAccountSelectResultSchema - ) + if (!MobileWebAccountSelectPayloadSchema.safeParse(payload).success) { + return Promise.reject(new MobileWebBridgeClientError('invalid_request', false)) + } + const method = selectMethod(payload) + return requestMobileWebHost(this.requests, method, undefined, { + accountId: payload.accountId, + ...(method === 'accounts.selectCodexForTarget' ? { target: payload.codexTarget } : {}) + }).then(() => null) } resetCreditCapability(): Promise { @@ -77,7 +92,27 @@ export class MobileWebAccountRequestClient { onEvent: (event: MobileWebAccountEvent) => void, onError: (error: MobileWebBridgeClientError) => void ): MobileWebBridgeSubscription { - return this.subscriptions.subscribeAccount(onEvent, onError) + return this.subscriptions.subscribeHost( + { method: 'accounts.subscribe', params: {} }, + (event) => { + const type = + typeof event === 'object' && event !== null && 'type' in event ? event.type : undefined + if (type === 'end' || type === 'error') { + onEvent({ type }) + return + } + if (type !== 'ready' && type !== 'snapshot') { + onError(new MobileWebBridgeClientError('invalid_message', false)) + return + } + try { + onEvent({ type, snapshot: parseSnapshot((event as { snapshot: unknown }).snapshot) }) + } catch { + onError(new MobileWebBridgeClientError('invalid_message', false)) + } + }, + onError + ) } } diff --git a/src/mobile-web/src/mobile-web-bridge-grant-operation-scope.test.ts b/src/mobile-web/src/mobile-web-bridge-grant-operation-scope.test.ts index e6051fd8fd1..cf3a03bf092 100644 --- a/src/mobile-web/src/mobile-web-bridge-grant-operation-scope.test.ts +++ b/src/mobile-web/src/mobile-web-bridge-grant-operation-scope.test.ts @@ -55,7 +55,7 @@ describe('mobile web bridge grant operation scope', () => { operation: 'snapshot', limits: { ...LIMITS, maxRequestBytes: 8 } }, - { capability: 'account', operation: 'snapshot', limits: LIMITS } + { capability: 'agentHistory', operation: 'snapshot', limits: LIMITS } ]) const oversize = harness.client.workspaceSnapshot({ limit: 10 }).then( @@ -65,8 +65,13 @@ describe('mobile web bridge grant operation scope', () => { expect(harness.messages).toHaveLength(0) await expect(oversize).resolves.toMatchObject({ code: 'too_large', retryable: false }) - void harness.client.account.snapshot() - expect(harness.messages).toMatchObject([{ capability: 'account', operation: 'snapshot' }]) + void harness.client.agentHistory.snapshot({ + workspaceId: WORKSPACE_ID, + scope: 'workspace', + query: '', + force: false + }) + expect(harness.messages).toMatchObject([{ capability: 'agentHistory', operation: 'snapshot' }]) }) }) diff --git a/src/shared/mobile-web/account-operation-contract.ts b/src/shared/mobile-web/account-operation-contract.ts index 41880e4e325..375ddd3e038 100644 --- a/src/shared/mobile-web/account-operation-contract.ts +++ b/src/shared/mobile-web/account-operation-contract.ts @@ -3,7 +3,7 @@ import { z } from 'zod' export const MOBILE_WEB_ACCOUNT_LIMIT = 32 const AccountIdSchema = z.string().min(1).max(256) -const AccountEmailSchema = z.string().min(1).max(320) +const AccountEmailSchema = z.string().max(320).catch('') const OptionalLabelSchema = z.string().max(240).nullable().optional() const TimestampSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) @@ -34,133 +34,109 @@ export const MobileWebRateLimitRuntimeTargetSchema = z } }) -const MobileWebRuntimeSelectionSchema = z - .object({ - host: AccountIdSchema.nullable(), - wsl: z - .record(z.string().min(1).max(255), AccountIdSchema.nullable()) - .refine((entries) => Object.keys(entries).length <= MOBILE_WEB_ACCOUNT_LIMIT) - }) - .strict() +const MobileWebRuntimeSelectionSchema = z.object({ + host: AccountIdSchema.nullable(), + wsl: z + .record(z.string().min(1).max(255), AccountIdSchema.nullable()) + .refine((entries) => Object.keys(entries).length <= MOBILE_WEB_ACCOUNT_LIMIT) +}) -const MobileWebRateLimitWindowSchema = z - .object({ - usedPercent: z.number().finite().min(0).max(100), - windowMinutes: z.number().int().nonnegative().max(1_000_000), - resetsAt: TimestampSchema.nullable(), - resetDescription: z.string().max(240).nullable() - }) - .strict() +const MobileWebRateLimitWindowSchema = z.object({ + usedPercent: z.number().finite().min(0).max(100), + windowMinutes: z.number().int().nonnegative().max(1_000_000), + resetsAt: TimestampSchema.nullable(), + resetDescription: z.string().max(240).nullable() +}) -const MobileWebRateLimitResetCreditSchema = z - .object({ - status: z.string().min(1).max(64), - expiresAt: TimestampSchema.nullable(), - grantedAt: TimestampSchema.nullable() - }) - .strict() +const MobileWebRateLimitResetCreditSchema = z.object({ + status: z.string().min(1).max(64), + expiresAt: TimestampSchema.nullable(), + grantedAt: TimestampSchema.nullable() +}) -const MobileWebRateLimitResetCreditsSchema = z - .object({ - availableCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - totalEarnedCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), - nextExpiresAt: TimestampSchema.nullable().optional(), - credits: z.array(MobileWebRateLimitResetCreditSchema).max(MOBILE_WEB_ACCOUNT_LIMIT).optional() - }) - .strict() +const MobileWebRateLimitResetCreditsSchema = z.object({ + availableCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + totalEarnedCount: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(), + nextExpiresAt: TimestampSchema.nullable().optional(), + credits: z.array(MobileWebRateLimitResetCreditSchema).max(MOBILE_WEB_ACCOUNT_LIMIT).optional() +}) -const MobileWebProviderRateLimitsSchema = z - .object({ - provider: z.enum(['claude', 'codex']), - session: MobileWebRateLimitWindowSchema.nullable(), - weekly: MobileWebRateLimitWindowSchema.nullable(), - fableWeekly: MobileWebRateLimitWindowSchema.nullable().optional(), - monthly: MobileWebRateLimitWindowSchema.nullable().optional(), - buckets: z - .array( - MobileWebRateLimitWindowSchema.extend({ - name: z.string().min(1).max(240) - }).strict() - ) +const MobileWebProviderRateLimitsSchema = z.object({ + provider: z.enum(['claude', 'codex']), + session: MobileWebRateLimitWindowSchema.nullable(), + weekly: MobileWebRateLimitWindowSchema.nullable(), + fableWeekly: MobileWebRateLimitWindowSchema.nullable().optional(), + monthly: MobileWebRateLimitWindowSchema.nullable().optional(), + buckets: z + .array( + MobileWebRateLimitWindowSchema.extend({ + name: z.string().min(1).max(240) + }) + ) + .max(MOBILE_WEB_ACCOUNT_LIMIT) + .optional(), + rateLimitResetCredits: MobileWebRateLimitResetCreditsSchema.nullable().optional(), + updatedAt: TimestampSchema, + error: z.string().max(512).nullable(), + status: z.enum(['idle', 'fetching', 'ok', 'error', 'unavailable']) +}) + +const MobileWebInactiveAccountUsageSchema = z.object({ + accountId: AccountIdSchema, + rateLimits: MobileWebProviderRateLimitsSchema.nullable(), + updatedAt: TimestampSchema, + isFetching: z.boolean() +}) + +const MobileWebClaudeAccountSchema = z.object({ + id: AccountIdSchema, + email: AccountEmailSchema, + managedAuthRuntime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().max(255).nullable().optional(), + authMethod: z.enum(['subscription-oauth', 'unknown']).optional(), + organizationUuid: z.string().max(256).nullable().optional(), + organizationName: OptionalLabelSchema, + createdAt: TimestampSchema.optional(), + updatedAt: TimestampSchema.optional(), + lastAuthenticatedAt: TimestampSchema.optional() +}) + +const MobileWebCodexAccountSchema = z.object({ + id: AccountIdSchema, + email: AccountEmailSchema, + managedHomeRuntime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().max(255).nullable().optional(), + workspaceLabel: OptionalLabelSchema, + workspaceAccountId: z.string().max(256).nullable().optional(), + createdAt: TimestampSchema.optional(), + updatedAt: TimestampSchema, + lastAuthenticatedAt: TimestampSchema.optional() +}) + +export const MobileWebAccountsSnapshotSchema = z.object({ + claude: z.object({ + accounts: z.array(MobileWebClaudeAccountSchema).max(MOBILE_WEB_ACCOUNT_LIMIT), + activeAccountId: AccountIdSchema.nullable(), + activeAccountIdsByRuntime: MobileWebRuntimeSelectionSchema.optional() + }), + codex: z.object({ + accounts: z.array(MobileWebCodexAccountSchema).max(MOBILE_WEB_ACCOUNT_LIMIT), + activeAccountId: AccountIdSchema.nullable(), + activeAccountIdsByRuntime: MobileWebRuntimeSelectionSchema.optional() + }), + rateLimits: z.object({ + claude: MobileWebProviderRateLimitsSchema.nullable(), + codex: MobileWebProviderRateLimitsSchema.nullable(), + claudeTarget: MobileWebRateLimitRuntimeTargetSchema, + codexTarget: MobileWebRateLimitRuntimeTargetSchema, + inactiveClaudeAccounts: z + .array(MobileWebInactiveAccountUsageSchema) + .max(MOBILE_WEB_ACCOUNT_LIMIT), + inactiveCodexAccounts: z + .array(MobileWebInactiveAccountUsageSchema) .max(MOBILE_WEB_ACCOUNT_LIMIT) - .optional(), - rateLimitResetCredits: MobileWebRateLimitResetCreditsSchema.nullable().optional(), - updatedAt: TimestampSchema, - error: z.string().max(512).nullable(), - status: z.enum(['idle', 'fetching', 'ok', 'error', 'unavailable']) }) - .strict() - -const MobileWebInactiveAccountUsageSchema = z - .object({ - accountId: AccountIdSchema, - rateLimits: MobileWebProviderRateLimitsSchema.nullable(), - updatedAt: TimestampSchema, - isFetching: z.boolean() - }) - .strict() - -const MobileWebClaudeAccountSchema = z - .object({ - id: AccountIdSchema, - email: AccountEmailSchema, - managedAuthRuntime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().max(255).nullable().optional(), - authMethod: z.enum(['subscription-oauth', 'unknown']).optional(), - organizationUuid: z.string().max(256).nullable().optional(), - organizationName: OptionalLabelSchema, - createdAt: TimestampSchema.optional(), - updatedAt: TimestampSchema.optional(), - lastAuthenticatedAt: TimestampSchema.optional() - }) - .strict() - -const MobileWebCodexAccountSchema = z - .object({ - id: AccountIdSchema, - email: AccountEmailSchema, - managedHomeRuntime: z.enum(['host', 'wsl']).optional(), - wslDistro: z.string().max(255).nullable().optional(), - workspaceLabel: OptionalLabelSchema, - workspaceAccountId: z.string().max(256).nullable().optional(), - createdAt: TimestampSchema.optional(), - updatedAt: TimestampSchema, - lastAuthenticatedAt: TimestampSchema.optional() - }) - .strict() - -export const MobileWebAccountsSnapshotSchema = z - .object({ - claude: z - .object({ - accounts: z.array(MobileWebClaudeAccountSchema).max(MOBILE_WEB_ACCOUNT_LIMIT), - activeAccountId: AccountIdSchema.nullable(), - activeAccountIdsByRuntime: MobileWebRuntimeSelectionSchema.optional() - }) - .strict(), - codex: z - .object({ - accounts: z.array(MobileWebCodexAccountSchema).max(MOBILE_WEB_ACCOUNT_LIMIT), - activeAccountId: AccountIdSchema.nullable(), - activeAccountIdsByRuntime: MobileWebRuntimeSelectionSchema.optional() - }) - .strict(), - rateLimits: z - .object({ - claude: MobileWebProviderRateLimitsSchema.nullable(), - codex: MobileWebProviderRateLimitsSchema.nullable(), - claudeTarget: MobileWebRateLimitRuntimeTargetSchema, - codexTarget: MobileWebRateLimitRuntimeTargetSchema, - inactiveClaudeAccounts: z - .array(MobileWebInactiveAccountUsageSchema) - .max(MOBILE_WEB_ACCOUNT_LIMIT), - inactiveCodexAccounts: z - .array(MobileWebInactiveAccountUsageSchema) - .max(MOBILE_WEB_ACCOUNT_LIMIT) - }) - .strict() - }) - .strict() +}) export const MobileWebCodexResetCreditExpectedScopeSchema = z .object({ @@ -230,13 +206,11 @@ export const MobileWebAccountConsumeResetResultSchema = z.union([ ]) export const MobileWebAccountSubscribePayloadSchema = z.object({}).strict() export const MobileWebAccountEventSchema = z.discriminatedUnion('type', [ - z - .object({ - type: z.enum(['ready', 'snapshot']), - snapshot: MobileWebAccountsSnapshotSchema - }) - .strict(), - z.object({ type: z.enum(['end', 'error']) }).strict() + z.object({ + type: z.enum(['ready', 'snapshot']), + snapshot: MobileWebAccountsSnapshotSchema + }), + z.object({ type: z.enum(['end', 'error']) }) ]) export type MobileWebAccountsSnapshot = z.infer diff --git a/src/shared/mobile-web/bridge-operation-registry-census.test.ts b/src/shared/mobile-web/bridge-operation-registry-census.test.ts index 9bc81803199..1e4bc46d6f3 100644 --- a/src/shared/mobile-web/bridge-operation-registry-census.test.ts +++ b/src/shared/mobile-web/bridge-operation-registry-census.test.ts @@ -30,7 +30,7 @@ describe('mobile web bridge operation registry census', () => { } expect(files.length).toBeGreaterThanOrEqual(40) - expect(named.size).toBeGreaterThanOrEqual(125) + expect(named.size).toBeGreaterThanOrEqual(123) expect([...named].filter((pair) => !registered.has(pair))).toEqual([]) }) @@ -61,7 +61,7 @@ describe('mobile web bridge operation registry census', () => { } } - expect(pairs.size).toBeGreaterThanOrEqual(125) + expect(pairs.size).toBeGreaterThanOrEqual(123) expect([...pairs].filter(([, schemas]) => schemas.size !== 1).map(([key]) => key)).toEqual([]) expect([...pairs.keys()].filter((key) => !registered.has(key))).toEqual([]) expect( diff --git a/src/shared/mobile-web/bridge-operation-registry.ts b/src/shared/mobile-web/bridge-operation-registry.ts index 0918623a13e..25447b17e0c 100644 --- a/src/shared/mobile-web/bridge-operation-registry.ts +++ b/src/shared/mobile-web/bridge-operation-registry.ts @@ -173,11 +173,8 @@ export const MOBILE_WEB_BRIDGE_OPERATIONS = { keyboard: 'mutation' }, account: { - snapshot: 'read', - select: 'mutation', resetCreditCapability: 'read', - consumeResetCredit: 'mutation', - subscribe: 'subscription' + consumeResetCredit: 'mutation' }, agentHistory: { snapshot: 'read',