diff --git a/cli/README.md b/cli/README.md index fdacc873..668c1644 100644 --- a/cli/README.md +++ b/cli/README.md @@ -118,6 +118,8 @@ Both `start` and `start-sync` accept repeatable `--workspace-root ` (or `- - The web `/browse` page surfaces scoped file trees rooted at those paths. - The runner refuses `list-directory` and `spawn-session` requests for paths outside the configured roots. +- The runner can serve read-only file, Git, and ripgrep requests for inactive + sessions through its machine-scoped `workspace-file-access` capability. - `~` and `~/foo` are expanded. Omitting the flag keeps manual session spawning unrestricted and leaves the diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index 3ac471e7..1ae310a9 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -569,7 +569,7 @@ describe('buildMachineMetadata runner-only capabilities', () => { it('advertises capabilities and supervisedRestart only for asRunner', () => { process.env.HAPI_RUNNER_SUPERVISED = '1' const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 }) - expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner'])) + expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner', 'workspace-file-access'])) expect(metadata.startedCliMtimeMs).toBe(42) expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000) expect(metadata.supervisedRestart).toBe(true) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index c066ccc6..1acc85a8 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -29,6 +29,7 @@ import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' +import { registerWorkspaceFileHandlers } from '../modules/common/workspaceFileHandlers' import { setAgyCatalogChangeListener } from '../modules/common/agyModels' import { listOpencodeModelsForCwd, @@ -133,6 +134,10 @@ export class ApiMachineClient { }) registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) + registerWorkspaceFileHandlers(this.rpcHandlerManager, { + resolveForCheck: (path) => this.pathPolicy.resolveForCheck(path), + isWithinSpawnRoots: (path) => this.pathPolicy.isWithinSpawnRoots(path), + }) // Only the machine daemon answers `:listAgyModels`, so it is // the one process that can tell the hub its catalog moved. diff --git a/cli/src/modules/common/generatedImages.ts b/cli/src/modules/common/generatedImages.ts index 9ad33178..bc048d07 100644 --- a/cli/src/modules/common/generatedImages.ts +++ b/cli/src/modules/common/generatedImages.ts @@ -1,5 +1,6 @@ import { basename, extname } from 'node:path' import { fileURLToPath } from 'node:url' +import { constants } from 'node:fs' import { open } from 'node:fs/promises' import { randomUUID } from 'node:crypto' import { asString, isObject } from '@hapi/protocol' @@ -8,10 +9,14 @@ import { asString, isObject } from '@hapi/protocol' * Read a regular file through one open descriptor: size is taken from the fd * (not a separate pathname `lstat`), then the buffer is allocated to that size * only. Rejects if the file grows/shrinks while reading so a TOCTOU swap cannot - * force an unbounded `readFile` allocation. + * force an unbounded `readFile` allocation. Non-blocking open prevents a FIFO + * or other special file from hanging the caller before its type can be checked. */ export async function readBoundedRegularFile(path: string, maxBytes: number): Promise { - const handle = await open(path, 'r') + const flags = process.platform === 'win32' + ? 'r' + : constants.O_RDONLY | constants.O_NONBLOCK + const handle = await open(path, flags) try { const info = await handle.stat() if (!info.isFile()) { diff --git a/cli/src/modules/common/handlers/git.ts b/cli/src/modules/common/handlers/git.ts index cc19c8cb..6a56cea6 100644 --- a/cli/src/modules/common/handlers/git.ts +++ b/cli/src/modules/common/handlers/git.ts @@ -45,7 +45,7 @@ function validateFilePath(filePath: string, workingDirectory: string): string | return null } -async function runGitCommand( +export async function runGitCommand( args: string[], cwd: string, timeout?: number diff --git a/cli/src/modules/common/workspaceFileHandlers.test.ts b/cli/src/modules/common/workspaceFileHandlers.test.ts new file mode 100644 index 00000000..cc95b4eb --- /dev/null +++ b/cli/src/modules/common/workspaceFileHandlers.test.ts @@ -0,0 +1,292 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { MachinePathPolicy } from '@/api/machinePathPolicy' +import { MAX_WORKSPACE_FILE_BYTES, registerWorkspaceFileHandlers, type WorkspaceFilePathPolicy } from './workspaceFileHandlers' + +const execFileAsync = promisify(execFile) + +async function callWorkspaceHandler( + rpc: RpcHandlerManager, + method: string, + params: unknown, +): Promise> { + const response = await rpc.handleRequest({ + method: `machine-test:${method}`, + params: JSON.stringify(params), + }) + return JSON.parse(response) as Record +} + +describe('workspace file RPC handlers', () => { + let rootDir: string + let rpc: RpcHandlerManager + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), 'hapi-workspace-file-handler-')) + await mkdir(join(rootDir, 'src')) + await writeFile(join(rootDir, 'src', 'index.ts'), 'export const answer = 42\n') + await writeFile(join(rootDir, '.env.example'), 'EXAMPLE=true\n') + + rpc = new RpcHandlerManager({ scopePrefix: 'machine-test' }) + const pathPolicy = new MachinePathPolicy({ workspaceRoots: [rootDir] }) + registerWorkspaceFileHandlers(rpc, pathPolicy) + }) + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }) + }) + + it('registers all read-only workspace operations on the machine scope', () => { + expect(rpc.hasHandler(RPC_METHODS.WorkspaceReadFile)).toBe(true) + expect(rpc.hasHandler(RPC_METHODS.WorkspaceListDirectory)).toBe(true) + expect(rpc.hasHandler(RPC_METHODS.WorkspaceStatFiles)).toBe(true) + expect(rpc.hasHandler(RPC_METHODS.WorkspaceGitStatus)).toBe(true) + expect(rpc.hasHandler(RPC_METHODS.WorkspaceGitDiffNumstat)).toBe(true) + expect(rpc.hasHandler(RPC_METHODS.WorkspaceGitDiffFile)).toBe(true) + expect(rpc.hasHandler(RPC_METHODS.WorkspaceRipgrep)).toBe(true) + }) + + it('lists, reads, and stats files beneath the requested workspace', async () => { + const list = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceListDirectory, { + cwd: rootDir, + path: '', + }) + expect(list.success).toBe(true) + expect((list.entries as Array<{ name: string }>).map((entry) => entry.name).sort()).toEqual([ + '.env.example', + 'src', + ]) + + const read = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: 'src/index.ts', + }) + expect(read).toMatchObject({ + success: true, + content: Buffer.from('export const answer = 42\n').toString('base64'), + size: 25, + }) + expect(read.modified).toEqual(expect.any(Number)) + + const stats = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceStatFiles, { + cwd: rootDir, + paths: ['src/index.ts', 'missing.ts'], + }) + expect(stats).toMatchObject({ + success: true, + entries: [ + { path: 'src/index.ts', size: 25 }, + { path: 'missing.ts' }, + ], + }) + }) + + it('rejects oversized and non-regular files before reading them', async () => { + const oversizedPath = join(rootDir, 'oversized.bin') + await writeFile(oversizedPath, '') + await truncate(oversizedPath, MAX_WORKSPACE_FILE_BYTES + 1) + + const oversized = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: 'oversized.bin', + }) + expect(oversized).toEqual({ success: false, error: 'File is too large to display inline' }) + + const directoryPath = join(rootDir, 'not-a-file') + await mkdir(directoryPath) + const directory = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: 'not-a-file', + }) + expect(directory).toEqual({ success: false, error: 'Path is not a regular file' }) + + if (process.platform !== 'win32') { + const fifoPath = join(rootDir, 'named-pipe') + await execFileAsync('mkfifo', [fifoPath]) + const fifo = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: 'named-pipe', + }) + expect(fifo).toEqual({ success: false, error: 'Path is not a regular file' }) + } + }) + + it('passes workspace paths to the runner policy without trimming them', async () => { + const checkedPaths: string[] = [] + const pathPolicy: WorkspaceFilePathPolicy = { + resolveForCheck: async (path) => { + checkedPaths.push(path) + return rootDir + }, + isWithinSpawnRoots: () => true, + } + rpc = new RpcHandlerManager({ scopePrefix: 'machine-test' }) + registerWorkspaceFileHandlers(rpc, pathPolicy) + + const workspacePath = `${rootDir} ` + const result = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceListDirectory, { + cwd: workspacePath, + path: '', + }) + + expect(result.success).toBe(true) + expect(checkedPaths[0]).toBe(workspacePath) + }) + + it('allows valid workspace paths beginning with two dots', async () => { + await writeFile(join(rootDir, '..notes.txt'), 'notes\n') + await mkdir(join(rootDir, '..assets')) + await writeFile(join(rootDir, '..assets', 'file.txt'), 'asset\n') + + const note = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: '..notes.txt', + }) + expect(note).toMatchObject({ + success: true, + content: Buffer.from('notes\n').toString('base64'), + }) + + const asset = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: '..assets/file.txt', + }) + expect(asset).toMatchObject({ + success: true, + content: Buffer.from('asset\n').toString('base64'), + }) + }) + + it('rejects workspace roots outside the runner allowlist', async () => { + const outsideDir = await mkdtemp(join(tmpdir(), 'hapi-workspace-file-outside-')) + try { + await writeFile(join(outsideDir, 'secret.txt'), 'secret') + const result = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: outsideDir, + path: 'secret.txt', + }) + expect(result).toEqual({ success: false, error: 'Path is outside workspace roots' }) + } finally { + await rm(outsideDir, { recursive: true, force: true }) + } + }) + + it('rejects file paths that escape the session workspace', async () => { + const outsideFile = join(tmpdir(), `hapi-workspace-file-secret-${Date.now()}.txt`) + await writeFile(outsideFile, 'secret') + try { + rpc = new RpcHandlerManager({ scopePrefix: 'machine-test' }) + registerWorkspaceFileHandlers(rpc, new MachinePathPolicy({ workspaceRoots: [tmpdir()] })) + const result = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceReadFile, { + cwd: rootDir, + path: `../${outsideFile.split(/[\\/]/).pop()}`, + }) + expect(result).toEqual({ success: false, error: 'Path is outside the session workspace' }) + } finally { + await rm(outsideFile, { force: true }) + } + }) + + it('runs Git and ripgrep against the requested workspace without a session CLI', async () => { + await execFileAsync('git', ['init', '-q'], { cwd: rootDir }) + await execFileAsync('git', ['add', '.'], { cwd: rootDir }) + await execFileAsync('git', [ + '-c', 'user.name=HAPI test', + '-c', 'user.email=hapi-test@example.invalid', + 'commit', '-qm', 'initial' + ], { cwd: rootDir }) + await writeFile(join(rootDir, 'src', 'index.ts'), 'export const answer = 43\n') + + const status = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitStatus, { cwd: rootDir }) + expect(status).toMatchObject({ success: true }) + expect(status.stdout).toContain('src/index.ts') + + const diffNumstat = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitDiffNumstat, { + cwd: rootDir, + staged: false, + }) + expect(diffNumstat).toMatchObject({ success: true }) + expect(diffNumstat.stdout).toContain('src/index.ts') + + const diffFile = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitDiffFile, { + cwd: rootDir, + filePath: 'src/index.ts', + staged: false, + }) + expect(diffFile).toMatchObject({ success: true }) + expect(diffFile.stdout).toContain('diff --git') + + const ripgrep = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceRipgrep, { + cwd: rootDir, + args: ['--files'], + fileSearch: { query: 'src/*.ts', limit: 10 }, + }) + expect(ripgrep).toMatchObject({ success: true }) + expect(String(ripgrep.stdout).replaceAll('\\', '/')).toContain('src/index.ts') + }) + + it('keeps Git fallback operations inside a nested session workspace', async () => { + const workspaceDir = join(rootDir, 'nested-workspace') + await mkdir(workspaceDir) + await writeFile(join(rootDir, 'sibling.txt'), 'sibling initial\n') + await writeFile(join(workspaceDir, 'inside.txt'), 'inside initial\n') + + await execFileAsync('git', ['init', '-q'], { cwd: rootDir }) + await execFileAsync('git', ['add', '.'], { cwd: rootDir }) + await execFileAsync('git', [ + '-c', 'user.name=HAPI test', + '-c', 'user.email=hapi-test@example.invalid', + 'commit', '-qm', 'initial' + ], { cwd: rootDir }) + + await writeFile(join(rootDir, 'sibling.txt'), 'sibling changed\n') + await writeFile(join(workspaceDir, 'inside.txt'), 'inside changed\n') + + const status = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitStatus, { cwd: workspaceDir }) + expect(status).toMatchObject({ success: true }) + expect(String(status.stdout)).toContain('inside.txt') + expect(String(status.stdout)).not.toContain('sibling.txt') + + const unstagedNumstat = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitDiffNumstat, { + cwd: workspaceDir, + staged: false, + }) + expect(unstagedNumstat).toMatchObject({ success: true }) + expect(String(unstagedNumstat.stdout)).toContain('inside.txt') + expect(String(unstagedNumstat.stdout)).not.toContain('sibling.txt') + + await execFileAsync('git', ['add', join('nested-workspace', 'inside.txt')], { cwd: rootDir }) + await writeFile(join(workspaceDir, 'inside.txt'), 'inside staged and changed\n') + const stagedNumstat = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitDiffNumstat, { + cwd: workspaceDir, + staged: true, + }) + expect(stagedNumstat).toMatchObject({ success: true }) + expect(String(stagedNumstat.stdout)).toContain('inside.txt') + expect(String(stagedNumstat.stdout)).not.toContain('sibling.txt') + + const insideDiff = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitDiffFile, { + cwd: workspaceDir, + filePath: 'inside.txt', + staged: false, + }) + expect(insideDiff).toMatchObject({ success: true }) + expect(String(insideDiff.stdout)).toContain('inside staged and changed') + + for (const filePath of [':(top)sibling.txt', ':(exclude)sibling.txt']) { + const escapedDiff = await callWorkspaceHandler(rpc, RPC_METHODS.WorkspaceGitDiffFile, { + cwd: workspaceDir, + filePath, + staged: false, + }) + expect(escapedDiff).toMatchObject({ success: true }) + expect(String(escapedDiff.stdout)).not.toContain('sibling changed') + } + }) +}) diff --git a/cli/src/modules/common/workspaceFileHandlers.ts b/cli/src/modules/common/workspaceFileHandlers.ts new file mode 100644 index 00000000..a2860a44 --- /dev/null +++ b/cli/src/modules/common/workspaceFileHandlers.ts @@ -0,0 +1,342 @@ +import { readdir, stat } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { + CommandResponse, + DirectoryEntry, + FileReadResponse, + ListDirectoryResponse, + StatFilesResponse, +} from '@hapi/protocol/apiTypes' +import { RPC_METHODS } from '@hapi/protocol/rpcMethods' +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' +import { runGitCommand } from './handlers/git' +import { run as runRipgrep, runFileSearch, type FileSearchOptions } from '@/modules/ripgrep/index' +import { MAX_GENERATED_IMAGE_BYTES, readBoundedRegularFile } from './generatedImages' +import { getErrorMessage, rpcError } from './rpcResponses' + +/** + * The runner is the authority for resolving and enforcing workspace paths. + * Keeping this interface small lets the machine client own the policy while + * these handlers remain focused on read-only file operations. + */ +export type WorkspaceFilePathPolicy = { + resolveForCheck: (path: string) => Promise + isWithinSpawnRoots: (path: string) => boolean +} + +type WorkspaceReadFileRequest = { + cwd?: unknown + path?: unknown +} + +type WorkspaceListDirectoryRequest = { + cwd?: unknown + path?: unknown +} + +type WorkspaceStatFilesRequest = { + cwd?: unknown + paths?: unknown +} + +type WorkspaceGitStatusRequest = { + cwd?: unknown + timeout?: number +} + +type WorkspaceGitDiffNumstatRequest = { + cwd?: unknown + staged?: boolean + timeout?: number +} + +type WorkspaceGitDiffFileRequest = { + cwd?: unknown + filePath?: unknown + staged?: boolean + timeout?: number +} + +type WorkspaceRipgrepRequest = { + cwd?: unknown + args?: unknown + fileSearch?: FileSearchOptions +} + +type ResolvedWorkspaceCwd = { cwd: string } | { error: string } +type ResolvedWorkspacePath = { cwd: string; path: string } | { error: string } + +const OUTSIDE_WORKSPACE_ERROR = 'Path is outside workspace roots' +const OUTSIDE_SESSION_WORKSPACE_ERROR = 'Path is outside the session workspace' +export const MAX_WORKSPACE_FILE_BYTES = MAX_GENERATED_IMAGE_BYTES + +function isWithinRoot(root: string, target: string): boolean { + const child = relative(root, target) + return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child)) +} + +async function resolveWorkspaceCwd( + pathPolicy: WorkspaceFilePathPolicy, + value: unknown, +): Promise { + const raw = typeof value === 'string' ? value : '' + if (!raw) { + return { error: 'Workspace path is required' } + } + + const cwd = await pathPolicy.resolveForCheck(raw) + if (!pathPolicy.isWithinSpawnRoots(cwd)) { + return { error: OUTSIDE_WORKSPACE_ERROR } + } + + return { cwd } +} + +async function resolveWorkspacePath( + pathPolicy: WorkspaceFilePathPolicy, + cwdValue: unknown, + pathValue: unknown, + options?: { allowEmptyPath?: boolean }, +): Promise { + const cwd = await resolveWorkspaceCwd(pathPolicy, cwdValue) + if ('error' in cwd) { + return cwd + } + + const rawPath = typeof pathValue === 'string' ? pathValue : '' + if (!options?.allowEmptyPath && !rawPath) { + return { error: 'Path is required' } + } + + const path = await pathPolicy.resolveForCheck(resolve(cwd.cwd, rawPath || '.')) + if (!pathPolicy.isWithinSpawnRoots(path)) { + return { error: OUTSIDE_WORKSPACE_ERROR } + } + if (!isWithinRoot(cwd.cwd, path)) { + return { error: OUTSIDE_SESSION_WORKSPACE_ERROR } + } + + return { cwd: cwd.cwd, path } +} + +async function listDirectory(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }) + const directoryEntries: DirectoryEntry[] = await Promise.all( + entries.map(async (entry) => { + const fullPath = join(path, entry.name) + let type: DirectoryEntry['type'] = 'other' + let size: number | undefined + let modified: number | undefined + + if (entry.isDirectory()) { + type = 'directory' + } else if (entry.isFile()) { + type = 'file' + } + + if (!entry.isSymbolicLink()) { + try { + const stats = await stat(fullPath) + size = stats.size + modified = stats.mtime.getTime() + } catch { + // Keep the entry visible even when metadata is unavailable. + } + } + + return { name: entry.name, type, size, modified } + }), + ) + + directoryEntries.sort((left, right) => { + if (left.type === 'directory' && right.type !== 'directory') return -1 + if (left.type !== 'directory' && right.type === 'directory') return 1 + return left.name.localeCompare(right.name) + }) + + return { success: true, entries: directoryEntries } +} + +/** + * Register read-only workspace operations on the machine-scoped runner + * socket. These are deliberately separate from session-scoped RPCs so an + * inactive session can still inspect its current workspace while its CLI is + * no longer connected. + */ +export function registerWorkspaceFileHandlers( + rpcHandlerManager: RpcHandlerManager, + pathPolicy: WorkspaceFilePathPolicy, +): void { + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceReadFile, + async (data) => { + const resolved = await resolveWorkspacePath(pathPolicy, data.cwd, data.path) + if ('error' in resolved) { + return rpcError(resolved.error) + } + + try { + const stats = await stat(resolved.path) + if (!stats.isFile()) { + throw new Error('Path is not a regular file') + } + const buffer = await readBoundedRegularFile(resolved.path, MAX_WORKSPACE_FILE_BYTES) + return { + success: true, + content: buffer.toString('base64'), + size: stats.size, + modified: stats.mtime.getTime(), + } + } catch (error) { + return rpcError(getErrorMessage(error, 'Failed to read file')) + } + }, + ) + + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceListDirectory, + async (data) => { + const resolved = await resolveWorkspacePath(pathPolicy, data.cwd, data.path, { allowEmptyPath: true }) + if ('error' in resolved) { + return rpcError(resolved.error) + } + + try { + return await listDirectory(resolved.path) + } catch (error) { + return rpcError(getErrorMessage(error, 'Failed to list directory')) + } + }, + ) + + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceStatFiles, + async (data) => { + if (!Array.isArray(data.paths) || data.paths.length > 500) { + return rpcError('Invalid file paths') + } + if (!data.paths.every((path): path is string => typeof path === 'string')) { + return rpcError('Invalid file paths') + } + + const cwd = await resolveWorkspaceCwd(pathPolicy, data.cwd) + if ('error' in cwd) { + return rpcError(cwd.error) + } + + const resolvedPaths: Array<{ requested: string; path: string }> = [] + for (const requested of data.paths) { + const path = await pathPolicy.resolveForCheck(resolve(cwd.cwd, requested)) + if (!pathPolicy.isWithinSpawnRoots(path)) { + return rpcError(OUTSIDE_WORKSPACE_ERROR) + } + if (!isWithinRoot(cwd.cwd, path)) { + return rpcError(OUTSIDE_SESSION_WORKSPACE_ERROR) + } + resolvedPaths.push({ requested, path }) + } + + const entries = await Promise.all(resolvedPaths.map(async ({ requested, path }) => { + try { + const stats = await stat(path) + return { + path: requested, + size: stats.size, + modified: stats.mtime.getTime(), + } + } catch { + return { path: requested } + } + })) + + return { success: true, entries } + }, + ) + + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceGitStatus, + async (data) => { + const resolved = await resolveWorkspaceCwd(pathPolicy, data.cwd) + if ('error' in resolved) { + return rpcError(resolved.error) + } + + try { + return await runGitCommand( + ['status', '--porcelain=v2', '--branch', '--untracked-files=all', '--', '.'], + resolved.cwd, + data.timeout, + ) + } catch (error) { + return rpcError(getErrorMessage(error, 'Command failed')) + } + }, + ) + + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceGitDiffNumstat, + async (data) => { + const resolved = await resolveWorkspaceCwd(pathPolicy, data.cwd) + if ('error' in resolved) { + return rpcError(resolved.error) + } + + const args = data.staged + ? ['diff', '--cached', '--numstat', '--', '.'] + : ['diff', '--numstat', '--', '.'] + try { + return await runGitCommand(args, resolved.cwd, data.timeout) + } catch (error) { + return rpcError(getErrorMessage(error, 'Command failed')) + } + }, + ) + + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceGitDiffFile, + async (data) => { + const filePath = typeof data.filePath === 'string' ? data.filePath : '' + const resolved = await resolveWorkspacePath(pathPolicy, data.cwd, filePath) + if ('error' in resolved) { + return rpcError(resolved.error) + } + + const args = data.staged + ? ['--literal-pathspecs', 'diff', '--cached', '--no-ext-diff', '--', filePath] + : ['--literal-pathspecs', 'diff', '--no-ext-diff', '--', filePath] + try { + return await runGitCommand(args, resolved.cwd, data.timeout) + } catch (error) { + return rpcError(getErrorMessage(error, 'Command failed')) + } + }, + ) + + rpcHandlerManager.registerHandler( + RPC_METHODS.WorkspaceRipgrep, + async (data) => { + if (!Array.isArray(data.args) || !data.args.every((arg): arg is string => typeof arg === 'string')) { + return rpcError('Invalid ripgrep arguments') + } + + const resolved = await resolveWorkspaceCwd(pathPolicy, data.cwd) + if ('error' in resolved) { + return rpcError(resolved.error) + } + + try { + const result = data.fileSearch + ? await runFileSearch(data.args, { ...data.fileSearch, cwd: resolved.cwd }) + : await runRipgrep(data.args, { cwd: resolved.cwd }) + return { + success: true, + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + } + } catch (error) { + return rpcError(getErrorMessage(error, 'Failed to run ripgrep')) + } + }, + ) +} diff --git a/hub/README.md b/hub/README.md index 64d08a2b..3b0eb32c 100644 --- a/hub/README.md +++ b/hub/README.md @@ -157,6 +157,11 @@ for request/response shapes and error semantics, and `src/web/routes/` for all e - `GET /api/sessions/:id/file` - Read file content. - `GET /api/sessions/:id/files` - File search with ripgrep. +For inactive sessions, including archived sessions, these read-only file and Git operations fall back to +the matching online Runner when it advertises `workspace-file-access`. The +Runner reads the current workspace under its configured roots; HAPI does not +copy or snapshot files during archive. + ### Events (`src/web/routes/events.ts`) - `GET /api/events` - SSE stream for live updates. diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts index f45a14b1..028cda38 100644 --- a/hub/src/sync/rpcGateway.test.ts +++ b/hub/src/sync/rpcGateway.test.ts @@ -119,6 +119,45 @@ describe('RpcGateway RPC timeouts', () => { }) }]) }) + + it('uses machine-scoped RPCs for workspace file operations', async () => { + const { gateway, calls } = createGateway() + + await gateway.readWorkspaceFile('machine-1', { cwd: '/workspace/project', path: 'src/index.ts' }) + await gateway.listWorkspaceDirectory('machine-1', { cwd: '/workspace/project', path: 'src' }) + await gateway.statWorkspaceFiles('machine-1', { cwd: '/workspace/project', paths: ['src/index.ts'] }) + await gateway.getWorkspaceGitStatus('machine-1', { cwd: '/workspace/project' }) + await gateway.getWorkspaceGitDiffNumstat('machine-1', { cwd: '/workspace/project', staged: true }) + await gateway.getWorkspaceGitDiffFile('machine-1', { + cwd: '/workspace/project', + filePath: 'src/index.ts', + staged: false + }) + await gateway.runWorkspaceRipgrep('machine-1', { + args: ['--files'], + cwd: '/workspace/project', + fileSearch: { query: '*.ts', limit: 20 } + }) + + expect(calls.map((call) => call.method)).toEqual([ + 'machine-1:workspace-read-file', + 'machine-1:workspace-list-directory', + 'machine-1:workspace-stat-files', + 'machine-1:workspace-git-status', + 'machine-1:workspace-git-diff-numstat', + 'machine-1:workspace-git-diff-file', + 'machine-1:workspace-ripgrep' + ]) + expect(JSON.parse(calls[0].params)).toEqual({ + cwd: '/workspace/project', + path: 'src/index.ts' + }) + expect(JSON.parse(calls[5].params)).toEqual({ + cwd: '/workspace/project', + filePath: 'src/index.ts', + staged: false + }) + }) }) // tiann/hapi#916: rpcCall throws a typed `RpcTargetMissingError` when the diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index acd01af0..38f57600 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -38,7 +38,14 @@ import type { PiModelsResponse, SlashCommandsResponse, StatFilesResponse, - UploadFileResponse + UploadFileResponse, + WorkspaceReadFileRequest, + WorkspaceListDirectoryRequest, + WorkspaceStatFilesRequest, + WorkspaceGitStatusRequest, + WorkspaceGitDiffNumstatRequest, + WorkspaceGitDiffFileRequest, + WorkspaceRipgrepRequest } from '@hapi/protocol/apiTypes' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -422,10 +429,38 @@ export class RpcGateway { return await this.sessionRpc(sessionId, RPC_METHODS.DeleteUpload, { sessionId, path }) as RpcDeleteUploadResponse } + async readWorkspaceFile(machineId: string, request: WorkspaceReadFileRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceReadFile, request) as RpcReadFileResponse + } + + async listWorkspaceDirectory(machineId: string, request: WorkspaceListDirectoryRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceListDirectory, request) as RpcListDirectoryResponse + } + + async statWorkspaceFiles(machineId: string, request: WorkspaceStatFilesRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceStatFiles, request) as RpcStatFilesResponse + } + + async getWorkspaceGitStatus(machineId: string, request: WorkspaceGitStatusRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceGitStatus, request) as RpcCommandResponse + } + + async getWorkspaceGitDiffNumstat(machineId: string, request: WorkspaceGitDiffNumstatRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceGitDiffNumstat, request) as RpcCommandResponse + } + + async getWorkspaceGitDiffFile(machineId: string, request: WorkspaceGitDiffFileRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceGitDiffFile, request) as RpcCommandResponse + } + async runRipgrep(sessionId: string, args: string[], cwd?: string, fileSearch?: FileSearchOptions): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.Ripgrep, { args, cwd, fileSearch }) as RpcCommandResponse } + async runWorkspaceRipgrep(machineId: string, request: WorkspaceRipgrepRequest): Promise { + return await this.machineRpc(machineId, RPC_METHODS.WorkspaceRipgrep, request) as RpcCommandResponse + } + async listSlashCommands(sessionId: string, agent: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ListSlashCommands, { agent }) as SlashCommandsResponse } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 2e4a3087..d8c70adb 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import { isKnownFlavor, isLiveLifecycleState, isSteeringSupportedForSession, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' +import { isKnownFlavor, isLiveLifecycleState, isSteeringSupportedForSession, MACHINE_CAPABILITIES, type LocalResumeTarget, type ResumableSession, type SessionEndReason } from '@hapi/protocol' import { cliBinaryUpdatedOnDisk, isMachineCapabilitySkewed, @@ -320,6 +320,57 @@ export class SyncEngine { return null } + /** + * Inactive sessions no longer have a session-scoped CLI socket, but their + * recorded machine may still have the long-lived runner online. In that + * case the runner can serve the current workspace without reviving the + * agent or taking a snapshot. Keep this gate strict: only an inactive row + * with matching machine metadata and the new read-only capability may use + * the fallback. + */ + private getInactiveWorkspaceFallback(sessionId: string): { machineId: string; cwd: string } | null { + const session = this.sessionCache.getSession(sessionId) + const machineId = session?.metadata?.machineId + const cwd = session?.metadata?.path + if (!session || session.active || !machineId || !cwd) { + return null + } + + const machine = this.resolveOnlineMachineForSession(session, session.namespace, { strictMachineId: true }) + if (!machine?.metadata?.capabilities?.includes(MACHINE_CAPABILITIES.WorkspaceFileAccess)) { + return null + } + + return { machineId: machine.id, cwd } + } + + private async withInactiveWorkspaceFallback( + sessionId: string, + originalError: unknown, + action: (fallback: { machineId: string; cwd: string }) => Promise + ): Promise { + if (!(originalError instanceof RpcTargetMissingError)) { + throw originalError + } + + const fallback = this.getInactiveWorkspaceFallback(sessionId) + if (!fallback) { + throw originalError + } + + try { + return await action(fallback) + } catch (fallbackError) { + // An older runner may be online but not implement this capability + // yet. Preserve the original session-RPC diagnostic in that case; + // real fallback errors still propagate normally. + if (fallbackError instanceof RpcTargetMissingError) { + throw originalError + } + throw fallbackError + } + } + async getCursorChatStoreStatus(sessionId: string, namespace: string): Promise { const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) if (!access.ok) { @@ -4296,19 +4347,49 @@ export class SyncEngine { } async getGitStatus(sessionId: string, cwd?: string): Promise { - return await this.rpcGateway.getGitStatus(sessionId, cwd) + try { + return await this.rpcGateway.getGitStatus(sessionId, cwd) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd: fallbackCwd }) => + this.rpcGateway.getWorkspaceGitStatus(machineId, { cwd: fallbackCwd }) + ) + } } async getGitDiffNumstat(sessionId: string, options: { cwd?: string; staged?: boolean }): Promise { - return await this.rpcGateway.getGitDiffNumstat(sessionId, options) + try { + return await this.rpcGateway.getGitDiffNumstat(sessionId, options) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd: fallbackCwd }) => + this.rpcGateway.getWorkspaceGitDiffNumstat(machineId, { + ...options, + cwd: fallbackCwd, + }) + ) + } } async getGitDiffFile(sessionId: string, options: { cwd?: string; filePath: string; staged?: boolean }): Promise { - return await this.rpcGateway.getGitDiffFile(sessionId, options) + try { + return await this.rpcGateway.getGitDiffFile(sessionId, options) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd: fallbackCwd }) => + this.rpcGateway.getWorkspaceGitDiffFile(machineId, { + ...options, + cwd: fallbackCwd, + }) + ) + } } async readSessionFile(sessionId: string, path: string): Promise { - return await this.rpcGateway.readSessionFile(sessionId, path) + try { + return await this.rpcGateway.readSessionFile(sessionId, path) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd }) => + this.rpcGateway.readWorkspaceFile(machineId, { cwd, path }) + ) + } } async readGeneratedImage(sessionId: string, imageId: string): Promise { @@ -4316,11 +4397,23 @@ export class SyncEngine { } async listDirectory(sessionId: string, path: string): Promise { - return await this.rpcGateway.listDirectory(sessionId, path) + try { + return await this.rpcGateway.listDirectory(sessionId, path) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd }) => + this.rpcGateway.listWorkspaceDirectory(machineId, { cwd, path }) + ) + } } async statFiles(sessionId: string, paths: string[]): Promise { - return await this.rpcGateway.statFiles(sessionId, paths) + try { + return await this.rpcGateway.statFiles(sessionId, paths) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd }) => + this.rpcGateway.statWorkspaceFiles(machineId, { cwd, paths }) + ) + } } async uploadFile(sessionId: string, filename: string, content: string, mimeType: string): Promise { @@ -4332,7 +4425,13 @@ export class SyncEngine { } async runRipgrep(sessionId: string, args: string[], cwd?: string, fileSearch?: FileSearchOptions): Promise { - return await this.rpcGateway.runRipgrep(sessionId, args, cwd, fileSearch) + try { + return await this.rpcGateway.runRipgrep(sessionId, args, cwd, fileSearch) + } catch (error) { + return await this.withInactiveWorkspaceFallback(sessionId, error, ({ machineId, cwd: fallbackCwd }) => + this.rpcGateway.runWorkspaceRipgrep(machineId, { args, cwd: fallbackCwd, fileSearch }) + ) + } } async listSlashCommands(sessionId: string, agent: string): Promise { diff --git a/hub/src/sync/syncEngineWorkspaceFallback.test.ts b/hub/src/sync/syncEngineWorkspaceFallback.test.ts new file mode 100644 index 00000000..0abee61d --- /dev/null +++ b/hub/src/sync/syncEngineWorkspaceFallback.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, spyOn } from 'bun:test' +import { MACHINE_CAPABILITIES } from '@hapi/protocol' +import { RpcRegistry } from '../socket/rpcRegistry' +import { Store } from '../store' +import { RpcGateway, RpcTargetMissingError } from './rpcGateway' +import { SyncEngine } from './syncEngine' + +function createArchivedWorkspaceEngine(): { engine: SyncEngine; sessionId: string; gateway: RpcGateway } { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + const session = engine.getOrCreateSession( + 'archived-workspace-fallback', + { + path: '/workspace/project', + host: 'runner-host', + machineId: 'machine-1', + flavor: 'claude', + lifecycleState: 'archived', + archivedBy: 'cli', + archiveReason: 'User terminated' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { + host: 'runner-host', + platform: 'win32', + happyCliVersion: 'test', + capabilities: [MACHINE_CAPABILITIES.WorkspaceFileAccess] + }, + { status: 'running' }, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + const gateway = (engine as unknown as { rpcGateway: RpcGateway }).rpcGateway + return { engine, sessionId: session.id, gateway } +} + +function missingTarget(method: string): RpcTargetMissingError { + return new RpcTargetMissingError(method, 'handler-not-registered') +} + +describe('SyncEngine archived workspace fallback', () => { + it('keeps the session-scoped RPC as the primary path', async () => { + const { engine, sessionId, gateway } = createArchivedWorkspaceEngine() + const sessionRead = spyOn(gateway, 'readSessionFile').mockResolvedValue({ success: true, content: 'YQ==' }) + const fallback = spyOn(gateway, 'readWorkspaceFile').mockResolvedValue({ success: true, content: 'Yg==' }) + + await expect(engine.readSessionFile(sessionId, 'src/index.ts')).resolves.toEqual({ success: true, content: 'YQ==' }) + expect(sessionRead).toHaveBeenCalledWith(sessionId, 'src/index.ts') + expect(fallback).not.toHaveBeenCalled() + engine.stop() + }) + + it('routes all read-only file operations through the online runner', async () => { + const { engine, sessionId, gateway } = createArchivedWorkspaceEngine() + + spyOn(gateway, 'getGitStatus').mockRejectedValue(missingTarget('session-1:git-status')) + const gitStatus = spyOn(gateway, 'getWorkspaceGitStatus').mockResolvedValue({ success: true, stdout: 'status' }) + expect(await engine.getGitStatus(sessionId, '/ignored/caller/path')).toEqual({ success: true, stdout: 'status' }) + expect(gitStatus).toHaveBeenCalledWith('machine-1', { cwd: '/workspace/project' }) + + spyOn(gateway, 'getGitDiffNumstat').mockRejectedValue(missingTarget('session-1:git-diff-numstat')) + const gitNumstat = spyOn(gateway, 'getWorkspaceGitDiffNumstat').mockResolvedValue({ success: true, stdout: '1\t2\tsrc/index.ts' }) + await engine.getGitDiffNumstat(sessionId, { cwd: '/ignored/caller/path', staged: true }) + expect(gitNumstat).toHaveBeenCalledWith('machine-1', { cwd: '/workspace/project', staged: true }) + + spyOn(gateway, 'getGitDiffFile').mockRejectedValue(missingTarget('session-1:git-diff-file')) + const gitFile = spyOn(gateway, 'getWorkspaceGitDiffFile').mockResolvedValue({ success: true, stdout: 'diff' }) + await engine.getGitDiffFile(sessionId, { cwd: '/ignored/caller/path', filePath: 'src/index.ts', staged: false }) + expect(gitFile).toHaveBeenCalledWith('machine-1', { + cwd: '/workspace/project', + filePath: 'src/index.ts', + staged: false + }) + + spyOn(gateway, 'readSessionFile').mockRejectedValue(missingTarget('session-1:readFile')) + const readFile = spyOn(gateway, 'readWorkspaceFile').mockResolvedValue({ success: true, content: 'YQ==' }) + await engine.readSessionFile(sessionId, 'src/index.ts') + expect(readFile).toHaveBeenCalledWith('machine-1', { cwd: '/workspace/project', path: 'src/index.ts' }) + + spyOn(gateway, 'listDirectory').mockRejectedValue(missingTarget('session-1:listDirectory')) + const listDirectory = spyOn(gateway, 'listWorkspaceDirectory').mockResolvedValue({ success: true, entries: [] }) + await engine.listDirectory(sessionId, 'src') + expect(listDirectory).toHaveBeenCalledWith('machine-1', { cwd: '/workspace/project', path: 'src' }) + + spyOn(gateway, 'statFiles').mockRejectedValue(missingTarget('session-1:statFiles')) + const statFiles = spyOn(gateway, 'statWorkspaceFiles').mockResolvedValue({ success: true, entries: [] }) + await engine.statFiles(sessionId, ['src/index.ts']) + expect(statFiles).toHaveBeenCalledWith('machine-1', { cwd: '/workspace/project', paths: ['src/index.ts'] }) + + spyOn(gateway, 'runRipgrep').mockRejectedValue(missingTarget('session-1:ripgrep')) + const ripgrep = spyOn(gateway, 'runWorkspaceRipgrep').mockResolvedValue({ success: true, stdout: 'src/index.ts\n' }) + await engine.runRipgrep(sessionId, ['--files'], '/ignored/caller/path', { query: '*.ts', limit: 20 }) + expect(ripgrep).toHaveBeenCalledWith('machine-1', { + args: ['--files'], + cwd: '/workspace/project', + fileSearch: { query: '*.ts', limit: 20 } + }) + + engine.stop() + }) + + it('does not fallback for non-target errors', async () => { + const { engine, sessionId, gateway } = createArchivedWorkspaceEngine() + const error = new Error('RPC timed out') + spyOn(gateway, 'readSessionFile').mockRejectedValue(error) + const fallback = spyOn(gateway, 'readWorkspaceFile').mockResolvedValue({ success: true, content: 'YQ==' }) + + const thrown = await engine.readSessionFile(sessionId, 'src/index.ts').catch((value: unknown) => value) + expect(thrown).toBe(error) + expect(fallback).not.toHaveBeenCalled() + engine.stop() + }) + + it('does not fallback when the runner lacks workspace capability', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine(store, {} as never, new RpcRegistry(), { broadcast() {} } as never) + const session = engine.getOrCreateSession( + 'inactive-running-row', + { + path: '/workspace/project', + host: 'runner-host', + machineId: 'machine-1', + flavor: 'claude', + lifecycleState: 'running' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'runner-host', platform: 'win32', happyCliVersion: 'test' }, + { status: 'running' }, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + const gateway = (engine as unknown as { rpcGateway: RpcGateway }).rpcGateway + const error = missingTarget(`${session.id}:readFile`) + spyOn(gateway, 'readSessionFile').mockRejectedValue(error) + const fallback = spyOn(gateway, 'readWorkspaceFile').mockResolvedValue({ success: true, content: 'YQ==' }) + + const thrown = await engine.readSessionFile(session.id, 'src/index.ts').catch((value: unknown) => value) + expect(thrown).toBe(error) + expect(fallback).not.toHaveBeenCalled() + engine.stop() + }) + + it('does not fallback when the recorded machine is offline', async () => { + const { engine, sessionId, gateway } = createArchivedWorkspaceEngine() + const machine = engine.getMachine('machine-1')! + machine.active = false + + const error = missingTarget(`${sessionId}:readFile`) + spyOn(gateway, 'readSessionFile').mockRejectedValue(error) + const fallback = spyOn(gateway, 'readWorkspaceFile').mockResolvedValue({ success: true, content: 'YQ==' }) + + const thrown = await engine.readSessionFile(sessionId, 'src/index.ts').catch((value: unknown) => value) + expect(thrown).toBe(error) + expect(fallback).not.toHaveBeenCalled() + + engine.stop() + }) + + it('does not fallback while the session is still active', async () => { + const { engine, sessionId, gateway } = createArchivedWorkspaceEngine() + engine.handleSessionAlive({ sid: sessionId, time: Date.now(), thinking: false }) + + const error = missingTarget(`${sessionId}:readFile`) + spyOn(gateway, 'readSessionFile').mockRejectedValue(error) + const fallback = spyOn(gateway, 'readWorkspaceFile').mockResolvedValue({ success: true, content: 'YQ==' }) + + const thrown = await engine.readSessionFile(sessionId, 'src/index.ts').catch((value: unknown) => value) + expect(thrown).toBe(error) + expect(fallback).not.toHaveBeenCalled() + engine.stop() + }) +}) diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index 4e8faa6c..3b87ccb0 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -780,6 +780,49 @@ export type PathExistsResponse = { export type MachinePathsExistsResponse = PathExistsResponse +/** Requests served by the long-lived runner for archived-session file access. */ +export type WorkspaceReadFileRequest = { + cwd: string + path: string +} + +export type WorkspaceListDirectoryRequest = { + cwd: string + path?: string +} + +export type WorkspaceStatFilesRequest = { + cwd: string + paths: string[] +} + +export type WorkspaceGitStatusRequest = { + cwd: string + timeout?: number +} + +export type WorkspaceGitDiffNumstatRequest = { + cwd: string + staged?: boolean + timeout?: number +} + +export type WorkspaceGitDiffFileRequest = { + cwd: string + filePath: string + staged?: boolean + timeout?: number +} + +export type WorkspaceRipgrepRequest = { + cwd: string + args: string[] + fileSearch?: { + query: string + limit: number + } +} + export type CodexModelSummary = { id: string displayName: string diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 8f2e3e2f..471a7598 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -23,6 +23,13 @@ export const RPC_METHODS = { GetDirectoryTree: 'getDirectoryTree', UploadFile: 'uploadFile', DeleteUpload: 'deleteUpload', + WorkspaceReadFile: 'workspace-read-file', + WorkspaceListDirectory: 'workspace-list-directory', + WorkspaceStatFiles: 'workspace-stat-files', + WorkspaceGitStatus: 'workspace-git-status', + WorkspaceGitDiffNumstat: 'workspace-git-diff-numstat', + WorkspaceGitDiffFile: 'workspace-git-diff-file', + WorkspaceRipgrep: 'workspace-ripgrep', Ripgrep: 'ripgrep', Difftastic: 'difftastic', Bash: 'bash', diff --git a/shared/src/runnerCapabilities.test.ts b/shared/src/runnerCapabilities.test.ts index ff842f32..cccef2ab 100644 --- a/shared/src/runnerCapabilities.test.ts +++ b/shared/src/runnerCapabilities.test.ts @@ -14,6 +14,7 @@ describe('runnerCapabilities', () => { expect(REQUIRED_MACHINE_CAPABILITIES).toContain(MACHINE_CAPABILITIES.CursorChatStoreStatus) expect(CURRENT_MACHINE_CAPABILITIES).toEqual(expect.arrayContaining([ ...REQUIRED_MACHINE_CAPABILITIES, + MACHINE_CAPABILITIES.WorkspaceFileAccess, ])) }) diff --git a/shared/src/runnerCapabilities.ts b/shared/src/runnerCapabilities.ts index 52d1db36..a5e9e69f 100644 --- a/shared/src/runnerCapabilities.ts +++ b/shared/src/runnerCapabilities.ts @@ -32,6 +32,8 @@ export const MACHINE_CAPABILITIES = { AgentAvailability: RPC_METHODS.AgentAvailability, CursorChatStoreStatus: RPC_METHODS.CursorChatStoreStatus, StopRunner: RPC_METHODS.StopRunner, + /** Read-only file/Git/search RPCs available on the long-lived runner. */ + WorkspaceFileAccess: 'workspace-file-access', } as const export type MachineCapability = @@ -42,6 +44,7 @@ export const CURRENT_MACHINE_CAPABILITIES: readonly MachineCapability[] = [ MACHINE_CAPABILITIES.AgentAvailability, MACHINE_CAPABILITIES.CursorChatStoreStatus, MACHINE_CAPABILITIES.StopRunner, + MACHINE_CAPABILITIES.WorkspaceFileAccess, ] /**