mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
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
117 lines
3.8 KiB
TypeScript
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)
|
|
}
|