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:
2026-03-08 00:06:44 +00:00
co-authored by Claude Opus 4.6
parent c38d5b0ea1
commit 9578110e8b
9 changed files with 386 additions and 180 deletions
+177
View File
@@ -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'));