diff --git a/apps/agent-worker/src/box.ts b/apps/agent-worker/src/box.ts index 69bf9b8..3a3e854 100644 --- a/apps/agent-worker/src/box.ts +++ b/apps/agent-worker/src/box.ts @@ -1,6 +1,8 @@ +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { env } from './env'; +import { assertContainedRealPath } from './path-containment'; import { ensureUserContainer, inspectUserBoxStatus, @@ -9,6 +11,14 @@ import { } from './runtime/docker'; import { resetBox } from './user-container'; +// Matches FileTreeNode in apps/next/src/components/agent-workspace/types.ts. +type FileTreeNode = { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileTreeNode[]; +}; + // Per-user box home layout, shared by the HTTP routes and the terminal WS bridge // so path derivation lives in exactly one place (must match worker.ts). export const boxHomePaths = (username: string) => ({ @@ -43,3 +53,101 @@ export const restartBox = async (username: string): Promise => { await stopBox(username); await startBox(username); }; + +// Directories never surfaced in the box file tree — VCS internals, package +// caches and build output that would bloat (or hang) the walk. +const BOX_TREE_IGNORE = new Set([ + '.git', + 'node_modules', + '.cache', + '.npm', + '.bun', + '.cargo', + 'dist', + 'build', + '.next', +]); + +// Home-relative paths (POSIX) that are always skipped regardless of depth. +const BOX_TREE_IGNORE_PATHS = new Set(['.local/share/Trash']); + +// Guard against a huge home hanging the request; stop descending past this. +const BOX_TREE_MAX_NODES = 5000; + +// Realpath-checked absolute path inside the user's home. Rejects lexical `..` +// escapes and symlinks that resolve outside home (reuses the Phase-1 +// containment helper); for writes, also rejects a symlinked final target. +const safeHomePath = ( + homeDir: string, + relPath: string, + opts: { forWrite?: boolean } = {}, +): Promise => + assertContainedRealPath(homeDir, relPath, { ...opts, label: 'home' }); + +export const listBoxTree = async (username: string): Promise => { + const { homeDir } = boxHomePaths(username); + let count = 0; + + const buildChildren = async ( + absoluteDir: string, + relativeDir: string, + ): Promise => { + const entries = await readdir(absoluteDir); + const nodes = await Promise.all( + entries + .sort((a, b) => a.localeCompare(b)) + .map(async (entry): Promise => { + const relativePath = relativeDir ? `${relativeDir}/${entry}` : entry; + if ( + BOX_TREE_IGNORE.has(entry) || + BOX_TREE_IGNORE_PATHS.has(relativePath) + ) { + return null; + } + if (count >= BOX_TREE_MAX_NODES) return null; + count += 1; + const absolutePath = path.join(absoluteDir, entry); + const stats = await stat(absolutePath).catch(() => null); + if (!stats) return null; + if (stats.isDirectory()) { + return { + name: entry, + path: relativePath, + type: 'directory', + children: await buildChildren(absolutePath, relativePath), + }; + } + return { name: entry, path: relativePath, type: 'file' }; + }), + ); + return nodes.filter((node): node is FileTreeNode => Boolean(node)); + }; + + return { + name: '~', + path: '', + type: 'directory', + children: await buildChildren(homeDir, ''), + }; +}; + +export const readBoxFile = async ( + username: string, + relPath: string, +): Promise => { + const { homeDir } = boxHomePaths(username); + const target = await safeHomePath(homeDir, relPath); + return await readFile(target, 'utf8'); +}; + +export const writeBoxFile = async ( + username: string, + relPath: string, + content: string, +): Promise<{ success: true }> => { + const { homeDir } = boxHomePaths(username); + const target = await safeHomePath(homeDir, relPath, { forWrite: true }); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + return { success: true }; +}; diff --git a/apps/agent-worker/src/path-containment.ts b/apps/agent-worker/src/path-containment.ts index f53550c..2154c8f 100644 --- a/apps/agent-worker/src/path-containment.ts +++ b/apps/agent-worker/src/path-containment.ts @@ -21,13 +21,15 @@ const realpathOrDeepestExisting = async (target: string): Promise => { export const assertContainedRealPath = async ( root: string, requested: string, - opts: { forWrite?: boolean } = {}, + opts: { forWrite?: boolean; label?: string } = {}, ): Promise => { const lexical = path.resolve(root, requested); const realRoot = await realpath(root); const resolved = await realpathOrDeepestExisting(lexical); if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) { - throw new Error(`Refusing to access path outside root: ${requested}`); + throw new Error( + `Refusing to access path outside ${opts.label ?? 'root'}: ${requested}`, + ); } if (opts.forWrite) { const info = await lstat(lexical).catch(() => null); diff --git a/apps/agent-worker/tests/unit/box-files.test.ts b/apps/agent-worker/tests/unit/box-files.test.ts new file mode 100644 index 0000000..e01a046 --- /dev/null +++ b/apps/agent-worker/tests/unit/box-files.test.ts @@ -0,0 +1,84 @@ +import { + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const tempDirs: string[] = []; + +// Point boxHomePaths('t').homeDir at a real tmp home, then import box fresh so +// env.workdir (captured at import) reflects the tmp workdir for this test. +const loadWithHome = async () => { + const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-box-')); + tempDirs.push(workdir); + vi.resetModules(); + process.env.SPOON_WORKER_TOKEN = 'test-worker-token'; + process.env.GITHUB_APP_ID = '123'; + process.env.GITHUB_APP_PRIVATE_KEY = + '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----'; + process.env.SPOON_AGENT_WORKDIR = workdir; + const box = await import('../../src/box'); + const homeDir = box.boxHomePaths('t').homeDir; + await mkdir(homeDir, { recursive: true }); + return { box, homeDir, workdir }; +}; + +describe('box file helpers', () => { + afterEach(async () => { + await Promise.all( + tempDirs.map((dir) => rm(dir, { force: true, recursive: true })), + ); + tempDirs.length = 0; + vi.resetModules(); + }); + + test('writeBoxFile then readBoxFile round-trips', async () => { + const { box } = await loadWithHome(); + await expect( + box.writeBoxFile('t', 'notes/todo.txt', 'hello box'), + ).resolves.toEqual({ success: true }); + await expect(box.readBoxFile('t', 'notes/todo.txt')).resolves.toBe( + 'hello box', + ); + }); + + test('readBoxFile rejects a lexical .. escape', async () => { + const { box } = await loadWithHome(); + await expect( + box.readBoxFile('t', '../../etc/passwd'), + ).rejects.toThrow(/outside home/); + }); + + test('rejects a symlink inside home that points outside on read and write', async () => { + const { box, homeDir, workdir } = await loadWithHome(); + const outside = path.join(workdir, 'outside'); + await mkdir(outside, { recursive: true }); + await writeFile(path.join(outside, 'secret'), 'S'); + await symlink(path.join(outside, 'secret'), path.join(homeDir, 'link')); + + await expect(box.readBoxFile('t', 'link')).rejects.toThrow(/outside home/); + await expect( + box.writeBoxFile('t', 'link', 'nope'), + ).rejects.toThrow(); + }); + + test('listBoxTree includes a created file and excludes node_modules', async () => { + const { box, homeDir } = await loadWithHome(); + await writeFile(path.join(homeDir, 'readme.md'), '# hi'); + await mkdir(path.join(homeDir, 'node_modules', 'pkg'), { recursive: true }); + await writeFile(path.join(homeDir, 'node_modules', 'pkg', 'x.js'), '1'); + + const tree = await box.listBoxTree('t'); + expect(tree.name).toBe('~'); + expect(tree.path).toBe(''); + expect(tree.type).toBe('directory'); + const names = (tree.children ?? []).map((child) => child.name); + expect(names).toContain('readme.md'); + expect(names).not.toContain('node_modules'); + }); +});