From d47ffa87f07d380414976820a739f3e1daae174a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 26 Apr 2026 13:57:35 -0700 Subject: [PATCH] Fix CEO AGENT_HOME paths and centralize workspace env propagation (#4551) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The local adapter layer is responsible for turning Paperclip runtime context into the environment seen by the child agent process. > - The CEO onboarding bundle tells the agent where to read and write its persistent memory and fact files. > - That bundle was using `./memory/...` and `./life/...`, which only works when the process cwd happens to equal the agent home directory. > - At the same time, six local adapters each duplicated the same workspace-env propagation logic, including `AGENT_HOME`, which makes this contract easy to drift. > - This pull request fixes the CEO instructions to use `$AGENT_HOME/...` and centralizes workspace-env propagation in one shared helper with shared tests. > - The benefit is a real bug fix for agent memory paths plus a single tested contract that makes future built-in adapter work less likely to forget `AGENT_HOME`. ## What Changed - Updated `server/src/onboarding-assets/ceo/HEARTBEAT.md` to use `$AGENT_HOME/memory/...` and `$AGENT_HOME/life/...` instead of cwd-relative `./memory/...` and `./life/...`. - Added `applyPaperclipWorkspaceEnv(...)` in `packages/adapter-utils/src/server-utils.ts` to centralize `PAPERCLIP_WORKSPACE_*` and `AGENT_HOME` propagation. - Added shared helper coverage in `packages/adapter-utils/src/server-utils.test.ts` for both populated and skip-empty cases. - Switched the built-in local adapters (`claude_local`, `codex_local`, `cursor_local`, `gemini_local`, `opencode_local`, `pi_local`) over to the shared helper instead of inline env assignment blocks. ## Verification - `pnpm install` - `pnpm exec vitest run packages/adapter-utils/src/server-utils.test.ts packages/adapters/claude-local/src/server/execute.remote.test.ts packages/adapters/codex-local/src/server/execute.remote.test.ts packages/adapters/cursor-local/src/server/execute.remote.test.ts packages/adapters/gemini-local/src/server/execute.remote.test.ts packages/adapters/opencode-local/src/server/execute.remote.test.ts packages/adapters/pi-local/src/server/execute.remote.test.ts` - Result: 7 test files passed, 31 tests passed, 0 failures. ## Risks - Low risk. - The only behavioral surface is the shared env propagation refactor across six adapters; if the helper diverged from prior semantics, an adapter could miss a workspace env var. - The shared helper test plus the affected adapter execute tests reduce that risk, and the helper preserves the prior "set only non-empty strings" behavior. ## Model Used - OpenAI Codex via Paperclip `codex_local` agent runtime; tool-assisted coding workflow with shell execution, file patching, git operations, and API interaction. The exact backend model identifier and context window are not surfaced by this local runtime. ## 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 and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/server-utils.test.ts | 45 +++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 35 +++++++++++++++ .../claude-local/src/server/execute.ts | 39 +++++----------- .../codex-local/src/server/execute.ts | 39 +++++----------- .../cursor-local/src/server/execute.ts | 27 ++++------- .../gemini-local/src/server/execute.ts | 15 ++++--- .../opencode-local/src/server/execute.ts | 15 ++++--- .../adapters/pi-local/src/server/execute.ts | 15 ++++--- server/src/onboarding-assets/ceo/HEARTBEAT.md | 6 +-- 9 files changed, 143 insertions(+), 93 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 06847445..f5e8f716 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; import { + applyPaperclipWorkspaceEnv, appendWithByteCap, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, renderPaperclipWakePrompt, @@ -425,6 +426,50 @@ describe("renderPaperclipWakePrompt", () => { }); }); +describe("applyPaperclipWorkspaceEnv", () => { + it("adds shared workspace env vars including AGENT_HOME", () => { + const env = applyPaperclipWorkspaceEnv( + {}, + { + workspaceCwd: "/tmp/workspace", + workspaceSource: "project_primary", + workspaceStrategy: "git_worktree", + workspaceId: "workspace-1", + workspaceRepoUrl: "https://github.com/paperclipai/paperclip.git", + workspaceRepoRef: "main", + workspaceBranch: "feature/test", + workspaceWorktreePath: "/tmp/worktree", + agentHome: "/tmp/agent-home", + }, + ); + + expect(env).toEqual({ + PAPERCLIP_WORKSPACE_CWD: "/tmp/workspace", + PAPERCLIP_WORKSPACE_SOURCE: "project_primary", + PAPERCLIP_WORKSPACE_STRATEGY: "git_worktree", + PAPERCLIP_WORKSPACE_ID: "workspace-1", + PAPERCLIP_WORKSPACE_REPO_URL: "https://github.com/paperclipai/paperclip.git", + PAPERCLIP_WORKSPACE_REPO_REF: "main", + PAPERCLIP_WORKSPACE_BRANCH: "feature/test", + PAPERCLIP_WORKSPACE_WORKTREE_PATH: "/tmp/worktree", + AGENT_HOME: "/tmp/agent-home", + }); + }); + + it("skips empty workspace env values", () => { + const env = applyPaperclipWorkspaceEnv( + {}, + { + workspaceCwd: "", + workspaceSource: null, + agentHome: "", + }, + ); + + expect(env).toEqual({}); + }); +}); + describe("appendWithByteCap", () => { it("keeps valid UTF-8 when trimming through multibyte text", () => { const output = appendWithByteCap("prefix ", "hello — world", 7); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 661010d3..425a1f3f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -835,6 +835,41 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec return vars; } +export function applyPaperclipWorkspaceEnv( + env: Record, + input: { + workspaceCwd?: string | null; + workspaceSource?: string | null; + workspaceStrategy?: string | null; + workspaceId?: string | null; + workspaceRepoUrl?: string | null; + workspaceRepoRef?: string | null; + workspaceBranch?: string | null; + workspaceWorktreePath?: string | null; + agentHome?: string | null; + }, +): Record { + const mappings = [ + ["PAPERCLIP_WORKSPACE_CWD", input.workspaceCwd], + ["PAPERCLIP_WORKSPACE_SOURCE", input.workspaceSource], + ["PAPERCLIP_WORKSPACE_STRATEGY", input.workspaceStrategy], + ["PAPERCLIP_WORKSPACE_ID", input.workspaceId], + ["PAPERCLIP_WORKSPACE_REPO_URL", input.workspaceRepoUrl], + ["PAPERCLIP_WORKSPACE_REPO_REF", input.workspaceRepoRef], + ["PAPERCLIP_WORKSPACE_BRANCH", input.workspaceBranch], + ["PAPERCLIP_WORKSPACE_WORKTREE_PATH", input.workspaceWorktreePath], + ["AGENT_HOME", input.agentHome], + ] as const; + + for (const [key, value] of mappings) { + if (typeof value === "string" && value.length > 0) { + env[key] = value; + } + } + + return env; +} + export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index a79e7224..365295b6 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -24,6 +24,7 @@ import { asStringArray, parseObject, parseJson, + applyPaperclipWorkspaceEnv, buildPaperclipEnv, readPaperclipRuntimeSkillEntries, joinPromptSections, @@ -193,33 +194,17 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 2a6b253a..89f67079 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -19,6 +19,7 @@ import { asString, asNumber, parseObject, + applyPaperclipWorkspaceEnv, buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, @@ -421,33 +422,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 6d7f7966..5056b222 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -24,6 +24,7 @@ import { asNumber, asStringArray, parseObject, + applyPaperclipWorkspaceEnv, buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, @@ -277,24 +278,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 4c79b753..756e88fd 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -25,6 +25,7 @@ import { asNumber, asString, asStringArray, + applyPaperclipWorkspaceEnv, buildPaperclipEnv, buildInvocationEnvForLogs, ensureAbsoluteDirectory, @@ -240,12 +241,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; - if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; - if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; - if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; - if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; - if (agentHome) env.AGENT_HOME = agentHome; + applyPaperclipWorkspaceEnv(env, { + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + agentHome, + }); if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 0aa86b17..fbb8d433 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -24,6 +24,7 @@ import { asNumber, asStringArray, parseObject, + applyPaperclipWorkspaceEnv, buildPaperclipEnv, joinPromptSections, buildInvocationEnvForLogs, @@ -199,12 +200,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; - if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; - if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; - if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; - if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; - if (agentHome) env.AGENT_HOME = agentHome; + applyPaperclipWorkspaceEnv(env, { + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + agentHome, + }); if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 819be8e1..00b011a6 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -23,6 +23,7 @@ import { asNumber, asStringArray, parseObject, + applyPaperclipWorkspaceEnv, buildPaperclipEnv, joinPromptSections, buildInvocationEnvForLogs, @@ -228,12 +229,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd; - if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; - if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; - if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; - if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; - if (agentHome) env.AGENT_HOME = agentHome; + applyPaperclipWorkspaceEnv(env, { + workspaceCwd, + workspaceSource, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + agentHome, + }); if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 573dfea5..722e4414 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -9,7 +9,7 @@ Run this checklist on every heartbeat. This covers both your local planning/memo ## 2. Local Planning Check -1. Read today's plan from `./memory/YYYY-MM-DD.md` under "## Today's Plan". +1. Read today's plan from `$AGENT_HOME/memory/YYYY-MM-DD.md` under "## Today's Plan". 2. Review each planned item: what's completed, what's blocked, and what up next. 3. For any blockers, resolve them yourself or escalate to the board. 4. If you're ahead, start on the next highest priority. @@ -57,8 +57,8 @@ Status quick guide: ## 7. Fact Extraction 1. Check for new conversations since last extraction. -2. Extract durable facts to the relevant entity in `./life/` (PARA). -3. Update `./memory/YYYY-MM-DD.md` with timeline entries. +2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA). +3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries. 4. Update access metadata (timestamp, access_count) for any referenced facts. ## 8. Exit