mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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<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) => {
|
||||
logger.debug('Read generated image request:', data.id)
|
||||
|
||||
|
||||
@@ -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<RpcReadFileResponse> {
|
||||
return await this.machineRpc(machineId, RPC_METHODS.ReadAbsoluteFile, { path }) as RpcReadFileResponse
|
||||
}
|
||||
|
||||
async readGeneratedImage(sessionId: string, imageId: string): Promise<RpcGeneratedImageResponse> {
|
||||
return await this.sessionRpc(sessionId, RPC_METHODS.ReadGeneratedImage, { id: imageId }) as RpcGeneratedImageResponse
|
||||
}
|
||||
|
||||
@@ -2056,6 +2056,10 @@ async uploadScratchlistAttachment(
|
||||
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> {
|
||||
return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function createAuthMiddleware(jwtSecret: Uint8Array): MiddlewareHandler<W
|
||||
|
||||
const authorization = c.req.header('authorization')
|
||||
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
|
||||
: undefined
|
||||
const token = tokenFromHeader ?? tokenFromQuery
|
||||
|
||||
@@ -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) => {
|
||||
const engine = requireSyncEngine(c, getSyncEngine)
|
||||
if (engine instanceof Response) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -886,6 +937,7 @@ function BrowsePage() {
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: App,
|
||||
notFoundComponent: AbsolutePathFilePreview,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
|
||||
Reference in New Issue
Block a user