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',