From 07757a59e9950548ced064175b76d113dc900396 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 14:25:18 -0500 Subject: [PATCH 1/2] Ensure agent home directories exist before use mkdir -p the CODEX_HOME directory in codex-local adapter and the agentHome directory in the heartbeat service before passing them to adapters. This prevents CLI tools from erroring when their home directory hasn't been created yet. Covers all local adapters that set AGENT_HOME. Co-Authored-By: Paperclip --- packages/adapters/codex-local/src/server/execute.ts | 1 + server/src/services/heartbeat.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 79d5a2ed..3be5135d 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -272,6 +272,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const home = resolveDefaultAgentWorkspaceDir(agent.id); + await fs.mkdir(home, { recursive: true }); + return home; + })(), }; context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; const runtimeServiceIntents = (() => { From d53714a145b8576f5a8bbb419fb4082db5a39147 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 14:44:27 -0500 Subject: [PATCH 2/2] fix: manage codex home per company by default --- doc/DEVELOPING.md | 4 + packages/adapters/codex-local/src/index.ts | 1 + .../codex-local/src/server/codex-home.ts | 37 +++---- .../codex-local/src/server/execute.ts | 10 +- .../src/__tests__/codex-local-execute.test.ts | 98 +++++++++++++++++++ 5 files changed, 122 insertions(+), 28 deletions(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 42e70fff..7d98cd6d 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -130,6 +130,10 @@ When a local agent run has no resolved project/session workspace, Paperclip fall This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups. +For `codex_local`, Paperclip also manages a per-company Codex home under the instance root and seeds it from the shared Codex login/config home (`$CODEX_HOME` or `~/.codex`): + +- `~/.paperclip/instances/default/companies//codex-home` + ## Worktree-local Instances When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 7a0aea51..0d881a2b 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -41,6 +41,7 @@ Operational fields: Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). - Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home. +- Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 774a0b35..c032fd24 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -6,6 +6,7 @@ import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; const SYMLINKED_SHARED_FILES = ["auth.json"] as const; +const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; function nonEmpty(value: string | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -15,35 +16,26 @@ export async function pathExists(candidate: string): Promise { return fs.access(candidate).then(() => true).catch(() => false); } -export function resolveCodexHomeDir( +export function resolveSharedCodexHomeDir( env: NodeJS.ProcessEnv = process.env, - companyId?: string, ): string { const fromEnv = nonEmpty(env.CODEX_HOME); - const baseHome = fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex"); - return companyId ? path.join(baseHome, "companies", companyId) : baseHome; + return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex"); } function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); } -function resolveWorktreeCodexHomeDir( +export function resolveManagedCodexHomeDir( env: NodeJS.ProcessEnv, companyId?: string, -): string | null { - if (!isWorktreeMode(env)) return null; - const paperclipHome = nonEmpty(env.PAPERCLIP_HOME); - if (!paperclipHome) return null; - const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID); - if (instanceId) { - return companyId - ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home") - : path.resolve(paperclipHome, "instances", instanceId, "codex-home"); - } +): string { + const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip"); + const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; return companyId - ? path.resolve(paperclipHome, "companies", companyId, "codex-home") - : path.resolve(paperclipHome, "codex-home"); + ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home") + : path.resolve(paperclipHome, "instances", instanceId, "codex-home"); } async function ensureParentDir(target: string): Promise { @@ -79,15 +71,14 @@ async function ensureCopiedFile(target: string, source: string): Promise { await fs.copyFile(source, target); } -export async function prepareWorktreeCodexHome( +export async function prepareManagedCodexHome( env: NodeJS.ProcessEnv, onLog: AdapterExecutionContext["onLog"], companyId?: string, -): Promise { - const targetHome = resolveWorktreeCodexHomeDir(env, companyId); - if (!targetHome) return null; +): Promise { + const targetHome = resolveManagedCodexHomeDir(env, companyId); - const sourceHome = resolveCodexHomeDir(env); + const sourceHome = resolveSharedCodexHomeDir(env); if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome; await fs.mkdir(targetHome, { recursive: true }); @@ -106,7 +97,7 @@ export async function prepareWorktreeCodexHome( await onLog( "stdout", - `[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`, + `[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`, ); return targetHome; } diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 3be5135d..b6bda8df 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -21,7 +21,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; -import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; +import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir } from "./codex-home.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -268,10 +268,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + it("uses a Paperclip-managed CODEX_HOME outside worktree mode while preserving shared auth and config", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-default-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const managedCodexHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "codex-home", + ); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"token":"shared"}\n', "utf8"); + await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "codex-mini-latest"\n', "utf8"); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + const previousPaperclipInWorktree = process.env.PAPERCLIP_IN_WORKTREE; + const previousCodexHome = process.env.CODEX_HOME; + process.env.HOME = root; + process.env.PAPERCLIP_HOME = paperclipHome; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_IN_WORKTREE; + process.env.CODEX_HOME = sharedCodexHome; + + try { + const logs: LogEntry[] = []; + const result = await execute({ + runId: "run-default", + 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: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async (stream, chunk) => { + logs.push({ stream, chunk }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.codexHome).toBe(managedCodexHome); + + const managedAuth = path.join(managedCodexHome, "auth.json"); + const managedConfig = path.join(managedCodexHome, "config.toml"); + expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(path.join(sharedCodexHome, "auth.json"))); + expect((await fs.lstat(managedConfig)).isFile()).toBe(true); + expect(await fs.readFile(managedConfig, "utf8")).toBe('model = "codex-mini-latest"\n'); + await expect(fs.lstat(path.join(sharedCodexHome, "companies", "company-1"))).rejects.toThrow(); + expect(logs).toContainEqual( + expect.objectContaining({ + stream: "stdout", + chunk: expect.stringContaining("Using Paperclip-managed Codex home"), + }), + ); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousPaperclipInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousPaperclipInstanceId; + if (previousPaperclipInWorktree === undefined) delete process.env.PAPERCLIP_IN_WORKTREE; + else process.env.PAPERCLIP_IN_WORKTREE = previousPaperclipInWorktree; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + 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");