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:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user