154 lines
4.7 KiB
TypeScript
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 };
|
|
};
|