diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index c12743e444..9184dd3697 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -68,6 +68,7 @@ const originalNodeVersion = process.version; const originalPaperclipHome = process.env.PAPERCLIP_HOME; const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID; const originalCodexHome = process.env.CODEX_HOME; +const originalOpenAiApiKey = process.env.OPENAI_API_KEY; // Older/newer ISO timestamps for the copy-back monotonic (strictly-newer) // decision predicate, plus a subscription-shaped auth.json fixture matching the @@ -118,6 +119,8 @@ afterEach(async () => { else process.env.PAPERCLIP_INSTANCE_ID = originalPaperclipInstanceId; if (originalCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = originalCodexHome; + if (originalOpenAiApiKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = originalOpenAiApiKey; await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); }); @@ -521,6 +524,99 @@ describe("codex_local ACP lane", () => { ); }); + it("detects shared managed Codex auth in ACP environment tests", async () => { + const root = await makeTempRoot("paperclip-codex-acp-managed-auth-"); + const commandPath = path.join(root, "bin", "codex-acp"); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const managedAgentHome = path.join( + root, + "paperclip-home", + "instances", + "test", + "companies", + "company-1", + "agents", + "agent-1", + "codex-home", + ); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(path.join(sharedCodexHome, "auth.json"), '{"OPENAI_API_KEY":"sk-shared"}', "utf8"); + setNodeVersion("v22.13.0"); + process.env.CODEX_HOME = sharedCodexHome; + delete process.env.OPENAI_API_KEY; + + const result = await testCodexAcpEnvironment({ + adapterType: "codex_local", + companyId: "company-1", + config: { + engine: "acp", + cwd: root, + agentCommand: commandPath, + env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" }, + }, + }); + + expect(result.status).toBe("pass"); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "codex_acp_native_auth_detected", + level: "info", + detail: expect.stringContaining(sharedCodexHome), + }), + ); + expect(result.checks).not.toContainEqual( + expect.objectContaining({ + code: "codex_acp_credentials_missing", + }), + ); + }); + + it("explains the Paperclip server credential boundary when ACP auth is missing", async () => { + const root = await makeTempRoot("paperclip-codex-acp-missing-auth-"); + const commandPath = path.join(root, "bin", "codex-acp"); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const managedAgentHome = path.join( + root, + "paperclip-home", + "instances", + "test", + "companies", + "company-1", + "agents", + "agent-1", + "codex-home", + ); + await fs.mkdir(path.dirname(commandPath), { recursive: true }); + await fs.writeFile(commandPath, "#!/usr/bin/env sh\n", "utf8"); + await fs.mkdir(sharedCodexHome, { recursive: true }); + setNodeVersion("v22.13.0"); + process.env.CODEX_HOME = sharedCodexHome; + delete process.env.OPENAI_API_KEY; + + const result = await testCodexAcpEnvironment({ + adapterType: "codex_local", + companyId: "company-1", + config: { + engine: "acp", + cwd: root, + agentCommand: commandPath, + env: { CODEX_HOME: managedAgentHome, OPENAI_API_KEY: "" }, + }, + }); + + expect(result.status).toBe("warn"); + expect(result.checks).toContainEqual( + expect.objectContaining({ + code: "codex_acp_credentials_missing", + level: "warn", + message: expect.stringContaining("Paperclip server"), + hint: expect.stringContaining("separate Codex/chat session"), + }), + ); + }); + it("executes through ACPX with Codex session config and ephemeral skills", async () => { const root = await makeTempRoot("paperclip-codex-acp-exec-"); const skill = await createRuntimeSkill(root); diff --git a/packages/adapters/codex-local/src/server/acp.ts b/packages/adapters/codex-local/src/server/acp.ts index c604d7ea6b..6cacce1a4c 100644 --- a/packages/adapters/codex-local/src/server/acp.ts +++ b/packages/adapters/codex-local/src/server/acp.ts @@ -40,6 +40,7 @@ import { classifyCodexAuthRefreshFailure } from "./parse.js"; import { copyBackCodexAuth } from "./codex-auth-copyback.js"; import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js"; import { + evaluateCodexCredentialReadiness, resolveSharedCodexHomeDir, stageCodexHomeForSync, } from "./codex-home.js"; @@ -491,19 +492,6 @@ function isNonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } -async function hasCodexNativeCredentials(codexHome: string): Promise { - const raw = await fs.readFile(path.join(codexHome, "auth.json"), "utf8").catch(() => null); - if (!raw) return false; - try { - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; - const record = parsed as Record; - return isNonEmpty(record.OPENAI_API_KEY) || isNonEmpty(record.refresh_token); - } catch { - return false; - } -} - export async function testCodexAcpEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { @@ -573,34 +561,50 @@ export async function testCodexAcpEnvironment( }); const envConfig = parseObject(config.env); - const considerHostEnv = !targetIsRemote; - const configApiKey = envConfig.OPENAI_API_KEY; - const hostApiKey = considerHostEnv ? process.env.OPENAI_API_KEY : undefined; - if (isNonEmpty(configApiKey) || isNonEmpty(hostApiKey)) { - const source = isNonEmpty(configApiKey) ? "adapter config env" : "server environment"; - checks.push({ - code: "codex_acp_openai_api_key_detected", - level: "info", - message: "OPENAI_API_KEY is set for Codex ACP authentication.", - detail: `Detected in ${source}.`, + if (!targetIsRemote) { + const configApiKey = isNonEmpty(envConfig.OPENAI_API_KEY) ? envConfig.OPENAI_API_KEY : null; + const hostApiKey = + Object.prototype.hasOwnProperty.call(envConfig, "OPENAI_API_KEY") + ? null + : isNonEmpty(process.env.OPENAI_API_KEY) + ? process.env.OPENAI_API_KEY + : null; + const configuredApiKey = configApiKey ?? hostApiKey; + const configuredCodexHome = isNonEmpty(envConfig.CODEX_HOME) ? envConfig.CODEX_HOME : null; + const credentialReadiness = await evaluateCodexCredentialReadiness({ + env: process.env, + companyId: ctx.companyId, + configuredCodexHome, + configuredApiKey, }); - } else if (!targetIsRemote) { - const codexHome = isNonEmpty(envConfig.CODEX_HOME) - ? envConfig.CODEX_HOME - : path.join(process.env.HOME ?? "", ".codex"); - if (codexHome && await hasCodexNativeCredentials(codexHome)) { + + if (credentialReadiness.ready && credentialReadiness.authMode === "api") { + checks.push({ + code: "codex_acp_openai_api_key_detected", + level: "info", + message: "OPENAI_API_KEY is set for Codex ACP authentication.", + detail: `Detected in ${configApiKey ? "adapter config env" : "server environment"}.`, + }); + } else if (credentialReadiness.ready && !credentialReadiness.managed) { + checks.push({ + code: "codex_acp_external_home_configured", + level: "info", + message: "Codex ACP will use an externally managed CODEX_HOME.", + detail: credentialReadiness.effectiveHome, + }); + } else if (credentialReadiness.ready) { checks.push({ code: "codex_acp_native_auth_detected", level: "info", message: "Codex ACP can use Codex native authentication.", - detail: `Credentials found in ${path.join(codexHome, "auth.json")}.`, + detail: `Credentials are available through ${credentialReadiness.effectiveHome} or shared source ${credentialReadiness.sharedSourceHome}.`, }); } else { checks.push({ code: "codex_acp_credentials_missing", level: "warn", - message: "No Codex ACP credentials were detected.", - hint: "Set OPENAI_API_KEY or run `codex login` before starting a Codex ACP agent.", + message: "No Codex ACP credentials visible to the Paperclip server were detected.", + hint: "Set OPENAI_API_KEY in the agent adapter env, set it in the Paperclip server environment, or run `codex login` for the same OS user that runs the Paperclip server before starting a Codex ACP agent. A `/login` in a separate Codex/chat session does not authenticate the server.", }); } }