fix(codex-local): seed managed auth into isolated homes (#8403)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The `codex_local` adapter isolates local Codex runs by assigning
managed `CODEX_HOME` state per company and per agent
> - PR #8272 tightened that isolation, but it left a gap: once
`CODEX_HOME` became explicit, the adapter treated it like a user-managed
override and skipped auth seeding
> - That meant newly isolated agents could launch with no usable
`auth.json`, hit OpenAI unauthenticated, and fail with `401 Missing
bearer`
> - Users who had already persisted one of those broken managed homes
could remain stranded even after config changes unless Paperclip
repaired the home itself
> - This pull request teaches Paperclip to seed managed homes correctly,
backfill already-stranded managed homes on startup, and reject
credential-less managed homes before they reach the provider
> - The benefit is that affected managed `codex_local` agents recover
automatically after upgrade and restart, without manual `CODEX_HOME`
surgery

## Linked Issues or Issue Description

- Fixes #497
- Refs #5028
- Related PR: #8272
- Related PR: #8399

## What Changed

- Distinguished Paperclip-managed `CODEX_HOME` paths from genuine
external overrides and always seeded auth into managed homes, even when
`CODEX_HOME` is explicit in config.
- Wrote API-key-backed `auth.json` files for managed homes when
`OPENAI_API_KEY` is configured, otherwise symlinked the shared Codex
auth for subscription/OAuth flows.
- Added a startup reconciliation pass that backfills already-isolated
managed homes created by the broken release so upgrade plus restart
repairs stranded agents automatically.
- Preserved previously resolved API-key auth when the stored
`OPENAI_API_KEY` binding is secret-backed and startup cannot resolve the
secret value directly.
- Hardened the managed-home preflight to require a credential-bearing
`auth.json`, not just file presence, and documented the recovery
behavior.
- Added regression tests covering managed-home seeding, fail-fast
behavior, and server-side startup reconciliation.

## Verification

```bash
pnpm exec vitest run packages/adapters/codex-local/src/server/codex-home.test.ts packages/adapters/codex-local/src/server/execute.auth.test.ts server/src/__tests__/codex-auth-reconciliation.test.ts server/src/__tests__/codex-local-execute.test.ts server/src/__tests__/server-startup-feedback-export.test.ts
pnpm --filter @paperclipai/adapter-codex-local typecheck
pnpm --filter @paperclipai/server typecheck
pnpm check:tokens
pnpm exec vitest run server/src/__tests__/server-startup-feedback-export.test.ts
```

GitHub Actions `PR` workflow is green on latest head `e5b1e08d4`,
including policy, typecheck, test shards, build, e2e, serialized server
suites, and canary dry run. Greptile Review is green on latest head with
0 comments added. No UI changes.

## Risks

- Startup reconciliation now mutates persisted managed Codex homes at
boot. Risk is low because it only touches Paperclip-managed
company/agent home paths and no-ops when a home already has usable auth.
- Genuine external `CODEX_HOME` overrides remain intentionally
self-managed, so those users still own repair steps inside their custom
home.
- Hosts with neither shared Codex auth nor an explicit per-agent API key
now fail earlier with a clearer adapter error instead of surfacing a
downstream `401`, which changes timing but not capability.

## Model Used

- OpenAI Codex, GPT-5-based coding agent in a local Codex session; exact
served model ID/context window were not exposed to the session. Tool use
and code execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-06-20 16:17:54 -07:00
committed by GitHub
parent e281095cb4
commit 67ca8287e8
12 changed files with 1034 additions and 14 deletions
@@ -2,7 +2,14 @@ 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,
reconcileManagedCodexHome,
seedManagedCodexHome,
} from "./codex-home.js";
describe("codex managed home", () => {
afterEach(() => {
@@ -194,3 +201,304 @@ 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 credential-bearing 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(false);
await fs.writeFile(path.join(root, "auth.json"), '{"foo":"bar"}', "utf8");
expect(await codexHomeHasUsableAuth(root)).toBe(false);
await fs.writeFile(path.join(root, "auth.json"), '{"token":"shared"}', "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 });
}
});
});
// Startup backfill for already-isolated managed homes.
describe("reconcileManagedCodexHome", () => {
async function makeFixture() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-reconcile-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const agentHome = path.join(
paperclipHome,
"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");
const env = {
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
} satisfies NodeJS.ProcessEnv;
return { root, sharedCodexHome, sharedAuth, agentHome, agentAuth, env };
}
it("seeds a previously-stranded managed home and is a no-op on re-run", async () => {
const fx = await makeFixture();
try {
// The isolation guard created the per-agent home with no auth.json.
expect(await codexHomeHasUsableAuth(fx.agentHome)).toBe(false);
const first = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
env: fx.env,
});
expect(first.status).toBe("seeded");
expect(first.home).toBe(fx.agentHome);
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
const second = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
env: fx.env,
});
expect(second.status).toBe("already_seeded");
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("reports source_auth_missing when shared auth is unavailable", async () => {
const fx = await makeFixture();
try {
await fs.rm(fx.sharedAuth, { force: true });
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
env: fx.env,
});
expect(result.status).toBe("source_auth_missing");
await expect(fs.lstat(fx.agentAuth)).rejects.toThrow();
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("leaves a genuine external override untouched", async () => {
const fx = await makeFixture();
try {
const external = path.join(fx.root, "user-codex");
await fs.mkdir(external, { recursive: true });
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: external,
env: fx.env,
});
expect(result.status).toBe("external_override");
expect(await codexHomeHasUsableAuth(external)).toBe(false);
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("reports no_managed_home when no CODEX_HOME is configured", async () => {
const fx = await makeFixture();
try {
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: null,
env: fx.env,
});
expect(result).toEqual({ status: "no_managed_home", home: null });
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("preserves an existing API-key auth.json when the key is secret-bound", async () => {
const fx = await makeFixture();
try {
// A prior execute-time run resolved the secret and wrote a regular-file
// auth.json containing the key.
await fs.mkdir(fx.agentHome, { recursive: true });
await fs.writeFile(
fx.agentAuth,
JSON.stringify({ OPENAI_API_KEY: "sk-secret-resolved" }),
{ mode: 0o600 },
);
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKeySecretBound: true,
env: fx.env,
});
expect(result.status).toBe("already_seeded");
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(false);
expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
OPENAI_API_KEY: "sk-secret-resolved",
});
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("seeds the shared symlink for a secret-bound key when no auth exists yet", async () => {
const fx = await makeFixture();
try {
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKeySecretBound: true,
env: fx.env,
});
expect(result.status).toBe("seeded");
expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
it("writes an API-key auth.json into a managed home when an apiKey is supplied", async () => {
const fx = await makeFixture();
try {
const result = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKey: "sk-reconcile-1",
env: fx.env,
});
expect(result.status).toBe("seeded");
const written = JSON.parse(await fs.readFile(fx.agentAuth, "utf8"));
expect(written).toEqual({ OPENAI_API_KEY: "sk-reconcile-1" });
const second = await reconcileManagedCodexHome({
companyId: "company-1",
configuredCodexHome: fx.agentHome,
apiKey: "sk-reconcile-1",
env: fx.env,
});
expect(second.status).toBe("already_seeded");
expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
OPENAI_API_KEY: "sk-reconcile-1",
});
} finally {
await fs.rm(fx.root, { recursive: true, force: true });
}
});
});
@@ -7,6 +7,7 @@ import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-uti
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 AUTH_CREDENTIAL_KEYS = /(?:openai[_-]?key|api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|session|auth)/i;
function nonEmpty(value: string | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@@ -16,6 +17,28 @@ export async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
function hasUsableAuthPayload(authPayload: unknown): boolean {
if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
return false;
}
for (const [key, value] of Object.entries(authPayload as Record<string, unknown>)) {
if (!AUTH_CREDENTIAL_KEYS.test(key)) continue;
if (key.toLowerCase() === "token_type") continue;
if (typeof value === "string" && value.trim().length > 0) return true;
}
return false;
}
function readApiKeyFromAuthPayload(authPayload: unknown): string | null {
if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
return null;
}
const raw = (authPayload as Record<string, unknown>).OPENAI_API_KEY;
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
}
export function resolveSharedCodexHomeDir(
env: NodeJS.ProcessEnv = process.env,
): string {
@@ -41,6 +64,59 @@ 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> {
const authPath = path.join(home, "auth.json");
if (!(await pathExists(authPath))) return false;
try {
const raw = await fs.readFile(authPath, "utf8");
const parsed = JSON.parse(raw);
return hasUsableAuthPayload(parsed);
} catch {
return false;
}
}
async function codexHomeHasMatchingApiKeyAuth(home: string, apiKey: string): Promise<boolean> {
const authPath = path.join(home, "auth.json");
const existing = await fs.lstat(authPath).catch(() => null);
if (!existing || existing.isSymbolicLink()) return false;
try {
const raw = await fs.readFile(authPath, "utf8");
const parsed = JSON.parse(raw);
return readApiKeyFromAuthPayload(parsed) === apiKey.trim();
} catch {
return false;
}
}
async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
@@ -116,13 +192,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 +251,98 @@ 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;
}
export type ReconcileManagedCodexHomeStatus =
| "no_managed_home"
| "external_override"
| "already_seeded"
| "source_auth_missing"
| "seeded";
export interface ReconcileManagedCodexHomeInput {
companyId: string | undefined;
configuredCodexHome: string | null | undefined;
apiKey?: string | null;
/**
* Set when the agent's persisted `OPENAI_API_KEY` is a secret binding that
* could not be resolved in this context (e.g. startup reconciliation, which
* never resolves secrets). When true and the home already has usable auth,
* reconciliation preserves that auth instead of downgrading it to the shared
* subscription symlink.
*/
apiKeySecretBound?: boolean;
env?: NodeJS.ProcessEnv;
onLog?: AdapterExecutionContext["onLog"];
}
export interface ReconcileManagedCodexHomeResult {
status: ReconcileManagedCodexHomeStatus;
home: string | null;
}
const noopOnLog: AdapterExecutionContext["onLog"] = async () => {};
/**
* Idempotently reconciles a persisted `codex_local` agent home. Phase 1 seeds
* managed homes at execute time; this is the backfill for agents that already
* carry a persisted (but unseeded) per-agent `CODEX_HOME` and have not run
* since the seeding fix landed. Shares the managed-home detection
* (`isManagedCodexHomePath`) and seeding (`seedManagedCodexHome`) logic so a
* genuine external/user override is never touched. Safe to re-run: when a valid
* `auth.json` is already present (and no API-key rewrite is requested) it is a
* no-op and reports `already_seeded`.
*/
export async function reconcileManagedCodexHome(
input: ReconcileManagedCodexHomeInput,
): Promise<ReconcileManagedCodexHomeResult> {
const env = input.env ?? process.env;
const configured = nonEmpty(input.configuredCodexHome ?? undefined);
if (!configured) return { status: "no_managed_home", home: null };
const resolved = path.resolve(configured);
if (!isManagedCodexHomePath(env, input.companyId, resolved)) {
return { status: "external_override", home: resolved };
}
const apiKey = nonEmpty(input.apiKey ?? undefined);
const hadUsableAuth = await codexHomeHasUsableAuth(resolved);
// A secret-bound OPENAI_API_KEY cannot be resolved here, so we cannot rewrite
// it into auth.json. If the home already has usable auth — typically an
// API-key auth.json written at execute time when the secret WAS resolved —
// preserve it. Re-seeding without the key would delete that file and restore
// the shared subscription symlink, silently changing the agent's credentials
// on every boot while the persisted config still says "use the secret key".
if (input.apiKeySecretBound && hadUsableAuth) {
return { status: "already_seeded", home: resolved };
}
if (apiKey && await codexHomeHasMatchingApiKeyAuth(resolved, apiKey)) {
return { status: "already_seeded", home: resolved };
}
await seedManagedCodexHome(resolved, env, input.onLog ?? noopOnLog, { apiKey });
if (!apiKey && !(await codexHomeHasUsableAuth(resolved))) {
return { status: "source_auth_missing", home: resolved };
}
// Without an API key, seeding only changes disk state when auth was missing.
// With an API key, the matching-file short-circuit above filters out the
// already-seeded case before this write path.
const status: ReconcileManagedCodexHomeStatus =
!apiKey && hadUsableAuth ? "already_seeded" : "seeded";
return { status, home: resolved };
}
@@ -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,44 @@ 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 usable 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.
@@ -1,4 +1,11 @@
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export {
reconcileManagedCodexHome,
isManagedCodexHomePath,
type ReconcileManagedCodexHomeInput,
type ReconcileManagedCodexHomeResult,
type ReconcileManagedCodexHomeStatus,
} from "./codex-home.js";
export { listCodexSkills, syncCodexSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexTransientUpstreamError, isCodexUnknownSessionError } from "./parse.js";