Files
orca/mobile/src/mobile-web/mobile-web-source-control-commit-preflight.ts
T
Jinwoo-H 4c7fd1aec6 fix(mobile-web): make the shell to page bridge direction forward compatible
W1 (P1): every shell-authored result and event schema was .strict(), and the page fails a schema mismatch as invalid_message with retryable:false. The shell (APK) and the page (served by the desktop) ship from different releases, so one additive field or one new session tab kind from a newer APK killed the subscription and its one-shot fallback on the same byte - Loading tabs forever, surviving force-quit. tolerantMobileWebShellPayload deep-rewrites a schema at the page's two shell-payload parse sites: strict objects strip unknown keys, an array of unions drops members it cannot classify, and an unknown value for an optional/nullable closed set reads as absent. Page to shell request schemas keep .strict() - the shell is the security authority there. A census ratchet walks every contract export the page parses and fails if a strict node survives the transform.

W3 (P2): a host RPC failure collapsed into host_error, which is retryable, so method_not_found and the mobile allowlist's forbidden looked like blips. Both now map to unsupported_capability (non-retryable) through mobileWebBrokerHostRpcError, applied by codemod to the 44 regular 'if (!x.ok) throw host_error' sites.

W4 (P3): BrowserScreencastResult gains the navigation member the host already emits. Decoders unchanged.

W2 (P2): the file: confinement test gains a clientKind runtime case - pairedDeviceId is minted for scope 'runtime' too, so the fence also governs the web client, a remote desktop, and remote orca CLI.

W5 (P3): inputFloor and queryReplyAuthority stay literals with a WHY comment. Traced: the host publishes neither over the terminal stream. isMobileTerminalQueryReplyAuthority is never sent, and opcode-17 WriteUnavailable reports one refused write with no regain signal, so it is not the floor state the field declares.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-04 16:41:17 -04:00

117 lines
3.8 KiB
TypeScript

import {
MobileWebSourceControlCommitEntrySchema,
type MobileWebSourceControlCommitEntry
} from '../../../src/shared/mobile-web/source-control-commit-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
export async function assertFreshMobileWebCommitSnapshot(
client: RpcClient,
snapshot: {
workspaceId: string
expectedHead: string
stagedEntries: readonly MobileWebSourceControlCommitEntry[]
},
hostWorkspaceId: string
): Promise<void> {
const response = await client.sendRequest('git.status', {
worktree: `id:${hostWorkspaceId}`
})
if (!response.ok) {
throw mobileWebBrokerHostRpcError(response.error)
}
assertMobileWebSourceControlCommitPreflight({
result: response.result,
expectedHead: snapshot.expectedHead,
stagedEntries: snapshot.stagedEntries
})
}
export function assertMobileWebSourceControlCommitPreflight(args: {
result: unknown
expectedHead: string
stagedEntries: readonly MobileWebSourceControlCommitEntry[]
}): void {
if (!isRecord(args.result) || !Array.isArray(args.result.entries)) {
throw new MobileWebBrokerError('host_error')
}
if (
args.result.didHitLimit === true ||
readHead(args.result.head) !== args.expectedHead ||
hasUnresolvedEntry(args.result.entries)
) {
throw new MobileWebBrokerError('conflict')
}
const current = readCurrentStagedEntries(args.result.entries)
if (current.length !== args.stagedEntries.length) {
throw new MobileWebBrokerError('conflict')
}
const expected = new Map(
args.stagedEntries.map((entry) => [commitEntryKey(entry), entry] as const)
)
if (expected.size !== args.stagedEntries.length) {
throw new MobileWebBrokerError('conflict')
}
for (const entry of current) {
const expectedEntry = expected.get(commitEntryKey(entry))
if (!expectedEntry || !sameCommitEntry(entry, expectedEntry)) {
throw new MobileWebBrokerError('conflict')
}
}
}
function readCurrentStagedEntries(entries: unknown[]): MobileWebSourceControlCommitEntry[] {
const staged: MobileWebSourceControlCommitEntry[] = []
const paths = new Set<string>()
for (const candidate of entries) {
if (!isRecord(candidate) || candidate.area !== 'staged') {
continue
}
const parsed = MobileWebSourceControlCommitEntrySchema.safeParse({
relativePath: candidate.path,
...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }),
status: candidate.status,
area: candidate.area,
...(candidate.conflictStatus === undefined
? {}
: { conflictStatus: candidate.conflictStatus })
})
if (!parsed.success || paths.has(parsed.data.relativePath)) {
throw new MobileWebBrokerError('conflict')
}
paths.add(parsed.data.relativePath)
staged.push(parsed.data)
}
return staged
}
function hasUnresolvedEntry(entries: unknown[]): boolean {
return entries.some((entry) => isRecord(entry) && entry.conflictStatus === 'unresolved')
}
function sameCommitEntry(
current: MobileWebSourceControlCommitEntry,
expected: MobileWebSourceControlCommitEntry
): boolean {
return (
current.relativePath === expected.relativePath &&
current.oldRelativePath === expected.oldRelativePath &&
current.status === expected.status &&
current.area === expected.area &&
current.conflictStatus === expected.conflictStatus
)
}
function commitEntryKey(entry: MobileWebSourceControlCommitEntry): string {
return entry.relativePath
}
function readHead(value: unknown): string | null {
return typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value) ? value : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}