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:
@@ -439,6 +439,7 @@ async function handleClaudeCodeChat(
|
|||||||
sessionKey: sessionId,
|
sessionKey: sessionId,
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
|
role: ws.data.role,
|
||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -560,7 +561,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
|||||||
if (session?.piProcess) {
|
if (session?.piProcess) {
|
||||||
try {
|
try {
|
||||||
if (session.model.startsWith('claude-code')) {
|
if (session.model.startsWith('claude-code')) {
|
||||||
sidecar.killClaude(sessionId);
|
sidecar.killClaude(sessionId, session.email);
|
||||||
logger.info('Killed Claude Code process via sidecar', { sessionId });
|
logger.info('Killed Claude Code process via sidecar', { sessionId });
|
||||||
} else {
|
} else {
|
||||||
sidecar.abortPi(sessionId, randomUUID());
|
sidecar.abortPi(sessionId, randomUUID());
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ async function handleCommand(ws, msg) {
|
|||||||
const cwd = config.cwd ?? process.cwd();
|
const cwd = config.cwd ?? process.cwd();
|
||||||
const homeDir = config.homeDir ?? process.cwd();
|
const homeDir = config.homeDir ?? process.cwd();
|
||||||
const userLabel = config.userLabel ?? 'officer';
|
const userLabel = config.userLabel ?? 'officer';
|
||||||
const username = config.username ?? null;
|
|
||||||
const cols = config.cols ?? 80;
|
const cols = config.cols ?? 80;
|
||||||
const rows = config.rows ?? 24;
|
const rows = config.rows ?? 24;
|
||||||
const isHost = !!config.host;
|
const isHost = !!config.host;
|
||||||
@@ -136,11 +135,8 @@ async function handleCommand(ws, msg) {
|
|||||||
spawnCommand = shell.command;
|
spawnCommand = shell.command;
|
||||||
spawnArgs = shell.args ?? [];
|
spawnArgs = shell.args ?? [];
|
||||||
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(config.env ?? {}) };
|
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(config.env ?? {}) };
|
||||||
} else if (username) {
|
|
||||||
spawnCommand = 'sudo';
|
|
||||||
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
|
|
||||||
ptyEnv = { TERM: 'xterm-256color' };
|
|
||||||
} else {
|
} else {
|
||||||
|
// Sandboxed mode: shell config contains the full bwrap command
|
||||||
spawnCommand = shell.command;
|
spawnCommand = shell.command;
|
||||||
spawnArgs = shell.args ?? [];
|
spawnArgs = shell.args ?? [];
|
||||||
|
|
||||||
@@ -150,20 +146,11 @@ async function handleCommand(ws, msg) {
|
|||||||
console.error('[pty-sidecar] ensureUserFiles failed:', err);
|
console.error('[pty-sidecar] ensureUserFiles failed:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
ptyEnv = {
|
// bwrap sets env vars internally via --setenv, so use minimal host env
|
||||||
...process.env,
|
ptyEnv = { TERM: 'xterm-256color' };
|
||||||
HOME: homeDir,
|
|
||||||
ZDOTDIR: homeDir,
|
|
||||||
ZSH: `${homeDir}/.oh-my-zsh`,
|
|
||||||
SHELL: shell.command,
|
|
||||||
USER: userLabel,
|
|
||||||
LOGNAME: userLabel,
|
|
||||||
OFFICER_TERMINAL_USER: userLabel,
|
|
||||||
TERM: 'xterm-256color',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ptyCwd = username ? undefined : cwd;
|
const ptyCwd = isHost ? cwd : undefined;
|
||||||
|
|
||||||
let term;
|
let term;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
|
|||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir } from '@@/data-path';
|
||||||
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
import { sendPtyCommand, sendPtyCommandAsync, on, isTerminalConnected } from '@@/sidecar-registry';
|
||||||
import type { PtyInitConfig } from '../../sidecar/protocol';
|
import type { PtyInitConfig } from '../../sidecar/protocol';
|
||||||
|
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../../sidecar/sandbox';
|
||||||
|
|
||||||
type WSData = {
|
type WSData = {
|
||||||
userId: number;
|
userId: number;
|
||||||
@@ -81,10 +82,21 @@ export const terminalWebsocket = {
|
|||||||
mkdirSync(dirname(homeDir), { recursive: true });
|
mkdirSync(dirname(homeDir), { recursive: true });
|
||||||
mkdirSync(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 = {
|
config = {
|
||||||
sessionId,
|
sessionId,
|
||||||
username,
|
shell: { command: bwrapArgs[0]!, args: bwrapArgs.slice(1) },
|
||||||
cwd: resolveCwd(homeDir, ws.data.cwd),
|
cwd: SANDBOX_HOME,
|
||||||
homeDir,
|
homeDir,
|
||||||
userLabel: email,
|
userLabel: email,
|
||||||
cols: ws.data.cols,
|
cols: ws.data.cols,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type ClaudeCodeParams = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
role?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClaudeCodeResult = {
|
type ClaudeCodeResult = {
|
||||||
@@ -18,8 +19,8 @@ type ClaudeCodeResult = {
|
|||||||
cost: MessageCost;
|
cost: MessageCost;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clearClaudeCodeSession(sessionKey: string): void {
|
export function clearClaudeCodeSession(sessionKey: string, email?: string): void {
|
||||||
sidecar.clearClaudeSession(sessionKey);
|
sidecar.clearClaudeSession(sessionKey, email);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCodeResult> {
|
||||||
@@ -37,6 +38,7 @@ type ClaudeCodeStreamingParams = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
role?: string;
|
||||||
onEvent: (event: PiEvent) => void;
|
onEvent: (event: PiEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kill: () => {
|
kill: () => {
|
||||||
sidecar.killClaude(params.sessionKey);
|
sidecar.killClaude(params.sessionKey, params.email);
|
||||||
unsub();
|
unsub();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,70 +1,12 @@
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { join } from 'node:path';
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import type { Subprocess } from 'bun';
|
import type { Subprocess } from 'bun';
|
||||||
import type { PiEvent } from '../../api/pi/types';
|
import type { PiEvent } from '../../api/pi/types';
|
||||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||||
|
import { buildSandboxPrefix, buildRunuserSuffix, SANDBOX_HOME } from '../sandbox';
|
||||||
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
import { setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||||
import { parseStream } from './stream-parser';
|
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 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
|
// Resolve absolute path to claude binary
|
||||||
const CLAUDE_BIN = (() => {
|
const CLAUDE_BIN = (() => {
|
||||||
@@ -72,18 +14,27 @@ const CLAUDE_BIN = (() => {
|
|||||||
return result.stdout.toString().trim() || 'claude';
|
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
|
// Active streaming processes
|
||||||
const activeProcs = new Map<string, Subprocess>();
|
const activeProcs = new Map<string, Subprocess>();
|
||||||
|
|
||||||
function buildAuthEnv(
|
// MCP config path, set by user-instance at startup
|
||||||
shellUsername: string,
|
let mcpConfigPath: string | undefined;
|
||||||
homeDir: string,
|
|
||||||
isServiceUser: boolean,
|
export function setMcpConfigPath(path: string): void {
|
||||||
userHasCredentials: boolean,
|
mcpConfigPath = path;
|
||||||
): 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() };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Blocking send ──
|
// ── Blocking send ──
|
||||||
@@ -98,17 +49,20 @@ type ClaudeCodeOutput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
|
||||||
const { userId, email, username, prompt, sessionKey } = params;
|
const { prompt, sessionKey, email } = params;
|
||||||
const homeDir = getHomeDir(email);
|
|
||||||
const shellUsername = toShellUsername(username, email);
|
|
||||||
|
|
||||||
refreshClaudeMd(email);
|
|
||||||
const mcpConfigPath = generateMcpConfig(email);
|
|
||||||
|
|
||||||
const existingSession = getClaudeSession(sessionKey);
|
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];
|
const subModel = params.model?.split('/')[1];
|
||||||
if (subModel) claudeArgs.push('--model', subModel);
|
if (subModel) claudeArgs.push('--model', subModel);
|
||||||
@@ -117,23 +71,17 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
|||||||
claudeArgs.push('--resume', existingSession);
|
claudeArgs.push('--resume', existingSession);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
const isSuperAdmin = params.role === 'Super Admin';
|
||||||
const userHasCredentials = !isServiceUser && (await hasOwnCredentials(homeDir));
|
const spawnCmd = isSuperAdmin ? claudeArgs : [...buildSandboxArgs(email), ...claudeArgs];
|
||||||
const authEnv = buildAuthEnv(shellUsername, homeDir, isServiceUser, userHasCredentials);
|
const spawnCwd = isSuperAdmin ? (process.env.HOME ?? join(DATA_PATH, email, 'home')) : undefined;
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
const proc = Bun.spawn(spawnCmd, {
|
||||||
...authEnv,
|
stdin: 'pipe',
|
||||||
PATH: process.env.PATH ?? '',
|
stdout: 'pipe',
|
||||||
TERM: 'xterm-256color',
|
stderr: 'pipe',
|
||||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
cwd: spawnCwd,
|
||||||
};
|
env: process.env as Record<string, string>,
|
||||||
|
});
|
||||||
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 timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -194,16 +142,9 @@ export async function spawnClaudeStreaming(
|
|||||||
params: ClaudeSpawnStreamingParams,
|
params: ClaudeSpawnStreamingParams,
|
||||||
onEvent: (event: PiEvent) => void,
|
onEvent: (event: PiEvent) => void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { userId, email, username, prompt, sessionKey, cwd } = params;
|
const { prompt, sessionKey, email } = params;
|
||||||
const shellUsername = toShellUsername(username, email);
|
|
||||||
const homeDir = getHomeDir(email);
|
|
||||||
const workDir = cwd ?? homeDir;
|
|
||||||
|
|
||||||
refreshClaudeMd(email);
|
|
||||||
const mcpConfigPath = generateMcpConfig(email);
|
|
||||||
|
|
||||||
const existingSession = getClaudeSession(sessionKey);
|
const existingSession = getClaudeSession(sessionKey);
|
||||||
const isResume = !!existingSession;
|
|
||||||
|
|
||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
CLAUDE_BIN,
|
CLAUDE_BIN,
|
||||||
@@ -214,10 +155,10 @@ export async function spawnClaudeStreaming(
|
|||||||
'stream-json',
|
'stream-json',
|
||||||
'--verbose',
|
'--verbose',
|
||||||
'--include-partial-messages',
|
'--include-partial-messages',
|
||||||
'--mcp-config',
|
|
||||||
mcpConfigPath,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (mcpConfigPath) claudeArgs.push('--mcp-config', mcpConfigPath);
|
||||||
|
|
||||||
const subModel = params.model?.split('/')[1];
|
const subModel = params.model?.split('/')[1];
|
||||||
if (subModel) claudeArgs.push('--model', subModel);
|
if (subModel) claudeArgs.push('--model', subModel);
|
||||||
|
|
||||||
@@ -226,30 +167,17 @@ export async function spawnClaudeStreaming(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { CLAUDECODE: _, ...cleanEnv } = process.env;
|
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 proc = Bun.spawn(spawnCmd, {
|
||||||
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',
|
stdin: 'ignore',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: { ...cleanEnv, ...env },
|
cwd: spawnCwd,
|
||||||
})
|
env: cleanEnv as Record<string, string>,
|
||||||
: Bun.spawn(
|
});
|
||||||
['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs],
|
|
||||||
{ cwd: workDir, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
||||||
);
|
|
||||||
|
|
||||||
activeProcs.set(sessionKey, proc);
|
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'));
|
||||||
@@ -3,6 +3,7 @@ import { readdirSync, existsSync, mkdirSync } from 'node:fs';
|
|||||||
import type { Subprocess } from 'bun';
|
import type { Subprocess } from 'bun';
|
||||||
import type { PiEvent, MessageCost } from '../../api/pi/types';
|
import type { PiEvent, MessageCost } from '../../api/pi/types';
|
||||||
import type { PiSpawnParams, PiSessionInfo } from '../protocol';
|
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 DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
|
||||||
const PI_CONFIG_DIR = join(require('node:os').homedir(), '.pi', 'agent');
|
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 getUserExtensionsDir = (email: string) => join(DATA_PATH, email, 'extensions');
|
||||||
const getGlobalToolsDir = () => join(DATA_PATH, 'tools');
|
const getGlobalToolsDir = () => join(DATA_PATH, 'tools');
|
||||||
const getUserToolsDir = (email: string) => join(DATA_PATH, email, '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 {
|
function isPidAlive(pid: number): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -268,30 +259,42 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
|
|||||||
mkdirSync(cwd, { recursive: true });
|
mkdirSync(cwd, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSuperAdmin = role === 'Super Admin';
|
||||||
const homeDir = getHomeDirForRole(email, role);
|
const homeDir = getHomeDirForRole(email, role);
|
||||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||||
const shellUsername = username ? toShellUsername(username, email) : toShellUsername('', email);
|
|
||||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
|
||||||
|
|
||||||
|
let proc: Subprocess;
|
||||||
|
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
// Super Admin: run directly with host env, no sandbox
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
HOME: isServiceUser ? (process.env.HOME ?? '') : homeDir,
|
...process.env as Record<string, string>,
|
||||||
|
HOME: process.env.HOME ?? homeDir,
|
||||||
OFFICER_USER_HOME: homeDir,
|
OFFICER_USER_HOME: homeDir,
|
||||||
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
OFFICER_USER_ROOT: join(DATA_PATH, email),
|
||||||
PI_CODING_AGENT_DIR: isServiceUser ? PI_CONFIG_DIR : join(homeDir, '.pi', 'agent'),
|
PI_CODING_AGENT_DIR: PI_CONFIG_DIR,
|
||||||
PI_TOOLS_DIRS: toolsDirs,
|
PI_TOOLS_DIRS: toolsDirs,
|
||||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
PATH: process.env.PATH ?? '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const proc = isServiceUser
|
proc = Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env });
|
||||||
? Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...env } })
|
} else {
|
||||||
: Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], {
|
// Non-admin: run inside bwrap sandbox
|
||||||
cwd,
|
const sandboxToolsDirs = [getGlobalToolsDir(), `${SANDBOX_DATA}/tools`].join(':');
|
||||||
stdin: 'pipe',
|
const prefix = buildSandboxPrefix(email);
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
// 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 };
|
const session: PiSession = { sessionId, email, userId, model, cwd, proc, onEvent };
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export type ClaudeSpawnParams = {
|
|||||||
prompt: string;
|
prompt: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
role?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClaudeSpawnStreamingParams = {
|
export type ClaudeSpawnStreamingParams = {
|
||||||
@@ -89,6 +90,7 @@ export type ClaudeSpawnStreamingParams = {
|
|||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
role?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ClaudeCodeResult = {
|
export type ClaudeCodeResult = {
|
||||||
@@ -134,7 +136,6 @@ export type PtyInitConfig = {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
homeDir?: string;
|
homeDir?: string;
|
||||||
userLabel?: string;
|
userLabel?: string;
|
||||||
username?: string;
|
|
||||||
host?: boolean;
|
host?: boolean;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
|||||||
@@ -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, '--'];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user