feat(mobile-web): serve page-safe file searches and text reads from desktop

Reuse host file methods behind Desktop-owned identity redaction and move list/text presentation into the hosted page. Preserve older shells and Desktop versions through legacy fallback, without changing the generic bridge contract.
This commit is contained in:
Jinwoo-H
2026-09-06 17:35:23 -04:00
parent a8bbed52da
commit 31024ff031
12 changed files with 356 additions and 75 deletions
@@ -21,6 +21,7 @@ pages; no protocol or manifest bump is planned.
- [x] First complete generic unary slice: `9910fccc298`.
Desktop catalog, opaque workspace binding, source-control status/diff,
page-side presentation, legacy fallback, hard payload and concurrency bounds.
- [x] Directory and binary chunk reads use generic forwarding: `a8bbed52da4`.
- [ ] Complete the generic bridge and migrate remaining domain consumers.
- [ ] Complete iOS end-to-end evidence and Android final smoke check.
@@ -157,8 +158,11 @@ pnpm test src/shared/mobile-web src/mobile-web src/main/runtime/rpc
pnpm run build:mobile-web
```
Run mobile tests separately from the web export: a previous overlapping run
failed React Native resolution while an isolated rerun passed. Build the terminal
Run mobile tests and simulator Metro separately from the web export. The root
`build:mobile-web-rnw` script runs `pnpm --dir mobile install --frozen-lockfile`,
which replaces dependency directories and can invalidate a live Metro resolver.
A previous overlapping mobile test run also failed React Native resolution while
an isolated rerun passed. Build the terminal
WebView engine if mobile typechecking needs it. Run Kotlin unit tests with the
configured JDK 17/Android SDK; prebuild Android when required. Run native Swift
store tests when native package/CSP behavior changes. Format only changed files
@@ -187,3 +191,24 @@ with `pnpm exec oxfmt --write`.
Exact command tails: `/tmp/orca-ota-e2e/file-gates/`.
- iOS retry reached native compilation but failed at React-RCTFabric
`RCTFabricSurface.mm`; capturing full compiler diagnostics before proceeding.
- iOS native build now passes after regenerating Pods and replacing stale derived
compiler caches. The old derived data is preserved at
`/tmp/orca-ota-e2e/stale-ios-derived-data`; no dependency source patches needed.
Full successful log: `/tmp/orca-ota-e2e/ios-native-build-clean.log`.
- Running the existing iOS adversarial-content journey (which includes source
control) on that build, with `--skip-native-build`, in
`/tmp/orca-ota-e2e/ios-journey`. Pairing runtime started and Metro is loading.
- In progress: file list/search and text reads via Desktop privacy adapters
`mobileWeb.files.searchPaths` / `mobileWeb.files.read`. They reuse existing
host methods and remove private workspace/root fields before forwarding.
Future result fields remain available to the page; native request contract
stays unchanged. Focused tests pass; full gates running.
- File list/search/text migration passes every required gate: mobile 831 files /
5,493 tests; root 317 files / 2,699 tests. Page build:
`8143c37da629651d2e672268423846e8d44fbf33e90637f4436791b4a33e7a53`,
52 assets / 9,688,231 bytes. Logs: `/tmp/orca-ota-e2e/file-text-gates/`.
- First iOS hosted run paired successfully, then page export's dependency
reinstall invalidated the live Metro resolver (`InitializeCore` not found).
Retired that launcher; rerun after all builds, with no concurrent install/export.
@@ -1,18 +1,16 @@
import { sanitizeListResult } from '../../../src/shared/mobile-web/file-list-presentation'
import { Buffer } from 'buffer/'
import {
MOBILE_WEB_FILE_CONTENT_MAX_BYTES,
MobileWebFileChunkPayloadSchema,
MobileWebFileDirectoryPayloadSchema,
MobileWebFileEntrySchema,
MobileWebFileListPayloadSchema,
MobileWebFileListResultSchema,
MobileWebFileOpenPayloadSchema,
MobileWebFileReadPayloadSchema,
MobileWebFileReadResultSchema,
MobileWebFileSearchPayloadSchema,
type MobileWebFileChunkWireResult,
type MobileWebFileDirectoryResult,
type MobileWebFileEntry,
type MobileWebFileListResult,
type MobileWebFileReadWireResult
} from '../../../src/shared/mobile-web/bridge-operation-contract'
@@ -143,41 +141,6 @@ async function listFiles(
return sanitizeListResult(response.result, pageWorkspaceId, hostWorkspaceId, limit)
}
function sanitizeListResult(
result: unknown,
pageWorkspaceId: string,
hostWorkspaceId: string,
limit: number
): MobileWebFileListResult {
if (!isRecord(result) || result.worktree !== hostWorkspaceId || !Array.isArray(result.files)) {
throw new MobileWebBrokerError('host_error')
}
const files = result.files.slice(0, limit).flatMap((value): MobileWebFileEntry[] => {
if (!isRecord(value) || typeof value.relativePath !== 'string') {
return []
}
const parsed = MobileWebFileEntrySchema.safeParse({
relativePath: value.relativePath,
basename: value.relativePath.split('/').at(-1)?.slice(0, 255),
kind: value.kind === 'binary' ? 'binary' : 'text'
})
return parsed.success ? [parsed.data] : []
})
const totalCount =
typeof result.totalCount === 'number' &&
Number.isSafeInteger(result.totalCount) &&
result.totalCount >= 0
? result.totalCount
: result.files.length
return MobileWebFileListResultSchema.parse({
workspaceId: pageWorkspaceId,
files,
totalCount,
truncated:
result.truncated === true || result.files.length > files.length || totalCount > files.length
})
}
function sanitizeReadResult(
result: unknown,
pageWorkspaceId: string,
+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_FILE_READ_METHODS } from './mobile-web-file-reads'
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'
@@ -103,5 +104,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...PAIRING_METHODS,
...UPDATER_METHODS,
MOBILE_WEB_HOST_CATALOG_METHOD,
...MOBILE_WEB_FILE_READ_METHODS,
...MOBILE_WEB_PACKAGE_METHODS
]
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MOBILE_WEB_FILE_READ_METHODS } from './mobile-web-file-reads'
import { FILE_METHODS } from './files'
import type { RpcContext } from '../core'
afterEach(() => vi.restoreAllMocks())
describe('page-safe host file reads', () => {
it.each(['searchPaths', 'read'])(
'reuses files.%s while keeping host identity off the page',
async (operation) => {
const source = FILE_METHODS.find((method) => method.name === `files.${operation}`)!
const method = MOBILE_WEB_FILE_READ_METHODS.find(
(entry) => entry.name === `mobileWeb.files.${operation}`
)!
const handler = vi.spyOn(source, 'handler').mockResolvedValue({
worktree: 'host-workspace-id',
rootPath: '/private/host/repository',
relativePath: 'docs/readme.md',
content: 'hello',
files: [],
futureField: { kind: 'new-domain-shape' }
})
const context = { signal: new AbortController().signal } as RpcContext
const params = {
worktree: 'id:host-workspace-id',
relativePath: 'docs/readme.md',
query: 'docs'
}
expect(method.params).toBe(source.params)
const result = await method.handler(params, context)
expect(handler).toHaveBeenCalledWith(params, context)
expect(result).toEqual({
relativePath: 'docs/readme.md',
content: 'hello',
files: [],
futureField: { kind: 'new-domain-shape' }
})
expect(JSON.stringify(result)).not.toMatch(/host-workspace-id|private\/host/)
}
)
it('preserves an execution-host failure instead of answering locally', async () => {
const source = FILE_METHODS.find((method) => method.name === 'files.read')!
vi.spyOn(source, 'handler').mockRejectedValue(new Error('SSH provider unavailable'))
const method = MOBILE_WEB_FILE_READ_METHODS.find(
(entry) => entry.name === 'mobileWeb.files.read'
)!
await expect(
method.handler({ worktree: 'id:remote', relativePath: 'a.txt' }, {} as RpcContext)
).rejects.toThrow('SSH provider unavailable')
})
})
@@ -0,0 +1,24 @@
import { defineMethod, isStreamingMethod } from '../core'
import { FILE_METHODS } from './files'
// Desktop owns identity redaction; installed shells need no file-result vocabulary.
export const MOBILE_WEB_FILE_READ_METHODS = ['searchPaths', 'read'].map((operation) => {
const source = FILE_METHODS.find((method) => method.name === `files.${operation}`)
if (!source || isStreamingMethod(source)) {
throw new Error(`Missing unary file method: ${operation}`)
}
return defineMethod({
name: `mobileWeb.files.${operation}`,
params: source.params,
handler: async (params, context) => {
const result = await source.handler(params, context)
if (typeof result !== 'object' || result === null || Array.isArray(result)) {
throw new Error('Invalid file read result')
}
const pageResult: Record<string, unknown> = { ...result }
delete pageResult.worktree
delete pageResult.rootPath
return pageResult
}
})
})
@@ -12,6 +12,8 @@ describe('mobile web host catalog', () => {
'git.diff',
'files.readDir',
'files.readChunk',
'mobileWeb.files.searchPaths',
'mobileWeb.files.read',
'git.status',
'pairing.getEndpoints',
'files.searchPaths',
@@ -21,14 +23,28 @@ describe('mobile web host catalog', () => {
{} as RpcContext
)
expect(result).toEqual({
grants: ['git.status', 'git.diff', 'files.readDir', 'files.readChunk'].map((method) => ({
grants: [
'git.status',
'git.diff',
'files.readDir',
'files.readChunk',
'mobileWeb.files.searchPaths',
'mobileWeb.files.read'
].map((method) => ({
method,
workspaceParam: 'worktree',
maxRequestBytes: 16 * 1024,
maxResponseBytes: 512 * 1024
}))
})
for (const method of ['git.status', 'git.diff', 'files.readDir', 'files.readChunk']) {
for (const method of [
'git.status',
'git.diff',
'files.readDir',
'files.readChunk',
'mobileWeb.files.searchPaths',
'mobileWeb.files.read'
]) {
expect(ALL_RPC_METHODS.some((entry) => entry.name === method)).toBe(true)
}
})
@@ -3,7 +3,14 @@ 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', 'files.readDir', 'files.readChunk'].map((method) => [
[
'git.status',
'git.diff',
'files.readDir',
'files.readChunk',
'mobileWeb.files.searchPaths',
'mobileWeb.files.read'
].map((method) => [
method,
{
method,
@@ -32,6 +32,9 @@ function fixture(generic = true) {
context,
grants: [
{ capability: 'file', operation: 'directory', limits },
{ capability: 'file', operation: 'list', limits },
{ capability: 'file', operation: 'search', limits },
{ capability: 'file', operation: 'read', limits },
{ capability: 'file', operation: 'readChunk', limits },
...(generic ? [{ capability: 'workspace' as const, operation: 'hostRequest', limits }] : [])
],
@@ -140,3 +143,75 @@ describe('page-owned generic file reads', () => {
client.dispose()
})
})
describe('page-safe file listing and text', () => {
it('searches through the host privacy adapter using an opaque workspace', async () => {
const { client, messages, respond } = fixture()
const result = client.fileSearch({
workspaceId: directory.workspaceId,
query: 'report',
limit: 10
})
expect(messages[0]).toMatchObject({
operation: 'hostRequest',
payload: {
method: 'mobileWeb.files.searchPaths',
workspaceId: directory.workspaceId,
params: { query: 'report', limit: 10 }
}
})
respond({
files: [{ relativePath: 'docs/report.md', kind: 'text' }],
totalCount: 1,
truncated: false,
futureField: true
})
await expect(result).resolves.toEqual({
workspaceId: directory.workspaceId,
files: [{ relativePath: 'docs/report.md', basename: 'report.md', kind: 'text' }],
totalCount: 1,
truncated: false
})
client.dispose()
})
it('reads Unicode content directly without a shell base64 projection', async () => {
const { client, messages, respond } = fixture()
const result = client.fileRead({
workspaceId: directory.workspaceId,
relativePath: 'report.txt'
})
expect(messages[0]).toMatchObject({
operation: 'hostRequest',
payload: { method: 'mobileWeb.files.read' }
})
const content = '\uFEFFhello 🌍'
const byteLength = new TextEncoder().encode(content).byteLength
respond({
relativePath: 'report.txt',
content,
truncated: false,
byteLength,
futureField: 'new'
})
await expect(result).resolves.toEqual({
workspaceId: directory.workspaceId,
relativePath: 'report.txt',
content,
truncated: false,
byteLength
})
client.dispose()
})
it('rejects inconsistent content size', async () => {
const { client, respond } = fixture()
const result = client.fileRead({
workspaceId: directory.workspaceId,
relativePath: 'report.txt'
})
respond({ relativePath: 'report.txt', content: '🌍', truncated: false, byteLength: 1 })
await expect(result).rejects.toMatchObject({ code: 'invalid_message' })
client.dispose()
})
})
@@ -90,7 +90,7 @@ export class MobileWebFileReadClient {
)
}
private readHost<T>(
protected readHost<T>(
method: string,
workspaceId: string,
params: Record<string, unknown>,
@@ -1,3 +1,5 @@
import { sanitizeListResult } from '../../shared/mobile-web/file-list-presentation'
import { projectMobileWebHostFileContent } from './mobile-web-host-file-content'
import {
MOBILE_WEB_FILE_CHUNK_MAX_BYTES,
MobileWebFileListPayloadSchema,
@@ -46,49 +48,85 @@ export class MobileWebFileRequestClient extends MobileWebFileReadClient {
payload: MobileWebFileListPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebFileListResult> {
return this.requests
.request(
'file',
'list',
payload,
MobileWebFileListPayloadSchema,
MobileWebFileListResultSchema,
options
)
.then((result) => matchingFileList(payload, result))
const legacy = () =>
this.requests
.request(
'file',
'list',
payload,
MobileWebFileListPayloadSchema,
MobileWebFileListResultSchema,
options
)
.then((result) => matchingFileList(payload, result))
if (!MobileWebFileListPayloadSchema.safeParse(payload).success) {
return legacy()
}
return this.readHost(
'mobileWeb.files.searchPaths',
payload.workspaceId,
{ query: '', limit: payload.limit },
(result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit),
legacy,
options
)
}
search(
payload: MobileWebFileSearchPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebFileListResult> {
return this.requests
.request(
'file',
'search',
payload,
MobileWebFileSearchPayloadSchema,
MobileWebFileListResultSchema,
options
)
.then((result) => matchingFileList(payload, result))
const legacy = () =>
this.requests
.request(
'file',
'search',
payload,
MobileWebFileSearchPayloadSchema,
MobileWebFileListResultSchema,
options
)
.then((result) => matchingFileList(payload, result))
if (!MobileWebFileSearchPayloadSchema.safeParse(payload).success) {
return legacy()
}
return this.readHost(
'mobileWeb.files.searchPaths',
payload.workspaceId,
{ query: payload.query, limit: payload.limit },
(result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit),
legacy,
options
)
}
read(
payload: MobileWebFileReadPayload,
options?: MobileWebBridgeRequestOptions
): Promise<MobileWebFileReadResult> {
return this.requests
.request(
'file',
'read',
payload,
MobileWebFileReadPayloadSchema,
MobileWebFileReadResultSchema,
options
)
.then(decodeMobileWebFileContent)
.then((result) => matchingFile(payload, result))
const legacy = () =>
this.requests
.request(
'file',
'read',
payload,
MobileWebFileReadPayloadSchema,
MobileWebFileReadResultSchema,
options
)
.then(decodeMobileWebFileContent)
.then((result) => matchingFile(payload, result))
if (!MobileWebFileReadPayloadSchema.safeParse(payload).success) {
return legacy()
}
return this.readHost(
'mobileWeb.files.read',
payload.workspaceId,
{ relativePath: payload.relativePath },
(result) => projectMobileWebHostFileContent(result, payload),
legacy,
options
)
}
open(payload: MobileWebFileOpenPayload, options?: MobileWebBridgeRequestOptions): Promise<null> {
@@ -0,0 +1,32 @@
import { z } from 'zod'
import {
MOBILE_WEB_FILE_CONTENT_MAX_BYTES,
type MobileWebFileReadPayload,
type MobileWebFileReadResult
} from '../../shared/mobile-web/file-operation-contract'
import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error'
const HostContentSchema = z.object({
relativePath: z.string(),
content: z.string(),
truncated: z.boolean(),
byteLength: z.number().int().nonnegative()
})
export function projectMobileWebHostFileContent(
result: unknown,
payload: MobileWebFileReadPayload
): MobileWebFileReadResult {
const parsed = HostContentSchema.safeParse(result)
if (!parsed.success || parsed.data.relativePath !== payload.relativePath) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
const bytes = new TextEncoder().encode(parsed.data.content)
if (bytes.byteLength > parsed.data.byteLength) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
if (bytes.byteLength > MOBILE_WEB_FILE_CONTENT_MAX_BYTES) {
throw new MobileWebBridgeClientError('too_large', false)
}
return { ...parsed.data, workspaceId: payload.workspaceId }
}
@@ -0,0 +1,46 @@
import {
MobileWebFileEntrySchema,
MobileWebFileListResultSchema,
type MobileWebFileEntry,
type MobileWebFileListResult
} from './file-operation-contract'
import { MobileWebBrokerError } from './bridge-operation-error'
export function sanitizeListResult(
result: unknown,
pageWorkspaceId: string,
hostWorkspaceId: string | undefined,
limit: number
): MobileWebFileListResult {
if (!isRecord(result) || result.worktree !== hostWorkspaceId || !Array.isArray(result.files)) {
throw new MobileWebBrokerError('host_error')
}
const files = result.files.slice(0, limit).flatMap((value): MobileWebFileEntry[] => {
if (!isRecord(value) || typeof value.relativePath !== 'string') {
return []
}
const parsed = MobileWebFileEntrySchema.safeParse({
relativePath: value.relativePath,
basename: value.relativePath.split('/').at(-1)?.slice(0, 255),
kind: value.kind === 'binary' ? 'binary' : 'text'
})
return parsed.success ? [parsed.data] : []
})
const totalCount =
typeof result.totalCount === 'number' &&
Number.isSafeInteger(result.totalCount) &&
result.totalCount >= 0
? result.totalCount
: result.files.length
return MobileWebFileListResultSchema.parse({
workspaceId: pageWorkspaceId,
files,
totalCount,
truncated:
result.truncated === true || result.files.length > files.length || totalCount > files.length
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}