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
This commit is contained in:
Devin Foley 2026-04-26 13:57:35 -07:00 committed by GitHub
parent d1484551ee
commit d47ffa87f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 143 additions and 93 deletions

View file

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
applyPaperclipWorkspaceEnv,
appendWithByteCap, appendWithByteCap,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
renderPaperclipWakePrompt, 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", () => { describe("appendWithByteCap", () => {
it("keeps valid UTF-8 when trimming through multibyte text", () => { it("keeps valid UTF-8 when trimming through multibyte text", () => {
const output = appendWithByteCap("prefix ", "hello — world", 7); const output = appendWithByteCap("prefix ", "hello — world", 7);

View file

@ -835,6 +835,41 @@ export function buildPaperclipEnv(agent: { id: string; companyId: string }): Rec
return vars; return vars;
} }
export function applyPaperclipWorkspaceEnv(
env: Record<string, string>,
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<string, string> {
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 { export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...baseEnv }; const env: NodeJS.ProcessEnv = { ...baseEnv };
for (const key of Object.keys(env)) { for (const key of Object.keys(env)) {

View file

@ -24,6 +24,7 @@ import {
asStringArray, asStringArray,
parseObject, parseObject,
parseJson, parseJson,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv, buildPaperclipEnv,
readPaperclipRuntimeSkillEntries, readPaperclipRuntimeSkillEntries,
joinPromptSections, joinPromptSections,
@ -193,33 +194,17 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
if (wakePayloadJson) { if (wakePayloadJson) {
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
} }
if (effectiveWorkspaceCwd) { applyPaperclipWorkspaceEnv(env, {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; workspaceCwd: effectiveWorkspaceCwd,
} workspaceSource,
if (workspaceSource) { workspaceStrategy,
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; workspaceId,
} workspaceRepoUrl,
if (workspaceStrategy) { workspaceRepoRef,
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy; workspaceBranch,
} workspaceWorktreePath,
if (workspaceId) { agentHome,
env.PAPERCLIP_WORKSPACE_ID = workspaceId; });
}
if (workspaceRepoUrl) {
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (agentHome) {
env.AGENT_HOME = agentHome;
}
if (workspaceHints.length > 0) { if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
} }

View file

@ -19,6 +19,7 @@ import {
asString, asString,
asNumber, asNumber,
parseObject, parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv, buildPaperclipEnv,
buildInvocationEnvForLogs, buildInvocationEnvForLogs,
ensureAbsoluteDirectory, ensureAbsoluteDirectory,
@ -421,33 +422,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (wakePayloadJson) { if (wakePayloadJson) {
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
} }
if (effectiveWorkspaceCwd) { applyPaperclipWorkspaceEnv(env, {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; workspaceCwd: effectiveWorkspaceCwd,
} workspaceSource,
if (workspaceSource) { workspaceStrategy,
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; workspaceId,
} workspaceRepoUrl,
if (workspaceStrategy) { workspaceRepoRef,
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy; workspaceBranch,
} workspaceWorktreePath,
if (workspaceId) { agentHome,
env.PAPERCLIP_WORKSPACE_ID = workspaceId; });
}
if (workspaceRepoUrl) {
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
}
if (workspaceRepoRef) {
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
}
if (workspaceBranch) {
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
}
if (workspaceWorktreePath) {
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
}
if (agentHome) {
env.AGENT_HOME = agentHome;
}
if (workspaceHints.length > 0) { if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
} }

View file

@ -24,6 +24,7 @@ import {
asNumber, asNumber,
asStringArray, asStringArray,
parseObject, parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv, buildPaperclipEnv,
buildInvocationEnvForLogs, buildInvocationEnvForLogs,
ensureAbsoluteDirectory, ensureAbsoluteDirectory,
@ -277,24 +278,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (wakePayloadJson) { if (wakePayloadJson) {
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
} }
if (effectiveWorkspaceCwd) { applyPaperclipWorkspaceEnv(env, {
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; workspaceCwd: effectiveWorkspaceCwd,
} workspaceSource,
if (workspaceSource) { workspaceId,
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; workspaceRepoUrl,
} workspaceRepoRef,
if (workspaceId) { agentHome,
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;
}
if (workspaceHints.length > 0) { if (workspaceHints.length > 0) {
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
} }

View file

@ -25,6 +25,7 @@ import {
asNumber, asNumber,
asString, asString,
asStringArray, asStringArray,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv, buildPaperclipEnv,
buildInvocationEnvForLogs, buildInvocationEnvForLogs,
ensureAbsoluteDirectory, ensureAbsoluteDirectory,
@ -240,12 +241,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; applyPaperclipWorkspaceEnv(env, {
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; workspaceCwd: effectiveWorkspaceCwd,
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; workspaceSource,
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; workspaceId,
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; workspaceRepoUrl,
if (agentHome) env.AGENT_HOME = agentHome; workspaceRepoRef,
agentHome,
});
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;

View file

@ -24,6 +24,7 @@ import {
asNumber, asNumber,
asStringArray, asStringArray,
parseObject, parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv, buildPaperclipEnv,
joinPromptSections, joinPromptSections,
buildInvocationEnvForLogs, buildInvocationEnvForLogs,
@ -199,12 +200,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; applyPaperclipWorkspaceEnv(env, {
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; workspaceCwd: effectiveWorkspaceCwd,
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; workspaceSource,
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; workspaceId,
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; workspaceRepoUrl,
if (agentHome) env.AGENT_HOME = agentHome; workspaceRepoRef,
agentHome,
});
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;

View file

@ -23,6 +23,7 @@ import {
asNumber, asNumber,
asStringArray, asStringArray,
parseObject, parseObject,
applyPaperclipWorkspaceEnv,
buildPaperclipEnv, buildPaperclipEnv,
joinPromptSections, joinPromptSections,
buildInvocationEnvForLogs, buildInvocationEnvForLogs,
@ -228,12 +229,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd; applyPaperclipWorkspaceEnv(env, {
if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; workspaceCwd,
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; workspaceSource,
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; workspaceId,
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; workspaceRepoUrl,
if (agentHome) env.AGENT_HOME = agentHome; workspaceRepoRef,
agentHome,
});
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget);
if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl;

View file

@ -9,7 +9,7 @@ Run this checklist on every heartbeat. This covers both your local planning/memo
## 2. Local Planning Check ## 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. 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. 3. For any blockers, resolve them yourself or escalate to the board.
4. If you're ahead, start on the next highest priority. 4. If you're ahead, start on the next highest priority.
@ -57,8 +57,8 @@ Status quick guide:
## 7. Fact Extraction ## 7. Fact Extraction
1. Check for new conversations since last extraction. 1. Check for new conversations since last extraction.
2. Extract durable facts to the relevant entity in `./life/` (PARA). 2. Extract durable facts to the relevant entity in `$AGENT_HOME/life/` (PARA).
3. Update `./memory/YYYY-MM-DD.md` with timeline entries. 3. Update `$AGENT_HOME/memory/YYYY-MM-DD.md` with timeline entries.
4. Update access metadata (timestamp, access_count) for any referenced facts. 4. Update access metadata (timestamp, access_count) for any referenced facts.
## 8. Exit ## 8. Exit