mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix(codex-local): seed auth for managed CODEX_HOME + fail-fast guard
A codex_local agent is pinned by the server isolation guard to a per-agent managed CODEX_HOME with OPENAI_API_KEY="" and no provisioned credentials. The adapter previously skipped auth seeding whenever env.CODEX_HOME was set, so managed homes launched with zero credentials and the provider returned "401 Missing bearer". - Detect when a configured CODEX_HOME is a Paperclip-managed path (under the instance company tree) vs a genuine external override. - Seed ChatGPT-subscription (or per-agent API-key) auth.json into managed homes even when env.CODEX_HOME is set; leave real external overrides untouched. - Fail fast with a clear adapter error when a managed home ends up with no usable auth.json and no API key, instead of emitting unauthenticated calls. - Document the seeding contract in the codex-local adapter docs. Adds unit tests for managed-vs-external detection, auth seeding, the auth-check helper, and the no-credentials fail-fast error path.
This commit is contained in:
1 parent
d5ceb82571
commit
c734e0ebb5
5 files changed
+304
-13
No files matched your search
@@ -45,6 +45,16 @@ Paperclip currently applies that only when the selected model is `gpt-5.4`. On o
|
||||
|
||||
When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity.
|
||||
|
||||
### Per-agent isolation and auth seeding
|
||||
|
||||
For `codex_local` agents the server isolation guard pins each agent to a per-agent home (`<instance>/companies/<companyId>/agents/<agentId>/codex-home`) and sets `OPENAI_API_KEY=""` so an agent can never spend against the host API key or share another agent's Codex state.
|
||||
|
||||
A managed home is created empty, so the adapter must provision auth into it before launching Codex — otherwise the agent runs with zero credentials and the provider returns `401 Missing bearer`. The seeding contract:
|
||||
|
||||
- **Managed homes** (the default home and any configured `CODEX_HOME` under the company tree) are always seeded: the ChatGPT-subscription `auth.json` is symlinked from the host Codex home, or, when a per-agent `OPENAI_API_KEY` is configured, an API-key `auth.json` is written instead.
|
||||
- **Genuine external overrides** (a `CODEX_HOME` outside the Paperclip-managed company tree) are treated as self-managed and are never seeded or overwritten.
|
||||
- **Fail-fast guard:** if a managed home ends up with no usable `auth.json` and no configured API key, the run fails with an explicit `adapter_failed` ("no Codex credentials provisioned for managed home …") rather than emitting an unauthenticated request.
|
||||
|
||||
## Manual Local CLI
|
||||
|
||||
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||
|
||||
@@ -2,7 +2,13 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ensureSymlink, prepareManagedCodexHome } from "./codex-home.js";
|
||||
import {
|
||||
codexHomeHasUsableAuth,
|
||||
ensureSymlink,
|
||||
isManagedCodexHomePath,
|
||||
prepareManagedCodexHome,
|
||||
seedManagedCodexHome,
|
||||
} from "./codex-home.js";
|
||||
|
||||
describe("codex managed home", () => {
|
||||
afterEach(() => {
|
||||
@@ -194,3 +200,117 @@ describe("codex managed home", () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("isManagedCodexHomePath", () => {
|
||||
const env = {
|
||||
PAPERCLIP_HOME: "/srv/paperclip",
|
||||
PAPERCLIP_INSTANCE_ID: "default",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
const companyRoot = path.resolve(
|
||||
"/srv/paperclip/instances/default/companies/company-1",
|
||||
);
|
||||
|
||||
it("treats the per-agent managed home as managed", () => {
|
||||
expect(
|
||||
isManagedCodexHomePath(
|
||||
env,
|
||||
"company-1",
|
||||
path.join(companyRoot, "agents", "agent-7", "codex-home"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats the shared company home as managed", () => {
|
||||
expect(
|
||||
isManagedCodexHomePath(env, "company-1", path.join(companyRoot, "codex-home")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a path outside the company tree as an external override", () => {
|
||||
expect(isManagedCodexHomePath(env, "company-1", "/home/dev/.codex")).toBe(false);
|
||||
expect(
|
||||
isManagedCodexHomePath(
|
||||
env,
|
||||
"company-1",
|
||||
path.resolve("/srv/paperclip/instances/default/companies/company-2/codex-home"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without a companyId", () => {
|
||||
expect(isManagedCodexHomePath(env, undefined, path.join(companyRoot, "codex-home"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("codexHomeHasUsableAuth", () => {
|
||||
it("is true for a real auth.json and false when missing", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-"));
|
||||
try {
|
||||
expect(await codexHomeHasUsableAuth(root)).toBe(false);
|
||||
await fs.writeFile(path.join(root, "auth.json"), "{}", "utf8");
|
||||
expect(await codexHomeHasUsableAuth(root)).toBe(true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is false for a dangling auth.json symlink", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-dangling-"));
|
||||
try {
|
||||
await fs.symlink(path.join(root, "missing-source.json"), path.join(root, "auth.json"));
|
||||
expect(await codexHomeHasUsableAuth(root)).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedManagedCodexHome", () => {
|
||||
it("symlinks auth.json from the shared source into an explicit per-agent home", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-"));
|
||||
try {
|
||||
const sharedCodexHome = path.join(root, "shared-codex-home");
|
||||
const agentHome = path.join(
|
||||
root,
|
||||
"instances",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-7",
|
||||
"codex-home",
|
||||
);
|
||||
const sharedAuth = path.join(sharedCodexHome, "auth.json");
|
||||
const agentAuth = path.join(agentHome, "auth.json");
|
||||
|
||||
await fs.mkdir(sharedCodexHome, { recursive: true });
|
||||
await fs.writeFile(sharedAuth, '{"token":"shared"}', "utf8");
|
||||
|
||||
await seedManagedCodexHome(agentHome, { CODEX_HOME: sharedCodexHome }, async () => {});
|
||||
|
||||
expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(true);
|
||||
expect(await fs.realpath(agentAuth)).toBe(await fs.realpath(sharedAuth));
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes an API-key auth.json into the home when an apiKey is supplied", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-"));
|
||||
try {
|
||||
const agentHome = path.join(root, "agent-home");
|
||||
const emptyShared = path.join(root, "empty-shared");
|
||||
await fs.mkdir(emptyShared, { recursive: true });
|
||||
await seedManagedCodexHome(agentHome, { CODEX_HOME: emptyShared }, async () => {}, {
|
||||
apiKey: "sk-test-123",
|
||||
});
|
||||
|
||||
const written = JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8"));
|
||||
expect(written).toEqual({ OPENAI_API_KEY: "sk-test-123" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,38 @@ export function resolveManagedCodexHomeDir(
|
||||
: path.resolve(instanceRoot, "codex-home");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `homePath` lives under the Paperclip-managed company tree
|
||||
* (`<instanceRoot>/companies/<companyId>/...`). This covers both the shared
|
||||
* company `codex-home` and the per-agent `agents/<agentId>/codex-home` set by
|
||||
* the server-side isolation guard. A path outside that tree is a genuine
|
||||
* external/user-supplied override that Paperclip must not seed or overwrite.
|
||||
*/
|
||||
export function isManagedCodexHomePath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
companyId: string | undefined,
|
||||
homePath: string,
|
||||
): boolean {
|
||||
if (!companyId) return false;
|
||||
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
|
||||
homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
env,
|
||||
});
|
||||
const companyRoot = path.resolve(instanceRoot, "companies", companyId);
|
||||
const resolved = path.resolve(homePath);
|
||||
return resolved === companyRoot || resolved.startsWith(companyRoot + path.sep);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the Codex home has a usable `auth.json`. Uses `fs.access` (follows
|
||||
* symlinks), so a dangling auth symlink whose source has been removed counts as
|
||||
* no usable credentials.
|
||||
*/
|
||||
export async function codexHomeHasUsableAuth(home: string): Promise<boolean> {
|
||||
return pathExists(path.join(home, "auth.json"));
|
||||
}
|
||||
|
||||
async function ensureParentDir(target: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
}
|
||||
@@ -116,13 +148,20 @@ export async function writeApiKeyAuthJson(home: string, apiKey: string): Promise
|
||||
await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export async function prepareManagedCodexHome(
|
||||
/**
|
||||
* Seeds auth/config into an explicit Paperclip-managed `targetHome`. Symlinks
|
||||
* `auth.json` from the shared source home (so ChatGPT-subscription credentials
|
||||
* stay live and single-use refresh tokens are not copied), copies the static
|
||||
* shared config files, and — when an API key is supplied — writes an API-key
|
||||
* `auth.json` instead. Used both for the default company home and for the
|
||||
* per-agent home set by the server isolation guard.
|
||||
*/
|
||||
export async function seedManagedCodexHome(
|
||||
targetHome: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
companyId?: string,
|
||||
options: { apiKey?: string | null } = {},
|
||||
): Promise<string> {
|
||||
const targetHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
): Promise<void> {
|
||||
const apiKey = nonEmpty(options.apiKey ?? undefined);
|
||||
|
||||
const sourceHome = resolveSharedCodexHomeDir(env);
|
||||
@@ -168,6 +207,15 @@ export async function prepareManagedCodexHome(
|
||||
`[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareManagedCodexHome(
|
||||
env: NodeJS.ProcessEnv,
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
companyId?: string,
|
||||
options: { apiKey?: string | null } = {},
|
||||
): Promise<string> {
|
||||
const targetHome = resolveManagedCodexHomeDir(env, companyId);
|
||||
await seedManagedCodexHome(targetHome, env, onLog, options);
|
||||
return targetHome;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { execute } from "./execute.js";
|
||||
|
||||
describe("codex managed-home auth fail-fast", () => {
|
||||
const cleanupDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
while (cleanupDirs.length > 0) {
|
||||
const dir = cleanupDirs.pop();
|
||||
if (!dir) continue;
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails fast when a managed CODEX_HOME has no auth.json and OPENAI_API_KEY is empty", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-failfast-"));
|
||||
cleanupDirs.push(root);
|
||||
|
||||
const paperclipHome = path.join(root, "paperclip-home");
|
||||
const emptySharedHome = path.join(root, "shared-codex-home");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
// A managed per-agent home with no credentials seeded into it.
|
||||
const managedAgentHome = path.join(
|
||||
paperclipHome,
|
||||
"instances",
|
||||
"default",
|
||||
"companies",
|
||||
"company-1",
|
||||
"agents",
|
||||
"agent-1",
|
||||
"codex-home",
|
||||
);
|
||||
await fs.mkdir(emptySharedHome, { recursive: true });
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
// Source home has no auth.json, so nothing is symlinked into the managed home.
|
||||
vi.stubEnv("PAPERCLIP_HOME", paperclipHome);
|
||||
vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
|
||||
vi.stubEnv("CODEX_HOME", emptySharedHome);
|
||||
|
||||
await expect(
|
||||
execute({
|
||||
runId: "run-failfast",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "codex",
|
||||
cwd: workspaceDir,
|
||||
env: {
|
||||
CODEX_HOME: managedAgentHome,
|
||||
OPENAI_API_KEY: "",
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
onLog: async () => {},
|
||||
}),
|
||||
).rejects.toThrow(/no Codex credentials provisioned for managed home/);
|
||||
|
||||
// The managed home must not have been left with a usable auth.json.
|
||||
await expect(fs.access(path.join(managedAgentHome, "auth.json"))).rejects.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -44,7 +44,15 @@ import {
|
||||
isCodexTransientUpstreamError,
|
||||
isCodexUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
|
||||
import {
|
||||
codexHomeHasUsableAuth,
|
||||
isManagedCodexHomePath,
|
||||
pathExists,
|
||||
prepareManagedCodexHome,
|
||||
resolveManagedCodexHomeDir,
|
||||
resolveSharedCodexHomeDir,
|
||||
seedManagedCodexHome,
|
||||
} from "./codex-home.js";
|
||||
import { prepareCodexRuntimeConfig } from "./runtime-config.js";
|
||||
import { resolveCodexDesiredSkillNames } from "./skills.js";
|
||||
import { buildCodexExecArgs } from "./codex-args.js";
|
||||
@@ -340,15 +348,43 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
typeof envConfig.OPENAI_API_KEY === "string" && envConfig.OPENAI_API_KEY.trim().length > 0
|
||||
? envConfig.OPENAI_API_KEY.trim()
|
||||
: null;
|
||||
const preparedManagedCodexHome =
|
||||
configuredCodexHome
|
||||
? null
|
||||
: await prepareManagedCodexHome(process.env, onLog, agent.companyId, {
|
||||
apiKey: configuredOpenAiApiKey,
|
||||
});
|
||||
// A configured CODEX_HOME that lives under the Paperclip-managed company tree
|
||||
// (the per-agent home set by the server isolation guard) still needs auth
|
||||
// seeded — it ships with no credentials and OPENAI_API_KEY="" by default.
|
||||
// Only a genuine external/user-supplied override is treated as self-managed
|
||||
// and left untouched.
|
||||
const configuredHomeIsManaged =
|
||||
configuredCodexHome != null &&
|
||||
isManagedCodexHomePath(process.env, agent.companyId, configuredCodexHome);
|
||||
if (configuredCodexHome == null) {
|
||||
await prepareManagedCodexHome(process.env, onLog, agent.companyId, {
|
||||
apiKey: configuredOpenAiApiKey,
|
||||
});
|
||||
} else if (configuredHomeIsManaged) {
|
||||
await seedManagedCodexHome(configuredCodexHome, process.env, onLog, {
|
||||
apiKey: configuredOpenAiApiKey,
|
||||
});
|
||||
}
|
||||
const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId);
|
||||
const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome;
|
||||
const effectiveCodexHome = configuredCodexHome ?? defaultCodexHome;
|
||||
await fs.mkdir(effectiveCodexHome, { recursive: true });
|
||||
|
||||
// Never launch a managed CODEX_HOME with no credentials. Without auth.json and
|
||||
// with OPENAI_API_KEY="" the provider rejects every request with
|
||||
// "401 Missing bearer"; fail fast with a clear adapter error instead of
|
||||
// emitting unauthenticated calls. External overrides manage their own auth.
|
||||
const effectiveHomeIsManaged = configuredCodexHome == null || configuredHomeIsManaged;
|
||||
if (
|
||||
effectiveHomeIsManaged &&
|
||||
!configuredOpenAiApiKey &&
|
||||
!(await codexHomeHasUsableAuth(effectiveCodexHome))
|
||||
) {
|
||||
throw new Error(
|
||||
`no Codex credentials provisioned for managed home "${effectiveCodexHome}" ` +
|
||||
`(no auth.json and OPENAI_API_KEY is empty). Sign in to Codex on the host ` +
|
||||
`with a ChatGPT subscription, or configure a per-agent OPENAI_API_KEY.`,
|
||||
);
|
||||
}
|
||||
// Merge custom model providers (PAPERCLIP_CODEX_PROVIDERS) into the managed
|
||||
// CODEX_HOME's config.toml BEFORE the home is shipped to a remote execution
|
||||
// target, so both local and sandboxed Codex processes pick up the routing.
|
||||
|
||||
Reference in new issue
Block a user