mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
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
This commit is contained in:
@@ -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<unknown> {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<MobileWebBridgePageMessage, { type: 'request' }> {
|
||||
return mobileWebBridgeRequestMessage({
|
||||
requestId: id.repeat(22),
|
||||
capability: 'account',
|
||||
operation,
|
||||
payload
|
||||
})
|
||||
}
|
||||
|
||||
function subscriptionRequest(): Extract<MobileWebBridgePageMessage, { type: 'request' }> {
|
||||
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
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
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 ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>, 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<T extends { id: string }>(
|
||||
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<string, string | null> },
|
||||
visibleIds: ReadonlySet<string>
|
||||
) {
|
||||
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<const T extends string>(
|
||||
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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<MobileWebAccountEvent> {
|
||||
constructor(config: MobileWebSubscriptionLedgerConfig<MobileWebAccountEvent>) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -218,7 +218,6 @@ async function subscribeSpeech(args: Deps, request: SubscriptionRequest): Promis
|
||||
export const MOBILE_WEB_SUBSCRIPTION_CAPABILITY_ARMS: Partial<
|
||||
Record<MobileWebBridgeCapability, SubscriptionArm>
|
||||
> = {
|
||||
account: (args) => executeMobileWebAccountCapability(args),
|
||||
browser: subscribeBrowser,
|
||||
workspace: subscribeWorkspace,
|
||||
terminal: subscribeTerminal,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof mobileWebBridgeRequestMessage> {
|
||||
return mobileWebBridgeRequestMessage({
|
||||
requestId: bridgeId(1),
|
||||
subscriptionId: bridgeId(2),
|
||||
capability: 'account',
|
||||
operation: 'subscribe',
|
||||
payload: {}
|
||||
capability: 'workspace',
|
||||
operation: 'hostSubscribe',
|
||||
payload: { method: 'accounts.subscribe', params: {} }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<MobileWebAccountsSnapshot> {
|
||||
return this.requests.request(
|
||||
'account',
|
||||
'snapshot',
|
||||
{},
|
||||
MobileWebAccountSnapshotPayloadSchema,
|
||||
MobileWebAccountsSnapshotSchema
|
||||
)
|
||||
return requestMobileWebHost(this.requests, 'accounts.list', undefined, {
|
||||
refreshUsage: true
|
||||
}).then(parseSnapshot)
|
||||
}
|
||||
|
||||
select(payload: MobileWebAccountSelectPayload): Promise<null> {
|
||||
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<boolean> {
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<typeof MobileWebAccountsSnapshotSchema>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user