feat(mobile-web): forward desktop-advertised source-control reads

Add authenticated method catalog queries and a bounded generic unary workspace lane. Preserve opaque workspace bindings across awaits and keep actual host calls counted after page cancellation. Move source-control read presentation to code the hosted page can run, with legacy fallback for older shells, older desktops, and oversized raw responses. Preserve v2 and all legacy handlers. Update dispatch, reauthorization, and response corpus ratchets deliberately. Full requested gates pass; the mobile suite passed after rerunning outside concurrent web export. Broader identifier families, generic subscriptions, and native route migration remain separate work.
This commit is contained in:
Jinwoo-H
2026-09-06 17:03:14 -04:00
parent 11646f11e0
commit 9910fccc29
27 changed files with 823 additions and 251 deletions
+1
View File
@@ -7,6 +7,7 @@
"../src/shared/event-loop-yield.ts",
"../src/shared/file-link-location.ts",
"../src/shared/is-record.ts",
"../src/shared/sha256.ts",
"../src/shared/mobile-markdown-document.ts",
"../src/shared/mobile-web/**/*",
"../src/shared/terminal-quick-command-limits.ts",
@@ -61,7 +61,7 @@ acceptance.
| Native mobile shell | Pairing and host selection; secure credential storage; authenticated encrypted transport; QR scanning; notifications and deep links; package verification, cache, private origin, and recovery; clipboard, haptics, audio, camera and file/photo pickers; native settings, onboarding, privacy, About, and diagnostics |
| Desktop-served React Native Web application | Workspace list and creation; sessions and terminal presentation; files, previews, diffs, source control, reviews, tasks, accounts, browser presentation, Agent History, and native-chat presentation |
| Desktop runtime | Builds and ships the matching web package; serves its manifest and chunks through authenticated RPC; reauthorizes every workspace mutation; enforces host, workspace, provider, path, and resource limits |
| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and native capabilities; carries connection and route state without exposing transport credentials |
| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and native capabilities; carries connection and route state without exposing transport credentials |
The page never receives the raw RPC client, pairing credential, host endpoint,
private key, cache path, or unrestricted native module access. Native-owned
@@ -186,8 +186,18 @@ edges still meet the device and keep their measured values.
page history writes on that fragment).
- The shell grants named operation/capability pairs with request, response,
concurrency, subscription, rate, and message limits.
- The page cannot invoke a generic RPC passthrough. Desktop still authorizes
every operation against the current connection and opaque workspace scope.
- The page can use `workspace.hostRequest` for desktop-advertised unary methods.
The shell queries `mobileWeb.host.catalog` over the authenticated connection,
resolves the existing opaque workspace handle, and forwards bounded domain
JSON without a shell-owned response schema. The initial catalog grants
`git.status` and `git.diff`; the page falls back to legacy reads when the
catalog is unavailable or a raw response exceeds its bridge budget.
- Generic forwarding retains byte, depth, node-count, rate and actual in-flight
limits. Cancelling a page request does not release its host-work slot until
the host call settles. Catalog authors must grant only page-safe results;
methods returning private identifiers need an opaque mapping before adoption.
Native-chat/session/terminal/file migrations and generic subscriptions remain
future work; their existing adapters and grants still apply.
- Clipboard reads, pickers, external links, haptics, dictation, and related
native actions require the relevant grant; privacy-sensitive actions also
require the system permission the platform asks for. The shell's own
@@ -220,11 +230,12 @@ depends on its direction and on whether it adds a field or an operation:
`invalid_message` with `retryable: false`, nothing re-subscribes, and the
one-shot fallback shares the schema, so both legs die on the same byte.
`shell-payload-tolerance-census.test.ts` fails if a strict node survives.
- **Additive field, page to shell** (any request payload) requires a grant.
Request schemas stay `.strict()` because the shell is the security authority
and must reject what it cannot account for; a newer page that sends a field
an older shell does not know gets `invalid_request`. Gate the field's use on
the operation grant that introduces it, the same way an operation is gated.
- **Additive field, page to shell** in a native or legacy payload requires negotiation.
Native-capability and legacy request schemas stay `.strict()`; a newer page
that sends a field an older shell does not know gets `invalid_request`. Gate
those fields through shell features. The generic host request has a strict
routing envelope but opaque bounded domain params, so desktop/page field
additions on that lane do not need an APK schema change.
- **Additive operation, either direction** negotiates through `init.grants`.
The page fails an ungranted operation immediately with
`unsupported_capability`, so a newer page against an older shell degrades at
@@ -1,10 +1,7 @@
import type { MobileWebBridgeErrorCode } from '../../../src/shared/mobile-web/bridge-contract'
export class MobileWebBrokerError extends Error {
constructor(readonly code: MobileWebBridgeErrorCode) {
super(code)
}
}
import { MobileWebBrokerError } from '../../../src/shared/mobile-web/bridge-operation-error'
export { MobileWebBrokerError } from '../../../src/shared/mobile-web/bridge-operation-error'
export function mobileWebBridgeErrorCode(error: unknown): MobileWebBridgeErrorCode {
if (error instanceof MobileWebBrokerError) {
@@ -1,3 +1,4 @@
import { requireMobileWebConnectedClient } from './mobile-web-connected-client'
import {
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS,
type MobileWebBridgePageMessage,
@@ -25,6 +26,7 @@ import { rememberMobileWebBrokerRoute } from './mobile-web-broker-route-memory'
import { resolveMobileWebHostNavigationRoute } from './mobile-web-host-navigation-route'
import {
mobileWebEncodedByteLength,
mobileWebRequestSurvivesCancellation,
mobileWebAgentHistoryContinuation,
mobileWebOperationKey,
mobileWebPendingForOperation,
@@ -36,10 +38,6 @@ import {
type PageRequest = Extract<MobileWebBridgePageMessage, { type: 'request' }>
type PendingRequest = { operationKey: string; subscriptionId?: string; cancelled: boolean }
// Native alerts outlive client churn and explicit cancels; the OS dialog owns the resolution.
function survivesCancellation(pending: PendingRequest): boolean {
return pending.operationKey === 'native.alert'
}
export class MobileWebCapabilityBroker {
private readonly pending = new Map<string, PendingRequest>()
private readonly replay = new MobileWebBrokerReplayGuard()
@@ -50,6 +48,7 @@ export class MobileWebCapabilityBroker {
private readonly commitMessageGeneration = new MobileWebCommitMessageGeneration()
private readonly authorities: MobileWebCapabilityAuthorities
private readonly messages: MobileWebBrokerMessageSender
private hostRequestsInFlight = 0
private disposed = false
constructor(private readonly options: MobileWebCapabilityBrokerOptions) {
@@ -110,7 +109,7 @@ export class MobileWebCapabilityBroker {
this.terminalStreams.dispose(null, MOBILE_WEB_TERMINAL_CLIENT_CLOSURE)
this.speechAuthority.replaceClient()
for (const [requestId, pending] of this.pending) {
if (survivesCancellation(pending)) {
if (mobileWebRequestSurvivesCancellation(pending)) {
continue
}
pending.cancelled = true
@@ -154,6 +153,9 @@ export class MobileWebCapabilityBroker {
return
}
const isHostRequest =
request.capability === 'workspace' &&
(request.operation === 'hostRequest' || request.operation === 'hostCatalog')
const grant = MOBILE_WEB_PRODUCTION_GRANT_INDEX.get(mobileWebOperationKey(request))
const expectsSubscription = mobileWebRequestExpectsSubscription(request)
if (!grant || (request.mode === 'subscription') !== expectsSubscription) {
@@ -172,6 +174,7 @@ export class MobileWebCapabilityBroker {
return
}
if (
(isHostRequest && this.hostRequestsInFlight >= 4) ||
this.pending.size >= MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS ||
mobileWebPendingForOperation(this.pending.values(), mobileWebOperationKey(request)) +
this.subscriptions.countForOperation(mobileWebOperationKey(request)) +
@@ -200,6 +203,9 @@ export class MobileWebCapabilityBroker {
cancelled: false
}
this.pending.set(request.requestId, pending)
if (isHostRequest) {
this.hostRequestsInFlight += 1
}
try {
const payload = await this.execute(request, () => this.isPending(request.requestId, pending))
if (!this.isPending(request.requestId, pending)) {
@@ -219,6 +225,10 @@ export class MobileWebCapabilityBroker {
await this.messages.error(request.requestId, code, isRetryableMobileWebBridgeError(code))
}
} finally {
// Cancellation retires the page request before the host releases its retained work.
if (isHostRequest) {
this.hostRequestsInFlight -= 1
}
this.authorities.sourceControlBranchCompare.releaseClaim(request.requestId)
if (this.pending.get(request.requestId) === pending) {
this.pending.delete(request.requestId)
@@ -258,14 +268,7 @@ export class MobileWebCapabilityBroker {
}
private connectedClient(): RpcClient {
if (!this.options.isConnected()) {
throw new MobileWebBrokerError('not_connected')
}
const client = this.options.getClient()
if (!client) {
throw new MobileWebBrokerError('not_connected')
}
return client
return requireMobileWebConnectedClient(this.options)
}
private async cancel(target: 'request' | 'subscription', id: string): Promise<void> {
@@ -288,7 +291,7 @@ export class MobileWebCapabilityBroker {
if (!pending) {
return
}
if (survivesCancellation(pending)) {
if (mobileWebRequestSurvivesCancellation(pending)) {
return
}
pending.cancelled = true
@@ -49,7 +49,7 @@ describe('mobile web capability dispatch census', () => {
})
expect(unresolved.map(({ capability, operation }) => `${capability}.${operation}`)).toEqual([])
expect(registeredOperations()).toHaveLength(226)
expect(registeredOperations()).toHaveLength(228)
})
it('carries a dispatch arm for exactly the capabilities that own operations of that mode', () => {
@@ -6,6 +6,7 @@ import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/
import type { MobileWebBridgeCapability } from '../../../src/shared/mobile-web/bridge-operation-registry'
import { MobileWebSourceControlSubscribePayloadSchema } from '../../../src/shared/mobile-web/source-control-operation-contract'
import { MobileWebSpeechSubscribePayloadSchema } from '../../../src/shared/mobile-web/speech-operation-contract'
import { executeMobileWebHostRequest, readMobileWebHostCatalog } from './mobile-web-host-requests'
import { executeMobileWebAccountCapability } from './mobile-web-account-capability'
import { executeMobileWebAgentHistoryOperation } from './mobile-web-agent-history-operations'
import { MobileWebBrokerError } from './mobile-web-broker-error'
@@ -70,6 +71,17 @@ async function executeBrowser(args: Deps, request: OnceRequest): Promise<unknown
}
async function executeWorkspace(args: Deps, request: OnceRequest): Promise<unknown> {
if (request.operation === 'hostCatalog') {
return readMobileWebHostCatalog(args.connectedClient(), request.payload)
}
if (request.operation === 'hostRequest') {
return executeMobileWebHostRequest({
client: args.connectedClient(),
authority: args.workspaceAuthority,
payload: request.payload,
isActive: args.isRequestActive
})
}
if (request.capability !== 'workspace' && request.capability !== 'settings') {
throw new MobileWebBrokerError('unsupported_capability')
}
@@ -0,0 +1,16 @@
import type { RpcClient } from '../transport/rpc-client'
import type { MobileWebCapabilityBrokerOptions } from './mobile-web-capability-broker-options'
import { MobileWebBrokerError } from './mobile-web-broker-error'
export function requireMobileWebConnectedClient(
options: Pick<MobileWebCapabilityBrokerOptions, 'isConnected' | 'getClient'>
): RpcClient {
if (!options.isConnected()) {
throw new MobileWebBrokerError('not_connected')
}
const client = options.getClient()
if (!client) {
throw new MobileWebBrokerError('not_connected')
}
return client
}
@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { executeMobileWebHostRequest } from './mobile-web-host-requests'
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture'
const grant = {
method: 'future.domainRead',
workspaceParam: 'worktree',
maxRequestBytes: 16 * 1024,
maxResponseBytes: 512 * 1024
}
function fixture() {
const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(1))
authority.synchronize([{ workspaceId: 'host-workspace', repoId: 'host-repo' }])
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const args = {
authority,
client: { sendRequest } as unknown as RpcClient,
isActive: () => true,
payload: {
method: grant.method,
workspaceId: authority.pageWorkspaceId('host-workspace'),
params: { futureField: { futureVariant: 'added-by-desktop' } }
}
}
return { args, sendRequest }
}
describe('host-advertised unary forwarding', () => {
it('forwards future fields and methods without a shell method entry', async () => {
const { args, sendRequest } = fixture()
const result = { futureResult: [{ kind: 'future-kind', value: 4 }] }
sendRequest
.mockResolvedValueOnce({ ok: true, result: { grants: [grant] } })
.mockResolvedValueOnce({ ok: true, result })
await expect(executeMobileWebHostRequest(args)).resolves.toEqual(result)
expect(sendRequest).toHaveBeenLastCalledWith(grant.method, {
...args.payload.params,
worktree: 'id:host-workspace'
})
expect(JSON.stringify(result)).not.toContain('host-workspace')
})
it('refuses methods the desktop did not advertise', async () => {
const { args, sendRequest } = fixture()
sendRequest.mockResolvedValueOnce({ ok: true, result: { grants: [] } })
await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({
code: 'unsupported_capability'
})
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('does not forward after authority retirement during catalog lookup', async () => {
const { args, sendRequest } = fixture()
sendRequest.mockImplementationOnce(async () => {
args.authority.clear()
return { ok: true, result: { grants: [grant] } }
})
await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'not_found' })
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('keeps native hard ceilings even when the trusted host permits a larger result', async () => {
const { args, sendRequest } = fixture()
sendRequest
.mockResolvedValueOnce({
ok: true,
result: { grants: [{ ...grant, maxResponseBytes: 10_000_000 }] }
})
.mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(640 * 1024) } })
await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' })
})
it('enforces host request bounds before executing', async () => {
const { args, sendRequest } = fixture()
sendRequest.mockResolvedValueOnce({
ok: true,
result: { grants: [{ ...grant, maxRequestBytes: 1 }] }
})
await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' })
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('retains in-flight admission after page cancellation until host work settles', async () => {
const finishCatalog: (() => void)[] = []
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockImplementation(async (method) => {
if (method === 'worktree.ps') {
return {
ok: true,
result: {
worktrees: [{ worktreeId: 'host-workspace', repo: '/repo', displayName: 'Workspace' }]
}
}
}
return new Promise((resolve) =>
finishCatalog.push(() =>
resolve({
ok: true,
result: { grants: [{ ...grant, method: 'git.status' }] }
})
)
)
})
const { client } = createMobileWebBridgeRoundtripFixture({
grants: MOBILE_WEB_PRODUCTION_GRANTS,
rpcClient: { sendRequest } as unknown as RpcClient
})
const snapshot = await client.workspaceSnapshot({ limit: 10 })
const payload = { workspaceId: snapshot.workspaces[0]!.id, limit: 10 }
for (let i = 0; i < 4; i++) {
const controller = new AbortController()
const pending = client.sourceControlStatus(payload, { signal: controller.signal })
const rejection = expect(pending).rejects.toMatchObject({ code: 'cancelled' })
controller.abort()
await rejection
}
await expect(client.sourceControlStatus(payload)).rejects.toMatchObject({
code: 'rate_limited'
})
expect(finishCatalog).toHaveLength(4)
finishCatalog.forEach((finish) => finish())
})
it.each([true, false])(
'renders status with catalog availability %s',
async (catalogAvailable) => {
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockImplementation(async (method) => {
if (method === 'worktree.ps') {
return {
ok: true,
result: {
worktrees: [{ worktreeId: 'host-workspace', repo: '/repo', displayName: 'Workspace' }]
}
}
}
if (method === 'mobileWeb.host.catalog') {
return catalogAvailable
? { ok: true, result: { grants: [{ ...grant, method: 'git.status' }] } }
: { ok: false, error: { code: 'method_not_found', message: 'unavailable' } }
}
return { ok: true, result: { entries: [], conflictOperation: 'unknown', branch: 'main' } }
})
const { client, pageMessages } = createMobileWebBridgeRoundtripFixture({
grants: MOBILE_WEB_PRODUCTION_GRANTS,
rpcClient: { sendRequest } as unknown as RpcClient
})
const snapshot = await client.workspaceSnapshot({ limit: 10 })
const workspaceId = snapshot.workspaces[0]!.id
await expect(client.sourceControlStatus({ workspaceId, limit: 10 })).resolves.toMatchObject({
workspaceId,
branch: 'main',
entries: []
})
expect(
pageMessages.some(
(message) => message.type === 'request' && message.operation === 'hostRequest'
)
).toBe(true)
expect(
pageMessages.some((message) => message.type === 'request' && message.operation === 'status')
).toBe(!catalogAvailable)
}
)
})
@@ -0,0 +1,66 @@
import {
MobileWebHostCatalogPayloadSchema,
MobileWebHostCatalogResultSchema,
MobileWebHostRequestPayloadSchema,
mobileWebHostPayloadWithinBounds
} from '../../../src/shared/mobile-web/host-rpc-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error'
import { mobileWebEncodedByteLength } from './mobile-web-request-accounting'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
export async function readMobileWebHostCatalog(client: RpcClient, input: unknown) {
const payload = MobileWebHostCatalogPayloadSchema.parse(input)
const response = await client.sendRequest('mobileWeb.host.catalog', payload)
if (!response.ok) {
throw mobileWebBrokerHostRpcError(response.error)
}
if (!mobileWebHostPayloadWithinBounds(response.result)) {
throw new MobileWebBrokerError('too_large')
}
return MobileWebHostCatalogResultSchema.parse(response.result)
}
export async function executeMobileWebHostRequest(args: {
client: RpcClient
authority: MobileWebWorkspaceAuthority
payload: unknown
isActive: () => boolean
}): Promise<unknown> {
const payload = MobileWebHostRequestPayloadSchema.parse(args.payload)
if (!mobileWebHostPayloadWithinBounds(payload.params)) {
throw new MobileWebBrokerError('too_large')
}
const hostWorkspaceId = args.authority.hostWorkspaceId(payload.workspaceId)
const catalog = await readMobileWebHostCatalog(args.client, { methods: [payload.method] })
const grant = catalog.grants.find((entry) => entry.method === payload.method)
if (!grant) {
throw new MobileWebBrokerError('unsupported_capability')
}
if (!args.isActive()) {
throw new MobileWebBrokerError('cancelled')
}
args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId)
const params = { ...payload.params, [grant.workspaceParam]: `id:${hostWorkspaceId}` }
if (
!mobileWebHostPayloadWithinBounds(params) ||
mobileWebEncodedByteLength(params) > grant.maxRequestBytes
) {
throw new MobileWebBrokerError('too_large')
}
const response = await args.client.sendRequest(payload.method, params)
if (!response.ok) {
throw mobileWebBrokerHostRpcError(response.error)
}
if (!args.isActive()) {
throw new MobileWebBrokerError('cancelled')
}
args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId)
if (
!mobileWebHostPayloadWithinBounds(response.result) ||
mobileWebEncodedByteLength(response.result) > grant.maxResponseBytes
) {
throw new MobileWebBrokerError('too_large')
}
return response.result
}
@@ -19,6 +19,7 @@ const REAUTHORIZATION_SITES: Record<string, number> = {
'mobile-web-agent-history-resume.ts': 1,
'mobile-web-file-operations.ts': 1,
'mobile-web-file-write.ts': 1,
'mobile-web-host-requests.ts': 2,
'mobile-web-markdown-operations.ts': 2,
'mobile-web-native-chat-binding.ts': 1,
'mobile-web-provider-review-creation.ts': 2,
@@ -102,6 +103,13 @@ function shellSources(): Map<string, string> {
}
function dispatchModules(sources: Map<string, string>, operation: string): string[] {
// The generic arm delegates handle resolution to its bounded executor.
if (operation === 'hostRequest') {
expect(sources.get('mobile-web-capability-execution-arms.ts')).toContain(
'executeMobileWebHostRequest({'
)
return ['mobile-web-host-requests.ts']
}
const patterns = [
new RegExp(`operation === '${operation}'`),
new RegExp(`case '${operation}':`),
@@ -15,6 +15,8 @@ export type { MobileWebOperationGrant } from './mobile-web-production-grant-tabl
export const MOBILE_WEB_PRODUCTION_GRANTS = [
...capabilityGrants('workspace', {
hostCatalog: grantLimits(8 * 1024, 32 * 1024, 2, 8, 2),
hostRequest: grantLimits(600 * 1024, 600 * 1024, 4, 12, 4),
snapshot: grantLimits(1 * 1024, 128 * 1024, 2, 4, 1),
repositories: grantLimits(256, 128 * 1024, 2, 4, 1),
subscribe: grantLimits(256, 1 * 1024, 1, 4, 1),
@@ -77,3 +77,8 @@ export function mobileWebPendingRequestForSubscription(
}
return null
}
// Native alerts outlive client churn and explicit cancels; the OS dialog owns the resolution.
export function mobileWebRequestSurvivesCancellation(pending: { operationKey: string }): boolean {
return pending.operationKey === 'native.alert'
}
@@ -12,6 +12,7 @@ import {
import { MobileWebBridgeSubscriptionClient } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-client'
import type { MobileWebBridgeSubscriptionSetup } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-setup'
import { MobileWebOneShotRequestClient } from '../../../src/mobile-web/src/mobile-web-one-shot-request-client'
import { MobileWebHostResultSchema } from '../../../src/shared/mobile-web/host-rpc-contract'
import { tolerantMobileWebShellPayload } from '../../../src/shared/mobile-web/shell-payload-tolerance'
import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants'
@@ -69,6 +70,10 @@ describe('mobile web shell response schema corpus', () => {
it('rejects invalid success payloads through every one-shot result schema', async () => {
let caseCount = 0
for (const { name, schema } of resultSchemas) {
// Domain payloads on the generic lane are interpreted by the matching hosted page.
if (schema === MobileWebHostResultSchema) {
continue
}
const rejected = RESPONSE_PAYLOAD_CORPUS.filter((payload) => pageRejects(schema, payload))
expect(rejected.length, name).toBeGreaterThanOrEqual(8)
for (const payload of rejected) {
@@ -79,6 +84,15 @@ describe('mobile web shell response schema corpus', () => {
expect(caseCount).toBeGreaterThan(1_200)
})
it('keeps generic host domain results opaque to the shell contract', () => {
expect(resultSchemas.filter(({ schema }) => schema === MobileWebHostResultSchema)).toHaveLength(
1
)
for (const payload of RESPONSE_PAYLOAD_CORPUS) {
expect(MobileWebHostResultSchema.parse(payload)).toEqual(payload)
}
})
it('retires every subscription after an invalid event payload', async () => {
let caseCount = 0
for (const { name, schema } of eventSchemas) {
@@ -1,159 +1,4 @@
import { Buffer } from 'buffer/'
import { sha256 } from '@noble/hashes/sha256'
import { buildMobileWebSourceControlDiffPage } from '../../../src/shared/mobile-web/source-control-diff-page'
import {
MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS,
MobileWebSourceControlDiffResultSchema,
MobileWebSourceControlStatusEntrySchema,
MobileWebSourceControlStatusResultSchema,
type MobileWebSourceControlDiffPayload,
type MobileWebSourceControlDiffResult,
type MobileWebSourceControlStatusEntry,
type MobileWebSourceControlStatusResult
} from '../../../src/shared/mobile-web/source-control-operation-contract'
import { MobileWebBrokerError } from './mobile-web-broker-error'
export function sanitizeMobileWebSourceControlStatus(
result: unknown,
workspaceId: string,
limit: number
): MobileWebSourceControlStatusResult {
if (!isRecord(result) || !Array.isArray(result.entries)) {
throw new MobileWebBrokerError('host_error')
}
const entries = result.entries.slice(0, limit).flatMap((candidate) => {
const entry = sanitizeStatusEntry(candidate)
return entry ? [entry] : []
})
const reportedTotal = safeNonnegativeInteger(result.statusLength)
const totalCount = Math.max(entries.length, reportedTotal ?? result.entries.length)
const branch = boundedNonemptyString(result.branch, 240)
const head =
typeof result.head === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(result.head)
? result.head
: undefined
return MobileWebSourceControlStatusResultSchema.parse({
workspaceId,
...(branch ? { branch } : {}),
...(head ? { head } : {}),
conflictOperation: readConflictOperation(result.conflictOperation),
entries,
totalCount,
truncated:
result.didHitLimit === true ||
result.entries.length > limit ||
entries.length < Math.min(result.entries.length, limit) ||
totalCount > entries.length
})
}
export function sanitizeMobileWebSourceControlDiff(
result: unknown,
payload: MobileWebSourceControlDiffPayload
): MobileWebSourceControlDiffResult {
const identity = {
workspaceId: payload.workspaceId,
relativePath: payload.relativePath,
area: payload.area
}
if (!isRecord(result)) {
throw new MobileWebBrokerError('host_error')
}
if (result.kind === 'binary') {
return MobileWebSourceControlDiffResultSchema.parse({ ...identity, kind: 'binary' })
}
if (result.kind === 'too-large' || hostLimitedDiff(result)) {
return MobileWebSourceControlDiffResultSchema.parse({
...identity,
kind: 'too-large',
reason: 'host-limit',
...(hostDiffCharacterCount(result) === undefined
? {}
: { characterCount: hostDiffCharacterCount(result) })
})
}
if (
result.kind !== 'text' ||
typeof result.originalContent !== 'string' ||
typeof result.modifiedContent !== 'string'
) {
throw new MobileWebBrokerError('host_error')
}
const characterCount = result.originalContent.length + result.modifiedContent.length
const revision =
characterCount > MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS
? '0'.repeat(64)
: diffRevision(result.originalContent, result.modifiedContent)
if (payload.expectedRevision && revision !== payload.expectedRevision) {
throw new MobileWebBrokerError('conflict')
}
return MobileWebSourceControlDiffResultSchema.parse(
buildMobileWebSourceControlDiffPage({
...identity,
revision,
originalContent: result.originalContent,
modifiedContent: result.modifiedContent,
offset: payload.offset,
limit: payload.limit
})
)
}
function sanitizeStatusEntry(candidate: unknown): MobileWebSourceControlStatusEntry | null {
if (!isRecord(candidate)) {
return null
}
const parsed = MobileWebSourceControlStatusEntrySchema.safeParse({
relativePath: candidate.path,
...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }),
status: candidate.status,
area: candidate.area,
...(candidate.conflictStatus === undefined ? {} : { conflictStatus: candidate.conflictStatus }),
...(safeNonnegativeInteger(candidate.added) === undefined ? {} : { added: candidate.added }),
...(safeNonnegativeInteger(candidate.removed) === undefined
? {}
: { removed: candidate.removed })
})
return parsed.success ? parsed.data : null
}
function diffRevision(originalContent: string, modifiedContent: string): string {
const digest = sha256.create()
digest.update(new TextEncoder().encode(originalContent))
digest.update(Uint8Array.of(0))
digest.update(new TextEncoder().encode(modifiedContent))
return Buffer.from(digest.digest()).toString('hex')
}
function hostLimitedDiff(result: Record<string, unknown>): boolean {
return isRecord(result.largeDiffRenderLimit) && result.largeDiffRenderLimit.limited === true
}
function hostDiffCharacterCount(result: Record<string, unknown>): number | undefined {
if (result.kind === 'too-large') {
return (
safeNonnegativeInteger(result.characterCount) ?? safeNonnegativeInteger(result.byteLength)
)
}
return isRecord(result.largeDiffRenderLimit)
? safeNonnegativeInteger(result.largeDiffRenderLimit.characterCount)
: undefined
}
function readConflictOperation(value: unknown): 'merge' | 'rebase' | 'cherry-pick' | 'unknown' {
return value === 'merge' || value === 'rebase' || value === 'cherry-pick' ? value : 'unknown'
}
function safeNonnegativeInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
function boundedNonemptyString(value: unknown, limit: number): string | undefined {
return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export {
sanitizeMobileWebSourceControlStatus,
sanitizeMobileWebSourceControlDiff
} from '../../../src/shared/mobile-web/source-control-host-presentation'
+2
View File
@@ -45,6 +45,7 @@ import { UPDATER_METHODS } from './updater'
import { AGENT_SESSION_METHODS } from './agent-session'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import { ARTIFACT_METHODS } from './artifacts'
import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog'
import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package'
import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged'
import { AGENT_HOOK_METHODS } from './agent-hooks'
@@ -101,5 +102,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...EMULATOR_METHODS,
...PAIRING_METHODS,
...UPDATER_METHODS,
MOBILE_WEB_HOST_CATALOG_METHOD,
...MOBILE_WEB_PACKAGE_METHODS
]
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog'
import type { RpcContext } from '../core'
import { ALL_RPC_METHODS } from './index'
describe('mobile web host catalog', () => {
it('advertises only page-safe registered methods and never credential operations', () => {
const result = MOBILE_WEB_HOST_CATALOG_METHOD.handler(
{
methods: ['git.status', 'git.diff', 'git.status', 'pairing.getEndpoints', 'future.unknown']
},
{} as RpcContext
)
expect(result).toEqual({
grants: ['git.status', 'git.diff'].map((method) => ({
method,
workspaceParam: 'worktree',
maxRequestBytes: 16 * 1024,
maxResponseBytes: 512 * 1024
}))
})
for (const method of ['git.status', 'git.diff']) {
expect(ALL_RPC_METHODS.some((entry) => entry.name === method)).toBe(true)
}
})
})
@@ -0,0 +1,26 @@
import { MobileWebHostCatalogPayloadSchema } from '../../../../shared/mobile-web/host-rpc-contract'
import { defineMethod } from '../core'
// Only page-safe results belong here; transport credentials never enter this catalog.
const PAGE_METHODS = new Map(
['git.status', 'git.diff'].map((method) => [
method,
{
method,
workspaceParam: 'worktree',
maxRequestBytes: 16 * 1024,
maxResponseBytes: 512 * 1024
}
])
)
export const MOBILE_WEB_HOST_CATALOG_METHOD = defineMethod({
name: 'mobileWeb.host.catalog',
params: MobileWebHostCatalogPayloadSchema,
handler: ({ methods }) => ({
grants: [...new Set(methods)].flatMap((method) => {
const grant = PAGE_METHODS.get(method)
return grant ? [grant] : []
})
})
})
@@ -179,6 +179,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'markdown.saveTab',
'mobileWeb.package.asset',
'mobileWeb.package.asset.gzip',
'mobileWeb.host.catalog',
'mobileWeb.package.manifest',
'notifications.getMissedSince',
'notifications.subscribe',
@@ -0,0 +1,40 @@
import {
MobileWebHostCatalogPayloadSchema,
MobileWebHostCatalogResultSchema,
MobileWebHostRequestPayloadSchema,
MobileWebHostResultSchema
} from '../../shared/mobile-web/host-rpc-contract'
import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export function requestMobileWebHost(
requests: MobileWebOneShotRequestClient,
method: string,
workspaceId: string,
params: Record<string, unknown>,
options?: MobileWebBridgeRequestOptions
): Promise<unknown> {
return requests.request(
'workspace',
'hostRequest',
{ method, workspaceId, params },
MobileWebHostRequestPayloadSchema,
MobileWebHostResultSchema,
options
)
}
export function readMobileWebHostMethods(
requests: MobileWebOneShotRequestClient,
methods: string[],
options?: MobileWebBridgeRequestOptions
) {
return requests.request(
'workspace',
'hostCatalog',
{ methods },
MobileWebHostCatalogPayloadSchema,
MobileWebHostCatalogResultSchema,
options
)
}
@@ -35,6 +35,13 @@ export class MobileWebOneShotRequestClient {
}
) {}
supports(
capability: MobileWebBridgeCapability,
operation: MobileWebBridgeOperationName
): boolean {
return this.options.getGrant(capability, operation) !== undefined
}
request<TCapability extends MobileWebBridgeCapability, TPayload, TResult>(
capability: TCapability,
operation: MobileWebBridgeOperationName<TCapability>,
@@ -0,0 +1,123 @@
import {
MobileWebSourceControlDiffPayloadSchema,
MobileWebSourceControlDiffResultSchema,
MobileWebSourceControlStatusPayloadSchema,
MobileWebSourceControlStatusResultSchema,
type MobileWebSourceControlDiffPayload,
type MobileWebSourceControlDiffResult,
type MobileWebSourceControlStatusPayload,
type MobileWebSourceControlStatusResult
} from '../../shared/mobile-web/source-control-operation-contract'
import {
sanitizeMobileWebSourceControlStatus,
sanitizeMobileWebSourceControlDiff
} from '../../shared/mobile-web/source-control-host-presentation'
import { requestMobileWebHost } from './mobile-web-host-request-client'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { requireEchoedWorkspaceId } from './mobile-web-result-echo'
import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export class MobileWebSourceControlReadClient {
constructor(protected readonly requests: MobileWebOneShotRequestClient) {}
status(
payload: MobileWebSourceControlStatusPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebSourceControlStatusResult> {
const legacy = () =>
this.requests
.request(
'sourceControl',
'status',
payload,
MobileWebSourceControlStatusPayloadSchema,
MobileWebSourceControlStatusResultSchema,
options
)
.then((result) => {
if (result.entries.length > payload.limit) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return requireEchoedWorkspaceId(payload.workspaceId, result)
})
if (
this.requests.supports('workspace', 'hostRequest') &&
MobileWebSourceControlStatusPayloadSchema.safeParse(payload).success
) {
return requestMobileWebHost(
this.requests,
'git.status',
payload.workspaceId,
{ reuseLineStats: true },
options
)
.then((result) =>
sanitizeMobileWebSourceControlStatus(result, payload.workspaceId, payload.limit)
)
.catch((error: unknown) => {
if (
error instanceof MobileWebBridgeClientError &&
(error.code === 'unsupported_capability' || error.code === 'too_large')
) {
return legacy()
}
throw error
})
}
return legacy()
}
diff(
payload: MobileWebSourceControlDiffPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebSourceControlDiffResult> {
const legacy = () =>
this.requests
.request(
'sourceControl',
'diff',
payload,
MobileWebSourceControlDiffPayloadSchema,
MobileWebSourceControlDiffResultSchema,
options
)
.then((result) => {
if (
result.relativePath !== payload.relativePath ||
result.area !== payload.area ||
(result.kind === 'text' &&
(result.offset !== payload.offset ||
result.rows.length > payload.limit ||
(payload.expectedRevision !== undefined &&
result.revision !== payload.expectedRevision)))
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return requireEchoedWorkspaceId(payload.workspaceId, result)
})
if (
this.requests.supports('workspace', 'hostRequest') &&
MobileWebSourceControlDiffPayloadSchema.safeParse(payload).success
) {
return requestMobileWebHost(
this.requests,
'git.diff',
payload.workspaceId,
{ filePath: payload.relativePath, staged: payload.area === 'staged' },
options
)
.then((result) => sanitizeMobileWebSourceControlDiff(result, payload))
.catch((error: unknown) => {
if (
error instanceof MobileWebBridgeClientError &&
(error.code === 'unsupported_capability' || error.code === 'too_large')
) {
return legacy()
}
throw error
})
}
return legacy()
}
}
@@ -42,74 +42,12 @@ import {
type MobileWebSourceControlStagePayload,
type MobileWebSourceControlUnstagePayload
} from '../../shared/mobile-web/source-control-mutation-contract'
import {
MobileWebSourceControlDiffPayloadSchema,
MobileWebSourceControlDiffResultSchema,
MobileWebSourceControlStatusPayloadSchema,
MobileWebSourceControlStatusResultSchema,
type MobileWebSourceControlDiffPayload,
type MobileWebSourceControlDiffResult,
type MobileWebSourceControlStatusPayload,
type MobileWebSourceControlStatusResult
} from '../../shared/mobile-web/source-control-operation-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
import { requireEchoedWorkspaceId } from './mobile-web-result-echo'
import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state'
import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client'
export class MobileWebSourceControlRequestClient {
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
status(
payload: MobileWebSourceControlStatusPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebSourceControlStatusResult> {
return this.requests
.request(
'sourceControl',
'status',
payload,
MobileWebSourceControlStatusPayloadSchema,
MobileWebSourceControlStatusResultSchema,
options
)
.then((result) => {
if (result.entries.length > payload.limit) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return requireEchoedWorkspaceId(payload.workspaceId, result)
})
}
diff(
payload: MobileWebSourceControlDiffPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebSourceControlDiffResult> {
return this.requests
.request(
'sourceControl',
'diff',
payload,
MobileWebSourceControlDiffPayloadSchema,
MobileWebSourceControlDiffResultSchema,
options
)
.then((result) => {
if (
result.relativePath !== payload.relativePath ||
result.area !== payload.area ||
(result.kind === 'text' &&
(result.offset !== payload.offset ||
result.rows.length > payload.limit ||
(payload.expectedRevision !== undefined &&
result.revision !== payload.expectedRevision)))
) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return requireEchoedWorkspaceId(payload.workspaceId, result)
})
}
import { MobileWebSourceControlReadClient } from './mobile-web-source-control-read-request-client'
export class MobileWebSourceControlRequestClient extends MobileWebSourceControlReadClient {
branches(
payload: MobileWebSourceControlBranchesPayload,
options?: MobileWebBridgeRequestOptions
@@ -0,0 +1,7 @@
import type { MobileWebBridgeErrorCode } from './bridge-contract'
export class MobileWebBrokerError extends Error {
constructor(readonly code: MobileWebBridgeErrorCode) {
super(code)
}
}
@@ -6,6 +6,8 @@ export type MobileWebBridgeOperationKind = 'read' | 'mutation' | 'subscription'
export const MOBILE_WEB_BRIDGE_OPERATIONS = {
workspace: {
hostCatalog: 'read',
hostRequest: 'mutation',
snapshot: 'read',
repositories: 'read',
subscribe: 'subscription',
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
MobileWebHostRequestPayloadSchema,
mobileWebHostPayloadWithinBounds
} from './host-rpc-contract'
describe('generic host payload transport', () => {
it('preserves unknown domain fields but rejects unknown envelope fields', () => {
const payload = {
method: 'future.read',
workspaceId: 'opaque',
params: { future: { kind: 'new' } }
}
expect(MobileWebHostRequestPayloadSchema.parse(payload)).toEqual(payload)
expect(
MobileWebHostRequestPayloadSchema.safeParse({ ...payload, nativeAuthority: true }).success
).toBe(false)
})
it('bounds depth, node count and encoded bytes independently of domain shape', () => {
let nested: unknown = null
for (let i = 0; i < 34; i++) {
nested = { nested }
}
expect(mobileWebHostPayloadWithinBounds(nested)).toBe(false)
expect(mobileWebHostPayloadWithinBounds(Array(40_001).fill(null))).toBe(false)
expect(mobileWebHostPayloadWithinBounds('é'.repeat(310 * 1024))).toBe(false)
expect(mobileWebHostPayloadWithinBounds({ future: [{ value: true }] })).toBe(true)
})
})
@@ -0,0 +1,67 @@
import { z } from 'zod'
import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from './bridge-limits'
const MethodSchema = z
.string()
.min(1)
.max(160)
.regex(/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+$/)
export const MobileWebHostCatalogPayloadSchema = z
.object({ methods: z.array(MethodSchema).min(1).max(32) })
.strict()
export const MobileWebHostRequestPayloadSchema = z
.object({
method: MethodSchema,
workspaceId: z.string().min(1).max(160),
params: z.record(z.string(), z.unknown())
})
.strict()
export const MobileWebHostGrantSchema = z.object({
method: MethodSchema,
workspaceParam: z
.string()
.min(1)
.max(80)
.regex(/^[A-Za-z][A-Za-z0-9]*$/),
maxRequestBytes: z.number().int().positive(),
maxResponseBytes: z.number().int().positive()
})
export const MobileWebHostCatalogResultSchema = z.object({
grants: z.array(MobileWebHostGrantSchema).max(32)
})
export const MobileWebHostResultSchema = z.unknown()
export type MobileWebHostGrant = z.infer<typeof MobileWebHostGrantSchema>
export type MobileWebHostRequestPayload = z.infer<typeof MobileWebHostRequestPayloadSchema>
export function mobileWebHostPayloadWithinBounds(value: unknown): boolean {
const pending = [{ value, depth: 0 }]
let nodes = 0
while (pending.length > 0) {
const entry = pending.pop()!
if (++nodes > 40_000 || entry.depth > 32) {
return false
}
if (entry.value !== null && typeof entry.value === 'object') {
for (const child of Object.values(entry.value)) {
pending.push({ value: child, depth: entry.depth + 1 })
if (pending.length > 40_000) {
return false
}
}
} else if (
entry.value !== null &&
!['string', 'boolean', 'number'].includes(typeof entry.value)
) {
return false
}
}
return (
new TextEncoder().encode(JSON.stringify(value)).byteLength <=
MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES
)
}
@@ -0,0 +1,157 @@
import { sha256 } from '../sha256'
import { buildMobileWebSourceControlDiffPage } from './source-control-diff-page'
import {
MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS,
MobileWebSourceControlDiffResultSchema,
MobileWebSourceControlStatusEntrySchema,
MobileWebSourceControlStatusResultSchema,
type MobileWebSourceControlDiffPayload,
type MobileWebSourceControlDiffResult,
type MobileWebSourceControlStatusEntry,
type MobileWebSourceControlStatusResult
} from './source-control-operation-contract'
import { MobileWebBrokerError } from './bridge-operation-error'
export function sanitizeMobileWebSourceControlStatus(
result: unknown,
workspaceId: string,
limit: number
): MobileWebSourceControlStatusResult {
if (!isRecord(result) || !Array.isArray(result.entries)) {
throw new MobileWebBrokerError('host_error')
}
const entries = result.entries.slice(0, limit).flatMap((candidate) => {
const entry = sanitizeStatusEntry(candidate)
return entry ? [entry] : []
})
const reportedTotal = safeNonnegativeInteger(result.statusLength)
const totalCount = Math.max(entries.length, reportedTotal ?? result.entries.length)
const branch = boundedNonemptyString(result.branch, 240)
const head =
typeof result.head === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(result.head)
? result.head
: undefined
return MobileWebSourceControlStatusResultSchema.parse({
workspaceId,
...(branch ? { branch } : {}),
...(head ? { head } : {}),
conflictOperation: readConflictOperation(result.conflictOperation),
entries,
totalCount,
truncated:
result.didHitLimit === true ||
result.entries.length > limit ||
entries.length < Math.min(result.entries.length, limit) ||
totalCount > entries.length
})
}
export function sanitizeMobileWebSourceControlDiff(
result: unknown,
payload: MobileWebSourceControlDiffPayload
): MobileWebSourceControlDiffResult {
const identity = {
workspaceId: payload.workspaceId,
relativePath: payload.relativePath,
area: payload.area
}
if (!isRecord(result)) {
throw new MobileWebBrokerError('host_error')
}
if (result.kind === 'binary') {
return MobileWebSourceControlDiffResultSchema.parse({ ...identity, kind: 'binary' })
}
if (result.kind === 'too-large' || hostLimitedDiff(result)) {
return MobileWebSourceControlDiffResultSchema.parse({
...identity,
kind: 'too-large',
reason: 'host-limit',
...(hostDiffCharacterCount(result) === undefined
? {}
: { characterCount: hostDiffCharacterCount(result) })
})
}
if (
result.kind !== 'text' ||
typeof result.originalContent !== 'string' ||
typeof result.modifiedContent !== 'string'
) {
throw new MobileWebBrokerError('host_error')
}
const characterCount = result.originalContent.length + result.modifiedContent.length
const revision =
characterCount > MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS
? '0'.repeat(64)
: diffRevision(result.originalContent, result.modifiedContent)
if (payload.expectedRevision && revision !== payload.expectedRevision) {
throw new MobileWebBrokerError('conflict')
}
return MobileWebSourceControlDiffResultSchema.parse(
buildMobileWebSourceControlDiffPage({
...identity,
revision,
originalContent: result.originalContent,
modifiedContent: result.modifiedContent,
offset: payload.offset,
limit: payload.limit
})
)
}
function sanitizeStatusEntry(candidate: unknown): MobileWebSourceControlStatusEntry | null {
if (!isRecord(candidate)) {
return null
}
const parsed = MobileWebSourceControlStatusEntrySchema.safeParse({
relativePath: candidate.path,
...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }),
status: candidate.status,
area: candidate.area,
...(candidate.conflictStatus === undefined ? {} : { conflictStatus: candidate.conflictStatus }),
...(safeNonnegativeInteger(candidate.added) === undefined ? {} : { added: candidate.added }),
...(safeNonnegativeInteger(candidate.removed) === undefined
? {}
: { removed: candidate.removed })
})
return parsed.success ? parsed.data : null
}
function diffRevision(originalContent: string, modifiedContent: string): string {
return Array.from(
sha256(new TextEncoder().encode(`${originalContent}\0${modifiedContent}`)),
(byte) => byte.toString(16).padStart(2, '0')
).join('')
}
function hostLimitedDiff(result: Record<string, unknown>): boolean {
return isRecord(result.largeDiffRenderLimit) && result.largeDiffRenderLimit.limited === true
}
function hostDiffCharacterCount(result: Record<string, unknown>): number | undefined {
if (result.kind === 'too-large') {
return (
safeNonnegativeInteger(result.characterCount) ?? safeNonnegativeInteger(result.byteLength)
)
}
return isRecord(result.largeDiffRenderLimit)
? safeNonnegativeInteger(result.largeDiffRenderLimit.characterCount)
: undefined
}
function readConflictOperation(value: unknown): 'merge' | 'rebase' | 'cherry-pick' | 'unknown' {
return value === 'merge' || value === 'rebase' || value === 'cherry-pick' ? value : 'unknown'
}
function safeNonnegativeInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
}
function boundedNonemptyString(value: unknown, limit: number): string | undefined {
return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}