From cadfcd1bc6d873b54f8d1bab253c58ca85f9fd9b Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 15:42:14 -0500 Subject: [PATCH] Log resolved adapter command in run metadata Co-Authored-By: Paperclip --- packages/adapter-utils/src/server-utils.ts | 31 ++++++ .../claude-local/src/server/execute.ts | 19 +++- .../codex-local/src/server/execute.ts | 13 ++- .../cursor-local/src/server/execute.ts | 13 ++- .../gemini-local/src/server/execute.ts | 13 ++- .../opencode-local/src/server/execute.ts | 13 ++- .../adapters/pi-local/src/server/execute.ts | 13 ++- .../__tests__/claude-local-execute.test.ts | 99 +++++++++++++++++++ .../src/__tests__/codex-local-execute.test.ts | 64 ++++++++++++ server/src/adapters/process/execute.ts | 15 ++- server/src/adapters/utils.ts | 2 + 11 files changed, 274 insertions(+), 21 deletions(-) create mode 100644 server/src/__tests__/claude-local-execute.test.ts diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 12989f72..4a5affdf 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -201,6 +201,33 @@ export function redactEnvForLogs(env: Record): Record, + options: { + runtimeEnv?: NodeJS.ProcessEnv | Record; + includeRuntimeKeys?: string[]; + resolvedCommand?: string | null; + resolvedCommandEnvKey?: string; + } = {}, +): Record { + const merged: Record = { ...env }; + const runtimeEnv = options.runtimeEnv ?? {}; + + for (const key of options.includeRuntimeKeys ?? []) { + if (key in merged) continue; + const value = runtimeEnv[key]; + if (typeof value !== "string" || value.length === 0) continue; + merged[key] = value; + } + + const resolvedCommand = options.resolvedCommand?.trim(); + if (resolvedCommand) { + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + } + + return redactEnvForLogs(merged); +} + export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record { const resolveHostForUrl = (rawHost: string): string => { const host = rawHost.trim(); @@ -269,6 +296,10 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc return null; } +export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + return (await resolveCommandPath(command, cwd, env)) ?? command; +} + function quoteForCmd(arg: string) { if (!arg.length) return '""'; const escaped = arg.replace(/"/g, '""'); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 8ac1d7ee..c7d6c6a8 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -14,10 +14,11 @@ import { buildPaperclipEnv, readPaperclipRuntimeSkillEntries, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, + resolveCommandForLogs, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -68,11 +69,13 @@ interface ClaudeExecutionInput { interface ClaudeRuntimeConfig { command: string; + resolvedCommand: string; cwd: string; workspaceId: string | null; workspaceRepoUrl: string | null; workspaceRepoRef: string | null; env: Record; + loggedEnv: Record; timeoutSec: number; graceSec: number; extraArgs: string[]; @@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { if (idx === args.length - 1 && value !== "-") return ``; return value; }), - env: redactEnvForLogs(env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index df339690..ed41754a 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -9,12 +9,13 @@ import { asStringArray, parseObject, buildPaperclipEnv, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -271,6 +272,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise ( index === args.length - 1 ? `` : value )), - env: redactEnvForLogs(env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index f3a5ae50..7c034c69 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -10,11 +10,12 @@ import { parseObject, buildPaperclipEnv, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, + resolveCommandForLogs, renderTemplate, runChildProcess, readPaperclipRuntimeSkillEntries, @@ -186,6 +187,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise`], - env: redactEnvForLogs(preparedRuntimeConfig.env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index a78bc1d4..b1d9d801 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -10,12 +10,13 @@ import { parseObject, buildPaperclipEnv, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -204,6 +205,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv: process.argv.slice(2), + prompt: fs.readFileSync(0, "utf8"), + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +describe("claude execute", () => { + it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-")); + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "claude"); + const capturePath = path.join(root, "capture.json"); + const claudeConfigDir = path.join(root, "claude-config"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(claudeConfigDir, { recursive: true }); + await writeFakeClaudeCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-meta", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "claude", + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.HOME).toBe(root); + expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index b83e3db7..4f584435 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -195,6 +195,70 @@ describe("codex execute", () => { } }); + it("logs HOME and the resolved executable path in invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-meta-")); + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-meta", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "codex", + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.HOME).toBe(root); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index 1d0cf9f6..ff2bf82e 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -5,7 +5,9 @@ import { asStringArray, parseObject, buildPaperclipEnv, - redactEnvForLogs, + buildInvocationEnvForLogs, + ensurePathInEnv, + resolveCommandForLogs, runChildProcess, } from "../utils.js"; @@ -21,6 +23,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise