fix(hub,cli): read absolute paths via runner RPC (macOS TCC) with machine fallback; absolute-path preview route in web

This commit is contained in:
2026-08-03 00:35:28 +08:00
parent 53588c5aeb
commit 8ae9db2a03
7 changed files with 146 additions and 1 deletions
+29
View File
@@ -15,6 +15,10 @@ interface ReadFileRequest {
type ReadFileResponse = FileReadResponse type ReadFileResponse = FileReadResponse
interface ReadAbsoluteFileRequest {
path: string
}
interface ReadGeneratedImageRequest { interface ReadGeneratedImageRequest {
id: string id: string
} }
@@ -53,6 +57,31 @@ export function registerFileHandlers(rpcHandlerManager: RpcHandlerManager, worki
} }
}) })
// Absolute-path file read for the web UI's raw preview endpoint. The
// runner process has the user's terminal file permissions (unlike a
// launchd-spawned hub, which macOS TCC can block from protected folders
// such as Documents).
rpcHandlerManager.registerHandler<ReadAbsoluteFileRequest, ReadFileResponse>(RPC_METHODS.ReadAbsoluteFile, async (data) => {
logger.debug('Read absolute file request:', data.path)
if (!data.path.startsWith('/')) {
return rpcError('Path must be absolute')
}
try {
const fileStat = await stat(data.path)
if (!fileStat.isFile()) {
return rpcError('Not a file')
}
if (fileStat.size > 10 * 1024 * 1024) {
return rpcError('File too large to preview')
}
const buffer = await readFile(data.path)
return { success: true, content: buffer.toString('base64') }
} catch (error) {
logger.debug('Failed to read absolute file:', error)
return rpcError(getErrorMessage(error, 'Failed to read file'))
}
})
rpcHandlerManager.registerHandler<ReadGeneratedImageRequest, ReadGeneratedImageResponse>(RPC_METHODS.ReadGeneratedImage, async (data) => { rpcHandlerManager.registerHandler<ReadGeneratedImageRequest, ReadGeneratedImageResponse>(RPC_METHODS.ReadGeneratedImage, async (data) => {
logger.debug('Read generated image request:', data.id) logger.debug('Read generated image request:', data.id)
+4
View File
@@ -271,6 +271,10 @@ export class RpcGateway {
return await this.sessionRpc(sessionId, RPC_METHODS.ReadFile, { path }) as RpcReadFileResponse return await this.sessionRpc(sessionId, RPC_METHODS.ReadFile, { path }) as RpcReadFileResponse
} }
async readAbsoluteFileForMachine(machineId: string, path: string): Promise<RpcReadFileResponse> {
return await this.machineRpc(machineId, RPC_METHODS.ReadAbsoluteFile, { path }) as RpcReadFileResponse
}
async readGeneratedImage(sessionId: string, imageId: string): Promise<RpcGeneratedImageResponse> { async readGeneratedImage(sessionId: string, imageId: string): Promise<RpcGeneratedImageResponse> {
return await this.sessionRpc(sessionId, RPC_METHODS.ReadGeneratedImage, { id: imageId }) as RpcGeneratedImageResponse return await this.sessionRpc(sessionId, RPC_METHODS.ReadGeneratedImage, { id: imageId }) as RpcGeneratedImageResponse
} }
+4
View File
@@ -2056,6 +2056,10 @@ async uploadScratchlistAttachment(
return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds) return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds)
} }
async readAbsoluteFileForMachine(machineId: string, path: string): Promise<import('./rpcGateway').RpcReadFileResponse> {
return await this.rpcGateway.readAbsoluteFileForMachine(machineId, path)
}
async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> { async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> {
return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId) return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId)
} }
+1 -1
View File
@@ -24,7 +24,7 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler<W
const authorization = c.req.header('authorization') const authorization = c.req.header('authorization')
const tokenFromHeader = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined const tokenFromHeader = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined
const tokenFromQuery = (path === '/api/events' || path.endsWith('/file/raw')) const tokenFromQuery = (path === '/api/events' || path === '/api/files/raw' || path.endsWith('/file/raw'))
? c.req.query().token ? c.req.query().token
: undefined : undefined
const token = tokenFromHeader ?? tokenFromQuery const token = tokenFromHeader ?? tokenFromQuery
+55
View File
@@ -195,6 +195,61 @@ export function createGitRoutes(getSyncEngine: () => SyncEngine | null): Hono<We
}) })
}) })
// Raw file bytes by absolute path, for previewing files the agent links
// as plain hub URLs (e.g. http://host/Users/.../index.html). Requires the
// JWT query token. Reading is delegated to the machine (runner) RPC so it
// works under macOS TCC (the hub process itself may be blocked from
// protected folders such as Documents).
app.get('/files/raw', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) {
return engine
}
const parsed = filePathSchema.safeParse(c.req.query())
if (!parsed.success) {
return c.json({ error: 'Invalid file path' }, 400)
}
const filePath = parsed.data.path
if (!filePath.startsWith('/')) {
return c.json({ error: 'Path must be absolute' }, 400)
}
const machines = engine.getMachines()
const requestedMachineId = c.req.query('machineId')
const candidates = [
...(requestedMachineId ? machines.filter((m) => m.id === requestedMachineId) : []),
...machines.filter((m) => m.active),
...machines
]
if (candidates.length === 0) {
return c.json({ error: 'No online machine available' }, 503)
}
// Try machines in order — a stale machine entry (e.g. an old runner
// whose process is gone) can respond with "handler not registered",
// so fall through to the next candidate.
let lastError: string | null = null
for (const machine of candidates) {
try {
const result = await runRpc(() => engine.readAbsoluteFileForMachine(machine.id, filePath))
if (result.success && typeof result.content === 'string') {
const bytes = Uint8Array.from(Buffer.from(result.content, 'base64'))
const isHtml = /\.html?$/i.test(filePath)
return c.body(bytes, 200, {
'Content-Type': isHtml ? 'text/html; charset=utf-8' : 'application/octet-stream',
'Content-Disposition': 'inline',
'Cache-Control': 'private, max-age=60'
})
}
lastError = result.error ?? 'Failed to read file'
} catch (error) {
lastError = error instanceof Error ? error.message : 'Failed to read file'
}
}
return c.json({ success: false, error: lastError ?? 'Failed to read file' }, 404)
})
app.get('/sessions/:id/generated-images/:imageId', async (c) => { app.get('/sessions/:id/generated-images/:imageId', async (c) => {
const engine = requireSyncEngine(c, getSyncEngine) const engine = requireSyncEngine(c, getSyncEngine)
if (engine instanceof Response) { if (engine instanceof Response) {
+1
View File
@@ -15,6 +15,7 @@ export const RPC_METHODS = {
GitDiffNumstat: 'git-diff-numstat', GitDiffNumstat: 'git-diff-numstat',
GitDiffFile: 'git-diff-file', GitDiffFile: 'git-diff-file',
ReadFile: 'readFile', ReadFile: 'readFile',
ReadAbsoluteFile: 'readAbsoluteFile',
ReadGeneratedImage: 'readGeneratedImage', ReadGeneratedImage: 'readGeneratedImage',
WriteFile: 'writeFile', WriteFile: 'writeFile',
ListDirectory: 'listDirectory', ListDirectory: 'listDirectory',
+52
View File
@@ -84,6 +84,57 @@ function BackIcon(props: { className?: string }) {
) )
} }
/**
* Preview page for absolute file paths opened directly against the hub
* (e.g. an agent links http://host/Users/.../index.html). The app boots via
* the SPA fallback, this route renders the file through the raw endpoint.
*/
function AbsolutePathFilePreview() {
const { t } = useTranslation()
const { baseUrl, token } = useAppContext()
const navigate = useNavigate()
const location = useLocation()
const pathname = decodeURIComponent(location.pathname)
const isAbsoluteFilePath = /^\/(Users|home|private|tmp|opt|var|srv|mnt|data|root)\//.test(pathname)
const previewUrl = token
? `${baseUrl}/api/files/raw?path=${encodeURIComponent(pathname)}&token=${encodeURIComponent(token)}`
: null
if (!isAbsoluteFilePath) {
return (
<div className="flex h-full items-center justify-center p-8 text-sm text-[var(--app-hint)]">
{t('file.page.missingPath')}
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col bg-[var(--app-bg)]">
<div className="flex items-center gap-2 border-b border-[var(--app-divider)] p-3 pt-[calc(0.75rem+env(safe-area-inset-top))]">
<button
type="button"
onClick={() => navigate({ to: '/sessions' })}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
>
<BackIcon />
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold">{pathname.split('/').pop()}</div>
<div className="truncate text-xs text-[var(--app-hint)]">{pathname}</div>
</div>
</div>
{previewUrl ? (
<iframe
src={previewUrl}
title={pathname}
sandbox="allow-scripts"
className="min-h-0 w-full flex-1 bg-white"
/>
) : null}
</div>
)
}
function PlusIcon(props: { className?: string }) { function PlusIcon(props: { className?: string }) {
return ( return (
<svg <svg
@@ -886,6 +937,7 @@ function BrowsePage() {
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: App, component: App,
notFoundComponent: AbsolutePathFilePreview,
}) })
const indexRoute = createRoute({ const indexRoute = createRoute({