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:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user