refactor(page): parse the file list with its schema instead of a sanitizer

The desktop wrapper already types and redacts the search result; the page
strips unknown fields with the contract schema like readDir does, and the
last shell-era sanitizer module goes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 18:13:36 -04:00
parent efe3fe4b99
commit 2c58cbeb7f
3 changed files with 17 additions and 50 deletions
@@ -165,7 +165,7 @@ describe('page-safe file listing and text', () => {
}
})
respond({
files: [{ relativePath: 'docs/report.md', kind: 'text' }],
files: [{ relativePath: 'docs/report.md', basename: 'report.md', kind: 'text' }],
totalCount: 1,
truncated: false,
futureField: true
@@ -1,9 +1,9 @@
import { z } from 'zod'
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,
MobileWebFileListResultSchema,
MobileWebFileOpenPayloadSchema,
MobileWebFileReadPayloadSchema,
MobileWebFileSearchPayloadSchema,
@@ -47,6 +47,19 @@ const WriteResultSchema = z.union([
z.object({ outcome: z.enum(['conflict', 'too_large']) })
])
// The desktop already types and redacts the list; the page only strips fields it does not know.
function projectListResult(
result: unknown,
workspaceId: string,
limit: number
): MobileWebFileListResult {
const parsed = MobileWebFileListResultSchema.omit({ workspaceId: true }).strip().safeParse(result)
if (!parsed.success || parsed.data.files.length > limit) {
throw new MobileWebBridgeClientError('invalid_message', false)
}
return { ...parsed.data, workspaceId }
}
export class MobileWebFileRequestClient extends MobileWebFileReadClient {
list(
payload: MobileWebFileListPayload,
@@ -59,7 +72,7 @@ export class MobileWebFileRequestClient extends MobileWebFileReadClient {
'mobileWeb.files.searchPaths',
payload.workspaceId,
{ query: '', limit: payload.limit },
(result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit),
(result) => projectListResult(result, payload.workspaceId, payload.limit),
options
)
}
@@ -75,7 +88,7 @@ export class MobileWebFileRequestClient extends MobileWebFileReadClient {
'mobileWeb.files.searchPaths',
payload.workspaceId,
{ query: payload.query, limit: payload.limit },
(result) => sanitizeListResult(result, payload.workspaceId, undefined, payload.limit),
(result) => projectListResult(result, payload.workspaceId, payload.limit),
options
)
}
@@ -1,46 +0,0 @@
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)
}