Files
spoon/apps/agent-worker/src/box.ts
T

154 lines
4.7 KiB
TypeScript

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,
stopWorkspaceContainer,
userContainerName,
} 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) => ({
homeDir: path.resolve(env.workdir, 'homes', username),
containerHome: path.posix.join('/home', username),
});
export type BoxStatus = {
running: boolean;
image: string | null;
startedAt: string | null;
memoryLimitBytes: number | null;
containerName: string;
};
export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
const status = await inspectUserBoxStatus(username);
return { ...status, containerName: userContainerName(username) };
};
export const startBox = async (username: string): Promise<void> => {
const { homeDir, containerHome } = boxHomePaths(username);
await ensureUserContainer({ username, workdir: homeDir, containerHome });
};
export const stopBox = async (username: string): Promise<void> => {
await stopWorkspaceContainer(userContainerName(username));
resetBox(username);
};
export const restartBox = async (username: string): Promise<void> => {
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<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 };
};