fix: manage codex home per company by default
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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
|
return companyId
|
||||||
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
|
? path.resolve(paperclipHome, "instances", instanceId, "companies", companyId, "codex-home")
|
||||||
: path.resolve(paperclipHome, "instances", instanceId, "codex-home");
|
: path.resolve(paperclipHome, "instances", instanceId, "codex-home");
|
||||||
}
|
|
||||||
return companyId
|
|
||||||
? path.resolve(paperclipHome, "companies", companyId, "codex-home")
|
|
||||||
: path.resolve(paperclipHome, "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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user