fix sandbox tool/extension/skill discovery for members

Sandbox now mounts global content at short /officer/* paths to avoid
bwrap intermediate directory traversal issues. Pi uses NODE_PATH for
extension dependency resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 07:13:19 +00:00
co-authored by Claude Opus 4.6
parent 8264e7b995
commit 8377da8a77
2 changed files with 96 additions and 30 deletions
+76 -28
View File
@@ -1,9 +1,17 @@
import { join } from 'node:path'; import { join, dirname } from 'node:path';
import { readdirSync, existsSync, mkdirSync } from 'node:fs'; 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'; 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 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');
@@ -41,6 +49,22 @@ const PI_CMD = (() => {
return [piBin]; 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 // Active Pi processes
type PiSession = { type PiSession = {
sessionId: string; sessionId: string;
@@ -56,34 +80,46 @@ const sessions = new Map<string, PiSession>();
// ── Helpers ── // ── Helpers ──
function collectSkillFlags(email: string): string[] { function collectSkillFlagsFromDir(scanDir: string, targetDir: string): string[] {
const flags: string[] = []; const flags: string[] = [];
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)]; if (!existsSync(scanDir)) return flags;
for (const dir of dirs) {
if (!existsSync(dir)) continue; for (const entry of readdirSync(scanDir, { withFileTypes: true })) {
for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue;
if (!entry.isDirectory()) continue; if (existsSync(join(scanDir, entry.name, 'SKILL.md'))) {
if (existsSync(join(dir, entry.name, 'SKILL.md'))) { flags.push('--skill', `${targetDir}/${entry.name}`);
flags.push('--skill', `${dir}/${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; return flags;
} }
function collectExtensionFlags(email: string): string[] { function collectExtensionFlags(email: string): string[] {
const flags: string[] = []; return [
const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)]; ...collectExtensionFlagsFromDir(getGlobalExtensionsDir(), getGlobalExtensionsDir()),
for (const dir of dirs) { ...collectExtensionFlagsFromDir(getUserExtensionsDir(email), getUserExtensionsDir(email)),
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;
} }
async function resolveApiKeyForModel(model: string): Promise<string | null> { async function resolveApiKeyForModel(model: string): Promise<string | null> {
@@ -237,8 +273,19 @@ export type PiSpawnOptions = {
export async function spawnPi(options: PiSpawnOptions): Promise<void> { export async function spawnPi(options: PiSpawnOptions): Promise<void> {
const { sessionId, email, userId, username, role, cwd, model, sessionFile, onEvent } = options; const { sessionId, email, userId, username, role, cwd, model, sessionFile, onEvent } = options;
const skillFlags = collectSkillFlags(email); const isSuperAdmin = role === 'Super Admin';
const extensionFlags = collectExtensionFlags(email); 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 = [ const piArgs = [
...PI_CMD, ...PI_CMD,
@@ -260,7 +307,6 @@ 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(':');
@@ -269,7 +315,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
if (isSuperAdmin) { if (isSuperAdmin) {
// Super Admin: run directly with host env, no sandbox // Super Admin: run directly with host env, no sandbox
const env: Record<string, string> = { const env: Record<string, string> = {
...process.env as Record<string, string>, ...(process.env as Record<string, string>),
HOME: process.env.HOME ?? homeDir, 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),
@@ -278,11 +324,12 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
TERM: 'xterm-256color', 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 }); proc = Bun.spawn(piArgs, { cwd, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env });
} else { } else {
// Non-admin: run inside bwrap sandbox // 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); const prefix = buildSandboxPrefix(email);
// Pi-specific env vars // Pi-specific env vars
@@ -292,6 +339,7 @@ export async function spawnPi(options: PiSpawnOptions): Promise<void> {
prefix.push('--setenv', 'PI_TOOLS_DIRS', sandboxToolsDirs); prefix.push('--setenv', 'PI_TOOLS_DIRS', sandboxToolsDirs);
prefix.push('--setenv', 'OFFICER_EMAIL_DB', `${SANDBOX_DATA}/emails.db`); prefix.push('--setenv', 'OFFICER_EMAIL_DB', `${SANDBOX_DATA}/emails.db`);
prefix.push('--setenv', 'TERM', 'xterm-256color'); prefix.push('--setenv', 'TERM', 'xterm-256color');
if (PI_NODE_MODULES) prefix.push('--setenv', 'NODE_PATH', PI_NODE_MODULES);
const sandboxArgs = [...prefix, ...buildRunuserSuffix()]; const sandboxArgs = [...prefix, ...buildRunuserSuffix()];
proc = Bun.spawn([...sandboxArgs, ...piArgs], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }); proc = Bun.spawn([...sandboxArgs, ...piArgs], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
+20 -2
View File
@@ -17,15 +17,20 @@ const OS_USERNAME = (() => {
return result.stdout.toString().trim() || 'pastilhas'; 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_DATA = '/data';
export const SANDBOX_HOME = `${SANDBOX_DATA}/home`; 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. // Build bwrap sandbox prefix for a given user email.
// Returns args up to (but not including) the `-- runuser` suffix. // Returns args up to (but not including) the `-- runuser` suffix.
// Callers can append extra `--setenv` args before calling `buildRunuserSuffix()`. // Callers can append extra `--setenv` args before calling `buildRunuserSuffix()`.
export function buildSandboxPrefix(email: string): string[] { export function buildSandboxPrefix(email: string): string[] {
const userDataDir = join(DATA_PATH, email); const userDataDir = join(DATA_PATH, email);
const globalSkillsDir = join(DATA_PATH, 'skills');
const globalToolsDir = join(DATA_PATH, 'tools'); const globalToolsDir = join(DATA_PATH, 'tools');
const globalExtensionsDir = join(DATA_PATH, 'extensions'); const globalExtensionsDir = join(DATA_PATH, 'extensions');
@@ -83,10 +88,23 @@ export function buildSandboxPrefix(email: string): string[] {
// Project source (for MCP server) // Project source (for MCP server)
args.push('--ro-bind', PROJECT_ROOT, PROJECT_ROOT); 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(globalToolsDir)) args.push('--ro-bind', globalToolsDir, globalToolsDir);
if (existsSync(globalExtensionsDir)) args.push('--ro-bind', globalExtensionsDir, globalExtensionsDir); 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) // User data (read-write, mounted at /data to avoid intermediate dir permission issues)
args.push('--bind', userDataDir, SANDBOX_DATA); args.push('--bind', userDataDir, SANDBOX_DATA);