diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 2a8397c5..79af1be3 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -204,8 +204,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise injectedSkillKeys.has(entry.key) && entry.source.length > 0) + .map((entry) => path.join(entry.source, "bin")); + const mergedEnv = ensurePathInEnv({ ...process.env, ...env }); + const pathKey = + typeof mergedEnv.Path === "string" && mergedEnv.Path.length > 0 && !mergedEnv.PATH + ? "Path" + : "PATH"; + const basePath = mergedEnv[pathKey] ?? ""; + if (skillBinDirs.length > 0) { + const existing = basePath.split(path.delimiter).filter(Boolean); + const additions = skillBinDirs.filter((dir) => !existing.includes(dir)); + if (additions.length > 0) { + mergedEnv[pathKey] = [...additions, basePath].filter(Boolean).join(path.delimiter); + } + } const runtimeEnv = Object.fromEntries( - Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + Object.entries(mergedEnv).filter( (entry): entry is [string, string] => typeof entry[1] === "string", ), ); diff --git a/server/src/__tests__/pi-local-execute.test.ts b/server/src/__tests__/pi-local-execute.test.ts index e0f49a27..03d1a0ee 100644 --- a/server/src/__tests__/pi-local-execute.test.ts +++ b/server/src/__tests__/pi-local-execute.test.ts @@ -27,6 +27,25 @@ process.exit(0); await fs.chmod(commandPath, 0o755); } +async function writeEnvDumpPiCommand(commandPath: string, envDumpPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +if (process.argv.includes("--list-models")) { + console.log("provider model"); + console.log("google gemini-3-flash-preview"); + process.exit(0); +} +fs.writeFileSync(${JSON.stringify(envDumpPath)}, process.env.PATH || ""); +console.log(JSON.stringify({ type: "agent_start" })); +console.log(JSON.stringify({ type: "turn_start" })); +console.log(JSON.stringify({ type: "turn_end", message: { role: "assistant", content: "" }, toolResults: [] })); +console.log(JSON.stringify({ type: "agent_end", messages: [] })); +process.exit(0); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + describe("pi_local execute", () => { it("fails the run when Pi exhausts automatic retries despite exiting 0", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-execute-")); @@ -73,4 +92,116 @@ describe("pi_local execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("prepends installed skill bin/ dirs to the spawned Pi child PATH", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-path-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "pi"); + const skillDir = path.join(root, "skills", "demo-skill"); + const skillBinDir = path.join(skillDir, "bin"); + const envDumpPath = path.join(root, "captured-path.txt"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(skillBinDir, { recursive: true }); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# demo-skill\n", "utf8"); + await writeEnvDumpPiCommand(commandPath, envDumpPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + await execute({ + runId: "run-pi-skill-path", + agent: { + id: "agent-skill-path", + companyId: "company-skill-path", + name: "Pi Agent", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "google/gemini-3-flash-preview", + promptTemplate: "Keep working.", + paperclipRuntimeSkills: [ + { key: "demo-skill", runtimeName: "demo-skill", source: skillDir, required: true }, + ], + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + const capturedPath = await fs.readFile(envDumpPath, "utf8"); + const entries = capturedPath.split(path.delimiter); + expect(entries[0]).toBe(skillBinDir); + expect(entries.filter((entry) => entry === skillBinDir)).toHaveLength(1); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("does not expose bin/ dirs from skills that are not injected", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-path-neg-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "pi"); + const nonInjectedSkillDir = path.join(root, "skills", "not-injected"); + const nonInjectedBinDir = path.join(nonInjectedSkillDir, "bin"); + const envDumpPath = path.join(root, "captured-path.txt"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(nonInjectedBinDir, { recursive: true }); + await fs.writeFile(path.join(nonInjectedSkillDir, "SKILL.md"), "# not-injected\n", "utf8"); + await writeEnvDumpPiCommand(commandPath, envDumpPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + await execute({ + runId: "run-pi-skill-path-neg", + agent: { + id: "agent-skill-path-neg", + companyId: "company-skill-path-neg", + name: "Pi Agent", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "google/gemini-3-flash-preview", + promptTemplate: "Keep working.", + // required:false with no explicit paperclipSkillSync preference → + // resolvePaperclipDesiredSkillNames returns [] → skill is not injected. + paperclipRuntimeSkills: [ + { key: "not-injected", runtimeName: "not-injected", source: nonInjectedSkillDir, required: false }, + ], + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + const capturedPath = await fs.readFile(envDumpPath, "utf8"); + expect(capturedPath.split(path.delimiter)).not.toContain(nonInjectedBinDir); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); });