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>
235 lines
6.7 KiB
TypeScript
235 lines
6.7 KiB
TypeScript
import { join } from 'node:path';
|
|
import type { Subprocess } from 'bun';
|
|
import type { ChatEvent } from '../../api/chat/types';
|
|
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
|
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
|
import { parseStream } from './stream-parser';
|
|
|
|
const SEND_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
|
// Use /usr/local/bin/claude so it's visible inside bwrap sandbox (which ro-binds /usr).
|
|
// The actual binary lives at ~/.local/bin/claude, symlinked from /usr/local/bin/claude.
|
|
const CLAUDE_BIN = '/usr/local/bin/claude';
|
|
|
|
// Capture original HOME before user-instance overrides it
|
|
const HOST_HOME = process.env.HOME!;
|
|
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
|
|
|
// Active streaming processes
|
|
const activeProcs = new Map<string, Subprocess>();
|
|
|
|
// MCP config paths, set by user-instance at startup
|
|
let mcpHostPath: string | undefined; // path on the host filesystem
|
|
|
|
export function setMcpConfigPath(hostPath: string): void {
|
|
mcpHostPath = hostPath;
|
|
}
|
|
|
|
// ── Blocking send ──
|
|
|
|
type ClaudeCodeOutput = {
|
|
result: string;
|
|
session_id: string;
|
|
cost_usd: number;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
is_error: boolean;
|
|
};
|
|
|
|
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
|
const { prompt, sessionKey, email } = params;
|
|
|
|
const existingSession = getClaudeSession(sessionKey);
|
|
|
|
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
|
|
|
const mcpConfig = mcpHostPath;
|
|
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
|
|
|
const subModel = params.model?.split('/')[1];
|
|
if (subModel) claudeArgs.push('--model', subModel);
|
|
|
|
if (existingSession) {
|
|
claudeArgs.push('--resume', existingSession);
|
|
}
|
|
|
|
const spawnCmd = claudeArgs;
|
|
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions
|
|
// dir); fall back to the owner's host home.
|
|
const spawnCwd = params.cwd ?? HOST_HOME;
|
|
|
|
const proc = Bun.spawn(spawnCmd, {
|
|
stdin: 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
cwd: spawnCwd,
|
|
env: process.env as Record<string, string>,
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
try {
|
|
proc.kill();
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
}, SEND_TIMEOUT_MS);
|
|
|
|
try {
|
|
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
const exitCode = await proc.exited;
|
|
clearTimeout(timeout);
|
|
|
|
if (exitCode !== 0 && !stdout.trim()) {
|
|
throw new Error(`claude exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`);
|
|
}
|
|
|
|
let output: ClaudeCodeOutput;
|
|
try {
|
|
output = JSON.parse(stdout) as ClaudeCodeOutput;
|
|
} catch {
|
|
return {
|
|
text: stdout.trim() || '(no response)',
|
|
sessionId: sessionKey,
|
|
model: 'claude-code',
|
|
cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 },
|
|
};
|
|
}
|
|
|
|
if (output.is_error) {
|
|
throw new Error(output.result || 'Claude Code returned an error');
|
|
}
|
|
|
|
if (output.session_id) {
|
|
setClaudeSession(sessionKey, output.session_id);
|
|
}
|
|
|
|
return {
|
|
text: output.result || '(no response)',
|
|
sessionId: sessionKey,
|
|
model: 'claude-code',
|
|
cost: {
|
|
inputTokens: output.input_tokens ?? 0,
|
|
outputTokens: output.output_tokens ?? 0,
|
|
totalUSD: output.cost_usd ?? 0,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// ── Streaming send ──
|
|
|
|
export async function spawnClaudeStreaming(
|
|
params: ClaudeSpawnStreamingParams,
|
|
onEvent: (event: ChatEvent) => void,
|
|
): Promise<void> {
|
|
const { prompt, sessionKey, email } = params;
|
|
|
|
const existingSession = getClaudeSession(sessionKey);
|
|
|
|
const claudeArgs = [
|
|
CLAUDE_BIN,
|
|
'-p',
|
|
prompt,
|
|
'--dangerously-skip-permissions',
|
|
'--output-format',
|
|
'stream-json',
|
|
'--verbose',
|
|
'--include-partial-messages',
|
|
];
|
|
|
|
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
|
const mcpConfig = mcpHostPath;
|
|
if (mcpConfig) claudeArgs.push('--mcp-config', mcpConfig);
|
|
|
|
const subModel = params.model?.split('/')[1];
|
|
if (subModel) claudeArgs.push('--model', subModel);
|
|
|
|
// Resume: an in-memory mapping (subsequent turns of a live chat) takes precedence; otherwise a
|
|
// caller-supplied session uuid (reopening a session from the /chat list) resumes Claude's transcript.
|
|
const resumeId = existingSession ?? params.resumeSessionId;
|
|
if (resumeId) {
|
|
claudeArgs.push('--resume', resumeId);
|
|
if (!existingSession) setClaudeSession(sessionKey, resumeId);
|
|
}
|
|
|
|
const spawnCmd = claudeArgs;
|
|
// Honor a caller-provided working directory (e.g. /chat runs from a dedicated general_chat_sessions
|
|
// dir); fall back to the owner's host home.
|
|
const spawnCwd = params.cwd ?? HOST_HOME;
|
|
|
|
const proc = Bun.spawn(spawnCmd, {
|
|
stdin: 'ignore',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
cwd: spawnCwd,
|
|
env: cleanEnv as Record<string, string>,
|
|
});
|
|
|
|
activeProcs.set(sessionKey, proc);
|
|
|
|
const timeout = setTimeout(() => {
|
|
try {
|
|
proc.kill();
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' });
|
|
}, SEND_TIMEOUT_MS);
|
|
|
|
// Process NDJSON stream
|
|
try {
|
|
const stdout = proc.stdout as ReadableStream<Uint8Array>;
|
|
const callbacks = {
|
|
onEvent,
|
|
onSessionId: (sessionId: string) => setClaudeSession(sessionKey, sessionId),
|
|
};
|
|
|
|
const state = await parseStream(stdout, callbacks);
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!state.gotResult) {
|
|
const exitCode = await proc.exited;
|
|
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text();
|
|
if (state.textBuffer) {
|
|
onEvent({ type: 'text', text: state.textBuffer });
|
|
}
|
|
if (exitCode !== 0) {
|
|
onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` });
|
|
} else {
|
|
onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } });
|
|
}
|
|
}
|
|
} catch (err) {
|
|
clearTimeout(timeout);
|
|
onEvent({ type: 'error', message: String(err) });
|
|
} finally {
|
|
activeProcs.delete(sessionKey);
|
|
}
|
|
}
|
|
|
|
export function killClaudeSession(sessionKey: string): boolean {
|
|
const proc = activeProcs.get(sessionKey);
|
|
if (proc) {
|
|
try {
|
|
proc.kill();
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
activeProcs.delete(sessionKey);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function clearSession(sessionKey: string): void {
|
|
clearClaudeSession(sessionKey);
|
|
}
|
|
|
|
export function getActiveSessionKeys(): string[] {
|
|
return Array.from(activeProcs.keys());
|
|
}
|