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
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user