feat(worker): home-scoped box file tree/read/write with realpath containment
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
|
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import {
|
import {
|
||||||
ensureUserContainer,
|
ensureUserContainer,
|
||||||
inspectUserBoxStatus,
|
inspectUserBoxStatus,
|
||||||
@@ -9,6 +11,14 @@ import {
|
|||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
import { resetBox } from './user-container';
|
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
|
// 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).
|
// so path derivation lives in exactly one place (must match worker.ts).
|
||||||
export const boxHomePaths = (username: string) => ({
|
export const boxHomePaths = (username: string) => ({
|
||||||
@@ -43,3 +53,101 @@ export const restartBox = async (username: string): Promise<void> => {
|
|||||||
await stopBox(username);
|
await stopBox(username);
|
||||||
await startBox(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<string> =>
|
||||||
|
assertContainedRealPath(homeDir, relPath, { ...opts, label: 'home' });
|
||||||
|
|
||||||
|
export const listBoxTree = async (username: string): Promise<FileTreeNode> => {
|
||||||
|
const { homeDir } = boxHomePaths(username);
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
const buildChildren = async (
|
||||||
|
absoluteDir: string,
|
||||||
|
relativeDir: string,
|
||||||
|
): Promise<FileTreeNode[]> => {
|
||||||
|
const entries = await readdir(absoluteDir);
|
||||||
|
const nodes = await Promise.all(
|
||||||
|
entries
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.map(async (entry): Promise<FileTreeNode | null> => {
|
||||||
|
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<string> => {
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,13 +21,15 @@ const realpathOrDeepestExisting = async (target: string): Promise<string> => {
|
|||||||
export const assertContainedRealPath = async (
|
export const assertContainedRealPath = async (
|
||||||
root: string,
|
root: string,
|
||||||
requested: string,
|
requested: string,
|
||||||
opts: { forWrite?: boolean } = {},
|
opts: { forWrite?: boolean; label?: string } = {},
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
const lexical = path.resolve(root, requested);
|
const lexical = path.resolve(root, requested);
|
||||||
const realRoot = await realpath(root);
|
const realRoot = await realpath(root);
|
||||||
const resolved = await realpathOrDeepestExisting(lexical);
|
const resolved = await realpathOrDeepestExisting(lexical);
|
||||||
if (resolved !== realRoot && !resolved.startsWith(`${realRoot}${path.sep}`)) {
|
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) {
|
if (opts.forWrite) {
|
||||||
const info = await lstat(lexical).catch(() => null);
|
const info = await lstat(lexical).catch(() => null);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user