fix(codex): warn when sandbox auth is shadowed (#9259)

This commit is contained in:
Nicky Leach 2026-07-15 10:02:28 -07:00 committed by GitHub
parent 89ce36d7af
commit 3a727bf780
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 456 additions and 1 deletions

View File

@ -6,6 +6,7 @@ export type {
AdapterRuntimeServiceReport,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterRuntimeEvent,
AdapterRuntimeMcpServer,
AdapterRuntimeMcpAccess,
AdapterExecutionContext,

View File

@ -144,6 +144,15 @@ export interface AdapterRuntimeMcpAccess {
getServers(): AdapterRuntimeMcpServer[];
}
export interface AdapterRuntimeEvent {
eventType: string;
stream?: "system" | "stdout" | "stderr";
level?: "info" | "warn" | "error";
color?: string;
message?: string;
payload?: Record<string, unknown>;
}
export interface AdapterExecutionContext {
runId: string;
agent: AdapterAgent;
@ -162,6 +171,7 @@ export interface AdapterExecutionContext {
runtimeMcp?: AdapterRuntimeMcpAccess;
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
onEvent?: (event: AdapterRuntimeEvent) => Promise<void>;
onRuntimeProgress?: RuntimeStatusSink;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
authToken?: string;

View File

@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import {
CODEX_SANDBOX_AUTH_EXISTS_COMMAND,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE,
resolveCodexAuthPrecedence,
} from "./auth-precedence.js";
describe("resolveCodexAuthPrecedence", () => {
describe("precedence order", () => {
it("configured_api_key wins over all other sources", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: true,
hostAuthJson: true,
sandboxAuthJson: true,
});
expect(result.winner).toBe("configured_api_key");
});
it("host_auth_json wins when no configured api key", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: false,
hostAuthJson: true,
sandboxAuthJson: true,
});
expect(result.winner).toBe("host_auth_json");
});
it("sandbox_auth_json wins when no host credentials", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: false,
hostAuthJson: false,
sandboxAuthJson: true,
});
expect(result.winner).toBe("sandbox_auth_json");
});
it("none when no credentials present", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: false,
hostAuthJson: false,
sandboxAuthJson: false,
});
expect(result.winner).toBe("none");
});
});
describe("sandbox login shadowing and warning", () => {
it("warns when configured_api_key shadows sandbox login", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: true,
hostAuthJson: false,
sandboxAuthJson: true,
});
expect(result.sandboxLoginShadowed).toBe(true);
expect(result.shouldWarn).toBe(true);
});
it("warns when host_auth_json shadows sandbox login", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: false,
hostAuthJson: true,
sandboxAuthJson: true,
});
expect(result.sandboxLoginShadowed).toBe(true);
expect(result.shouldWarn).toBe(true);
});
it("does not warn when sandbox login wins (no host credentials)", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: false,
hostAuthJson: false,
sandboxAuthJson: true,
});
expect(result.sandboxLoginShadowed).toBe(false);
expect(result.shouldWarn).toBe(false);
});
it("does not warn when no sandbox login exists (sandbox-only gate)", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: true,
hostAuthJson: true,
sandboxAuthJson: false,
});
expect(result.sandboxLoginShadowed).toBe(false);
expect(result.shouldWarn).toBe(false);
});
it("does not warn when no credentials exist at all (fail-open)", () => {
const result = resolveCodexAuthPrecedence({
configuredApiKey: false,
hostAuthJson: false,
sandboxAuthJson: false,
});
expect(result.sandboxLoginShadowed).toBe(false);
expect(result.shouldWarn).toBe(false);
});
});
describe("constants", () => {
it("warning message is stable and human-readable", () => {
expect(CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING).toBe(
"snapshot login present but configured or host credentials take precedence",
);
});
it("log line wraps the warning message", () => {
expect(CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE).toContain(
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
);
expect(CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE).toMatch(
/^\[paperclip\] Warning:/,
);
});
it("sandbox auth exists command is a static shell test", () => {
expect(CODEX_SANDBOX_AUTH_EXISTS_COMMAND).toBe(
'test -f "$HOME/.codex/auth.json"',
);
expect(CODEX_SANDBOX_AUTH_EXISTS_COMMAND).not.toContain("cat");
expect(CODEX_SANDBOX_AUTH_EXISTS_COMMAND).not.toContain("readFile");
});
});
});

View File

@ -0,0 +1,46 @@
export const CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING =
"snapshot login present but configured or host credentials take precedence";
export const CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE =
`[paperclip] Warning: ${CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING}.\n`;
export const CODEX_SANDBOX_AUTH_EXISTS_COMMAND =
'test -f "$HOME/.codex/auth.json"';
export type CodexAuthPrecedenceWinner =
| "configured_api_key"
| "host_auth_json"
| "sandbox_auth_json"
| "none";
export interface CodexAuthPrecedenceInput {
configuredApiKey: boolean;
hostAuthJson: boolean;
sandboxAuthJson: boolean;
}
export interface CodexAuthPrecedenceResolution {
winner: CodexAuthPrecedenceWinner;
sandboxLoginShadowed: boolean;
shouldWarn: boolean;
}
export function resolveCodexAuthPrecedence(
input: CodexAuthPrecedenceInput,
): CodexAuthPrecedenceResolution {
const winner: CodexAuthPrecedenceWinner =
input.configuredApiKey
? "configured_api_key"
: input.hostAuthJson
? "host_auth_json"
: input.sandboxAuthJson
? "sandbox_auth_json"
: "none";
const sandboxLoginShadowed =
input.sandboxAuthJson &&
(winner === "configured_api_key" || winner === "host_auth_json");
return {
winner,
sandboxLoginShadowed,
shouldWarn: sandboxLoginShadowed,
};
}

View File

@ -0,0 +1,162 @@
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 {
CODEX_SANDBOX_AUTH_EXISTS_COMMAND,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE,
} from "./auth-precedence.js";
const {
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} = vi.hoisted(() => ({
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => undefined),
ensureAdapterExecutionTargetRuntimeCommandInstalled: vi.fn(async () => undefined),
prepareAdapterExecutionTargetRuntime: vi.fn(async () => ({
target: { kind: "remote", transport: "sandbox", remoteCwd: "/sandbox/workspace" },
workspaceRemoteDir: "/sandbox/workspace",
runtimeRootDir: "/sandbox/.paperclip-runtime",
assetDirs: { home: "/sandbox/.paperclip-runtime/codex/home" },
restoreWorkspace: vi.fn(async () => undefined),
})),
resolveAdapterExecutionTargetCommandForLogs: vi.fn(async () => "/usr/bin/codex"),
runAdapterExecutionTargetProcess: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
})),
runAdapterExecutionTargetShellCommand: vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: null,
startedAt: new Date().toISOString(),
})),
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => null),
}));
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
"@paperclipai/adapter-utils/execution-target",
);
return {
...actual,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
prepareAdapterExecutionTargetRuntime,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
};
});
import { execute } from "./execute.js";
describe("codex sandbox auth precedence warning", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.clearAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("logs and emits a run event when sandbox login is shadowed by host auth", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-precedence-"));
cleanupDirs.push(root);
const workspaceDir = path.join(root, "workspace");
const hostCodexHome = path.join(root, "host-codex-home");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(hostCodexHome, { recursive: true });
await fs.writeFile(
path.join(hostCodexHome, "auth.json"),
JSON.stringify({ OPENAI_API_KEY: "fake-host-auth" }),
"utf8",
);
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
const events: Array<{ eventType: string; level?: string; message?: string; payload?: Record<string, unknown> }> = [];
await execute({
runId: "run-auth-precedence",
agent: {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
adapterType: "codex_local",
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
engine: "cli",
command: "codex",
cwd: workspaceDir,
env: { CODEX_HOME: hostCodexHome },
},
context: {},
executionTarget: {
kind: "remote",
transport: "sandbox",
providerKey: "fixture",
remoteCwd: "/workspace",
runner: { execute: vi.fn() },
},
onLog: async (stream, chunk) => {
logs.push({ stream, chunk });
},
onEvent: async (event) => {
events.push(event);
},
});
expect(prepareAdapterExecutionTargetRuntime).toHaveBeenCalledWith(expect.objectContaining({
assets: [expect.objectContaining({ key: "home", localDir: hostCodexHome })],
}));
expect(runAdapterExecutionTargetShellCommand).toHaveBeenCalledWith(
"run-auth-precedence",
expect.objectContaining({ kind: "remote", transport: "sandbox", remoteCwd: "/sandbox/workspace" }),
CODEX_SANDBOX_AUTH_EXISTS_COMMAND,
expect.objectContaining({ env: {}, timeoutSec: 5 }),
);
expect(logs).toContainEqual({
stream: "stderr",
chunk: CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE,
});
expect(events).toContainEqual({
eventType: "codex.auth_precedence_warning",
stream: "system",
level: "warn",
message: CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
payload: {
configuredApiKey: false,
hostAuthJson: true,
sandboxAuthJson: true,
winner: "host_auth_json",
sandboxLoginShadowed: true,
},
});
expect(runAdapterExecutionTargetProcess).toHaveBeenCalledOnce();
});
});

View File

@ -17,6 +17,7 @@ import {
resolveAdapterExecutionTargetTimeoutSec,
resolveAdapterExecutionTargetCommandForLogs,
runAdapterExecutionTargetProcess,
runAdapterExecutionTargetShellCommand,
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import {
@ -54,6 +55,7 @@ import {
isCodexUnknownSessionError,
} from "./parse.js";
import {
codexHomeHasUsableAuth,
evaluateCodexCredentialReadiness,
isManagedCodexHomePath,
pathExists,
@ -65,6 +67,12 @@ import {
writeManagedCodexMcpConfig,
type ManagedCodexMcpGateway,
} from "./codex-home.js";
import {
CODEX_SANDBOX_AUTH_EXISTS_COMMAND,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE,
resolveCodexAuthPrecedence,
} from "./auth-precedence.js";
import { prepareCodexRuntimeConfig } from "./runtime-config.js";
import { resolveCodexDesiredSkillNames } from "./skills.js";
import { buildCodexExecArgs } from "./codex-args.js";
@ -270,6 +278,76 @@ function managedMcpGatewaysFromContext(context: Record<string, unknown>): Manage
.filter((gateway): gateway is ManagedCodexMcpGateway => Boolean(gateway));
}
type ResolvedExecutionTarget = ReturnType<typeof readAdapterExecutionTarget>;
type MaybeResolvedExecutionTarget = ResolvedExecutionTarget | undefined;
async function sandboxCodexAuthJsonExists(input: {
runId: string;
target: MaybeResolvedExecutionTarget;
cwd: string;
}): Promise<boolean> {
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
return false;
}
try {
const result = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
CODEX_SANDBOX_AUTH_EXISTS_COMMAND,
{
cwd: input.cwd,
env: {},
timeoutSec: 5,
},
);
return !result.timedOut && result.exitCode === 0;
} catch {
return false;
}
}
async function emitSandboxAuthPrecedenceWarningIfNeeded(input: {
runId: string;
target: MaybeResolvedExecutionTarget;
cwd: string;
configuredApiKey: boolean;
hostAuthJson: boolean;
onLog: AdapterExecutionContext["onLog"];
onEvent: AdapterExecutionContext["onEvent"];
}): Promise<void> {
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
return;
}
const sandboxAuthJson = await sandboxCodexAuthJsonExists({
runId: input.runId,
target: input.target,
cwd: input.cwd,
});
const resolution = resolveCodexAuthPrecedence({
configuredApiKey: input.configuredApiKey,
hostAuthJson: input.hostAuthJson,
sandboxAuthJson,
});
if (!resolution.shouldWarn) return;
await input.onLog("stderr", CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE);
await input.onEvent?.({
eventType: "codex.auth_precedence_warning",
stream: "system",
level: "warn",
message: CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
payload: {
configuredApiKey: input.configuredApiKey,
hostAuthJson: input.hostAuthJson,
sandboxAuthJson,
winner: resolution.winner,
sandboxLoginShadowed: resolution.sandboxLoginShadowed,
},
});
}
function buildCodexTransientHandoffNote(input: {
previousSessionId: string | null;
fallbackMode: CodexTransientFallbackMode;
@ -372,7 +450,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
await ctx.onLog("stderr", formatCodexAcpFallbackMessage(engineSelection.fallbackReason));
}
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const { runId, agent, runtime, config, context, onLog, onMeta, onEvent, onSpawn, authToken } = ctx;
const promptTemplate = asString(
config.promptTemplate,
@ -581,6 +659,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
? preparedExecutionTargetRuntime?.assetDirs.home ??
path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home")
: null;
await emitSandboxAuthPrecedenceWarningIfNeeded({
runId,
target: runtimeExecutionTarget,
cwd: effectiveExecutionCwd,
configuredApiKey: Boolean(configuredOpenAiApiKey),
hostAuthJson: await codexHomeHasUsableAuth(effectiveCodexHome),
onLog,
onEvent,
});
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...paperclipBaseEnv };

View File

@ -1,4 +1,13 @@
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export {
resolveCodexAuthPrecedence,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING,
CODEX_SANDBOX_AUTH_PRECEDENCE_WARNING_LOG_LINE,
CODEX_SANDBOX_AUTH_EXISTS_COMMAND,
type CodexAuthPrecedenceInput,
type CodexAuthPrecedenceResolution,
type CodexAuthPrecedenceWinner,
} from "./auth-precedence.js";
export * from "./acp.js";
export { getConfigSchema } from "./config-schema.js";
export {

View File

@ -17,6 +17,7 @@ export type {
AdapterExecutionContext,
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterRuntimeEvent,
AdapterRuntimeMcpServer,
AdapterRuntimeMcpAccess,
AdapterModelProfileDefinition,

View File

@ -76,6 +76,7 @@ import type {
AdapterExecutionResult,
AdapterInvocationMeta,
AdapterModelProfileDefinition,
AdapterRuntimeEvent,
AdapterRuntimeMcpAccess,
AdapterRuntimeMcpServer,
AdapterSessionCodec,
@ -12851,6 +12852,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
});
};
const onAdapterEvent = async (event: AdapterRuntimeEvent) => {
const eventType = event.eventType.trim();
if (!eventType) return;
await appendRunEvent(currentRun, seq++, {
eventType: eventType.slice(0, 120),
stream: event.stream,
level: event.level,
color: event.color,
message: event.message,
payload: event.payload,
});
};
const adapter = getServerAdapter(agent.adapterType);
const localAgentJwtScope =
issueRef?.workMode === "skill_test"
@ -13089,6 +13103,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
runtimeMcp,
onLog,
onMeta: onAdapterMeta,
onEvent: onAdapterEvent,
onRuntimeProgress: async (progress) => {
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
},