diff --git a/src/servers/sidecar/pi/pi-manager.ts b/src/servers/sidecar/pi/pi-manager.ts index 6fbbada5..9e9d0658 100644 --- a/src/servers/sidecar/pi/pi-manager.ts +++ b/src/servers/sidecar/pi/pi-manager.ts @@ -1,9 +1,17 @@ -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; 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'; +import { + buildSandboxPrefix, + buildRunuserSuffix, + SANDBOX_DATA, + SANDBOX_GLOBAL_EXTENSIONS, + SANDBOX_GLOBAL_SKILLS, + SANDBOX_GLOBAL_TOOLS, + 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'); @@ -41,6 +49,22 @@ const PI_CMD = (() => { return [piBin]; })(); +// Pi's nested node_modules — needed for NODE_PATH so extensions can resolve Pi's dependencies +// (e.g. @sinclair/typebox used by the tool-loader extension) +const PI_NODE_MODULES = (() => { + const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' }); + const piBin = whichResult.stdout.toString().trim() || 'pi'; + const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' }); + const realPath = readlinkResult.stdout.toString().trim(); + // cli.js is at .../pi-coding-agent/dist/cli.js — node_modules is at .../pi-coding-agent/node_modules + if (realPath) { + const pkgDir = join(dirname(realPath), '..'); + const nm = join(pkgDir, 'node_modules'); + if (existsSync(nm)) return nm; + } + return null; +})(); + // Active Pi processes type PiSession = { sessionId: string; @@ -56,34 +80,46 @@ const sessions = new Map(); // ── Helpers ── -function collectSkillFlags(email: string): string[] { +function collectSkillFlagsFromDir(scanDir: string, targetDir: string): string[] { const flags: string[] = []; - const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)]; - for (const dir of dirs) { - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (existsSync(join(dir, entry.name, 'SKILL.md'))) { - flags.push('--skill', `${dir}/${entry.name}`); - } + if (!existsSync(scanDir)) return flags; + + for (const entry of readdirSync(scanDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (existsSync(join(scanDir, entry.name, 'SKILL.md'))) { + flags.push('--skill', `${targetDir}/${entry.name}`); } } + + return flags; +} + +function collectSkillFlags(email: string): string[] { + return [ + ...collectSkillFlagsFromDir(getGlobalSkillsDir(), getGlobalSkillsDir()), + ...collectSkillFlagsFromDir(getUserSkillsDir(email), getUserSkillsDir(email)), + ]; +} + +function collectExtensionFlagsFromDir(scanDir: string, targetDir: string): string[] { + const flags: string[] = []; + if (!existsSync(scanDir)) return flags; + + for (const entry of readdirSync(scanDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (existsSync(join(scanDir, entry.name, 'index.ts'))) { + flags.push('--extension', `${targetDir}/${entry.name}/index.ts`); + } + } + return flags; } function collectExtensionFlags(email: string): string[] { - const flags: string[] = []; - const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)]; - for (const dir of dirs) { - if (!existsSync(dir)) continue; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - if (existsSync(join(dir, entry.name, 'index.ts'))) { - flags.push('--extension', `${dir}/${entry.name}/index.ts`); - } - } - } - return flags; + return [ + ...collectExtensionFlagsFromDir(getGlobalExtensionsDir(), getGlobalExtensionsDir()), + ...collectExtensionFlagsFromDir(getUserExtensionsDir(email), getUserExtensionsDir(email)), + ]; } async function resolveApiKeyForModel(model: string): Promise { @@ -237,8 +273,19 @@ export type PiSpawnOptions = { export async function spawnPi(options: PiSpawnOptions): Promise { const { sessionId, email, userId, username, role, cwd, model, sessionFile, onEvent } = options; - const skillFlags = collectSkillFlags(email); - const extensionFlags = collectExtensionFlags(email); + const isSuperAdmin = role === 'Super Admin'; + const skillFlags = isSuperAdmin + ? collectSkillFlags(email) + : [ + ...collectSkillFlagsFromDir(getGlobalSkillsDir(), SANDBOX_GLOBAL_SKILLS), + ...collectSkillFlagsFromDir(getUserSkillsDir(email), `${SANDBOX_DATA}/skills`), + ]; + const extensionFlags = isSuperAdmin + ? collectExtensionFlags(email) + : [ + ...collectExtensionFlagsFromDir(getGlobalExtensionsDir(), SANDBOX_GLOBAL_EXTENSIONS), + ...collectExtensionFlagsFromDir(getUserExtensionsDir(email), `${SANDBOX_DATA}/extensions`), + ]; const piArgs = [ ...PI_CMD, @@ -260,7 +307,6 @@ 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(':'); @@ -269,7 +315,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise { if (isSuperAdmin) { // Super Admin: run directly with host env, no sandbox const env: Record = { - ...process.env as Record, + ...(process.env as Record), HOME: process.env.HOME ?? homeDir, OFFICER_USER_HOME: homeDir, OFFICER_USER_ROOT: join(DATA_PATH, email), @@ -278,11 +324,12 @@ export async function spawnPi(options: PiSpawnOptions): Promise { OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), TERM: 'xterm-256color', }; + if (PI_NODE_MODULES) env.NODE_PATH = PI_NODE_MODULES; 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 sandboxToolsDirs = [SANDBOX_GLOBAL_TOOLS, `${SANDBOX_DATA}/tools`].join(':'); const prefix = buildSandboxPrefix(email); // Pi-specific env vars @@ -292,6 +339,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise { prefix.push('--setenv', 'PI_TOOLS_DIRS', sandboxToolsDirs); prefix.push('--setenv', 'OFFICER_EMAIL_DB', `${SANDBOX_DATA}/emails.db`); prefix.push('--setenv', 'TERM', 'xterm-256color'); + if (PI_NODE_MODULES) prefix.push('--setenv', 'NODE_PATH', PI_NODE_MODULES); const sandboxArgs = [...prefix, ...buildRunuserSuffix()]; proc = Bun.spawn([...sandboxArgs, ...piArgs], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }); diff --git a/src/servers/sidecar/sandbox.ts b/src/servers/sidecar/sandbox.ts index dc0f54ac..b61535da 100644 --- a/src/servers/sidecar/sandbox.ts +++ b/src/servers/sidecar/sandbox.ts @@ -17,15 +17,20 @@ const OS_USERNAME = (() => { return result.stdout.toString().trim() || 'pastilhas'; })(); -// Sandbox mount point for user data (short path avoids intermediate dir traversal issues) +// Sandbox mount points export const SANDBOX_DATA = '/data'; export const SANDBOX_HOME = `${SANDBOX_DATA}/home`; +export const SANDBOX_GLOBAL_ROOT = '/officer'; +export const SANDBOX_GLOBAL_SKILLS = `${SANDBOX_GLOBAL_ROOT}/skills`; +export const SANDBOX_GLOBAL_EXTENSIONS = `${SANDBOX_GLOBAL_ROOT}/extensions`; +export const SANDBOX_GLOBAL_TOOLS = `${SANDBOX_GLOBAL_ROOT}/tools`; // 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 globalSkillsDir = join(DATA_PATH, 'skills'); const globalToolsDir = join(DATA_PATH, 'tools'); const globalExtensionsDir = join(DATA_PATH, 'extensions'); @@ -83,10 +88,23 @@ export function buildSandboxPrefix(email: string): string[] { // 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) + // Ensure DATA_PATH intermediate dirs are traversable (same issue as HOME) + args.push('--perms', '0755', '--dir', DATA_PATH); + + // Global content mounted at original paths for existing host-path references + if (existsSync(globalSkillsDir)) args.push('--ro-bind', globalSkillsDir, globalSkillsDir); if (existsSync(globalToolsDir)) args.push('--ro-bind', globalToolsDir, globalToolsDir); if (existsSync(globalExtensionsDir)) args.push('--ro-bind', globalExtensionsDir, globalExtensionsDir); + // Ensure sandbox-local global root is traversable before mounting nested paths under it. + args.push('--perms', '0755', '--dir', SANDBOX_GLOBAL_ROOT); + + // Global content also mounted at short sandbox-local paths so nested imports do not + // depend on traversing host-specific parent directories created by bwrap. + if (existsSync(globalSkillsDir)) args.push('--ro-bind', globalSkillsDir, SANDBOX_GLOBAL_SKILLS); + if (existsSync(globalToolsDir)) args.push('--ro-bind', globalToolsDir, SANDBOX_GLOBAL_TOOLS); + if (existsSync(globalExtensionsDir)) args.push('--ro-bind', globalExtensionsDir, SANDBOX_GLOBAL_EXTENSIONS); + // User data (read-write, mounted at /data to avoid intermediate dir permission issues) args.push('--bind', userDataDir, SANDBOX_DATA);