remove the dead multi-user surface

Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.

Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.

Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.

Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.

getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-25 23:30:20 +01:00
co-authored by Claude Opus 5
parent 92de996412
commit 044aacf4d5
85 changed files with 2761 additions and 2121 deletions
+51 -53
View File
@@ -2,15 +2,12 @@ import type { ServerWebSocket } from 'bun';
import { join, isAbsolute } from 'node:path';
import { mkdirSync, writeFileSync, chmodSync, rmSync, readdirSync, readFileSync } from 'node:fs';
import { getTaskByDirName } from './task-files';
import { getHomeDirForRole, DATA_PATH } from '../../data-path';
import { buildSandboxPrefix, buildRunuserSuffix } from '../../sidecar/sandbox';
import { getOwnerHomeDir } from '../../data-path';
type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
};
type RunMessage = {
@@ -79,11 +76,19 @@ function descendantPids(root: number): number[] {
function killTree(root: number) {
const pids = [root, ...descendantPids(root)];
for (const pid of pids) {
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
try {
process.kill(pid, 'SIGTERM');
} catch {
/* already gone */
}
}
setTimeout(() => {
for (const pid of pids) {
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
try {
process.kill(pid, 'SIGKILL');
} catch {
/* gone */
}
}
}, 2000);
}
@@ -96,21 +101,31 @@ function send(ws: ServerWebSocket<WSData>, msg: OutMessage) {
function getRunner(language: string): string[] {
switch (language) {
case 'bash': return ['bash'];
case 'python': return ['python3'];
case 'typescript': return ['bun', 'run'];
case 'javascript': return ['node'];
default: return ['bash'];
case 'bash':
return ['bash'];
case 'python':
return ['python3'];
case 'typescript':
return ['bun', 'run'];
case 'javascript':
return ['node'];
default:
return ['bash'];
}
}
function getFileName(language: string): string {
switch (language) {
case 'bash': return 'run.sh';
case 'python': return 'run.py';
case 'typescript': return 'index.ts';
case 'javascript': return 'index.js';
default: return 'run.sh';
case 'bash':
return 'run.sh';
case 'python':
return 'run.py';
case 'typescript':
return 'index.ts';
case 'javascript':
return 'index.js';
default:
return 'run.sh';
}
}
@@ -142,7 +157,7 @@ function buildArgs(inputs: Record<string, string>, argsOrder?: string[] | null):
}
async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
const { email, role, sandboxed } = ws.data;
const { email } = ws.data;
// Resolve task from the file-backed store
const task = await getTaskByDirName(msg.taskDirName);
@@ -178,44 +193,19 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
// msg.cwd arrives from the file browser relative to the user's home; Bun.spawn needs it absolute
// (a missing cwd surfaces as ENOENT naming the binary, not the directory)
const homeDir = getHomeDirForRole(email, role);
const homeDir = getOwnerHomeDir(email);
const cwd = msg.cwd ? (isAbsolute(msg.cwd) ? msg.cwd : join(homeDir, msg.cwd)) : homeDir;
let spawnCmd: string[];
let spawnEnv: Record<string, string>;
let spawnCwd: string;
if (sandboxed) {
const prefix = buildSandboxPrefix(email);
const suffix = buildRunuserSuffix();
// Translate paths in inputs and args: DATA_PATH/{email}/... → /data/...
const userDataPrefix = join(DATA_PATH, email);
const translatePath = (v: string) => v.startsWith(userDataPrefix) ? '/data' + v.slice(userDataPrefix.length) : v;
const envArgs: string[] = [];
for (const [key, value] of Object.entries(inputEnv)) {
envArgs.push('--setenv', key, translatePath(value));
}
// Translate positional args too
const sandboxCmd = cmd.map((arg) => translatePath(arg));
// Script is in /tmp which is a tmpfs inside bwrap — need to bind-mount the host tmp dir
const scriptDir = join(scriptPath, '..');
const extraMounts = ['--ro-bind', scriptDir, scriptDir];
spawnCmd = [...prefix, ...extraMounts, ...envArgs, ...suffix, ...sandboxCmd];
spawnEnv = {};
spawnCwd = '/';
} else {
spawnCmd = cmd;
spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
spawnCwd = cwd;
}
const spawnCmd = cmd;
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
const spawnCwd = cwd;
const cleanup = () => {
try { rmSync(join(scriptPath, '..'), { recursive: true, force: true }); } catch { /* best effort */ }
try {
rmSync(join(scriptPath, '..'), { recursive: true, force: true });
} catch {
/* best effort */
}
};
send(ws, { type: 'started', taskName: task.name });
@@ -231,7 +221,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
activeProcs.set(ws, {
proc,
kill: () => {
try { killTree(proc.pid); } catch { /* already dead */ }
try {
killTree(proc.pid);
} catch {
/* already dead */
}
},
});
@@ -239,7 +233,11 @@ async function handleRun(ws: ServerWebSocket<WSData>, msg: RunMessage) {
// file for minutes with no output). Bun's default 120s idle timeout would otherwise close the
// socket → close(ws) → killTree kills the task mid-run. A ping resets the idle timer.
const keepAlive = setInterval(() => {
try { ws.ping(); } catch { /* socket gone */ }
try {
ws.ping();
} catch {
/* socket gone */
}
}, 30_000);
const stdoutReader = proc.stdout.getReader();