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:
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user