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 }); + }, + }; +}