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
+54 -126
View File
@@ -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);