Merge pull request #1379 from paperclipai/fix/codex-managed-home-followups

Default codex-local to a managed per-company CODEX_HOME
This commit is contained in:
Dotta
2026-03-20 14:45:55 -05:00
committed by GitHub
6 changed files with 128 additions and 29 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.
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
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:
- 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.
`;

View File

@@ -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<boolean> {
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<void> {
@@ -79,15 +71,14 @@ async function ensureCopiedFile(target: string, source: string): Promise<void> {
await fs.copyFile(source, target);
}
export async function prepareWorktreeCodexHome(
export async function prepareManagedCodexHome(
env: NodeJS.ProcessEnv,
onLog: AdapterExecutionContext["onLog"],
companyId?: string,
): Promise<string | null> {
const targetHome = resolveWorktreeCodexHomeDir(env, companyId);
if (!targetHome) return null;
): Promise<string> {
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;
}

View File

@@ -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<AdapterExec
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const preparedWorktreeCodexHome =
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog, agent.companyId);
const defaultCodexHome = resolveCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome ?? defaultCodexHome;
const preparedManagedCodexHome =
configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId);
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
await fs.mkdir(effectiveCodexHome, { recursive: true });
const codexWorkspaceSkillsDir = resolveCodexWorkspaceSkillsDir(cwd);
await ensureCodexSkillsInjected(
onLog,

View File

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

View File

@@ -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 = (() => {