Files
platform/src/servers/sidecar/claude/claude-manager.ts
T
brunorezioandClaude Opus 5 044aacf4d5 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>
2026-07-25 23:30:20 +01:00

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());
}