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 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<string, PiSession>();
// ── 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<string | null> {
@@ -237,8 +273,19 @@ export type PiSpawnOptions = {
export async function spawnPi(options: PiSpawnOptions): Promise<void> {
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<void> {
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<void> {
if (isSuperAdmin) {
// Super Admin: run directly with host env, no sandbox
const env: Record<string, string> = {
...process.env as Record<string, string>,
...(process.env as Record<string, string>),
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<void> {
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<void> {
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' });
+20 -2
View File
@@ -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);