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
+210 -69
View File
@@ -5,9 +5,8 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { getUserSettings } from 'officerdb';
import { getTaskByDirName } from './task-files';
import { getHomeDirForRole, getHomeDir } from '../../data-path';
import { getHomeDir } from '../../data-path';
import { resolveBaseCwd } from '../chat/websocket';
import { SANDBOX_HOME } from '../../sidecar/sandbox';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import type { ChatEvent, MessageCost } from '../chat/types';
import * as jobManager from './pipeline-job-manager';
@@ -41,7 +40,12 @@ type PipelineConfig = {
// Messages sent to client
export type OutMessage =
| { type: 'pipeline:init'; steps: Array<{ task: string; foreach?: string; concurrency?: number }> }
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
| {
type: 'step:start';
stepIndex: number;
taskName: string;
iteration?: { current: number; total: number; label: string };
}
| { type: 'step:complete'; stepIndex: number; cost?: MessageCost }
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
| { type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
@@ -51,8 +55,22 @@ export type OutMessage =
| { type: 'iteration:error'; stepIndex: number; label: string; error: string }
| { type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
| { type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
| {
type: 'tool:start';
toolCallId: string;
toolName: string;
toolInput: Record<string, unknown>;
stepIndex: number;
iterationLabel?: string;
}
| {
type: 'tool:result';
toolCallId: string;
output: string;
isError: boolean;
stepIndex: number;
iterationLabel?: string;
}
| { type: 'pipeline:complete'; totalCost: MessageCost }
| { type: 'error'; message: string }
| { type: 'stopped' };
@@ -67,7 +85,6 @@ type RunStepParams = {
userId: number;
email: string;
username: string;
role: string;
taskDirName: string;
prompt: string;
cwd: string;
@@ -92,14 +109,28 @@ async function refreshProxyToken(): Promise<void> {
const ACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const WAITING_INTERVAL_MS = 10 * 1000; // emit "waiting" every 10s
async function runAgenticStep({ userId, email, username, role, taskDirName, prompt, cwd, model, abortSignal, emit, stepIndex, iterationLabel }: RunStepParams): Promise<MessageCost> {
async function runAgenticStep({
userId,
email,
username,
taskDirName,
prompt,
cwd,
model,
abortSignal,
emit,
stepIndex,
iterationLabel,
}: RunStepParams): Promise<MessageCost> {
const sessionId = randomUUID();
const isClaudeCode = model.startsWith('claude-code');
// Ensure fresh OAuth token before spawning Claude Code
if (isClaudeCode) await refreshProxyToken();
console.log(`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`);
console.log(
`[pipeline] starting step ${stepIndex} for session ${sessionId} (model=${model})${iterationLabel ? ` [${iterationLabel}]` : ''}`,
);
return new Promise<MessageCost>(async (resolve, reject) => {
if (abortSignal.aborted) return reject(new Error('Pipeline aborted'));
@@ -129,19 +160,42 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
emit({ type: 'assistant:text', text: event.text, stepIndex, iterationLabel });
break;
case 'tool:start':
emit({ type: 'tool:start', toolCallId: event.toolCallId, toolName: event.toolName, toolInput: event.toolInput, stepIndex, iterationLabel });
emit({
type: 'tool:start',
toolCallId: event.toolCallId,
toolName: event.toolName,
toolInput: event.toolInput,
stepIndex,
iterationLabel,
});
break;
case 'tool:result':
emit({ type: 'tool:result', toolCallId: event.toolCallId, output: event.output, isError: event.isError, stepIndex, iterationLabel });
emit({
type: 'tool:result',
toolCallId: event.toolCallId,
output: event.output,
isError: event.isError,
stepIndex,
iterationLabel,
});
break;
case 'result':
settle(() => { cleanup?.(); resolve(event.cost); });
settle(() => {
cleanup?.();
resolve(event.cost);
});
break;
case 'error':
settle(() => { cleanup?.(); reject(new Error(event.message)); });
settle(() => {
cleanup?.();
reject(new Error(event.message));
});
break;
case 'stopped':
settle(() => { cleanup?.(); reject(new Error('Step was stopped')); });
settle(() => {
cleanup?.();
reject(new Error('Step was stopped'));
});
break;
}
};
@@ -149,14 +203,20 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
// Poll for abort signal and activity timeout
const abortPoll = setInterval(() => {
if (abortSignal.aborted) {
settle(() => { cleanup?.(); reject(new Error('Pipeline was stopped')); });
settle(() => {
cleanup?.();
reject(new Error('Pipeline was stopped'));
});
return;
}
// Activity timeout (skip for Claude Code which has its own mechanisms)
if (!isClaudeCode && Date.now() - lastActivity > ACTIVITY_TIMEOUT_MS) {
const elapsed = Math.round((Date.now() - stepStart) / 1000);
console.error(`[pipeline] step ${stepIndex} timed out after ${elapsed}s of inactivity (session=${sessionId})`);
settle(() => { cleanup?.(); reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`)); });
settle(() => {
cleanup?.();
reject(new Error(`Step timed out — no response from model for ${Math.round(ACTIVITY_TIMEOUT_MS / 1000)}s`));
});
}
}, 500);
@@ -176,12 +236,14 @@ async function runAgenticStep({ userId, email, username, role, taskDirName, prom
sessionKey: sessionId,
cwd,
model,
role,
onEvent,
});
cleanup = handle.kill;
} catch (err) {
settle(() => { cleanup?.(); reject(err); });
settle(() => {
cleanup?.();
reject(err);
});
}
});
}
@@ -192,16 +254,6 @@ function resolveInputTemplate(template: string, variables: Record<string, string
return template.replace(/\$\{(\w+)\}/g, (_, key) => variables[key] ?? '');
}
/** Convert a host-side absolute path to the path the agent sees inside the sandbox. */
function toAgentPath(hostPath: string, email: string, role: string): string {
if (role === 'Super Admin') return hostPath;
const hostHome = getHomeDir(email);
if (hostPath.startsWith(hostHome)) {
return SANDBOX_HOME + hostPath.slice(hostHome.length);
}
return hostPath;
}
function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targetDir?: string): string {
const inputLines = Object.entries(inputs)
.filter(([, v]) => v.trim())
@@ -219,7 +271,6 @@ function buildStepPrompt(taskBody: string, inputs: Record<string, string>, targe
type RunScriptStepParams = {
email: string;
role: string;
task: { name: string; implementation: string; language: string; args?: string[] | null };
inputs: Record<string, string>;
cwd: string;
@@ -230,25 +281,43 @@ type RunScriptStepParams = {
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';
}
}
async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit, stepIndex }: RunScriptStepParams): Promise<void> {
async function runScriptStep({
email,
task,
inputs,
cwd,
abortSignal,
emit,
stepIndex,
}: RunScriptStepParams): Promise<void> {
const language = task.language ?? 'bash';
// Write script to temp file
@@ -260,7 +329,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
chmodSync(scriptPath, 0o755);
const cleanup = () => {
try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* best effort */
}
};
// Build env vars from inputs
@@ -275,7 +348,7 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
const runner = getRunner(language);
const cmd = [...runner, scriptPath, ...positionalArgs];
const spawnEnv = { ...process.env as Record<string, string>, ...inputEnv };
const spawnEnv = { ...(process.env as Record<string, string>), ...inputEnv };
console.log(`[pipeline] running script step ${stepIndex}: ${task.name} (cwd=${cwd})`);
@@ -305,7 +378,11 @@ async function runScriptStep({ email, role, task, inputs, cwd, abortSignal, emit
// Check abort periodically
const abortCheck = setInterval(() => {
if (abortSignal.aborted) {
try { proc.kill(); } catch { /* already dead */ }
try {
proc.kill();
} catch {
/* already dead */
}
}
}, 500);
@@ -336,7 +413,6 @@ type ForeachParams = {
userId: number;
email: string;
username: string;
role: string;
stepIdx: number;
step: PipelineStep;
stepTask: { name: string; body: string };
@@ -352,10 +428,22 @@ type ForeachParams = {
};
async function runForeach({
userId, email, username, role, stepIdx, step, stepTask, subdirs, baseCwd,
inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
userId,
email,
username,
stepIdx,
step,
stepTask,
subdirs,
baseCwd,
inputs,
cwd,
abortSignal,
totalCost,
emit,
concurrency,
model,
}: ForeachParams) {
// Determine skip vs run
const toSkip: string[] = [];
const toRun: string[] = [];
@@ -400,13 +488,15 @@ async function runForeach({
}
const cwdRelative = cwd ? `${cwd}/${subdir}` : subdir;
const resolvedCwd = resolveBaseCwd(email, role, cwdRelative);
const targetDir = toAgentPath(resolvedCwd, email, role);
const prompt = buildStepPrompt(stepTask.body!,iterInputs, targetDir);
const resolvedCwd = resolveBaseCwd(email, cwdRelative);
const targetDir = resolvedCwd;
const prompt = buildStepPrompt(stepTask.body!, iterInputs, targetDir);
try {
const cost = await runAgenticStep({
userId, email, username, role,
userId,
email,
username,
taskDirName: step.task,
prompt,
cwd: resolvedCwd,
@@ -424,12 +514,19 @@ async function runForeach({
emit({ type: 'iteration:complete', stepIndex: stepIdx, label: subdir, cost });
} catch (err) {
if (!abortSignal.aborted) {
emit({ type: 'iteration:error', stepIndex: stepIdx, label: subdir, error: err instanceof Error ? err.message : String(err) });
emit({
type: 'iteration:error',
stepIndex: stepIdx,
label: subdir,
error: err instanceof Error ? err.message : String(err),
});
}
}
};
const p = run().then(() => { executing.delete(p); });
const p = run().then(() => {
executing.delete(p);
});
executing.add(p);
if (executing.size >= concurrency) {
@@ -446,7 +543,6 @@ export type ExecutePipelineParams = {
userId: number;
email: string;
username: string;
role: string;
taskDirName: string;
inputs: Record<string, string>;
cwd?: string;
@@ -456,7 +552,18 @@ export type ExecutePipelineParams = {
emit: EmitEvent;
};
export async function executePipeline({ userId, email, username, role, taskDirName, inputs, cwd, model: modelOverride, startAt, abortSignal, emit }: ExecutePipelineParams): Promise<void> {
export async function executePipeline({
userId,
email,
username,
taskDirName,
inputs,
cwd,
model: modelOverride,
startAt,
abortSignal,
emit,
}: ExecutePipelineParams): Promise<void> {
const pipelineTask = await getTaskByDirName(taskDirName);
if (!pipelineTask) {
emit({ type: 'error', message: `Task not found: ${taskDirName}` });
@@ -473,7 +580,7 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
return;
}
const baseCwd = resolveBaseCwd(email, role, cwd);
const baseCwd = resolveBaseCwd(email, cwd);
let model = modelOverride || (await resolveModel(userId));
// Claude-only: coerce any legacy non-Claude task-model preference to the Claude default.
if (!model.startsWith('claude-code')) model = DEFAULT_MODEL;
@@ -532,8 +639,13 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
try {
await runScriptStep({
email, role,
task: { name: stepTask.name, implementation: stepTask.implementation, language: stepTask.language ?? 'bash', args: stepTask.args as string[] | null },
email,
task: {
name: stepTask.name,
implementation: stepTask.implementation,
language: stepTask.language ?? 'bash',
args: stepTask.args as string[] | null,
},
inputs: resolvedInputs,
cwd: baseCwd,
abortSignal,
@@ -566,19 +678,33 @@ export async function executePipeline({ userId, email, username, role, taskDirNa
const concurrency = step.concurrency ? runtimeConcurrency : 1;
await runForeach({
userId, email, username, role,
stepIdx, step, stepTask: { name: stepTask.name, body: stepTask.body! },
subdirs, baseCwd, inputs, cwd, abortSignal, totalCost, emit, concurrency, model,
userId,
email,
username,
stepIdx,
step,
stepTask: { name: stepTask.name, body: stepTask.body! },
subdirs,
baseCwd,
inputs,
cwd,
abortSignal,
totalCost,
emit,
concurrency,
model,
});
} else {
// Single execution step
emit({ type: 'step:start', stepIndex: stepIdx, taskName: stepTask.name });
const targetDir = toAgentPath(baseCwd, email, role);
const prompt = buildStepPrompt(stepTask.body!,resolvedInputs, targetDir);
const targetDir = baseCwd;
const prompt = buildStepPrompt(stepTask.body!, resolvedInputs, targetDir);
const cost = await runAgenticStep({
userId, email, username, role,
userId,
email,
username,
taskDirName: step.task,
prompt,
cwd: baseCwd,
@@ -609,8 +735,6 @@ type WSData = {
userId: number;
email: string;
username: string;
role: string;
sandboxed: boolean;
};
type ClientMessage =
@@ -633,7 +757,7 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
switch (msg.type) {
case 'run': {
const { userId, email, username, role } = ws.data;
const { userId, email, username } = ws.data;
// Resolve task name for the DB record
const task = await getTaskByDirName(msg.taskDirName);
@@ -646,7 +770,6 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
userId,
email,
username,
role,
taskDirName: msg.taskDirName,
taskName: task.name,
inputs: msg.inputs,
@@ -672,7 +795,13 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
// Job not live — send the DB state
const job = await jobManager.getJob(msg.jobId);
if (job) {
send(ws, { type: 'job:state', jobId: msg.jobId, status: job.status, progress: job.progress, cost: job.totalCost });
send(ws, {
type: 'job:state',
jobId: msg.jobId,
status: job.status,
progress: job.progress,
cost: job.totalCost,
});
} else {
send(ws, { type: 'error', message: `Job not found: ${msg.jobId}` });
}
@@ -682,7 +811,19 @@ export async function message(ws: ServerWebSocket<WSData>, raw: string | Buffer)
case 'list': {
const jobs = await jobManager.getJobsForUser(ws.data.userId);
send(ws, { type: 'job:list', jobs: jobs.map((j) => ({ id: j.id, taskDirName: j.taskDirName, taskName: j.taskName, status: j.status, isLive: j.isLive, totalCost: j.totalCost, createdAt: j.createdAt, completedAt: j.completedAt })) });
send(ws, {
type: 'job:list',
jobs: jobs.map((j) => ({
id: j.id,
taskDirName: j.taskDirName,
taskName: j.taskName,
status: j.status,
isLive: j.isLive,
totalCost: j.totalCost,
createdAt: j.createdAt,
completedAt: j.completedAt,
})),
});
break;
}
}