diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 7a02b058..2c52aa2b 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -45,6 +45,7 @@ import { isCodexUnknownSessionError, } from "./parse.js"; import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; +import { prepareCodexRuntimeConfig } from "./runtime-config.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; import { buildCodexExecArgs } from "./codex-args.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; @@ -348,276 +349,306 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), ); - const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( - executionTarget, - asNumber(config.timeoutSec, 0), - ); - const graceSec = asNumber(config.graceSec, 20); - let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); - const preparedExecutionTargetRuntime = executionTargetIsRemote - ? await (async () => { - await onLog( - "stdout", - `[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`, - ); - return await prepareAdapterExecutionTargetRuntime({ - runId, - target: executionTarget, - adapterKey: "codex", - timeoutSec, - workspaceLocalDir: cwd, - installCommand: SANDBOX_INSTALL_COMMAND, - detectCommand: command, - assets: [ - { - key: "home", - localDir: effectiveCodexHome, - followSymlinks: true, - }, - ], - }); - })() - : null; - if (preparedExecutionTargetRuntime?.workspaceRemoteDir) { - effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir; - } - const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); - const executionTargetIsSandbox = - runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox"; - const restoreRemoteWorkspace = preparedExecutionTargetRuntime - ? () => preparedExecutionTargetRuntime.restoreWorkspace() - : null; - let paperclipBridge: Awaited> = null; - const remoteCodexHome = executionTargetIsRemote - ? preparedExecutionTargetRuntime?.assetDirs.home ?? - path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home") - : null; - const hasExplicitApiKey = - typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; - const env: Record = { ...buildPaperclipEnv(agent) }; - env.PAPERCLIP_RUN_ID = runId; - const wakeTaskId = - (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || - (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || - null; - const wakeReason = - typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 - ? context.wakeReason.trim() - : null; - const wakeCommentId = - (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || - (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || - null; - const approvalId = - typeof context.approvalId === "string" && context.approvalId.trim().length > 0 - ? context.approvalId.trim() - : null; - const approvalStatus = - typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 - ? context.approvalStatus.trim() - : null; - const linkedIssueIds = Array.isArray(context.issueIds) - ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); - const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); - if (wakeTaskId) { - env.PAPERCLIP_TASK_ID = wakeTaskId; - } - if (issueWorkMode) { - env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; - } - if (wakeReason) { - env.PAPERCLIP_WAKE_REASON = wakeReason; - } - if (wakeCommentId) { - env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; - } - if (approvalId) { - env.PAPERCLIP_APPROVAL_ID = approvalId; - } - if (approvalStatus) { - env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; - } - if (linkedIssueIds.length > 0) { - env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); - } - if (wakePayloadJson) { - env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; - } - refreshPaperclipWorkspaceEnvForExecution({ - env, - envConfig, - workspaceCwd: effectiveWorkspaceCwd, - workspaceSource, - workspaceStrategy, - workspaceId, - workspaceRepoUrl, - workspaceRepoRef, - workspaceBranch, - workspaceWorktreePath, - workspaceHints, - agentHome, - executionTargetIsRemote, - executionCwd: effectiveExecutionCwd, + const preparedRuntimeConfig = await prepareCodexRuntimeConfig({ + env: envConfigStrings, + codexHome: configuredCodexHome ? null : effectiveCodexHome, }); - if (runtimeServiceIntents.length > 0) { - env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); - } - if (runtimeServices.length > 0) { - env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); - } - if (runtimePrimaryUrl) { - env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; - } - env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome; - if (!hasExplicitApiKey && authToken) { - env.PAPERCLIP_API_KEY = authToken; - } - if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { - paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + try { + for (const note of preparedRuntimeConfig.notes) { + await onLog("stdout", `[paperclip] ${note}\n`); + } + // Inject skills into the same CODEX_HOME that Codex will actually run with + // (managed home in the default case, or an explicit override from adapter config). + const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome); + await ensureCodexSkillsInjected( + onLog, + { + skillsHome: codexSkillsDir, + skillsEntries: codexSkillEntries, + desiredSkillNames, + }, + ); + const timeoutSec = resolveAdapterExecutionTargetTimeoutSec( + executionTarget, + asNumber(config.timeoutSec, 0), + ); + const graceSec = asNumber(config.graceSec, 20); + let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + const preparedExecutionTargetRuntime = executionTargetIsRemote + ? await (async () => { + await onLog( + "stdout", + `[paperclip] Syncing workspace and CODEX_HOME to ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + return await prepareAdapterExecutionTargetRuntime({ + runId, + target: executionTarget, + adapterKey: "codex", + timeoutSec, + workspaceLocalDir: cwd, + installCommand: SANDBOX_INSTALL_COMMAND, + detectCommand: command, + assets: [ + { + key: "home", + localDir: effectiveCodexHome, + followSymlinks: true, + }, + ], + }); + })() + : null; + if (preparedExecutionTargetRuntime?.workspaceRemoteDir) { + effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir; + } + const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd); + const executionTargetIsSandbox = + runtimeExecutionTarget?.kind === "remote" && runtimeExecutionTarget.transport === "sandbox"; + const restoreRemoteWorkspace = preparedExecutionTargetRuntime + ? () => preparedExecutionTargetRuntime.restoreWorkspace() + : null; + let paperclipBridge: Awaited> = null; + const remoteCodexHome = executionTargetIsRemote + ? preparedExecutionTargetRuntime?.assetDirs.home ?? + path.posix.join(effectiveExecutionCwd, ".paperclip-runtime", "codex", "home") + : null; + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + const issueWorkMode = readPaperclipIssueWorkModeFromContext(context); + if (wakeTaskId) { + env.PAPERCLIP_TASK_ID = wakeTaskId; + } + if (issueWorkMode) { + env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; + } + if (wakeReason) { + env.PAPERCLIP_WAKE_REASON = wakeReason; + } + if (wakeCommentId) { + env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + } + if (approvalId) { + env.PAPERCLIP_APPROVAL_ID = approvalId; + } + if (approvalStatus) { + env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + } + if (linkedIssueIds.length > 0) { + env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig, + workspaceCwd: effectiveWorkspaceCwd, + workspaceSource, + workspaceStrategy, + workspaceId, + workspaceRepoUrl, + workspaceRepoRef, + workspaceBranch, + workspaceWorktreePath, + workspaceHints, + agentHome, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); + if (runtimeServiceIntents.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); + } + if (runtimeServices.length > 0) { + env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices); + } + if (runtimePrimaryUrl) { + env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl; + } + env.CODEX_HOME = remoteCodexHome ?? effectiveCodexHome; + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + if (executionTargetIsRemote && adapterExecutionTargetUsesPaperclipBridge(runtimeExecutionTarget)) { + paperclipBridge = await startAdapterExecutionTargetPaperclipBridge({ + runId, + target: runtimeExecutionTarget, + runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir, + adapterKey: "codex", + timeoutSec, + hostApiToken: env.PAPERCLIP_API_KEY, + onLog, + }); + if (paperclipBridge) { + Object.assign(env, paperclipBridge.env); + } + } + const effectiveEnv = Object.fromEntries( + Object.entries({ ...process.env, ...env }).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + const billingType = resolveCodexBillingType(effectiveEnv); + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv(effectiveEnv)).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ runId, - target: runtimeExecutionTarget, - runtimeRootDir: preparedExecutionTargetRuntime?.runtimeRootDir, - adapterKey: "codex", + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, timeoutSec, - hostApiToken: env.PAPERCLIP_API_KEY, + graceSec, onLog, }); - if (paperclipBridge) { - Object.assign(env, paperclipBridge.env); - } - } - const effectiveEnv = Object.fromEntries( - Object.entries({ ...process.env, ...env }).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - const billingType = resolveCodexBillingType(effectiveEnv); - const runtimeEnv = Object.fromEntries( - Object.entries(ensurePathInEnv(effectiveEnv)).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); - await ensureAdapterExecutionTargetRuntimeCommandInstalled({ - runId, - target: executionTarget, - installCommand: ctx.runtimeCommandSpec?.installCommand, - detectCommand: ctx.runtimeCommandSpec?.detectCommand, - cwd, - env: runtimeEnv, - timeoutSec, - graceSec, - onLog, - }); - await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); - const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); - const loggedEnv = buildInvocationEnvForLogs(env, { - runtimeEnv, - includeRuntimeKeys: ["HOME"], - resolvedCommand, - }); + await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); + const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); + const loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); - const runtimeSessionParams = parseObject(runtime.sessionParams); - const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); - const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); - const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); - const canResumeSession = - runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); - const codexTransientFallbackMode = readCodexTransientFallbackMode(context); - const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode); - const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode); - const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null; - if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { - await onLog( - "stdout", - `[paperclip] Codex session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, - ); - } else if (runtimeSessionId && !canResumeSession) { - await onLog( - "stdout", - `[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, - ); - } - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; - let instructionsPrefix = ""; - let instructionsChars = 0; - if (instructionsFilePath) { - try { - const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); - instructionsPrefix = - `${instructionsContents}\n\n` + - `The above agent instructions were loaded from ${instructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsDir}.\n\n`; - instructionsChars = instructionsPrefix.length; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && + adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget); + const codexTransientFallbackMode = readCodexTransientFallbackMode(context); + const forceSaferInvocation = fallbackModeUsesSaferInvocation(codexTransientFallbackMode); + const forceFreshSession = fallbackModeUsesFreshSession(codexTransientFallbackMode); + const sessionId = canResumeSession && !forceFreshSession ? runtimeSessionId : null; + if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) { await onLog( "stdout", - `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + `[paperclip] Codex session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`, + ); + } else if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`, ); } - } - const repoAgentsNote = - "Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery."; - const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); - const templateData = { - agentId: agent.id, - companyId: agent.companyId, - runId, - company: { id: agent.companyId }, - agent, - run: { id: runId, source: "on_demand" }, - context, - }; - const renderedBootstrapPrompt = - !sessionId && bootstrapPromptTemplate.trim().length > 0 - ? renderTemplate(bootstrapPromptTemplate, templateData).trim() - : ""; - const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); - const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; - const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; - instructionsChars = promptInstructionsPrefix.length; - const continuationSummary = parseObject(context.paperclipContinuationSummary); - const continuationSummaryBody = asString(continuationSummary.body, "").trim() || null; - const codexFallbackHandoffNote = - forceFreshSession - ? buildCodexTransientHandoffNote({ - previousSessionId: runtimeSessionId || runtime.sessionId || null, - fallbackMode: codexTransientFallbackMode ?? "fresh_session", - continuationSummaryBody, - }) - : ""; - const commandNotes = (() => { - if (!instructionsFilePath) { - const notes = [repoAgentsNote]; - if (forceSaferInvocation) { - notes.push("Codex transient fallback requested safer invocation settings for this retry."); + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + let instructionsChars = 0; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + instructionsChars = instructionsPrefix.length; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); } - if (forceFreshSession) { - notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); - } - return notes; } - if (instructionsPrefix.length > 0) { - if (shouldUseResumeDeltaPrompt) { + const repoAgentsNote = + "Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery."; + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; + instructionsChars = promptInstructionsPrefix.length; + const continuationSummary = parseObject(context.paperclipContinuationSummary); + const continuationSummaryBody = asString(continuationSummary.body, "").trim() || null; + const codexFallbackHandoffNote = + forceFreshSession + ? buildCodexTransientHandoffNote({ + previousSessionId: runtimeSessionId || runtime.sessionId || null, + fallbackMode: codexTransientFallbackMode ?? "fresh_session", + continuationSummaryBody, + }) + : ""; + const commandNotes = (() => { + if (!instructionsFilePath) { + const notes = [repoAgentsNote]; + if (forceSaferInvocation) { + notes.push("Codex transient fallback requested safer invocation settings for this retry."); + } + if (forceFreshSession) { + notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); + } + return notes; + } + if (instructionsPrefix.length > 0) { + if (shouldUseResumeDeltaPrompt) { + const notes = [ + `Loaded agent instructions from ${instructionsFilePath}`, + "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", + repoAgentsNote, + ]; + if (forceSaferInvocation) { + notes.push("Codex transient fallback requested safer invocation settings for this retry."); + } + if (forceFreshSession) { + notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); + } + return notes; + } const notes = [ `Loaded agent instructions from ${instructionsFilePath}`, - "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, repoAgentsNote, ]; if (forceSaferInvocation) { @@ -629,8 +660,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { + commandNotes.unshift(...preparedRuntimeConfig.notes); } - if (forceFreshSession) { - notes.push("Codex transient fallback forced a fresh session with a continuation handoff."); - } - return notes; - })(); - if (executionTargetIsSandbox) { - commandNotes.push( - "Added --skip-git-repo-check for sandbox execution because Codex requires an explicit trust bypass in headless remote workspaces.", - ); - } - const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); - const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); - const prompt = joinPromptSections([ - promptInstructionsPrefix, - renderedBootstrapPrompt, - wakePrompt, - codexFallbackHandoffNote, - sessionHandoffNote, - renderedPrompt, - ]); - const promptMetrics = { - promptChars: prompt.length, - instructionsChars, - bootstrapPromptChars: renderedBootstrapPrompt.length, - wakePromptChars: wakePrompt.length, - sessionHandoffChars: sessionHandoffNote.length, - heartbeatPromptChars: renderedPrompt.length, - }; - - const runAttempt = async (resumeSessionId: string | null) => { - const execArgs = buildCodexExecArgs( - forceSaferInvocation ? { ...config, fastMode: false } : config, - { - resumeSessionId, - skipGitRepoCheck: executionTargetIsSandbox, - }, - ); - const args = execArgs.args; - const commandNotesWithFastMode = - execArgs.fastModeIgnoredReason == null - ? commandNotes - : [...commandNotes, execArgs.fastModeIgnoredReason]; - if (onMeta) { - await onMeta({ - adapterType: "codex_local", - command: resolvedCommand, - cwd: effectiveExecutionCwd, - commandNotes: commandNotesWithFastMode, - commandArgs: args.map((value, idx) => { - if (idx === args.length - 1 && value !== "-") return ``; - return value; - }), - env: loggedEnv, - prompt, - promptMetrics, - context, - }); - } - - const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { - cwd, - env, - stdin: prompt, - timeoutSec, - graceSec, - onSpawn, - onLog: async (stream, chunk) => { - if (stream !== "stderr") { - await onLog(stream, chunk); - return; - } - const cleaned = stripCodexRolloutNoise(chunk); - if (!cleaned.trim()) return; - await onLog(stream, cleaned); - }, - }); - const cleanedStderr = stripCodexRolloutNoise(proc.stderr); - return { - proc: { - ...proc, - stderr: cleanedStderr, - }, - rawStderr: proc.stderr, - parsed: parseCodexJsonl(proc.stdout), + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + promptInstructionsPrefix, + renderedBootstrapPrompt, + wakePrompt, + codexFallbackHandoffNote, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, }; - }; - const toResult = ( - attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType }, - clearSessionOnMissingSession = false, - isRetry = false, - ): AdapterExecutionResult => { - if (attempt.proc.timedOut) { + const runAttempt = async (resumeSessionId: string | null) => { + const execArgs = buildCodexExecArgs( + forceSaferInvocation ? { ...config, fastMode: false } : config, + { + resumeSessionId, + skipGitRepoCheck: executionTargetIsSandbox, + }, + ); + const args = execArgs.args; + const commandNotesWithFastMode = + execArgs.fastModeIgnoredReason == null + ? commandNotes + : [...commandNotes, execArgs.fastModeIgnoredReason]; + if (onMeta) { + await onMeta({ + adapterType: "codex_local", + command: resolvedCommand, + cwd: effectiveExecutionCwd, + commandNotes: commandNotesWithFastMode, + commandArgs: args.map((value, idx) => { + if (idx === args.length - 1 && value !== "-") return ``; + return value; + }), + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, { + cwd, + env, + stdin: prompt, + timeoutSec, + graceSec, + onSpawn, + onLog: async (stream, chunk) => { + if (stream !== "stderr") { + await onLog(stream, chunk); + return; + } + const cleaned = stripCodexRolloutNoise(chunk); + if (!cleaned.trim()) return; + await onLog(stream, cleaned); + }, + }); + const cleanedStderr = stripCodexRolloutNoise(proc.stderr); + return { + proc: { + ...proc, + stderr: cleanedStderr, + }, + rawStderr: proc.stderr, + parsed: parseCodexJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const canFallbackToRuntimeSession = !isRetry && !forceFreshSession; + const resolvedSessionId = + attempt.parsed.sessionId ?? + (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd: effectiveExecutionCwd, + ...(executionTargetIsRemote + ? { + remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), + } + : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const fallbackErrorMessage = + parsedError || + stderrLine || + `Codex exited with code ${attempt.proc.exitCode ?? -1}`; + const transientRetryNotBefore = + (attempt.proc.exitCode ?? 0) !== 0 + ? extractCodexRetryNotBefore({ + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + errorMessage: fallbackErrorMessage, + }) + : null; + const transientUpstream = + (attempt.proc.exitCode ?? 0) !== 0 && + isCodexTransientUpstreamError({ + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + errorMessage: fallbackErrorMessage, + }); + return { exitCode: attempt.proc.exitCode, signal: attempt.proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - clearSession: clearSessionOnMissingSession, - }; - } - - const canFallbackToRuntimeSession = !isRetry && !forceFreshSession; - const resolvedSessionId = - attempt.parsed.sessionId ?? - (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); - const resolvedSessionParams = resolvedSessionId - ? ({ + timedOut: false, + errorMessage: + (attempt.proc.exitCode ?? 0) === 0 + ? null + : fallbackErrorMessage, + errorCode: + transientUpstream + ? "codex_transient_upstream" + : null, + errorFamily: transientUpstream ? "transient_upstream" : null, + retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null, + usage: attempt.parsed.usage, sessionId: resolvedSessionId, - cwd: effectiveExecutionCwd, - ...(executionTargetIsRemote - ? { - remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget), - } - : {}), - ...(workspaceId ? { workspaceId } : {}), - ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), - ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), - } as Record) - : null; - const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const fallbackErrorMessage = - parsedError || - stderrLine || - `Codex exited with code ${attempt.proc.exitCode ?? -1}`; - const transientRetryNotBefore = - (attempt.proc.exitCode ?? 0) !== 0 - ? extractCodexRetryNotBefore({ - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - errorMessage: fallbackErrorMessage, - }) - : null; - const transientUpstream = - (attempt.proc.exitCode ?? 0) !== 0 && - isCodexTransientUpstreamError({ - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - errorMessage: fallbackErrorMessage, - }); - - return { - exitCode: attempt.proc.exitCode, - signal: attempt.proc.signal, - timedOut: false, - errorMessage: - (attempt.proc.exitCode ?? 0) === 0 - ? null - : fallbackErrorMessage, - errorCode: - transientUpstream - ? "codex_transient_upstream" - : null, - errorFamily: transientUpstream ? "transient_upstream" : null, - retryNotBefore: transientRetryNotBefore ? transientRetryNotBefore.toISOString() : null, - usage: attempt.parsed.usage, - sessionId: resolvedSessionId, - sessionParams: resolvedSessionParams, - sessionDisplayId: resolvedSessionId, - provider: "openai", - biller: resolveCodexBiller(effectiveEnv, billingType), - model, - billingType, - costUsd: null, - resultJson: { - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - ...(transientUpstream ? { errorFamily: "transient_upstream" } : {}), - ...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}), - ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), - }, - summary: attempt.parsed.summary, - clearSession: Boolean((clearSessionOnMissingSession || forceFreshSession) && !resolvedSessionId), + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "openai", + biller: resolveCodexBiller(effectiveEnv, billingType), + model, + billingType, + costUsd: null, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + ...(transientUpstream ? { errorFamily: "transient_upstream" } : {}), + ...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}), + ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), + }, + summary: attempt.parsed.summary, + clearSession: Boolean((clearSessionOnMissingSession || forceFreshSession) && !resolvedSessionId), + }; }; - }; - try { - const initial = await runAttempt(sessionId); - if ( - sessionId && - !initial.proc.timedOut && - (initial.proc.exitCode ?? 0) !== 0 && - isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stdout", - `[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const retry = await runAttempt(null); - return toResult(retry, true, true); + try { + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial, false, false); + } finally { + if (paperclipBridge) { + await paperclipBridge.stop(); + } + if (restoreRemoteWorkspace) { + await onLog( + "stdout", + `[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`, + ); + await restoreRemoteWorkspace(); + } } - - return toResult(initial, false, false); } finally { - if (paperclipBridge) { - await paperclipBridge.stop(); - } - if (restoreRemoteWorkspace) { - await onLog( - "stdout", - `[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`, - ); - await restoreRemoteWorkspace(); - } + // Restore the managed config.toml so PAPERCLIP_CODEX_PROVIDERS changes + // (or removal) between runs never leave stale provider routing behind. This + // finally starts the moment prepareCodexRuntimeConfig returns, so a throw + // anywhere in the remaining setup (skill injection, remote runtime + // preparation, command building) restores the original config.toml too. + // If the process dies before reaching this, the next + // prepareCodexRuntimeConfig restores the original from the pre-run backup + // written at prepare time. + await preparedRuntimeConfig.cleanup(); } } diff --git a/packages/adapters/codex-local/src/server/runtime-config.test.ts b/packages/adapters/codex-local/src/server/runtime-config.test.ts new file mode 100644 index 00000000..0a2ad8aa --- /dev/null +++ b/packages/adapters/codex-local/src/server/runtime-config.test.ts @@ -0,0 +1,416 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareCodexRuntimeConfig } from "./runtime-config.js"; + +const cleanupPaths = new Set(); + +afterEach(async () => { + await Promise.all( + [...cleanupPaths].map(async (filepath) => { + await fs.rm(filepath, { recursive: true, force: true }); + cleanupPaths.delete(filepath); + }), + ); +}); + +async function makeCodexHome(configToml?: string): Promise { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); + cleanupPaths.add(home); + if (configToml !== undefined) { + await fs.writeFile(path.join(home, "config.toml"), configToml, "utf8"); + } + return home; +} + +async function readConfigToml(home: string): Promise { + return fs.readFile(path.join(home, "config.toml"), "utf8"); +} + +const BIFROST_PROVIDERS = { + providers: { + bifrost: { + name: "bifrost", + base_url: "http://gateway.example.svc.cluster.local:8080/v1", + env_key: "OPENAI_API_KEY", + wire_api: "responses", + }, + }, + model_provider: "bifrost", +}; + +describe("prepareCodexRuntimeConfig", () => { + it("is a no-op when PAPERCLIP_CODEX_PROVIDERS is unset", async () => { + const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n"); + const prepared = await prepareCodexRuntimeConfig({ env: { FOO: "bar" }, codexHome: home }); + + expect(prepared.notes).toEqual([]); + expect(await readConfigToml(home)).toBe("model = \"gpt-5.1-codex\"\n"); + await prepared.cleanup(); + }); + + it("is a no-op when the home has no config.toml and the env is unset", async () => { + const home = await makeCodexHome(); + const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home }); + + expect(prepared.notes).toEqual([]); + await expect(fs.access(path.join(home, "config.toml"))).rejects.toThrow(); + await prepared.cleanup(); + }); + + it("merges providers + model_provider into a fresh config.toml and cleans it up", async () => { + const home = await makeCodexHome(); + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).toContain('model_provider = "bifrost"'); + expect(content).toContain("[model_providers.bifrost]"); + expect(content).toContain('base_url = "http://gateway.example.svc.cluster.local:8080/v1"'); + expect(content).toContain('env_key = "OPENAI_API_KEY"'); + expect(content).toContain('wire_api = "responses"'); + expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true); + + await prepared.cleanup(); + await expect(fs.access(path.join(home, "config.toml"))).rejects.toThrow(); + }); + + it("preserves existing config.toml content, keeps model_provider in the root region, and restores on cleanup", async () => { + const original = [ + 'model = "gpt-5.1-codex"', + "", + "[profiles.dev]", + 'model = "gpt-5.1-codex-mini"', + "", + ].join("\n"); + const home = await makeCodexHome(original); + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + + const content = await readConfigToml(home); + // Existing content survives. + expect(content).toContain('model = "gpt-5.1-codex"'); + expect(content).toContain("[profiles.dev]"); + // model_provider must precede the first table header (TOML root region), + // and the provider tables must come after the user's tables. + expect(content.indexOf('model_provider = "bifrost"')).toBeLessThan( + content.indexOf("[profiles.dev]"), + ); + expect(content.indexOf("[model_providers.bifrost]")).toBeGreaterThan( + content.indexOf("[profiles.dev]"), + ); + + await prepared.cleanup(); + expect(await readConfigToml(home)).toBe(original); + }); + + it("wins over a pre-existing same-name [model_providers.*] section and root model_provider key", async () => { + const original = [ + 'model_provider = "stale"', + "", + "[model_providers.bifrost]", + 'base_url = "http://old.example/v1"', + 'env_key = "OLD_KEY"', + "", + "[model_providers.bifrost.http_headers]", + '"X-Old" = "1"', + "", + "[model_providers.other]", + 'base_url = "http://other.example/v1"', + "", + ].join("\n"); + const home = await makeCodexHome(original); + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).not.toContain('model_provider = "stale"'); + expect(content).not.toContain("http://old.example/v1"); + expect(content).not.toContain("X-Old"); + // Unrelated provider sections survive. + expect(content).toContain("[model_providers.other]"); + expect(content).toContain("http://other.example/v1"); + // Exactly one bifrost section: ours. + expect(content.split("[model_providers.bifrost]").length).toBe(2); + expect(content).toContain('base_url = "http://gateway.example.svc.cluster.local:8080/v1"'); + + await prepared.cleanup(); + expect(await readConfigToml(home)).toBe(original); + }); + + it("emits plain-object fields as inline tables and arrays as TOML arrays", async () => { + const home = await makeCodexHome(); + const prepared = await prepareCodexRuntimeConfig({ + env: { + PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ + providers: { + gw: { + base_url: "http://gw.example/v1", + query_params: { "api-version": "2026-01-01" }, + http_headers: { "X Team": "agents" }, + request_max_retries: 4, + }, + }, + }), + }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).toContain("[model_providers.gw]"); + // Bare keys (incl. hyphens) stay bare; keys with other characters get quoted. + expect(content).toContain('query_params = { api-version = "2026-01-01" }'); + expect(content).toContain('http_headers = { "X Team" = "agents" }'); + expect(content).toContain("request_max_retries = 4"); + // No model_provider was requested, so none is emitted. + expect(content).not.toContain("model_provider ="); + + await prepared.cleanup(); + }); + + it("expands {env:VAR} placeholders from the run env and process.env, leaving unresolvable ones intact", async () => { + const home = await makeCodexHome(); + process.env.PAPERCLIP_CODEX_TEST_PROCESS_KEY = "from-process-env"; + try { + const prepared = await prepareCodexRuntimeConfig({ + env: { + PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ + providers: { + gw: { + base_url: "http://gw.example/v1", + http_headers: { + "X-Run": "{env:PAPERCLIP_CODEX_TEST_RUN_KEY}", + "X-Process": "{env:PAPERCLIP_CODEX_TEST_PROCESS_KEY}", + "X-Missing": "{env:DEFINITELY_UNSET_VAR_XYZ}", + }, + }, + }, + model_provider: "gw", + }), + PAPERCLIP_CODEX_TEST_RUN_KEY: "from-run-env", + }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).toContain('X-Run = "from-run-env"'); + expect(content).toContain('X-Process = "from-process-env"'); + expect(content).toContain('X-Missing = "{env:DEFINITELY_UNSET_VAR_XYZ}"'); + + await prepared.cleanup(); + } finally { + delete process.env.PAPERCLIP_CODEX_TEST_PROCESS_KEY; + } + }); + + it("reads PAPERCLIP_CODEX_PROVIDERS from process.env when absent from the run env", async () => { + const home = await makeCodexHome(); + process.env.PAPERCLIP_CODEX_PROVIDERS = JSON.stringify(BIFROST_PROVIDERS); + try { + const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home }); + const content = await readConfigToml(home); + expect(content).toContain("[model_providers.bifrost]"); + await prepared.cleanup(); + } finally { + delete process.env.PAPERCLIP_CODEX_PROVIDERS; + } + }); + + it("surfaces a note for malformed PAPERCLIP_CODEX_PROVIDERS without touching config.toml", async () => { + const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n"); + const cases: Array<[raw: string, noteFragment: string]> = [ + ["not json", "contains invalid JSON"], + [JSON.stringify(["providers"]), "is not a JSON object"], + [JSON.stringify({ no_providers: true }), 'has no "providers" object'], + [JSON.stringify({ providers: {} }), "contains no usable entries"], + [JSON.stringify({ providers: { gw: "nope" } }), "skipped provider(s) with empty names or non-object values: gw"], + ]; + for (const [raw, noteFragment] of cases) { + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: raw }, + codexHome: home, + }); + expect(prepared.notes).toHaveLength(1); + expect(prepared.notes[0]).toContain(noteFragment); + expect(prepared.notes[0]).toContain("custom providers ignored"); + expect(await readConfigToml(home)).toBe("model = \"gpt-5.1-codex\"\n"); + await prepared.cleanup(); + } + }); + + it("stays silent when PAPERCLIP_CODEX_PROVIDERS is unset or empty", async () => { + const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n"); + const envs: Array> = [ + {}, + { PAPERCLIP_CODEX_PROVIDERS: "" }, + { PAPERCLIP_CODEX_PROVIDERS: " " }, + ]; + for (const env of envs) { + const prepared = await prepareCodexRuntimeConfig({ env, codexHome: home }); + expect(prepared.notes).toEqual([]); + expect(await readConfigToml(home)).toBe("model = \"gpt-5.1-codex\"\n"); + await prepared.cleanup(); + } + }); + + it("surfaces skipped non-object provider entries while merging the usable ones", async () => { + const home = await makeCodexHome(); + const prepared = await prepareCodexRuntimeConfig({ + env: { + PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ + providers: { + bad: "http://gateway.example/v1", + good: { base_url: "http://gateway.example/v1", env_key: "OPENAI_API_KEY" }, + }, + model_provider: "good", + }), + }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).toContain("[model_providers.good]"); + expect(content).not.toContain("[model_providers.bad]"); + expect( + prepared.notes.some((n) => n.includes("skipped provider(s) with empty names or non-object values: bad")), + ).toBe(true); + expect(prepared.notes.some((n) => n.includes("Merged 1 custom Codex model provider(s)"))).toBe(true); + await prepared.cleanup(); + }); + + it("escapes DEL (U+007F) in emitted TOML strings", async () => { + const del = String.fromCharCode(0x7f); + const home = await makeCodexHome(); + const prepared = await prepareCodexRuntimeConfig({ + env: { + PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ + providers: { gw: { base_url: "http://gw.example/v1", name: `del${del}name` } }, + }), + }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).not.toContain(del); + expect(content).toContain("u007fname"); + await prepared.cleanup(); + }); + + it("restores user provider sections from the pre-run backup after an interrupted run", async () => { + const userConfig = [ + 'model = "gpt-5.1-codex"', + "", + "[model_providers.bifrost]", + 'base_url = "http://user.example/v1"', + "", + ].join("\n"); + const home = await makeCodexHome(userConfig); + // Simulate a crash: the merge excises the user's same-name provider + // section (the managed definition must win), and cleanup() never runs. + await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + expect(await readConfigToml(home)).toContain('env_key = "OPENAI_API_KEY"'); + + const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home }); + expect(prepared.notes.some((n) => n.includes("backup"))).toBe(true); + expect(await readConfigToml(home)).toBe(userConfig); + await expect(fs.access(path.join(home, "config.toml.paperclip-backup"))).rejects.toThrow(); + await prepared.cleanup(); + }); + + it("removes the pre-run backup on cleanup", async () => { + const home = await makeCodexHome('approval_policy = "never"\n'); + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + const backupPath = path.join(home, "config.toml.paperclip-backup"); + expect(await fs.readFile(backupPath, "utf8")).toBe('approval_policy = "never"\n'); + + await prepared.cleanup(); + expect(await readConfigToml(home)).toBe('approval_policy = "never"\n'); + await expect(fs.access(backupPath)).rejects.toThrow(); + }); + + it("skips the merge and surfaces a note when CODEX_HOME is explicitly configured", async () => { + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: null, + }); + + expect(prepared.notes).toHaveLength(1); + expect(prepared.notes[0]).toContain("CODEX_HOME"); + await prepared.cleanup(); + }); + + it("self-heals stale managed blocks when the env is no longer set", async () => { + const home = await makeCodexHome(); + const crashed = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + // Simulate a crash: cleanup never runs, the managed blocks persist. + void crashed; + expect(await readConfigToml(home)).toContain("[model_providers.bifrost]"); + + const prepared = await prepareCodexRuntimeConfig({ env: {}, codexHome: home }); + expect(prepared.notes.some((n) => n.includes("stale"))).toBe(true); + const content = await readConfigToml(home); + expect(content).not.toContain("model_provider"); + expect(content).not.toContain("bifrost"); + await prepared.cleanup(); + }); + + it("re-running with changed providers replaces the previous managed blocks", async () => { + const home = await makeCodexHome("approval_policy = \"never\"\n"); + const first = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify(BIFROST_PROVIDERS) }, + codexHome: home, + }); + void first; // simulate crash: no cleanup + const second = await prepareCodexRuntimeConfig({ + env: { + PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ + providers: { gw2: { base_url: "http://gw2.example/v1", env_key: "OPENAI_API_KEY" } }, + model_provider: "gw2", + }), + }, + codexHome: home, + }); + + const content = await readConfigToml(home); + expect(content).toContain('approval_policy = "never"'); + expect(content).toContain("[model_providers.gw2]"); + expect(content).toContain('model_provider = "gw2"'); + expect(content).not.toContain("bifrost"); + expect(content.split("model_provider =").length).toBe(2); + await second.cleanup(); + }); + + it("rejects the block when model_provider names a filtered or missing provider", async () => { + const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n"); + const prepared = await prepareCodexRuntimeConfig({ + env: { + PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ + providers: { good: { base_url: "https://gateway.example/v1" }, bad: "not-an-object" }, + model_provider: "bad", + }), + }, + codexHome: home, + }); + expect(prepared.notes).toContain( + 'PAPERCLIP_CODEX_PROVIDERS: model_provider "bad" does not match any usable provider entry; custom providers ignored.', + ); + const content = await readConfigToml(home); + expect(content).toBe("model = \"gpt-5.1-codex\"\n"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/runtime-config.ts b/packages/adapters/codex-local/src/server/runtime-config.ts new file mode 100644 index 00000000..978c4fda --- /dev/null +++ b/packages/adapters/codex-local/src/server/runtime-config.ts @@ -0,0 +1,430 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +type PreparedCodexRuntimeConfig = { + notes: string[]; + cleanup: () => Promise; +}; + +type ParsedCodexProvidersConfig = { + providers: Record>; + modelProvider: string | null; +}; + +// Marker comments delimiting the Paperclip-managed regions of config.toml. +// TOML requires root-level keys (model_provider) to appear before the first +// table header, while [model_providers.*] tables must not swallow the user's +// root keys, so the managed content is split into a root block prepended to +// the file and a tables block appended to it. +const MANAGED_ROOT_BEGIN = "# >>> paperclip codex providers (root) -- managed, do not edit >>>"; +const MANAGED_ROOT_END = "# <<< paperclip codex providers (root) <<<"; +const MANAGED_TABLES_BEGIN = "# >>> paperclip codex providers (tables) -- managed, do not edit >>>"; +const MANAGED_TABLES_END = "# <<< paperclip codex providers (tables) <<<"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// Recursively replace {env:VAR} placeholders with the resolved value. Used to bake +// gateway provider secrets into config.toml SERVER-SIDE, where the value is +// reliably present. Prefer codex's own `env_key` indirection (codex reads the +// named env var at request time); placeholder expansion exists for fields that +// must carry a literal value (e.g. http_headers). Unresolvable placeholders are +// left intact. +function expandEnvPlaceholders(value: T, resolve: (name: string) => string | undefined): T { + if (typeof value === "string") { + return value.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (match, name: string) => { + const resolved = resolve(name); + return resolved !== undefined && resolved.length > 0 ? resolved : match; + }) as unknown as T; + } + if (Array.isArray(value)) { + return value.map((entry) => expandEnvPlaceholders(entry, resolve)) as unknown as T; + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const [key, entry] of Object.entries(value)) { + out[key] = expandEnvPlaceholders(entry, resolve); + } + return out as unknown as T; + } + return value; +} + +// PAPERCLIP_CODEX_PROVIDERS is a JSON object that maps 1:1 onto codex's +// config.toml schema: +// +// { +// "providers": { +// "": { // -> [model_providers.] +// "name": "My gateway", // optional display name +// "base_url": "http://...", // OpenAI-compatible endpoint +// "env_key": "OPENAI_API_KEY", // env var codex reads the bearer key from +// "wire_api": "responses", // protocol codex speaks to the provider +// ... // any other field codex supports +// // (query_params, http_headers, +// // env_http_headers, request_max_retries, ...) +// } +// }, +// "model_provider": "" // optional: top-level provider selection +// } +// +// Scalar fields are emitted verbatim as TOML key = value pairs; plain-object +// fields (query_params, http_headers, ...) are emitted as inline tables and +// arrays of scalars as TOML arrays. String values may use {env:VAR} +// placeholders, expanded server-side against the run env and process.env. +function parseCodexProvidersConfig( + raw: unknown, + resolveEnv: (name: string) => string | undefined, + notes: string[], +): ParsedCodexProvidersConfig | null { + if (typeof raw !== "string" || raw.trim().length === 0) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Surface the misconfiguration instead of silently dropping the provider + // config; an unparseable value would otherwise be undiagnosable. + notes.push("PAPERCLIP_CODEX_PROVIDERS contains invalid JSON; custom providers ignored."); + return null; + } + if (!isPlainObject(parsed)) { + notes.push("PAPERCLIP_CODEX_PROVIDERS is set but is not a JSON object; custom providers ignored."); + return null; + } + const rawProviders = parsed.providers; + if (!isPlainObject(rawProviders)) { + notes.push( + 'PAPERCLIP_CODEX_PROVIDERS has no "providers" object; custom providers ignored.', + ); + return null; + } + // Only keep provider entries with non-empty names and object values; surface + // the ones we drop so a malformed entry is just as diagnosable as malformed JSON. + const providers: Record> = {}; + const skipped: string[] = []; + for (const [key, value] of Object.entries(rawProviders)) { + if (key.trim().length === 0 || !isPlainObject(value)) { + skipped.push(key.trim().length === 0 ? "(empty name)" : key); + continue; + } + providers[key] = expandEnvPlaceholders(value, resolveEnv); + } + if (Object.keys(providers).length === 0) { + notes.push( + `PAPERCLIP_CODEX_PROVIDERS "providers" contains no usable entries${ + skipped.length > 0 + ? ` (skipped provider(s) with empty names or non-object values: ${skipped.join(", ")})` + : "" + }; custom providers ignored.`, + ); + return null; + } + if (skipped.length > 0) { + notes.push( + `PAPERCLIP_CODEX_PROVIDERS: skipped provider(s) with empty names or non-object values: ${skipped.join(", ")}.`, + ); + } + const modelProvider = + typeof parsed.model_provider === "string" && parsed.model_provider.trim().length > 0 + ? parsed.model_provider.trim() + : null; + // A selector pointing at a provider that did not survive filtering (or was + // never defined) would emit model_provider = "x" with no [model_providers.x] + // table, which codex rejects at runtime with an error that points nowhere + // near the env var. Treat it as the same class of misconfiguration as + // malformed JSON: reject the whole block with a visible note. + if (modelProvider !== null && !(modelProvider in providers)) { + notes.push( + `PAPERCLIP_CODEX_PROVIDERS: model_provider "${modelProvider}" does not match any usable provider entry; custom providers ignored.`, + ); + return null; + } + return { providers, modelProvider }; +} + +function escapeTomlString(value: string): string { + // TOML 1.0 basic strings require escaping U+0000-U+001F and U+007F (DEL). + return value.replace(/[\\"\u0000-\u001f\u007f]/g, (char) => { + switch (char) { + case "\\": + return "\\\\"; + case '"': + return '\\"'; + case "\n": + return "\\n"; + case "\r": + return "\\r"; + case "\t": + return "\\t"; + default: + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`; + } + }); +} + +const BARE_TOML_KEY_RE = /^[A-Za-z0-9_-]+$/; + +function tomlKey(key: string): string { + return BARE_TOML_KEY_RE.test(key) ? key : `"${escapeTomlString(key)}"`; +} + +// Hand-emitted TOML for a constrained value space (strings, numbers, booleans, +// arrays of scalars, plain objects as inline tables). Returns null for values +// that cannot be represented, which are then skipped. +function tomlValue(value: unknown): string | null { + if (typeof value === "string") return `"${escapeTomlString(value)}"`; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return Number.isFinite(value) ? String(value) : null; + if (Array.isArray(value)) { + const entries = value.map((entry) => tomlValue(entry)); + if (entries.some((entry) => entry === null)) return null; + return `[${entries.join(", ")}]`; + } + if (isPlainObject(value)) { + const pairs: string[] = []; + for (const [key, entry] of Object.entries(value)) { + const emitted = tomlValue(entry); + if (emitted === null) continue; + pairs.push(`${tomlKey(key)} = ${emitted}`); + } + return `{ ${pairs.join(", ")} }`; + } + return null; +} + +function emitProviderTable(name: string, fields: Record): string[] { + const lines = [`[model_providers.${tomlKey(name)}]`]; + for (const [key, value] of Object.entries(fields)) { + const emitted = tomlValue(value); + if (emitted === null) continue; + lines.push(`${tomlKey(key)} = ${emitted}`); + } + return lines; +} + +function stripManagedBlock(lines: string[], begin: string, end: string): string[] { + const out: string[] = []; + let inBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (!inBlock && trimmed === begin) { + inBlock = true; + continue; + } + if (inBlock) { + if (trimmed === end) inBlock = false; + continue; + } + out.push(line); + } + return out; +} + +export function stripManagedCodexProviderBlocks(content: string): string { + let lines = content.split("\n"); + lines = stripManagedBlock(lines, MANAGED_ROOT_BEGIN, MANAGED_ROOT_END); + lines = stripManagedBlock(lines, MANAGED_TABLES_BEGIN, MANAGED_TABLES_END); + return lines.join("\n"); +} + +const TABLE_HEADER_RE = /^\s*\[\s*([^\]]*?)\s*\]\s*(?:#.*)?$/; + +// Best-effort parse of a TOML table header into its dotted path segments, +// stripping surrounding quotes per segment. Dotted quoted segment names are +// out of scope for this merge (codex provider ids are simple identifiers). +function parseTableHeaderPath(line: string): string[] | null { + const match = TABLE_HEADER_RE.exec(line); + if (!match) return null; + return match[1] + .split(".") + .map((segment) => segment.trim()) + .map((segment) => segment.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1")); +} + +// Remove pre-existing definitions that would conflict with (or override) the +// managed content: [model_providers.] tables (and their subtables) for +// names we are about to define, and the root-level `model_provider` key when +// we set one. Duplicate TOML tables/keys are parse errors in codex, so the +// managed definitions must win by excising the originals. +function stripConflictingDefinitions( + content: string, + providerNames: string[], + removeRootModelProvider: boolean, +): string { + const names = new Set(providerNames); + const lines = content.split("\n"); + const out: string[] = []; + let inRootRegion = true; + let skippingSection = false; + for (const line of lines) { + const headerPath = parseTableHeaderPath(line); + if (headerPath) { + inRootRegion = false; + skippingSection = + headerPath.length >= 2 && + headerPath[0] === "model_providers" && + names.has(headerPath[1]); + if (skippingSection) continue; + } else if (skippingSection) { + continue; + } + if (inRootRegion && removeRootModelProvider && /^\s*model_provider\s*=/.test(line)) { + continue; + } + out.push(line); + } + return out.join("\n"); +} + +function buildMergedConfigToml(base: string, parsed: ParsedCodexProvidersConfig): string { + const sections: string[] = []; + if (parsed.modelProvider) { + sections.push( + [ + MANAGED_ROOT_BEGIN, + `model_provider = "${escapeTomlString(parsed.modelProvider)}"`, + MANAGED_ROOT_END, + ].join("\n"), + ); + } + const trimmedBase = base.replace(/^\n+/, "").replace(/\n+$/, ""); + if (trimmedBase.length > 0) sections.push(trimmedBase); + const tableLines: string[] = [MANAGED_TABLES_BEGIN]; + for (const [name, fields] of Object.entries(parsed.providers)) { + tableLines.push(...emitProviderTable(name, fields), ""); + } + while (tableLines[tableLines.length - 1] === "") tableLines.pop(); + tableLines.push(MANAGED_TABLES_END); + sections.push(tableLines.join("\n")); + return `${sections.join("\n\n")}\n`; +} + +async function readFileOrNull(filePath: string): Promise { + return fs.readFile(filePath, "utf8").catch(() => null); +} + +// Pre-run backup of the original config.toml, written before the merged file. +// If a run dies without reaching cleanup() (a setup throw between prepare and +// execution, SIGKILL, ...), the next prepare restores the original from this +// backup with full fidelity -- including user [model_providers.*] sections the +// merge excised, which block-stripping alone cannot bring back. +function configTomlBackupPath(configTomlPath: string): string { + return `${configTomlPath}.paperclip-backup`; +} + +// Merge custom Codex model providers supplied via PAPERCLIP_CODEX_PROVIDERS +// into the managed CODEX_HOME's config.toml. +// +// Codex has no CLI flag or env var for pointing at a custom OpenAI-compatible +// endpoint: custom endpoints are `[model_providers.]` tables in +// $CODEX_HOME/config.toml, selected by a top-level `model_provider = ""` +// key (the `--model` CLI flag picks the model WITHIN the selected provider). +// We accept the providers as config (not hard-coded) so the gateway URL, key +// indirection, and wire protocol stay declarative. +// +// The merge preserves any existing config.toml content (seeded from the shared +// ~/.codex by prepareManagedCodexHome): managed content lives between marker +// comments and conflicting pre-existing definitions are excised so the managed +// definitions win. cleanup() restores the original file; if a run dies before +// cleanup, the next prepare restores the original from the pre-run backup file +// written alongside config.toml (including when PAPERCLIP_CODEX_PROVIDERS is +// no longer set), falling back to stripping the stale managed blocks. +// +// When the adapter config explicitly sets env.CODEX_HOME (a user-managed home), +// pass codexHome: null -- the file is left untouched and a note is surfaced. +export async function prepareCodexRuntimeConfig(input: { + env: Record; + codexHome: string | null; +}): Promise { + const resolveEnv = (name: string): string | undefined => input.env[name] ?? process.env[name]; + const notes: string[] = []; + const parsed = parseCodexProvidersConfig( + input.env.PAPERCLIP_CODEX_PROVIDERS ?? process.env.PAPERCLIP_CODEX_PROVIDERS, + resolveEnv, + notes, + ); + + if (!parsed) { + // Self-heal state left behind by a crashed run (cleanup() never ran). + if (input.codexHome) { + const configTomlPath = path.join(input.codexHome, "config.toml"); + const reason = notes.length === 0 ? " (PAPERCLIP_CODEX_PROVIDERS is no longer set)" : ""; + const backupPath = configTomlBackupPath(configTomlPath); + const backup = await readFileOrNull(backupPath); + if (backup !== null) { + // Full-fidelity restore: the backup is the pre-run original, including + // any user provider sections the crashed run's merge excised. + await fs.writeFile(configTomlPath, backup, "utf8"); + await fs.rm(backupPath, { force: true }); + return { + notes: [ + ...notes, + `Restored "${configTomlPath}" from its pre-run backup, removing stale Paperclip-managed model providers left by an interrupted run${reason}.`, + ], + cleanup: async () => {}, + }; + } + // Fallback for pre-backup stale state: strip the managed blocks. + const existing = await readFileOrNull(configTomlPath); + if (existing !== null) { + const stripped = stripManagedCodexProviderBlocks(existing); + if (stripped !== existing) { + await fs.writeFile(configTomlPath, stripped, "utf8"); + return { + notes: [ + ...notes, + `Removed stale Paperclip-managed model provider blocks from "${configTomlPath}"${reason}.`, + ], + cleanup: async () => {}, + }; + } + } + } + return { notes, cleanup: async () => {} }; + } + + if (!input.codexHome) { + return { + notes: [ + ...notes, + "PAPERCLIP_CODEX_PROVIDERS is set but the adapter config explicitly sets env.CODEX_HOME; leaving the user-managed Codex home untouched (no model provider merge).", + ], + cleanup: async () => {}, + }; + } + + const configTomlPath = path.join(input.codexHome, "config.toml"); + const backupPath = configTomlBackupPath(configTomlPath); + // A surviving backup from an interrupted run is the true pre-run content; + // the current config.toml would still carry that run's managed blocks. + const original = (await readFileOrNull(backupPath)) ?? (await readFileOrNull(configTomlPath)); + const providerNames = Object.keys(parsed.providers); + const base = stripConflictingDefinitions( + stripManagedCodexProviderBlocks(original ?? ""), + providerNames, + parsed.modelProvider !== null, + ); + await fs.mkdir(input.codexHome, { recursive: true }); + // Persist the original BEFORE writing the merged file so a run that never + // reaches cleanup() can be restored by the next prepare. + await fs.writeFile(backupPath, original ?? "", "utf8"); + await fs.writeFile(configTomlPath, buildMergedConfigToml(base, parsed), "utf8"); + + return { + notes: [ + ...notes, + `Merged ${providerNames.length} custom Codex model provider(s) from PAPERCLIP_CODEX_PROVIDERS into "${configTomlPath}": ${providerNames.join(", ")}${ + parsed.modelProvider ? `; selected model_provider "${parsed.modelProvider}"` : "" + }.`, + ], + cleanup: async () => { + if (original === null) { + await fs.rm(configTomlPath, { force: true }); + } else { + await fs.writeFile(configTomlPath, original, "utf8"); + } + await fs.rm(backupPath, { force: true }); + }, + }; +}