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 79d5a2ed..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,11 @@ 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"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5743c430..0694efed 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2146,7 +2146,11 @@ export function heartbeatService(db: Db) { repoRef: executionWorkspace.repoRef, branchName: executionWorkspace.branchName, worktreePath: executionWorkspace.worktreePath, - agentHome: resolveDefaultAgentWorkspaceDir(agent.id), + agentHome: await (async () => { + const home = resolveDefaultAgentWorkspaceDir(agent.id); + await fs.mkdir(home, { recursive: true }); + return home; + })(), }; context.paperclipWorkspaces = resolvedWorkspace.workspaceHints; const runtimeServiceIntents = (() => {