fix: pass system prompt to codex cli

This commit is contained in:
Peter Steinberger
2026-04-08 18:15:10 +01:00
parent 1979a28803
commit 2d0e25c23a
17 changed files with 239 additions and 38 deletions

View File

@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
- Android/pairing: clear stale setup-code auth on new QR scans, bootstrap operator and node sessions from fresh pairing, prefer stored device tokens after bootstrap handoff, and pause pairing auto-retry while the app is backgrounded so scan-once Android pairing recovers reliably again. (#63199) Thanks @obviyus.
- Auto-reply/NO_REPLY: strip glued leading `NO_REPLY` tokens before reply normalization and ACP-visible streaming so silent sentinel text no longer leaks into user-visible replies while preserving substantive `NO_REPLY ...` text. Thanks @frankekn.
- Gateway/sessions: clear auto-fallback-pinned model overrides on `/reset` and `/new` while still preserving explicit user model selections, including legacy sessions created before override-source tracking existed. (#63155) Thanks @frankekn.
- Codex CLI: pass OpenClaw's system prompt through Codex's `model_instructions_file` config override so fresh Codex CLI sessions receive the same prompt guidance as Claude CLI sessions.
## 2026.4.8

View File

@@ -1,4 +1,4 @@
6092701439f9f56624f508eb2b240cb48375264c2667a99cb7e7823cb0ef18d1 config-baseline.json
065f474b340fc22b19358cb298131037cbb2a3411ef0b6f765072bbaafedf751 config-baseline.core.json
0a75b57f5dbb0bb1488eacb47111ee22ff42dd3747bfe07bb69c9445d5e55c3e config-baseline.json
ff15bb8b4231fc80174249ae89bcb61439d7adda5ee6be95e4d304680253a59f config-baseline.core.json
7f42b22b46c487d64aaac46001ba9d9096cf7bf0b1c263a54d39946303ff5018 config-baseline.channel.json
483d4f3c1d516719870ad6f2aba6779b9950f85471ee77b9994a077a7574a892 config-baseline.plugin.json

View File

@@ -124,6 +124,9 @@ The provider id becomes the left side of your model ref:
sessionMode: "existing",
sessionIdFields: ["session_id", "conversation_id"],
systemPromptArg: "--system",
// Codex-style CLIs can point at a prompt file instead:
// systemPromptFileConfigArg: "-c",
// systemPromptFileConfigKey: "model_instructions_file",
systemPromptWhen: "first",
imageArg: "--image",
imageMode: "repeat",
@@ -150,6 +153,12 @@ told us OpenClaw-style Claude CLI usage is allowed again, so OpenClaw treats
a new policy.
</Note>
The bundled OpenAI `codex-cli` backend passes OpenClaw's system prompt through
Codex's `model_instructions_file` config override (`-c
model_instructions_file="..."`). Codex does not expose a Claude-style
`--append-system-prompt` flag, so OpenClaw writes the assembled prompt to a
temporary file for each fresh Codex CLI session.
## Sessions
- If the CLI supports sessions, set `sessionArg` (e.g. `--session-id`) or

View File

@@ -38,6 +38,9 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin {
modelArg: "--model",
sessionIdFields: ["thread_id"],
sessionMode: "existing",
systemPromptFileConfigArg: "-c",
systemPromptFileConfigKey: "model_instructions_file",
systemPromptWhen: "first",
imageArg: "--image",
imageMode: "repeat",
reliability: {

View File

@@ -1,9 +1,10 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { CliBackendConfig } from "../config/types.js";
import type { CliBundleMcpMode } from "../plugins/types.js";
let createEmptyPluginRegistry: typeof import("../plugins/registry.js").createEmptyPluginRegistry;
let resetPluginRuntimeStateForTest: typeof import("../plugins/runtime.js").resetPluginRuntimeStateForTest;
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
let normalizeClaudeBackendConfig: typeof import("./cli-backends.js").normalizeClaudeBackendConfig;
let resolveCliBackendConfig: typeof import("./cli-backends.js").resolveCliBackendConfig;
@@ -96,11 +97,16 @@ beforeAll(async () => {
vi.doUnmock("../plugins/setup-registry.js");
vi.doUnmock("../plugins/cli-backends.runtime.js");
({ createEmptyPluginRegistry } = await import("../plugins/registry.js"));
({ setActivePluginRegistry } = await import("../plugins/runtime.js"));
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } =
await import("../plugins/runtime.js"));
({ normalizeClaudeBackendConfig, resolveCliBackendConfig, resolveCliBackendLiveTest } =
await import("./cli-backends.js"));
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
beforeEach(() => {
const registry = createEmptyPluginRegistry();
registry.cliBackends = [
@@ -177,6 +183,9 @@ beforeEach(() => {
"--skip-git-repo-check",
],
resumeArgs: ["exec", "resume", "{sessionId}", "--dangerously-bypass-approvals-and-sandbox"],
systemPromptFileConfigArg: "-c",
systemPromptFileConfigKey: "model_instructions_file",
systemPromptWhen: "first",
reliability: {
watchdog: {
fresh: {
@@ -657,6 +666,9 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => {
expect(resolved).not.toBeNull();
expect(resolved?.bundleMcp).toBe(true);
expect(resolved?.bundleMcpMode).toBe("codex-config-overrides");
expect(resolved?.config.systemPromptFileConfigArg).toBe("-c");
expect(resolved?.config.systemPromptFileConfigKey).toBe("model_instructions_file");
expect(resolved?.config.systemPromptWhen).toBe("first");
});
});

View File

@@ -10,6 +10,7 @@ import {
prepareCliPromptImagePayload,
resolveCliRunQueueKey,
writeCliImages,
writeCliSystemPromptFile,
} from "./cli-runner/helpers.js";
import * as promptImageUtils from "./pi-embedded-runner/run/images.js";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
@@ -143,6 +144,23 @@ describe("buildCliArgs", () => {
).toEqual(["-p", "--append-system-prompt", "Stable prefix\nDynamic suffix"]);
});
it("passes Codex system prompts via a model instructions file config override", () => {
expect(
buildCliArgs({
backend: {
command: "codex",
systemPromptFileConfigArg: "-c",
systemPromptFileConfigKey: "model_instructions_file",
},
baseArgs: ["exec", "--json"],
modelId: "gpt-5.4",
systemPrompt: "Stable prefix",
systemPromptFilePath: "/tmp/openclaw/system-prompt.md",
useResume: false,
}),
).toEqual(["exec", "--json", "-c", 'model_instructions_file="/tmp/openclaw/system-prompt.md"']);
});
it("replaces prompt placeholders before falling back to a trailing positional prompt", () => {
expect(
buildCliArgs({
@@ -412,6 +430,28 @@ describe("writeCliImages", () => {
});
});
describe("writeCliSystemPromptFile", () => {
it("writes stripped system prompts to a private temp file", async () => {
const written = await writeCliSystemPromptFile({
backend: {
command: "codex",
systemPromptFileConfigKey: "model_instructions_file",
},
systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`,
});
try {
expect(written.filePath).toContain("openclaw-cli-system-prompt-");
await expect(fs.readFile(written.filePath ?? "", "utf-8")).resolves.toBe(
"Stable prefix\nDynamic suffix",
);
} finally {
await written.cleanup();
}
await expect(fs.access(written.filePath ?? "")).rejects.toMatchObject({ code: "ENOENT" });
});
});
describe("resolveCliRunQueueKey", () => {
it("scopes Claude CLI serialization to the workspace for fresh runs", () => {
expect(

View File

@@ -51,6 +51,9 @@ function buildPreparedCliRunContext(params: {
input: "arg" as const,
modelArg: "--model",
sessionMode: "existing" as const,
systemPromptFileConfigArg: "-c",
systemPromptFileConfigKey: "model_instructions_file",
systemPromptWhen: "first" as const,
serialize: true,
};
const backend = { ...baseBackend, ...params.backend };
@@ -221,6 +224,39 @@ describe("runCliAgent spawn path", () => {
expect(input.scopeKey).toContain("thread-123");
});
it("passes Codex system prompts through model_instructions_file", async () => {
let promptFileText = "";
supervisorSpawnMock.mockImplementationOnce(async (...args: unknown[]) => {
const input = (args[0] ?? {}) as { argv?: string[] };
const configArgIndex = input.argv?.indexOf("-c") ?? -1;
expect(configArgIndex).toBeGreaterThanOrEqual(0);
const configArg = input.argv?.[configArgIndex + 1] ?? "";
const match = /^model_instructions_file="(.+)"$/.exec(configArg);
expect(match?.[1]).toBeTruthy();
promptFileText = await fs.readFile(match?.[1] ?? "", "utf-8");
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "ok",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
await executePreparedCliRun(
buildPreparedCliRunContext({
provider: "codex-cli",
model: "gpt-5.4",
runId: "run-codex-system-prompt-file",
}),
);
expect(promptFileText).toBe("You are a helpful assistant.");
});
it("cancels the managed CLI run when the abort signal fires", async () => {
const abortController = new AbortController();
let resolveWait!: (value: {

View File

@@ -135,6 +135,9 @@ function buildOpenAICodexCliBackendFixture(): CliBackendPlugin {
modelArg: "--model",
sessionIdFields: ["thread_id"],
sessionMode: "existing",
systemPromptFileConfigArg: "-c",
systemPromptFileConfigKey: "model_instructions_file",
systemPromptWhen: "first",
imageArg: "--image",
imageMode: "repeat",
reliability: {

View File

@@ -16,6 +16,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../../shared/string-coerce.js";
import { serializeTomlInlineValue } from "./toml-inline.js";
type PreparedCliBundleMcpConfig = {
backend: CliBackendConfig;
@@ -204,35 +205,6 @@ function normalizeGeminiServerConfig(
return next;
}
function escapeTomlString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function formatTomlKey(key: string): string {
return /^[A-Za-z0-9_-]+$/.test(key) ? key : `"${escapeTomlString(key)}"`;
}
function serializeTomlInlineValue(value: unknown): string {
if (typeof value === "string") {
return `"${escapeTomlString(value)}"`;
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (Array.isArray(value)) {
return `[${value.map((entry) => serializeTomlInlineValue(entry)).join(", ")}]`;
}
if (isRecord(value)) {
return `{ ${Object.entries(value)
.map(([key, entry]) => `${formatTomlKey(key)} = ${serializeTomlInlineValue(entry)}`)
.join(", ")} }`;
}
throw new Error(`Unsupported TOML value for Codex MCP config: ${String(value)}`);
}
function injectCodexMcpConfigArgs(args: string[] | undefined, config: BundleMcpConfig): string[] {
const overrides = serializeTomlInlineValue(
Object.fromEntries(

View File

@@ -25,6 +25,7 @@ import {
resolvePromptInput,
resolveSessionIdToSend,
resolveSystemPromptUsage,
writeCliSystemPromptFile,
} from "./helpers.js";
import {
cliBackendLog,
@@ -113,6 +114,13 @@ export async function executePreparedCliRun(
isNewSession: isNew,
systemPrompt: context.systemPrompt,
});
const systemPromptFile =
!useResume && systemPromptArg
? await writeCliSystemPromptFile({
backend,
systemPrompt: systemPromptArg,
})
: undefined;
let prompt = prependBootstrapPromptWarning(params.prompt, context.bootstrapPromptWarningLines, {
preserveExactPrompt: context.heartbeatPrompt,
@@ -144,6 +152,7 @@ export async function executePreparedCliRun(
modelId: context.normalizedModel,
sessionId: resolvedSessionId,
systemPrompt: systemPromptArg,
systemPromptFilePath: systemPromptFile?.filePath,
imagePaths,
promptArg: argsPrompt,
useResume,
@@ -350,6 +359,9 @@ export async function executePreparedCliRun(
});
});
} finally {
if (systemPromptFile) {
await systemPromptFile.cleanup();
}
if (cleanupImages) {
await cleanupImages();
}

View File

@@ -27,6 +27,7 @@ import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.
import { buildSystemPromptParams } from "../system-prompt-params.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
import { sanitizeImageBlocks } from "../tool-images.js";
import { formatTomlConfigOverride } from "./toml-inline.js";
export { buildCliSupervisorScopeKey, resolveCliNoOutputTimeoutMs } from "./reliability.js";
const CLI_RUN_QUEUE = new KeyedAsyncQueue();
@@ -153,7 +154,10 @@ export function resolveSystemPromptUsage(params: {
if (when === "first" && !params.isNewSession) {
return null;
}
if (!params.backend.systemPromptArg?.trim()) {
if (
!params.backend.systemPromptArg?.trim() &&
!params.backend.systemPromptFileConfigKey?.trim()
) {
return null;
}
return systemPrompt;
@@ -280,6 +284,29 @@ export async function writeCliImages(params: {
return { paths, cleanup };
}
export async function writeCliSystemPromptFile(params: {
backend: CliBackendConfig;
systemPrompt: string;
}): Promise<{ filePath?: string; cleanup: () => Promise<void> }> {
if (!params.backend.systemPromptFileConfigKey?.trim()) {
return { cleanup: async () => {} };
}
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-cli-system-prompt-"),
);
const filePath = path.join(tempDir, "system-prompt.md");
await fs.writeFile(filePath, stripSystemPromptCacheBoundary(params.systemPrompt), {
encoding: "utf-8",
mode: 0o600,
});
return {
filePath,
cleanup: async () => {
await fs.rm(tempDir, { recursive: true, force: true });
},
};
}
export async function prepareCliPromptImagePayload(params: {
backend: CliBackendConfig;
prompt: string;
@@ -328,6 +355,7 @@ export function buildCliArgs(params: {
modelId: string;
sessionId?: string;
systemPrompt?: string | null;
systemPromptFilePath?: string;
imagePaths?: string[];
promptArg?: string;
useResume: boolean;
@@ -336,7 +364,20 @@ export function buildCliArgs(params: {
if (params.backend.modelArg && params.modelId) {
args.push(params.backend.modelArg, params.modelId);
}
if (!params.useResume && params.systemPrompt && params.backend.systemPromptArg) {
if (
!params.useResume &&
params.systemPrompt &&
params.systemPromptFilePath &&
params.backend.systemPromptFileConfigKey
) {
args.push(
params.backend.systemPromptFileConfigArg ?? "-c",
formatTomlConfigOverride(
params.backend.systemPromptFileConfigKey,
params.systemPromptFilePath,
),
);
} else if (!params.useResume && params.systemPrompt && params.backend.systemPromptArg) {
args.push(params.backend.systemPromptArg, stripSystemPromptCacheBoundary(params.systemPrompt));
}
if (!params.useResume && params.sessionId) {

View File

@@ -0,0 +1,36 @@
function escapeTomlString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function formatTomlKey(key: string): string {
return /^[A-Za-z0-9_-]+$/.test(key) ? key : `"${escapeTomlString(key)}"`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function serializeTomlInlineValue(value: unknown): string {
if (typeof value === "string") {
return `"${escapeTomlString(value)}"`;
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
if (Array.isArray(value)) {
return `[${value.map((entry) => serializeTomlInlineValue(entry)).join(", ")}]`;
}
if (isRecord(value)) {
return `{ ${Object.entries(value)
.map(([key, entry]) => `${formatTomlKey(key)} = ${serializeTomlInlineValue(entry)}`)
.join(", ")} }`;
}
throw new Error(`Unsupported TOML inline value: ${String(value)}`);
}
export function formatTomlConfigOverride(key: string, value: unknown): string {
return `${key}=${serializeTomlInlineValue(value)}`;
}

View File

@@ -1,5 +1,20 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
async function resetProviderRuntimeState() {
const [
{ clearPluginManifestRegistryCache },
{ resetProviderRuntimeHookCacheForTest },
{ resetPluginLoaderTestStateForTest },
] = await Promise.all([
import("../plugins/manifest-registry.js"),
import("../plugins/provider-runtime.js"),
import("../plugins/loader.test-fixtures.js"),
]);
resetPluginLoaderTestStateForTest();
clearPluginManifestRegistryCache();
resetProviderRuntimeHookCacheForTest();
}
let createProviderAuthResolver: typeof import("./models-config.providers.secrets.js").createProviderAuthResolver;
async function loadSecretsModule() {
@@ -7,6 +22,7 @@ async function loadSecretsModule() {
vi.doUnmock("../plugins/provider-runtime.js");
vi.doUnmock("../secrets/provider-env-vars.js");
vi.resetModules();
await resetProviderRuntimeState();
({ createProviderAuthResolver } = await import("./models-config.providers.secrets.js"));
}

View File

@@ -3425,6 +3425,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
systemPromptArg: {
type: "string",
},
systemPromptFileConfigArg: {
type: "string",
},
systemPromptFileConfigKey: {
type: "string",
},
systemPromptMode: {
anyOf: [
{

View File

@@ -79,6 +79,10 @@ export type CliBackendConfig = {
sessionIdFields?: string[];
/** Flag used to pass system prompt. */
systemPromptArg?: string;
/** Config override flag used to pass a system prompt file (e.g. -c). */
systemPromptFileConfigArg?: string;
/** Config override key used to pass a system prompt file. */
systemPromptFileConfigKey?: string;
/** System prompt behavior (append vs replace). */
systemPromptMode?: "append" | "replace";
/** When to send system prompt. */

View File

@@ -535,6 +535,8 @@ export const CliBackendSchema = z
.optional(),
sessionIdFields: z.array(z.string()).optional(),
systemPromptArg: z.string().optional(),
systemPromptFileConfigArg: z.string().optional(),
systemPromptFileConfigKey: z.string().optional(),
systemPromptMode: z.union([z.literal("append"), z.literal("replace")]).optional(),
systemPromptWhen: z
.union([z.literal("first"), z.literal("always"), z.literal("never")])

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mergeMockedModule } from "../test-utils/vitest-module-mocks.js";
const mocks = vi.hoisted(() => ({
@@ -110,6 +110,10 @@ vi.mock("../logging/subsystem.js", () => ({
const { scheduleRestartSentinelWake } = await import("./server-restart-sentinel.js");
describe("scheduleRestartSentinelWake", () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.useRealTimers();
mocks.consumeRestartSentinel.mockResolvedValue({
@@ -178,7 +182,9 @@ describe("scheduleRestartSentinelWake", () => {
.mockResolvedValueOnce([{ channel: "whatsapp", messageId: "msg-2" }]);
const wakePromise = scheduleRestartSentinelWake({ deps: {} as never });
await vi.runAllTimersAsync();
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(750);
await wakePromise;
expect(mocks.enqueueDelivery).toHaveBeenCalledTimes(1);
@@ -218,7 +224,9 @@ describe("scheduleRestartSentinelWake", () => {
.mockRejectedValueOnce(new Error("transport still not ready"));
const wakePromise = scheduleRestartSentinelWake({ deps: {} as never });
await vi.runAllTimersAsync();
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(750);
await wakePromise;
expect(mocks.enqueueDelivery).toHaveBeenCalledTimes(1);