From 6e4aca9c67efa5cebd18b396707cfce58d755a72 Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Thu, 11 Jun 2026 06:12:23 +0200 Subject: [PATCH] feat(pi-local): env-driven gateway routing via PAPERCLIP_PI_PROVIDERS models.json (#7920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The `pi-local` adapter runs the Pi coding agent, including inside remote/sandboxed execution targets; Pi resolves `--provider P --model M` by an exact (provider, id) match against its model registry, and it has no base-url CLI flag or env var: a `models.json` in its agent config dir (`$PI_CODING_AGENT_DIR`, falling back to `$HOME/.pi/agent`) is its only mechanism for custom or OpenAI/Anthropic-compatible endpoints > - Deployments increasingly put an LLM gateway between the harness and the model for cost, governance, or data-residency reasons: LiteLLM, OpenRouter, Portkey, Kong, a corporate proxy, self-hosted models (vLLM/Ollama), or region-pinned/sovereign endpoints. Today there is no supported way to get such provider config into Pi's registry for orchestrated runs > - The opencode adapter gained the equivalent capability in #7837 and codex in #7919; this pull request is the Pi analogue, so the harness layer stays gateway-agnostic regardless of which CLI an agent uses; nothing here is specific to one hosting setup > - This pull request reads `PAPERCLIP_PI_PROVIDERS` (Pi's `models.json` `providers` shape), materialises a managed `models.json` in a temp agent-config dir, points `PI_CODING_AGENT_DIR` at it, and ships it to remote execution targets with the run > - The benefit is Pi works behind any compatible gateway with config only; with no env set, behavior is unchanged ## Linked Issues or Issue Description No existing issue; describing in-PR (feature / adapter enhancement). - **Gap:** there is no supported way to register custom/gateway providers + models for `pi-local`. Pi's only custom-endpoint mechanism is a `models.json` in its agent config dir, and orchestrated (especially sandboxed) runs have no way to provision one declaratively. - Related: #7837 (the opencode-local analogue, same env-driven gateway-routing pattern) and #7919 (the codex-local analogue). Searched for duplicate or related PRs: no existing pi-local gateway/provider-routing PR found. > Note on ROADMAP: this is adapter-level, opt-in config (defaults unchanged) that *enables* gateway routing for one harness; it is not the core "Cloud / Sandbox agents" platform work itself. ## What Changed - New `packages/adapters/pi-local/src/server/runtime-config.ts`: `preparePiRuntimeConfig()` reads `PAPERCLIP_PI_PROVIDERS` (a JSON object in pi's `models.json` `providers` shape) from the run env, then `process.env`. When set, it expands `{env:VAR}` placeholders (run env first, then process env; unresolvable placeholders left intact), writes `{"providers": ...}` to a managed temp dir as `models.json`, and returns env with `PI_CODING_AGENT_DIR` pointing at it plus a cleanup handle. - `execute.ts`: the prepared dir ships to remote execution targets as the managed-runtime asset `agentConfig` (same mechanism as opencode's `xdgConfig`), and `PI_CODING_AGENT_DIR` is repointed to the in-target path; cleanup runs in `finally`. - Misconfiguration is visible, not silent: a set-but-unusable `PAPERCLIP_PI_PROVIDERS` (invalid JSON, not an object, no provider objects) surfaces an explanatory note instead of proceeding unconfigured into an opaque model-not-found failure later, and provider entries with non-object values are skipped with a note naming them. Unset/empty stays a silent no-op (feature off). - Defaults unchanged: with `PAPERCLIP_PI_PROVIDERS` unset, the adapter behaves byte-for-byte as before, for local runs and for every existing sandbox provider. ## Verification - All pi-local tests green against this base (new: providers written verbatim, `{env:VAR}` expansion from run env/process env/unresolvable, no-op when unset, `PI_CODING_AGENT_DIR` set and shipped, the misconfiguration notes incl. skipped non-object entries, remote asset sync + env repoint). Typecheck and build clean. - Production end-to-end evidence (our deployment, used as verification, not as the scope of the change): a pi agent in a Kubernetes gVisor sandbox resolved a custom provider from the shipped `models.json`, completed an assigned issue through an Anthropic-compatible gateway, and landed a billed usage row. ## Risks Low. The entire feature is opt-in behind one env var; the only behavior change when it is set is the intended one. The managed dir replaces the host agent dir for the run by design (credentials travel inside the provider config or via env-key indirection), which is the correct posture for orchestrated runs. No migration/UI impact. ## Model Used Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended thinking + tool use, via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md (adapter-level opt-in config enabling gateway routing; not the core sandbox-platform work, noted above) - [x] I have searched GitHub for duplicate or related PRs and linked them above (#7837 and #7919 are the opencode/codex analogues; no pi-local duplicate found) - [x] I have either (a) linked existing issues OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (n/a, no UI) - [ ] I have updated relevant documentation to reflect my changes (env var documented inline; no central doc references the adapter env yet) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green (green on the previous head; re-running on the final note-copy polish commit) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (both prior review findings are fixed at head: the indirect notes-based guard is now an explicit `agentConfigDir` handle, and a failed `models.json` write no longer leaks the temp dir; a re-review is requested for the note-copy polish) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/server/execute.remote.test.ts | 75 ++ .../adapters/pi-local/src/server/execute.ts | 952 +++++++++--------- .../src/server/runtime-config.test.ts | 227 +++++ .../pi-local/src/server/runtime-config.ts | 152 +++ 4 files changed, 942 insertions(+), 464 deletions(-) create mode 100644 packages/adapters/pi-local/src/server/runtime-config.test.ts create mode 100644 packages/adapters/pi-local/src/server/runtime-config.ts diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index 6b9b64b9..b99e3fbd 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -216,6 +216,81 @@ describe("pi remote execution", () => { expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); + it("ships the managed Pi agent config and repoints PI_CODING_AGENT_DIR when PAPERCLIP_PI_PROVIDERS is set", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-providers-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-providers/workspace"; + await mkdir(workspaceDir, { recursive: true }); + + const providers = { + tensorix: { + baseUrl: "http://gateway.example.svc.cluster.local:8080/anthropic", + apiKey: "{env:ANTHROPIC_API_KEY}", + api: "anthropic-messages", + models: [{ id: "deepseek/deepseek-chat-v3.1", name: "DeepSeek v3.1" }], + }, + }; + + await execute({ + runId: "run-providers", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Builder", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "pi", + model: "tensorix/deepseek/deepseek-chat-v3.1", + env: { + PAPERCLIP_PI_PROVIDERS: JSON.stringify(providers), + ANTHROPIC_API_KEY: "sk-bf-REALVK", + }, + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ + remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/pi/agentConfig`, + })); + const call = runChildProcess.mock.calls[0] as unknown as + | [string, string, string[], { env: Record }] + | undefined; + expect(call?.[3].env.PI_CODING_AGENT_DIR).toBe( + `${managedRemoteWorkspace}/.paperclip-runtime/pi/agentConfig`, + ); + expect(call?.[2]).toContain("--provider"); + expect(call?.[2]).toContain("tensorix"); + expect(call?.[2]).toContain("--model"); + expect(call?.[2]).toContain("deepseek/deepseek-chat-v3.1"); + }); + it("resumes saved Pi sessions for remote SSH execution only when the identity matches", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-resume-")); cleanupDirs.push(rootDir); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 6bcb22ae..51579662 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -48,6 +48,7 @@ import { import { shellQuote } from "@paperclipai/adapter-utils/ssh"; import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; +import { preparePiRuntimeConfig } from "./runtime-config.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -317,476 +318,226 @@ 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); - } + // Materialize custom Pi providers (PAPERCLIP_PI_PROVIDERS) into a managed + // PI_CODING_AGENT_DIR before runtimeEnv is computed, so both local validation + // and the spawned Pi process resolve models against the managed models.json. + const preparedRuntimeConfig = await preparePiRuntimeConfig({ env }); + const localAgentConfigDir = preparedRuntimeConfig.agentConfigDir ?? ""; + if (localAgentConfigDir) { + env.PI_CODING_AGENT_DIR = localAgentConfigDir; } - const runtimeEnv = Object.fromEntries( - Object.entries(mergedEnv).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( - executionTarget, - asNumber(config.timeoutSec, 0), - ); - const graceSec = asNumber(config.graceSec, 20); - await ensureAdapterExecutionTargetRuntimeCommandInstalled({ - runId, - target: executionTarget, - installCommand: ctx.runtimeCommandSpec?.installCommand, - detectCommand: ctx.runtimeCommandSpec?.detectCommand, - cwd, - env: runtimeEnv, - timeoutSec, - graceSec, - onLog, - }); - await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { - installCommand: SANDBOX_INSTALL_COMMAND, - timeoutSec, - }); - const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); - let loggedEnv = buildInvocationEnvForLogs(env, { - runtimeEnv, - includeRuntimeKeys: ["HOME"], - resolvedCommand, - }); - - if (!executionTargetIsRemote) { - await ensurePiModelConfiguredAndAvailable({ - model, - command, + try { + // Prepend installed skill `bin/` dirs to PATH so an agent's bash tool can + // invoke skill binaries (e.g. `paperclip-get-issue`) by name. Without this, + // any pi_local agent whose AGENTS.md calls a skill command via bash hits + // exit 127 "command not found". Only include skills that ensurePiSkillsInjected + // actually linked — otherwise non-injected skills' binaries would be reachable + // to the agent. + const injectedSkillKeys = new Set(desiredPiSkillNames); + const skillBinDirs = piSkillEntries + .filter((entry) => 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(mergedEnv).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, cwd, env: runtimeEnv, - }); - } - - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - let restoreRemoteWorkspace: (() => Promise) | null = null; - let remoteRuntimeRootDir: string | null = null; - let localSkillsDir: string | null = null; - let remoteSkillsDir: string | null = null; - let paperclipBridge: Awaited> = null; - - if (executionTargetIsRemote) { - try { - localSkillsDir = await buildPiSkillsDir(config); - await onLog( - "stdout", - `[paperclip] Syncing workspace and Pi runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, - ); - const preparedRemoteRuntime = await prepareAdapterExecutionTargetRuntime({ - runId, - target: executionTarget, - adapterKey: "pi", - timeoutSec, - workspaceLocalDir: cwd, - installCommand: SANDBOX_INSTALL_COMMAND, - detectCommand: command, - assets: [ - { - key: "skills", - localDir: localSkillsDir, - followSymlinks: true, - }, - ], - }); - restoreRemoteWorkspace = () => preparedRemoteRuntime.restoreWorkspace(); - effectiveExecutionCwd = preparedRemoteRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; - refreshPaperclipWorkspaceEnvForExecution({ - env, - envConfig, - workspaceCwd: effectiveWorkspaceCwd, - workspaceSource, - workspaceId, - workspaceRepoUrl, - workspaceRepoRef, - workspaceHints, - agentHome, - executionTargetIsRemote, - executionCwd: effectiveExecutionCwd, - }); - if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) { - env.HOME = preparedRemoteRuntime.runtimeRootDir; - } - remoteRuntimeRootDir = preparedRemoteRuntime.runtimeRootDir; - remoteSkillsDir = preparedRemoteRuntime.assetDirs.skills ?? null; - } catch (error) { - await Promise.allSettled([ - restoreRemoteWorkspace?.(), - localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), - ]); - throw error; - } - } - const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); - if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { - paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ - runId, - target: runtimeExecutionTarget, - runtimeRootDir: remoteRuntimeRootDir, - adapterKey: "pi", - timeoutSec, - hostApiToken: env.PAPERCLIP_API_KEY, - onLog, - }); - if (paperclipBridge) { - Object.assign(env, paperclipBridge.env); - loggedEnv = buildInvocationEnvForLogs(env, { - runtimeEnv: Object.fromEntries( - Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ), - includeRuntimeKeys: ["HOME"], - resolvedCommand, - }); - } - } - - const runtimeSessionParams = parseObject(runtime.sessionParams); - const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); - const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); - const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); - const sessionTargetMatches = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); - const sessionParamsCwdMatches = - runtimeSessionCwd.length === 0 || - executionCwdsMatch(runtimeSessionCwd, effectiveExecutionCwd, executionTargetIsRemote); - const savedSessionCwd = - runtimeSessionId.length > 0 - ? await readSavedSessionCwd({ - runId, - sessionPath: runtimeSessionId, - executionTarget: runtimeExecutionTarget ?? null, - cwd, - env, - timeoutSec, - graceSec, - }) - : null; - const sessionHeaderCwdMatches = - runtimeSessionId.length === 0 || - (savedSessionCwd !== null && - executionCwdsMatch(savedSessionCwd, effectiveExecutionCwd, executionTargetIsRemote)); - const canResumeSession = - runtimeSessionId.length > 0 && - sessionTargetMatches && - sessionParamsCwdMatches && - sessionHeaderCwdMatches; - const sessionPath = canResumeSession - ? runtimeSessionId - : executionTargetIsRemote && remoteRuntimeRootDir - ? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString()) - : buildSessionPath(agent.id, new Date().toISOString()); - - if (runtimeSessionId && !canResumeSession) { - const staleSessionCwdNote = - savedSessionCwd !== null && !sessionHeaderCwdMatches - ? ` Pi stored cwd "${savedSessionCwd}" in the session header, so Paperclip will start a fresh session for "${effectiveExecutionCwd}".` - : ""; - await onLog( - "stdout", - executionTargetIsRemote - ? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution state and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote} Starting a fresh remote session.\n` - : `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote}\n`, - ); - } - - if (!canResumeSession) { - if (executionTargetIsRemote) { - await ensureAdapterExecutionTargetFile(runId, runtimeExecutionTarget, sessionPath, { - cwd, - env, - timeoutSec: 15, - graceSec: 5, - onLog, - }); - } else { - try { - await fs.writeFile(sessionPath, "", { flag: "wx" }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== "EEXIST") { - throw err; - } - } - } - } - - // Handle instructions file and build system prompt extension - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const resolvedInstructionsFilePath = instructionsFilePath - ? path.resolve(cwd, instructionsFilePath) - : ""; - const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; - - let systemPromptExtension = ""; - let instructionsReadFailed = false; - if (resolvedInstructionsFilePath) { - try { - const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); - systemPromptExtension = - `${instructionsContents}\n\n` + - `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsFileDir}.\n\n` + - DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE; - } catch (err) { - instructionsReadFailed = true; - const reason = err instanceof Error ? err.message : String(err); - await onLog( - "stdout", - `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, - ); - // Fall back to base prompt template - systemPromptExtension = promptTemplate; - } - } else { - systemPromptExtension = promptTemplate; - } - - const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); - const templateData = { - agentId: agent.id, - companyId: agent.companyId, - runId, - company: { id: agent.companyId }, - agent, - run: { id: runId, source: "on_demand" }, - context, - }; - const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData); - const renderedBootstrapPrompt = - !canResumeSession && bootstrapPromptTemplate.trim().length > 0 - ? renderTemplate(bootstrapPromptTemplate, templateData).trim() - : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession }); - const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0; - const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); - const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); - const userPrompt = joinPromptSections([ - renderedBootstrapPrompt, - wakePrompt, - sessionHandoffNote, - renderedHeartbeatPrompt, - ]); - const promptMetrics = { - systemPromptChars: renderedSystemPromptExtension.length, - promptChars: userPrompt.length, - bootstrapPromptChars: renderedBootstrapPrompt.length, - wakePromptChars: wakePrompt.length, - sessionHandoffChars: sessionHandoffNote.length, - heartbeatPromptChars: renderedHeartbeatPrompt.length, - }; - - const commandNotes = (() => { - if (!resolvedInstructionsFilePath) return [] as string[]; - if (instructionsReadFailed) { - return [ - `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, - ]; - } - return [ - `Loaded agent instructions from ${resolvedInstructionsFilePath}`, - `Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`, - ]; - })(); - - const buildArgs = (sessionFile: string): string[] => { - const args: string[] = []; - - // Use JSON mode for structured output with print mode (non-interactive) - args.push("--mode", "json"); - args.push("-p"); // Non-interactive mode: process prompt and exit - - // Use --append-system-prompt to extend Pi's default system prompt - args.push("--append-system-prompt", renderedSystemPromptExtension); - - if (provider) args.push("--provider", provider); - if (modelId) args.push("--model", modelId); - if (thinking) args.push("--thinking", thinking); - - args.push("--tools", "read,bash,edit,write,grep,find,ls"); - args.push("--session", sessionFile); - args.push("--skill", remoteSkillsDir ?? PI_AGENT_SKILLS_DIR); - - if (extraArgs.length > 0) args.push(...extraArgs); - - // Add the user prompt as the last argument - args.push(userPrompt); - - return args; - }; - - const runAttempt = async (sessionFile: string) => { - const args = buildArgs(sessionFile); - if (onMeta) { - await onMeta({ - adapterType: "pi_local", - command: resolvedCommand, - cwd: effectiveExecutionCwd, - commandNotes, - commandArgs: args, - env: loggedEnv, - prompt: userPrompt, - promptMetrics, - context, - }); - } - - // Buffer stdout by lines to handle partial JSON chunks - let stdoutBuffer = ""; - const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stderr") { - // Pass stderr through immediately (not JSONL) - await onLog(stream, chunk); - return; - } - - // Buffer stdout and emit only complete lines - stdoutBuffer += chunk; - const lines = stdoutBuffer.split("\n"); - // Keep the last (potentially incomplete) line in the buffer - stdoutBuffer = lines.pop() || ""; - - // Emit complete lines - for (const line of lines) { - if (line) { - await onLog(stream, line + "\n"); - } - } - }; - - const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { - cwd, - env: executionTargetIsRemote ? env : runtimeEnv, timeoutSec, graceSec, - onSpawn, - onLog: bufferedOnLog, + onLog, + }); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, { + installCommand: SANDBOX_INSTALL_COMMAND, + timeoutSec, + }); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); + let loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, }); - // Flush any remaining buffer content - if (stdoutBuffer) { - await onLog("stdout", stdoutBuffer); + if (!executionTargetIsRemote) { + await ensurePiModelConfiguredAndAvailable({ + model, + command, + cwd, + env: runtimeEnv, + }); } - return { - proc, - rawStderr: proc.stderr, - parsed: parsePiJsonl(proc.stdout), - }; - }; + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + let restoreRemoteWorkspace: (() => Promise) | null = null; + let remoteRuntimeRootDir: string | null = null; + let localSkillsDir: string | null = null; + let remoteSkillsDir: string | null = null; + let paperclipBridge: Awaited> = null; - const toResult = ( - attempt: { - proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; - rawStderr: string; - parsed: ReturnType; - }, - clearSessionOnMissingSession = false, - ): AdapterExecutionResult => { - if (attempt.proc.timedOut) { - return { - exitCode: attempt.proc.exitCode, - signal: attempt.proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - clearSession: clearSessionOnMissingSession, - }; - } - - const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath; - const resolvedSessionParams = resolvedSessionId - ? { - sessionId: resolvedSessionId, - cwd: effectiveExecutionCwd, - ...(workspaceId ? { workspaceId } : {}), - ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), - ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), - ...(executionTargetIsRemote - ? { - remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), - } - : {}), + if (executionTargetIsRemote) { + try { + localSkillsDir = await buildPiSkillsDir(config); + await onLog( + "stdout", + `[paperclip] Syncing workspace and Pi runtime assets to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + const preparedRemoteRuntime = await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "pi", + timeoutSec, + workspaceLocalDir: cwd, + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + assets: [ + { + key: "skills", + localDir: localSkillsDir, + followSymlinks: true, + }, + ...(localAgentConfigDir + ? [{ + key: "agentConfig", + localDir: localAgentConfigDir, + }] + : []), + ], + }); + restoreRemoteWorkspace = () => preparedRemoteRuntime.restoreWorkspace(); + effectiveExecutionCwd = preparedRemoteRuntime.workspaceRemoteDir ?? effectiveExecutionCwd; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + if (adapterExecutionTargetUsesManagedHome(executionTarget) && preparedRemoteRuntime.runtimeRootDir) { + env.HOME = preparedRemoteRuntime.runtimeRootDir; } - : null; + remoteRuntimeRootDir = preparedRemoteRuntime.runtimeRootDir; + remoteSkillsDir = preparedRemoteRuntime.assetDirs.skills ?? null; + if (localAgentConfigDir && preparedRemoteRuntime.assetDirs.agentConfig) { + env.PI_CODING_AGENT_DIR = preparedRemoteRuntime.assetDirs.agentConfig; + } + } catch (error) { + await Promise.allSettled([ + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + throw error; + } + } + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); + if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { + paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId, + target: runtimeExecutionTarget, + runtimeRootDir: remoteRuntimeRootDir, + adapterKey: "pi", + timeoutSec, + hostApiToken: env.PAPERCLIP_API_KEY, + onLog, + }); + if (paperclipBridge) { + Object.assign(env, paperclipBridge.env); + loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv: Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ), + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); + } + } - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const rawExitCode = attempt.proc.exitCode; - const parsedError = attempt.parsed.errors.find((error) => error.trim().length > 0) ?? ""; - const effectiveExitCode = (rawExitCode ?? 0) === 0 && parsedError ? 1 : rawExitCode; - const fallbackErrorMessage = parsedError || stderrLine || `Pi exited with code ${rawExitCode ?? -1}`; - - return { - exitCode: effectiveExitCode, - signal: attempt.proc.signal, - timedOut: false, - errorMessage: (effectiveExitCode ?? 0) === 0 ? null : fallbackErrorMessage, - usage: { - inputTokens: attempt.parsed.usage.inputTokens, - outputTokens: attempt.parsed.usage.outputTokens, - cachedInputTokens: attempt.parsed.usage.cachedInputTokens, - }, - sessionId: resolvedSessionId, - sessionParams: resolvedSessionParams, - sessionDisplayId: resolvedSessionId, - provider: provider, - biller: resolvePiBiller(runtimeEnv, provider), - model: model, - billingType: "unknown", - costUsd: attempt.parsed.usage.costUsd, - resultJson: { - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - }, - summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(), - clearSession: Boolean(clearSessionOnMissingSession), - }; - }; - - try { - const initial = await runAttempt(sessionPath); - const initialFailed = - !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0); - - if ( - canResumeSession && - initialFailed && - isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stdout", - `[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const newSessionPath = executionTargetIsRemote && remoteRuntimeRootDir + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); + const sessionTargetMatches = adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + const sessionParamsCwdMatches = + runtimeSessionCwd.length === 0 || + executionCwdsMatch(runtimeSessionCwd, effectiveExecutionCwd, executionTargetIsRemote); + const savedSessionCwd = + runtimeSessionId.length > 0 + ? await readSavedSessionCwd({ + runId, + sessionPath: runtimeSessionId, + executionTarget: runtimeExecutionTarget ?? null, + cwd, + env, + timeoutSec, + graceSec, + }) + : null; + const sessionHeaderCwdMatches = + runtimeSessionId.length === 0 || + (savedSessionCwd !== null && + executionCwdsMatch(savedSessionCwd, effectiveExecutionCwd, executionTargetIsRemote)); + const canResumeSession = + runtimeSessionId.length > 0 && + sessionTargetMatches && + sessionParamsCwdMatches && + sessionHeaderCwdMatches; + const sessionPath = canResumeSession + ? runtimeSessionId + : executionTargetIsRemote && remoteRuntimeRootDir ? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString()) : buildSessionPath(agent.id, new Date().toISOString()); + + if (runtimeSessionId && !canResumeSession) { + const staleSessionCwdNote = + savedSessionCwd !== null && !sessionHeaderCwdMatches + ? ` Pi stored cwd "${savedSessionCwd}" in the session header, so Paperclip will start a fresh session for "${effectiveExecutionCwd}".` + : ""; + await onLog( + "stdout", + executionTargetIsRemote + ? `[paperclip] Pi session "${runtimeSessionId}" does not match the current remote execution state and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote} Starting a fresh remote session.\n` + : `[paperclip] Pi session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".${staleSessionCwdNote}\n`, + ); + } + + if (!canResumeSession) { if (executionTargetIsRemote) { - await ensureAdapterExecutionTargetFile(runId, executionTarget, newSessionPath, { + await ensureAdapterExecutionTargetFile(runId, runtimeExecutionTarget, sessionPath, { cwd, env, timeoutSec: 15, @@ -795,23 +546,296 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined) : Promise.resolve(), + // Handle instructions file and build system prompt extension + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const resolvedInstructionsFilePath = instructionsFilePath + ? path.resolve(cwd, instructionsFilePath) + : ""; + const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + + let systemPromptExtension = ""; + let instructionsReadFailed = false; + if (resolvedInstructionsFilePath) { + try { + const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); + systemPromptExtension = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsFileDir}.\n\n` + + DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE; + } catch (err) { + instructionsReadFailed = true; + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, + ); + // Fall back to base prompt template + systemPromptExtension = promptTemplate; + } + } else { + systemPromptExtension = promptTemplate; + } + + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedSystemPromptExtension = renderTemplate(systemPromptExtension, templateData); + const renderedBootstrapPrompt = + !canResumeSession && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession }); + const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0; + const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const userPrompt = joinPromptSections([ + renderedBootstrapPrompt, + wakePrompt, + sessionHandoffNote, + renderedHeartbeatPrompt, ]); + const promptMetrics = { + systemPromptChars: renderedSystemPromptExtension.length, + promptChars: userPrompt.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedHeartbeatPrompt.length, + }; + + const commandNotes = (() => { + const notes = [...preparedRuntimeConfig.notes]; + if (!resolvedInstructionsFilePath) return notes; + if (instructionsReadFailed) { + notes.push( + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + } + notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`); + notes.push( + `Appended instructions + path directive to system prompt (relative references from ${instructionsFileDir}).`, + ); + return notes; + })(); + + const buildArgs = (sessionFile: string): string[] => { + const args: string[] = []; + + // Use JSON mode for structured output with print mode (non-interactive) + args.push("--mode", "json"); + args.push("-p"); // Non-interactive mode: process prompt and exit + + // Use --append-system-prompt to extend Pi's default system prompt + args.push("--append-system-prompt", renderedSystemPromptExtension); + + if (provider) args.push("--provider", provider); + if (modelId) args.push("--model", modelId); + if (thinking) args.push("--thinking", thinking); + + args.push("--tools", "read,bash,edit,write,grep,find,ls"); + args.push("--session", sessionFile); + args.push("--skill", remoteSkillsDir ?? PI_AGENT_SKILLS_DIR); + + if (extraArgs.length > 0) args.push(...extraArgs); + + // Add the user prompt as the last argument + args.push(userPrompt); + + return args; + }; + + const runAttempt = async (sessionFile: string) => { + const args = buildArgs(sessionFile); + if (onMeta) { + await onMeta({ + adapterType: "pi_local", + command: resolvedCommand, + cwd: effectiveExecutionCwd, + commandNotes, + commandArgs: args, + env: loggedEnv, + prompt: userPrompt, + promptMetrics, + context, + }); + } + + // Buffer stdout by lines to handle partial JSON chunks + let stdoutBuffer = ""; + const bufferedOnLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stderr") { + // Pass stderr through immediately (not JSONL) + await onLog(stream, chunk); + return; + } + + // Buffer stdout and emit only complete lines + stdoutBuffer += chunk; + const lines = stdoutBuffer.split("\n"); + // Keep the last (potentially incomplete) line in the buffer + stdoutBuffer = lines.pop() || ""; + + // Emit complete lines + for (const line of lines) { + if (line) { + await onLog(stream, line + "\n"); + } + } + }; + + const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { + cwd, + env: executionTargetIsRemote ? env : runtimeEnv, + timeoutSec, + graceSec, + onSpawn, + onLog: bufferedOnLog, + }); + + // Flush any remaining buffer content + if (stdoutBuffer) { + await onLog("stdout", stdoutBuffer); + } + + return { + proc, + rawStderr: proc.stderr, + parsed: parsePiJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = clearSessionOnMissingSession ? null : sessionPath; + const resolvedSessionParams = resolvedSessionId + ? { + sessionId: resolvedSessionId, + cwd: effectiveExecutionCwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + ...(executionTargetIsRemote + ? { + remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), + } + : {}), + } + : null; + + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const rawExitCode = attempt.proc.exitCode; + const parsedError = attempt.parsed.errors.find((error) => error.trim().length > 0) ?? ""; + const effectiveExitCode = (rawExitCode ?? 0) === 0 && parsedError ? 1 : rawExitCode; + const fallbackErrorMessage = parsedError || stderrLine || `Pi exited with code ${rawExitCode ?? -1}`; + + return { + exitCode: effectiveExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (effectiveExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: provider, + biller: resolvePiBiller(runtimeEnv, provider), + model: model, + billingType: "unknown", + costUsd: attempt.parsed.usage.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.finalMessage ?? attempt.parsed.messages.join("\n\n").trim(), + clearSession: Boolean(clearSessionOnMissingSession), + }; + }; + + try { + const initial = await runAttempt(sessionPath); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || initial.parsed.errors.length > 0); + + if ( + canResumeSession && + initialFailed && + isPiUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] Pi session "${runtimeSessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const newSessionPath = executionTargetIsRemote && remoteRuntimeRootDir + ? buildRemoteSessionPath(remoteRuntimeRootDir, agent.id, new Date().toISOString()) + : buildSessionPath(agent.id, new Date().toISOString()); + if (executionTargetIsRemote) { + await ensureAdapterExecutionTargetFile(runId, executionTarget, newSessionPath, { + cwd, + env, + timeoutSec: 15, + graceSec: 5, + onLog, + }); + } else { + try { + await fs.writeFile(newSessionPath, "", { flag: "wx" }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + } + } + const retry = await runAttempt(newSessionPath); + return toResult(retry, true); + } + + return toResult(initial); + } finally { + await Promise.all([ + paperclipBridge?.stop(), + restoreRemoteWorkspace?.(), + localSkillsDir ? fs.rm(path.dirname(localSkillsDir), { recursive: true, force: true }).catch(() => undefined) : Promise.resolve(), + ]); + } + } finally { + await preparedRuntimeConfig.cleanup(); } } diff --git a/packages/adapters/pi-local/src/server/runtime-config.test.ts b/packages/adapters/pi-local/src/server/runtime-config.test.ts new file mode 100644 index 00000000..cc131048 --- /dev/null +++ b/packages/adapters/pi-local/src/server/runtime-config.test.ts @@ -0,0 +1,227 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { preparePiRuntimeConfig } from "./runtime-config.js"; + +const cleanupPaths = new Set(); + +afterEach(async () => { + await Promise.all( + [...cleanupPaths].map(async (filepath) => { + await fs.rm(filepath, { recursive: true, force: true }); + cleanupPaths.delete(filepath); + }), + ); +}); + +async function readModelsJson(agentConfigDir: string): Promise> { + return JSON.parse(await fs.readFile(path.join(agentConfigDir, "models.json"), "utf8")) as Record< + string, + unknown + >; +} + +describe("preparePiRuntimeConfig", () => { + it("is a no-op when PAPERCLIP_PI_PROVIDERS is unset", async () => { + const prepared = await preparePiRuntimeConfig({ env: { FOO: "bar" } }); + + expect(prepared.env).toEqual({ FOO: "bar" }); + expect(prepared.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(prepared.notes).toEqual([]); + await prepared.cleanup(); + }); + + it("writes the providers JSON verbatim to a managed models.json and points PI_CODING_AGENT_DIR at it", async () => { + const providers = { + tensorix: { + baseUrl: "http://gateway.example.svc.cluster.local:8080/anthropic", + apiKey: "sk-literal", + api: "anthropic-messages", + models: [ + { + id: "deepseek/deepseek-chat-v3.1", + name: "DeepSeek v3.1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 32000, + }, + ], + }, + }; + + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: JSON.stringify(providers) }, + }); + const agentConfigDir = prepared.env.PI_CODING_AGENT_DIR; + expect(agentConfigDir).toBeTruthy(); + cleanupPaths.add(agentConfigDir); + + expect(await readModelsJson(agentConfigDir)).toEqual({ providers }); + expect(prepared.notes.some((n) => n.includes("tensorix"))).toBe(true); + + await prepared.cleanup(); + cleanupPaths.delete(agentConfigDir); + await expect(fs.access(agentConfigDir)).rejects.toThrow(); + }); + + it("reads PAPERCLIP_PI_PROVIDERS from process.env when absent from the run env", async () => { + const providers = { tensorix: { baseUrl: "http://gw/anthropic", api: "anthropic-messages", models: [] } }; + process.env.PAPERCLIP_PI_PROVIDERS = JSON.stringify(providers); + try { + const prepared = await preparePiRuntimeConfig({ env: {} }); + const agentConfigDir = prepared.env.PI_CODING_AGENT_DIR; + expect(agentConfigDir).toBeTruthy(); + cleanupPaths.add(agentConfigDir); + expect(await readModelsJson(agentConfigDir)).toEqual({ providers }); + await prepared.cleanup(); + } finally { + delete process.env.PAPERCLIP_PI_PROVIDERS; + } + }); + + it("expands {env:VAR} placeholders from the run env (bakes the literal key)", async () => { + const providers = { + tensorix: { baseUrl: "http://gw/anthropic", apiKey: "{env:ANTHROPIC_API_KEY}", api: "anthropic-messages", models: [] }, + }; + const prepared = await preparePiRuntimeConfig({ + env: { + PAPERCLIP_PI_PROVIDERS: JSON.stringify(providers), + ANTHROPIC_API_KEY: "sk-bf-REALVK", + }, + }); + const agentConfigDir = prepared.env.PI_CODING_AGENT_DIR; + cleanupPaths.add(agentConfigDir); + const modelsJson = (await readModelsJson(agentConfigDir)) as { + providers: { tensorix: { apiKey: string } }; + }; + expect(modelsJson.providers.tensorix.apiKey).toBe("sk-bf-REALVK"); + await prepared.cleanup(); + }); + + it("expands {env:VAR} placeholders from process.env when absent from the run env", async () => { + const providers = { + tensorix: { baseUrl: "http://gw/anthropic", apiKey: "{env:PAPERCLIP_PI_TEST_KEY}", api: "anthropic-messages", models: [] }, + }; + process.env.PAPERCLIP_PI_TEST_KEY = "sk-from-process-env"; + try { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: JSON.stringify(providers) }, + }); + const agentConfigDir = prepared.env.PI_CODING_AGENT_DIR; + cleanupPaths.add(agentConfigDir); + const modelsJson = (await readModelsJson(agentConfigDir)) as { + providers: { tensorix: { apiKey: string } }; + }; + expect(modelsJson.providers.tensorix.apiKey).toBe("sk-from-process-env"); + await prepared.cleanup(); + } finally { + delete process.env.PAPERCLIP_PI_TEST_KEY; + } + }); + + it("leaves an unresolvable {env:VAR} placeholder intact", async () => { + const providers = { + tensorix: { baseUrl: "http://gw/anthropic", apiKey: "{env:DEFINITELY_UNSET_VAR_XYZ}", api: "anthropic-messages", models: [] }, + }; + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: JSON.stringify(providers) }, + }); + const agentConfigDir = prepared.env.PI_CODING_AGENT_DIR; + cleanupPaths.add(agentConfigDir); + const modelsJson = (await readModelsJson(agentConfigDir)) as { + providers: { tensorix: { apiKey: string } }; + }; + expect(modelsJson.providers.tensorix.apiKey).toBe("{env:DEFINITELY_UNSET_VAR_XYZ}"); + await prepared.cleanup(); + }); + + it("ignores malformed PAPERCLIP_PI_PROVIDERS without writing a config", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: "not json" }, + }); + expect(prepared.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(prepared.notes).toEqual([ + "PAPERCLIP_PI_PROVIDERS contains invalid JSON; custom providers ignored.", + ]); + await prepared.cleanup(); + }); + + it("ignores provider entries that are not objects and names them in the note", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: JSON.stringify({ tensorix: "nope" }) }, + }); + expect(prepared.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(prepared.agentConfigDir).toBeNull(); + expect(prepared.notes).toEqual([ + "PAPERCLIP_PI_PROVIDERS: skipped provider(s) with non-object values: tensorix.", + ]); + await prepared.cleanup(); + }); + + it("surfaces skipped non-object entries while keeping the usable ones", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { + PAPERCLIP_PI_PROVIDERS: JSON.stringify({ + bad: "http://gw/v1", + tensorix: { baseUrl: "http://gw/anthropic", apiKey: "k", api: "anthropic-messages", models: [] }, + }), + }, + }); + const agentConfigDir = prepared.env.PI_CODING_AGENT_DIR; + cleanupPaths.add(agentConfigDir); + expect(prepared.agentConfigDir).toBe(agentConfigDir); + const modelsJson = (await readModelsJson(agentConfigDir)) as { + providers: Record; + }; + expect(modelsJson.providers.tensorix).toBeDefined(); + expect(modelsJson.providers.bad).toBeUndefined(); + expect(prepared.notes).toEqual([ + "PAPERCLIP_PI_PROVIDERS: skipped provider(s) with non-object values: bad.", + "Injected 1 custom Pi provider(s) from PAPERCLIP_PI_PROVIDERS into a managed models.json: tensorix.", + ]); + await prepared.cleanup(); + }); + + it("surfaces a note when PAPERCLIP_PI_PROVIDERS contains invalid JSON", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: "{not json" }, + }); + expect(prepared.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(prepared.notes).toEqual([ + "PAPERCLIP_PI_PROVIDERS contains invalid JSON; custom providers ignored.", + ]); + await prepared.cleanup(); + }); + + it("surfaces a note when PAPERCLIP_PI_PROVIDERS is not a JSON object", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: "[1,2]" }, + }); + expect(prepared.notes).toEqual([ + "PAPERCLIP_PI_PROVIDERS is set but is not a JSON object; custom providers ignored.", + ]); + await prepared.cleanup(); + }); + + it("surfaces the skipped entries when no provider objects remain", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: '{"a": 1}' }, + }); + expect(prepared.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(prepared.notes).toEqual([ + "PAPERCLIP_PI_PROVIDERS: skipped provider(s) with non-object values: a.", + ]); + await prepared.cleanup(); + }); + + it("stays silent when PAPERCLIP_PI_PROVIDERS is an empty object", async () => { + const prepared = await preparePiRuntimeConfig({ + env: { PAPERCLIP_PI_PROVIDERS: "{}" }, + }); + expect(prepared.env.PI_CODING_AGENT_DIR).toBeUndefined(); + expect(prepared.notes).toEqual([]); + await prepared.cleanup(); + }); +}); diff --git a/packages/adapters/pi-local/src/server/runtime-config.ts b/packages/adapters/pi-local/src/server/runtime-config.ts new file mode 100644 index 00000000..fce2bc0e --- /dev/null +++ b/packages/adapters/pi-local/src/server/runtime-config.ts @@ -0,0 +1,152 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +type PreparedPiRuntimeConfig = { + env: Record; + notes: string[]; + /** The managed agent-config dir, or null when no provider config was written. */ + agentConfigDir: string | null; + cleanup: () => Promise; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Recursively replace {env:VAR} placeholders with the resolved value. Used to bake +// gateway provider secrets (e.g. an LLM-gateway virtual key) into models.json +// SERVER-SIDE, where the value is reliably present. Pi resolves a provider apiKey +// by trying it as an env var name first, then as a literal -- but the (possibly +// sandboxed) run process env plumbing is not guaranteed to carry the key, so we +// resolve it here. Unresolvable placeholders are left intact; an env var set to +// an empty string counts as unresolvable (the placeholder stays for Pi to try). +function expandEnvPlaceholders(value: T, resolve: (name: string) => string | undefined): T { + if (typeof value === "string") { + return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => { + const resolved = resolve(name); + return resolved !== undefined && resolved.length > 0 ? resolved : match; + }) as unknown as T; + } + if (Array.isArray(value)) { + return value.map((entry) => expandEnvPlaceholders(entry, resolve)) as unknown as T; + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = expandEnvPlaceholders(entry, resolve); + } + return out as unknown as T; + } + return value; +} + +type ParsedProviderConfig = { + providers: Record | null; + warning: string | null; +}; + +function parseProviderConfig( + raw: unknown, + resolveEnv: (name: string) => string | undefined, +): ParsedProviderConfig { + // Unset/empty (or an empty JSON object) is the normal "feature off" case: no warning. + if (typeof raw !== "string" || raw.trim().length === 0) { + return { providers: null, warning: null }; + } + // A SET but unusable value is a misconfiguration; surface it so the run does + // not proceed silently unconfigured and fail later with an opaque + // model-not-found error pointing nowhere near the env var. + try { + const parsed = JSON.parse(raw); + if (!isPlainObject(parsed)) { + return { + providers: null, + warning: "PAPERCLIP_PI_PROVIDERS is set but is not a JSON object; custom providers ignored.", + }; + } + // Only keep provider entries that are themselves objects; surface the ones + // we drop so a malformed entry is just as diagnosable as malformed JSON. + const providers: Record = {}; + const skipped: string[] = []; + for (const [key, value] of Object.entries(parsed)) { + if (isPlainObject(value)) providers[key] = expandEnvPlaceholders(value, resolveEnv); + else skipped.push(key); + } + return { + providers: Object.keys(providers).length > 0 ? providers : null, + warning: skipped.length > 0 + ? `PAPERCLIP_PI_PROVIDERS: skipped provider(s) with non-object values: ${skipped.join(", ")}.` + : null, + }; + } catch { + return { + providers: null, + warning: "PAPERCLIP_PI_PROVIDERS contains invalid JSON; custom providers ignored.", + }; + } +} + +// Materialize custom Pi providers supplied via PAPERCLIP_PI_PROVIDERS (a JSON +// object in Pi's models.json "providers" shape) into a managed agent-config dir. +// +// Pi has no base-url CLI flag or env var: the only mechanism for pointing it at a +// custom/OpenAI- or Anthropic-compatible endpoint is a models.json file in its +// agent config dir, which Pi resolves from $PI_CODING_AGENT_DIR (falling back to +// $HOME/.pi/agent). Pi resolves `--provider P --model M` by an exact (provider, +// model id) match against that registry, so routing a gateway model requires the +// provider entry to enumerate its models explicitly. We accept the providers as +// config (not hard-coded) so the gateway URL, key, and model list stay declarative. +// +// When PAPERCLIP_PI_PROVIDERS is set we write {"providers": ...} to a fresh temp +// dir and point PI_CODING_AGENT_DIR at it; the managed dir intentionally replaces +// the host agent dir (credentials travel inside the providers config itself, via +// a literal apiKey or a server-side-expanded {env:VAR} placeholder). For remote +// execution targets, execute.ts ships the dir to the sandbox as a runtime asset +// and repoints PI_CODING_AGENT_DIR at the in-sandbox copy. +export async function preparePiRuntimeConfig(input: { + env: Record; +}): Promise { + const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name]; + const { providers, warning } = parseProviderConfig( + input.env.PAPERCLIP_PI_PROVIDERS ?? process.env.PAPERCLIP_PI_PROVIDERS, + resolveEnv, + ); + if (!providers) { + return { + env: input.env, + notes: warning ? [warning] : [], + agentConfigDir: null, + cleanup: async () => {}, + }; + } + + const agentConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-agent-config-")); + try { + await fs.writeFile( + path.join(agentConfigDir, "models.json"), + `${JSON.stringify({ providers }, null, 2)}\n`, + "utf8", + ); + } catch (err) { + // Never leak the temp dir when the write fails (e.g. disk-full): the + // caller only receives the cleanup handle on success. + await fs.rm(agentConfigDir, { recursive: true, force: true }).catch(() => undefined); + throw err; + } + + return { + env: { + ...input.env, + PI_CODING_AGENT_DIR: agentConfigDir, + }, + notes: [ + ...(warning ? [warning] : []), + `Injected ${Object.keys(providers).length} custom Pi provider(s) from PAPERCLIP_PI_PROVIDERS into a managed models.json: ${Object.keys(providers).join(", ")}.`, + ], + agentConfigDir, + cleanup: async () => { + await fs.rm(agentConfigDir, { recursive: true, force: true }); + }, + }; +}