From 8ae9db2a03b510f83a61aeb97006973da60c4307 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Mon, 3 Aug 2026 00:35:28 +0800 Subject: [PATCH] fix(hub,cli): read absolute paths via runner RPC (macOS TCC) with machine fallback; absolute-path preview route in web --- cli/src/modules/common/handlers/files.ts | 29 +++++++++++++ hub/src/sync/rpcGateway.ts | 4 ++ hub/src/sync/syncEngine.ts | 4 ++ hub/src/web/middleware/auth.ts | 2 +- hub/src/web/routes/git.ts | 55 ++++++++++++++++++++++++ shared/src/rpcMethods.ts | 1 + web/src/router.tsx | 52 ++++++++++++++++++++++ 7 files changed, 146 insertions(+), 1 deletion(-) diff --git a/cli/src/modules/common/handlers/files.ts b/cli/src/modules/common/handlers/files.ts index 0e3d28ba..0829a0e5 100644 --- a/cli/src/modules/common/handlers/files.ts +++ b/cli/src/modules/common/handlers/files.ts @@ -15,6 +15,10 @@ interface ReadFileRequest { type ReadFileResponse = FileReadResponse +interface ReadAbsoluteFileRequest { + path: string +} + interface ReadGeneratedImageRequest { 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(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(RPC_METHODS.ReadGeneratedImage, async (data) => { logger.debug('Read generated image request:', data.id) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index f2c8280f..d84bba93 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -271,6 +271,10 @@ export class RpcGateway { return await this.sessionRpc(sessionId, RPC_METHODS.ReadFile, { path }) as RpcReadFileResponse } + async readAbsoluteFileForMachine(machineId: string, path: string): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ReadAbsoluteFile, { path }) as RpcReadFileResponse + } + async readGeneratedImage(sessionId: string, imageId: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ReadGeneratedImage, { id: imageId }) as RpcGeneratedImageResponse } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index ee4c65a0..ee92a545 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -2056,6 +2056,10 @@ async uploadScratchlistAttachment( return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds) } + async readAbsoluteFileForMachine(machineId: string, path: string): Promise { + return await this.rpcGateway.readAbsoluteFileForMachine(machineId, path) + } + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId) } diff --git a/hub/src/web/middleware/auth.ts b/hub/src/web/middleware/auth.ts index dfeb2b5b..6d843323 100644 --- a/hub/src/web/middleware/auth.ts +++ b/hub/src/web/middleware/auth.ts @@ -24,7 +24,7 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler SyncEngine | null): Hono { + 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) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 9c461a72..ae6d18c3 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -15,6 +15,7 @@ export const RPC_METHODS = { GitDiffNumstat: 'git-diff-numstat', GitDiffFile: 'git-diff-file', ReadFile: 'readFile', + ReadAbsoluteFile: 'readAbsoluteFile', ReadGeneratedImage: 'readGeneratedImage', WriteFile: 'writeFile', ListDirectory: 'listDirectory', diff --git a/web/src/router.tsx b/web/src/router.tsx index 19cb63de..085f9091 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -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 ( +
+ {t('file.page.missingPath')} +
+ ) + } + + return ( +
+
+ +
+
{pathname.split('/').pop()}
+
{pathname}
+
+
+ {previewUrl ? ( +