shared bwrap sandbox, skip for super admin, extend to pi and terminals
- extract buildSandboxPrefix/buildRunuserSuffix into shared sandbox.ts - super admin bypasses bwrap for full host access (claude, pi, terminal) - member pi processes now use bwrap instead of sudo -u - member terminals now use bwrap instead of sudo -u - mount /run for systemd-resolved DNS inside sandbox - pass role through claude spawn params and channel types Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,70 +1,12 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import type { Subprocess } from 'bun';
|
||||
import type { PiEvent } from '../../api/pi/types';
|
||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox';
|
||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||
import { parseStream } from './stream-parser';
|
||||
import { getProxySecret } from './proxy';
|
||||
import { DATA_PATH, getHomeDir, getGlobalToolsDir, getUserToolsDir } from '../../data-path';
|
||||
import { generateContainerContext } from '../../generate-container-context';
|
||||
|
||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||
|
||||
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
|
||||
|
||||
function refreshClaudeMd(email: string): void {
|
||||
const claudeDir = join(getHomeDir(email), '.claude');
|
||||
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
||||
const contextFile = generateContainerContext(email);
|
||||
writeFileSync(join(claudeDir, 'CLAUDE.md'), readFileSync(contextFile, 'utf-8'));
|
||||
}
|
||||
|
||||
function generateMcpConfig(email: string): string {
|
||||
const contextDir = join(DATA_PATH, email, '.container-context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
const configPath = join(contextDir, 'mcp.json');
|
||||
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].filter(existsSync).join(':');
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
args: ['run', MCP_SERVER_SCRIPT],
|
||||
env: {
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
MCP_TOOLS_LOG: join(DATA_PATH, email, 'logs', 'mcp-tools.log'),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
const toShellUsername = (username: string, email: string): string => {
|
||||
const raw = username || email.split('@')[0]!;
|
||||
return (
|
||||
raw
|
||||
.replace(/@.*$/, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
.toLowerCase()
|
||||
.slice(0, 32) || 'officer'
|
||||
);
|
||||
};
|
||||
|
||||
async function hasOwnCredentials(homeDir: string): Promise<boolean> {
|
||||
try {
|
||||
return await Bun.file(join(homeDir, '.claude', '.credentials.json')).exists();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve absolute path to claude binary
|
||||
const CLAUDE_BIN = (() => {
|
||||
@@ -72,18 +14,27 @@ const CLAUDE_BIN = (() => {
|
||||
return result.stdout.toString().trim() || 'claude';
|
||||
})();
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
|
||||
// Build full bwrap sandbox args for Claude (prefix + Anthropic env + runuser suffix)
|
||||
function buildSandboxArgs(email: string): string[] {
|
||||
const prefix = buildSandboxPrefix(email);
|
||||
|
||||
// Claude-specific env vars
|
||||
if (process.env.ANTHROPIC_BASE_URL) prefix.push('--setenv', 'ANTHROPIC_BASE_URL', process.env.ANTHROPIC_BASE_URL);
|
||||
if (process.env.ANTHROPIC_API_KEY) prefix.push('--setenv', 'ANTHROPIC_API_KEY', process.env.ANTHROPIC_API_KEY);
|
||||
|
||||
return [...prefix, ...buildRunuserSuffix()];
|
||||
}
|
||||
|
||||
// Active streaming processes
|
||||
const activeProcs = new Map<string, Subprocess>();
|
||||
|
||||
function buildAuthEnv(
|
||||
shellUsername: string,
|
||||
homeDir: string,
|
||||
isServiceUser: boolean,
|
||||
userHasCredentials: boolean,
|
||||
): Record<string, string> {
|
||||
if (isServiceUser) return { HOME: process.env.HOME ?? '' };
|
||||
if (userHasCredentials) return { HOME: homeDir };
|
||||
return { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: getProxySecret() };
|
||||
// MCP config path, set by user-instance at startup
|
||||
let mcpConfigPath: string | undefined;
|
||||
|
||||
export function setMcpConfigPath(path: string): void {
|
||||
mcpConfigPath = path;
|
||||
}
|
||||
|
||||
// ── Blocking send ──
|
||||
@@ -98,17 +49,20 @@ type ClaudeCodeOutput = {
|
||||
};
|
||||
|
||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||
const { userId, email, username, prompt, sessionKey } = params;
|
||||
const homeDir = getHomeDir(email);
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
|
||||
refreshClaudeMd(email);
|
||||
const mcpConfigPath = generateMcpConfig(email);
|
||||
const { prompt, sessionKey, email } = params;
|
||||
|
||||
const existingSession = getClaudeSession(sessionKey);
|
||||
const isResume = !!existingSession;
|
||||
|
||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json', '--mcp-config', mcpConfigPath];
|
||||
const claudeArgs = [
|
||||
CLAUDE_BIN,
|
||||
'-p',
|
||||
prompt,
|
||||
'--dangerously-skip-permissions',
|
||||
'--output-format',
|
||||
'json',
|
||||
];
|
||||
|
||||
if (mcpConfigPath) claudeArgs.push('--mcp-config', mcpConfigPath);
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
if (subModel) claudeArgs.push('--model', subModel);
|
||||
@@ -117,23 +71,17 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
||||
claudeArgs.push('--resume', existingSession);
|
||||
}
|
||||
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
||||
const authEnv = buildAuthEnv(shellUsername, homeDir, isServiceUser, userHasCredentials);
|
||||
const isSuperAdmin = params.role === 'Super Admin';
|
||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
||||
const spawnCwd = isSuperAdmin ? (process.env.HOME ?? join(DATA_PATH, email, 'home')) : undefined;
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...authEnv,
|
||||
PATH: process.env.PATH ?? '',
|
||||
TERM: 'xterm-256color',
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
};
|
||||
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(claudeArgs, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env } })
|
||||
: Bun.spawn(
|
||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
||||
{ stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
||||
);
|
||||
const proc = Bun.spawn(spawnCmd, {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: spawnCwd,
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
@@ -194,16 +142,9 @@ export async function spawnClaudeStreaming(
|
||||
params: ClaudeSpawnStreamingParams,
|
||||
onEvent: (event: PiEvent) => void,
|
||||
): Promise<void> {
|
||||
const { userId, email, username, prompt, sessionKey, cwd } = params;
|
||||
const shellUsername = toShellUsername(username, email);
|
||||
const homeDir = getHomeDir(email);
|
||||
const workDir = cwd ?? homeDir;
|
||||
|
||||
refreshClaudeMd(email);
|
||||
const mcpConfigPath = generateMcpConfig(email);
|
||||
const { prompt, sessionKey, email } = params;
|
||||
|
||||
const existingSession = getClaudeSession(sessionKey);
|
||||
const isResume = !!existingSession;
|
||||
|
||||
const claudeArgs = [
|
||||
CLAUDE_BIN,
|
||||
@@ -214,10 +155,10 @@ export async function spawnClaudeStreaming(
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--include-partial-messages',
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
];
|
||||
|
||||
if (mcpConfigPath) claudeArgs.push('--mcp-config', mcpConfigPath);
|
||||
|
||||
const subModel = params.model?.split('/')[1];
|
||||
if (subModel) claudeArgs.push('--model', subModel);
|
||||
|
||||
@@ -226,30 +167,17 @@ export async function spawnClaudeStreaming(
|
||||
}
|
||||
|
||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
||||
const isSuperAdmin = params.role === 'Super Admin';
|
||||
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
||||
const spawnCwd = isSuperAdmin ? (process.env.HOME ?? join(DATA_PATH, email, 'home')) : undefined;
|
||||
|
||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
||||
const authEnv = buildAuthEnv(shellUsername, homeDir, isServiceUser, userHasCredentials);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...authEnv,
|
||||
PATH: cleanEnv.PATH ?? '',
|
||||
TERM: 'xterm-256color',
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
};
|
||||
|
||||
const proc = isServiceUser
|
||||
? Bun.spawn(claudeArgs, {
|
||||
cwd: workDir,
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...cleanEnv, ...env },
|
||||
})
|
||||
: Bun.spawn(
|
||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
||||
{ cwd: workDir, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
);
|
||||
const proc = Bun.spawn(spawnCmd, {
|
||||
stdin: 'ignore',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: spawnCwd,
|
||||
env: cleanEnv as Record<string, string>,
|
||||
});
|
||||
|
||||
activeProcs.set(sessionKey, proc);
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock } from './state';
|
||||
import { setMcpConfigPath } from './claude-manager';
|
||||
import { SANDBOX_DATA } from '../sandbox';
|
||||
import * as claudeManager from './claude-manager';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
|
||||
const email = process.env.CLAUDE_USER_EMAIL;
|
||||
if (!email) {
|
||||
console.error('[user-instance] CLAUDE_USER_EMAIL is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
|
||||
|
||||
const homeDir = join(DATA_PATH, email, 'home');
|
||||
const globalToolsDir = join(DATA_PATH, 'tools');
|
||||
const userToolsDir = join(DATA_PATH, email, 'tools');
|
||||
|
||||
// ── Path setup ──
|
||||
|
||||
// Set HOME so claude inherits it
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
// Init per-user state paths
|
||||
initPaths(email);
|
||||
|
||||
if (!acquireLock()) {
|
||||
console.error(`[claude:${email}] another instance is already running (lock file exists with live PID)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
loadState();
|
||||
|
||||
// ── CLAUDE.md refresh ──
|
||||
|
||||
function refreshClaudeMd(): void {
|
||||
const claudeDir = join(homeDir, '.claude');
|
||||
if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
// generateContainerContext writes and returns the file path
|
||||
// Import inline to avoid circular deps at module level
|
||||
const { generateContainerContext } = require('../../generate-container-context') as {
|
||||
generateContainerContext: (email: string) => string;
|
||||
};
|
||||
const contextFile = generateContainerContext(email!);
|
||||
writeFileSync(join(claudeDir, 'CLAUDE.md'), readFileSync(contextFile, 'utf-8'));
|
||||
}
|
||||
|
||||
// ── MCP config ──
|
||||
|
||||
function generateMcpConfig(): string {
|
||||
const contextDir = join(DATA_PATH, email!, '.container-context');
|
||||
mkdirSync(contextDir, { recursive: true });
|
||||
const configPath = join(contextDir, 'mcp.json');
|
||||
|
||||
// Inside the sandbox, user data is mounted at SANDBOX_DATA (/data)
|
||||
// Global tools stay at their original paths (mounted read-only at same path)
|
||||
const sandboxUserToolsDir = `${SANDBOX_DATA}/tools`;
|
||||
const toolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [sandboxUserToolsDir] : [])].join(':');
|
||||
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
args: ['run', MCP_SERVER_SCRIPT],
|
||||
env: {
|
||||
PI_TOOLS_DIRS: toolsDirs,
|
||||
OFFICER_EMAIL_DB: `${SANDBOX_DATA}/emails.db`,
|
||||
MCP_TOOLS_LOG: `${SANDBOX_DATA}/logs/mcp-tools.log`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config));
|
||||
// Return the sandbox path (config file is inside user data, mounted at /data)
|
||||
return `${SANDBOX_DATA}/.container-context/mcp.json`;
|
||||
}
|
||||
|
||||
// ── Startup ──
|
||||
|
||||
try {
|
||||
refreshClaudeMd();
|
||||
} catch (err) {
|
||||
console.error(`[claude:${email}] failed to refresh CLAUDE.md:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
|
||||
const mcpPath = generateMcpConfig();
|
||||
setMcpConfigPath(mcpPath);
|
||||
|
||||
console.log(`[claude:${email}] started (HOME=${homeDir})`);
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:spawn': {
|
||||
try {
|
||||
const result = await claudeManager.spawnClaude(cmd.params);
|
||||
reply({ type: 'claude:result', id: cmd.id, result });
|
||||
} catch (err) {
|
||||
reply({ type: 'claude:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:spawn-streaming': {
|
||||
reply({ type: 'claude:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
|
||||
|
||||
const onEvent = (event: import('../../api/pi/types').PiEvent) => {
|
||||
connection.send({ type: 'claude:event', sessionKey: cmd.params.sessionKey, event });
|
||||
};
|
||||
|
||||
claudeManager.spawnClaudeStreaming(cmd.params, onEvent).catch((err) => {
|
||||
connection.send({
|
||||
type: 'claude:event',
|
||||
sessionKey: cmd.params.sessionKey,
|
||||
event: { type: 'error', message: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
|
||||
case 'claude:clear-session':
|
||||
claudeManager.clearSession(cmd.sessionKey);
|
||||
reply({ type: 'claude:session-cleared', id: cmd.id });
|
||||
break;
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: `claude:${email}`,
|
||||
capabilities: ['claude'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
console.log(`[claude:${email}] ${signal} received, saving state...`);
|
||||
connection.destroy();
|
||||
await flushAndSave();
|
||||
releaseLock();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
Reference in New Issue
Block a user