From 9578110e8b6acc0a598ce4482dc3fb7263f5ac32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 8 Mar 2026 00:06:44 +0000 Subject: [PATCH] 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 --- src/servers/api/pi/websocket.ts | 3 +- src/servers/api/terminal/pty-sidecar.mjs | 21 +-- src/servers/api/terminal/websocket.ts | 16 +- src/servers/channels/send-claude-code.ts | 8 +- src/servers/sidecar/claude/claude-manager.ts | 180 ++++++------------- src/servers/sidecar/claude/user-instance.ts | 177 ++++++++++++++++++ src/servers/sidecar/pi/pi-manager.ts | 63 +++---- src/servers/sidecar/protocol.ts | 3 +- src/servers/sidecar/sandbox.ts | 95 ++++++++++ 9 files changed, 386 insertions(+), 180 deletions(-) create mode 100644 src/servers/sidecar/claude/user-instance.ts create mode 100644 src/servers/sidecar/sandbox.ts diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 4c48b64f..40ce5429 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -439,6 +439,7 @@ async function handleClaudeCodeChat( sessionKey: sessionId, cwd, model, + role: ws.data.role, onEvent, }); @@ -560,7 +561,7 @@ async function handleStop(ws: ServerWebSocket): Promise { if (session?.piProcess) { try { if (session.model.startsWith('claude-code')) { - sidecar.killClaude(sessionId); + sidecar.killClaude(sessionId, session.email); logger.info('Killed Claude Code process via sidecar', { sessionId }); } else { sidecar.abortPi(sessionId, randomUUID()); diff --git a/src/servers/api/terminal/pty-sidecar.mjs b/src/servers/api/terminal/pty-sidecar.mjs index b1d22de9..c09d2999 100644 --- a/src/servers/api/terminal/pty-sidecar.mjs +++ b/src/servers/api/terminal/pty-sidecar.mjs @@ -123,7 +123,6 @@ async function handleCommand(ws, msg) { const cwd = config.cwd ?? process.cwd(); const homeDir = config.homeDir ?? process.cwd(); const userLabel = config.userLabel ?? 'officer'; - const username = config.username ?? null; const cols = config.cols ?? 80; const rows = config.rows ?? 24; const isHost = !!config.host; @@ -136,11 +135,8 @@ async function handleCommand(ws, msg) { spawnCommand = shell.command; spawnArgs = shell.args ?? []; ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(config.env ?? {}) }; - } else if (username) { - spawnCommand = 'sudo'; - spawnArgs = ['-u', username, '-i', '/bin/zsh']; - ptyEnv = { TERM: 'xterm-256color' }; } else { + // Sandboxed mode: shell config contains the full bwrap command spawnCommand = shell.command; spawnArgs = shell.args ?? []; @@ -150,20 +146,11 @@ async function handleCommand(ws, msg) { console.error('[pty-sidecar] ensureUserFiles failed:', err); } - ptyEnv = { - ...process.env, - HOME: homeDir, - ZDOTDIR: homeDir, - ZSH: `${homeDir}/.oh-my-zsh`, - SHELL: shell.command, - USER: userLabel, - LOGNAME: userLabel, - OFFICER_TERMINAL_USER: userLabel, - TERM: 'xterm-256color', - }; + // bwrap sets env vars internally via --setenv, so use minimal host env + ptyEnv = { TERM: 'xterm-256color' }; } - const ptyCwd = username ? undefined : cwd; + const ptyCwd = isHost ? cwd : undefined; let term; try { diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index d687c4c4..8551d018 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'; import { getHomeDir } from '@@/data-path'; import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry'; import type { PtyInitConfig } from '../../sidecar/protocol'; +import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../../sidecar/sandbox'; type WSData = { userId: number; @@ -81,10 +82,21 @@ export const terminalWebsocket = { mkdirSync(dirname(homeDir), { recursive: true }); mkdirSync(homeDir, { recursive: true }); + // Build bwrap command for sandboxed terminal + const prefix = buildSandboxPrefix(email); + prefix.push('--setenv', 'ZDOTDIR', SANDBOX_HOME); + prefix.push('--setenv', 'ZSH', `${SANDBOX_HOME}/.oh-my-zsh`); + prefix.push('--setenv', 'SHELL', '/bin/zsh'); + prefix.push('--setenv', 'USER', email); + prefix.push('--setenv', 'LOGNAME', email); + prefix.push('--setenv', 'OFFICER_TERMINAL_USER', email); + prefix.push('--setenv', 'TERM', 'xterm-256color'); + const bwrapArgs = [...prefix, ...buildRunuserSuffix(), '/bin/zsh', '-i']; + config = { sessionId, - username, - cwd: resolveCwd(homeDir, ws.data.cwd), + shell: { command: bwrapArgs[0]!, args: bwrapArgs.slice(1) }, + cwd: SANDBOX_HOME, homeDir, userLabel: email, cols: ws.data.cols, diff --git a/src/servers/channels/send-claude-code.ts b/src/servers/channels/send-claude-code.ts index b9a72e03..9157e3b8 100644 --- a/src/servers/channels/send-claude-code.ts +++ b/src/servers/channels/send-claude-code.ts @@ -9,6 +9,7 @@ type ClaudeCodeParams = { prompt: string; sessionKey: string; model?: string; + role?: string; }; type ClaudeCodeResult = { @@ -18,8 +19,8 @@ type ClaudeCodeResult = { cost: MessageCost; }; -export function clearClaudeCodeSession(sessionKey: string): void { - sidecar.clearClaudeSession(sessionKey); +export function clearClaudeCodeSession(sessionKey: string, email?: string): void { + sidecar.clearClaudeSession(sessionKey, email); } export async function sendClaudeCode(params: ClaudeCodeParams): Promise { @@ -37,6 +38,7 @@ type ClaudeCodeStreamingParams = { sessionKey: string; cwd?: string; model?: string; + role?: string; onEvent: (event: PiEvent) => void; }; @@ -65,7 +67,7 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams) return { kill: () => { - sidecar.killClaude(params.sessionKey); + sidecar.killClaude(params.sessionKey, params.email); unsub(); }, }; diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 8cc22c79..09e60f7b 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -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 { - 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(); -function buildAuthEnv( - shellUsername: string, - homeDir: string, - isServiceUser: boolean, - userHasCredentials: boolean, -): Record { - 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 { - 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 = { - ...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, + }); const timeout = setTimeout(() => { try { @@ -194,16 +142,9 @@ export async function spawnClaudeStreaming( params: ClaudeSpawnStreamingParams, onEvent: (event: PiEvent) => void, ): Promise { - 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 = { - ...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, + }); activeProcs.set(sessionKey, proc); diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts new file mode 100644 index 00000000..289a86b4 --- /dev/null +++ b/src/servers/sidecar/claude/user-instance.ts @@ -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).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')); diff --git a/src/servers/sidecar/pi/pi-manager.ts b/src/servers/sidecar/pi/pi-manager.ts index ff146ac1..c67f9c40 100644 --- a/src/servers/sidecar/pi/pi-manager.ts +++ b/src/servers/sidecar/pi/pi-manager.ts @@ -3,6 +3,7 @@ import { readdirSync, existsSync, mkdirSync } from 'node:fs'; import type { Subprocess } from 'bun'; import type { PiEvent, MessageCost } from '../../api/pi/types'; import type { PiSpawnParams, PiSessionInfo } from '../protocol'; +import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_DATA, SANDBOX_HOME } from '../sandbox'; const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent'); @@ -15,16 +16,6 @@ const getGlobalExtensionsDir = () => join(DATA_PATH, 'extensions'); const getUserExtensionsDir = (email: string) => join(DATA_PATH, email, 'extensions'); const getGlobalToolsDir = () => join(DATA_PATH, 'tools'); const getUserToolsDir = (email: string) => join(DATA_PATH, email, 'tools'); -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' - ); -}; function isPidAlive(pid: number): boolean { try { @@ -268,30 +259,42 @@ export async function spawnPi(options: PiSpawnOptions): Promise { mkdirSync(cwd, { recursive: true }); } + const isSuperAdmin = role === 'Super Admin'; const homeDir = getHomeDirForRole(email, role); const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); - const shellUsername = username ? toShellUsername(username, email) : toShellUsername('', email); - const isServiceUser = shellUsername === (process.env.USER ?? ''); - const env: Record = { - HOME: isServiceUser ? (process.env.HOME ?? '') : homeDir, - OFFICER_USER_HOME: homeDir, - OFFICER_USER_ROOT: join(DATA_PATH, email), - PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'), - PI_TOOLS_DIRS: toolsDirs, - OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), - TERM: 'xterm-256color', - PATH: process.env.PATH ?? '', - }; + let proc: Subprocess; - const proc = isServiceUser - ? Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env } }) - : Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], { - cwd, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); + if (isSuperAdmin) { + // Super Admin: run directly with host env, no sandbox + const env: Record = { + ...process.env as Record, + HOME: process.env.HOME ?? homeDir, + OFFICER_USER_HOME: homeDir, + OFFICER_USER_ROOT: join(DATA_PATH, email), + PI_CODING_AGENT_DIR: PI_CONFIG_DIR, + PI_TOOLS_DIRS: toolsDirs, + OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), + TERM: 'xterm-256color', + }; + + proc = Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env }); + } else { + // Non-admin: run inside bwrap sandbox + const sandboxToolsDirs = [getGlobalToolsDir(), `${SANDBOX_DATA}/tools`].join(':'); + const prefix = buildSandboxPrefix(email); + + // Pi-specific env vars + prefix.push('--setenv', 'OFFICER_USER_HOME', SANDBOX_HOME); + prefix.push('--setenv', 'OFFICER_USER_ROOT', SANDBOX_DATA); + prefix.push('--setenv', 'PI_CODING_AGENT_DIR', `${SANDBOX_HOME}/.pi/agent`); + prefix.push('--setenv', 'PI_TOOLS_DIRS', sandboxToolsDirs); + prefix.push('--setenv', 'OFFICER_EMAIL_DB', `${SANDBOX_DATA}/emails.db`); + prefix.push('--setenv', 'TERM', 'xterm-256color'); + + const sandboxArgs = [...prefix, ...buildRunuserSuffix()]; + proc = Bun.spawn([...sandboxArgs, ...piArgs], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }); + } const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent }; sessions.set(sessionId, session); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index d5f9218e..96e1e80b 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -79,6 +79,7 @@ export type ClaudeSpawnParams = { prompt: string; sessionKey: string; model?: string; + role?: string; }; export type ClaudeSpawnStreamingParams = { @@ -89,6 +90,7 @@ export type ClaudeSpawnStreamingParams = { sessionKey: string; cwd?: string; model?: string; + role?: string; }; export type ClaudeCodeResult = { @@ -134,7 +136,6 @@ export type PtyInitConfig = { cwd?: string; homeDir?: string; userLabel?: string; - username?: string; host?: boolean; cols?: number; rows?: number; diff --git a/src/servers/sidecar/sandbox.ts b/src/servers/sidecar/sandbox.ts new file mode 100644 index 00000000..f800f609 --- /dev/null +++ b/src/servers/sidecar/sandbox.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +// Resolve paths for sandbox +const BUN_DIR = (() => { + const result = Bun.spawnSync({ cmd: ['which', 'bun'], stdout: 'pipe', stderr: 'ignore' }); + const binDir = dirname(result.stdout.toString().trim()); + return dirname(binDir); // e.g. /home/pastilhas/.bun +})(); + +const PROJECT_ROOT = resolve(import.meta.dir, '../../..'); +const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); + +// Resolve the OS username for runuser to drop privileges inside the sandbox +const OS_USERNAME = (() => { + const result = Bun.spawnSync({ cmd: ['id', '-un'], stdout: 'pipe', stderr: 'ignore' }); + return result.stdout.toString().trim() || 'pastilhas'; +})(); + +// Sandbox mount point for user data (short path avoids intermediate dir traversal issues) +export const SANDBOX_DATA = '/data'; +export const SANDBOX_HOME = `${SANDBOX_DATA}/home`; + +// Build bwrap sandbox prefix for a given user email. +// Returns args up to (but not including) the `-- runuser` suffix. +// Callers can append extra `--setenv` args before calling `buildRunuserSuffix()`. +export function buildSandboxPrefix(email: string): string[] { + const userDataDir = join(DATA_PATH, email); + const globalToolsDir = join(DATA_PATH, 'tools'); + const globalExtensionsDir = join(DATA_PATH, 'extensions'); + + const args = [ + 'sudo', + 'bwrap', + '--share-net', + '--die-with-parent', + '--proc', + '/proc', + '--dev', + '/dev', + '--perms', + '1777', + '--tmpfs', + '/tmp', + // System (read-only) + '--ro-bind', + '/usr', + '/usr', + '--ro-bind', + '/lib', + '/lib', + '--ro-bind', + '/bin', + '/bin', + '--ro-bind', + '/etc', + '/etc', + // /run is needed for systemd-resolved DNS (resolv.conf symlink target) + '--ro-bind', + '/run', + '/run', + ]; + + // Optional system paths + if (existsSync('/lib64')) args.push('--ro-bind', '/lib64', '/lib64'); + if (existsSync('/sbin')) args.push('--ro-bind', '/sbin', '/sbin'); + + // Bun runtime (e.g. /home/pastilhas/.bun) + args.push('--ro-bind', BUN_DIR, BUN_DIR); + + // Project source (for MCP server) + args.push('--ro-bind', PROJECT_ROOT, PROJECT_ROOT); + + // Global tools/extensions (read-only, mounted at original paths for MCP config references) + if (existsSync(globalToolsDir)) args.push('--ro-bind', globalToolsDir, globalToolsDir); + if (existsSync(globalExtensionsDir)) args.push('--ro-bind', globalExtensionsDir, globalExtensionsDir); + + // User data (read-write, mounted at /data to avoid intermediate dir permission issues) + args.push('--bind', userDataDir, SANDBOX_DATA); + + // Common env vars inside the sandbox (sudo strips the environment) + args.push('--setenv', 'HOME', SANDBOX_HOME); + args.push('--setenv', 'PATH', process.env.PATH ?? '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'); + + // Set working directory inside the sandbox + args.push('--chdir', SANDBOX_HOME); + + return args; +} + +// Build the runuser suffix that drops privileges to the OS user. +// Append this after any extra --setenv args. +export function buildRunuserSuffix(): string[] { + return ['--', 'runuser', '--preserve-environment', '-u', OS_USERNAME, '--']; +}