fix: manage codex home per company by default

This commit is contained in:
dotta
2026-03-20 14:44:27 -05:00
parent 07757a59e9
commit d53714a145
5 changed files with 122 additions and 28 deletions

View File

@@ -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. 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/<company-id>/codex-home`
## Worktree-local Instances ## Worktree-local Instances
When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory.

View File

@@ -41,6 +41,7 @@ Operational fields:
Notes: Notes:
- Prompts are piped via stdin (Codex receives "-" prompt argument). - 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. - 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). - 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. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling.
`; `;

View File

@@ -6,6 +6,7 @@ import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i; const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const; const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;
const SYMLINKED_SHARED_FILES = ["auth.json"] as const; const SYMLINKED_SHARED_FILES = ["auth.json"] as const;
const DEFAULT_PAPERCLIP_INSTANCE_ID = "default";
function nonEmpty(value: string | undefined): string | null { function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@@ -15,35 +16,26 @@ export async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false); return fs.access(candidate).then(() => true).catch(() => false);
} }
export function resolveCodexHomeDir( export function resolveSharedCodexHomeDir(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
companyId?: string,
): string { ): string {
const fromEnv = nonEmpty(env.CODEX_HOME); const fromEnv = nonEmpty(env.CODEX_HOME);
const baseHome = fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex"); return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex");
return companyId ? path.join(baseHome, "companies", companyId) : baseHome;
} }
function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {
return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
} }
function resolveWorktreeCodexHomeDir( export function resolveManagedCodexHomeDir(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
companyId?: string, companyId?: string,
): string | null { ): string {
if (!isWorktreeMode(env)) return null; const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip");
const paperclipHome = nonEmpty(env.PAPERCLIP_HOME); const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID;
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");
}
return companyId return companyId
? path.resolve(paperclipHome, "companies", companyId, "codex-home") ? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
: path.resolve(paperclipHome, "codex-home"); : path.resolve(paperclipHome, "instances", instanceId, "codex-home");
} }
async function ensureParentDir(target: string): Promise<void> { async function ensureParentDir(target: string): Promise<void> {
@@ -79,15 +71,14 @@ async function ensureCopiedFile(target: string, source: string): Promise<void> {
await fs.copyFile(source, target); await fs.copyFile(source, target);
} }
export async function prepareWorktreeCodexHome( export async function prepareManagedCodexHome(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"], onLog: AdapterExecutionContext["onLog"],
companyId?: string, companyId?: string,
): Promise<string | null> { ): Promise<string> {
const targetHome = resolveWorktreeCodexHomeDir(env, companyId); const targetHome = resolveManagedCodexHomeDir(env, companyId);
if (!targetHome) return null;
const sourceHome = resolveCodexHomeDir(env); const sourceHome = resolveSharedCodexHomeDir(env);
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome; if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
await fs.mkdir(targetHome, { recursive: true }); await fs.mkdir(targetHome, { recursive: true });
@@ -106,7 +97,7 @@ export async function prepareWorktreeCodexHome(
await onLog( await onLog(
"stdout", "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; return targetHome;
} }

View File

@@ -21,7 +21,7 @@ import {
runChildProcess, runChildProcess,
} from "@paperclipai/adapter-utils/server-utils"; } from "@paperclipai/adapter-utils/server-utils";
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; 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"; import { resolveCodexDesiredSkillNames } from "./skills.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -268,10 +268,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries); const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedWorktreeCodexHome = const preparedManagedCodexHome =
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog, agent.companyId); configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId);
const defaultCodexHome = resolveCodexHomeDir(process.env, agent.companyId); const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome ?? defaultCodexHome; const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true }); await fs.mkdir(effectiveCodexHome, { recursive: true });
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd); const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
await ensureCodexSkillsInjected( await ensureCodexSkillsInjected(

View File

@@ -41,6 +41,104 @@ type LogEntry = {
}; };
describe("codex execute", () => { describe("codex execute", () => {
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 () => { 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 root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-"));
const workspace = path.join(root, "workspace"); const workspace = path.join(root, "workspace");