diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61391b2f56e..edf34f856ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Setup/TUI: bound the Terminal hatch bootstrap run so a stalled provider request times out instead of leaving first-run hatching stuck behind the watchdog. (#76241) Thanks @joshavant.
- Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof.
- Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc.
+- Codex harness: forward OpenClaw workspace bootstrap files such as `SOUL.md` through native Codex config instructions while leaving `AGENTS.md` to Codex project-doc discovery. Fixes #76273. Thanks @zknicker.
## 2026.5.2
diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md
index 66dafe7a0b9..e8fd9d02eae 100644
--- a/docs/concepts/system-prompt.md
+++ b/docs/concepts/system-prompt.md
@@ -160,6 +160,13 @@ heartbeats are disabled for the default agent or
files concise — especially `MEMORY.md`, which can grow over time and lead to
unexpectedly high context usage and more frequent compaction.
+When a session runs on the native Codex harness, Codex loads `AGENTS.md`
+through its own project-doc discovery. OpenClaw still resolves the remaining
+bootstrap files and forwards them as Codex config instructions, so `SOUL.md`,
+`TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, and
+`MEMORY.md` keep the same workspace-context role without duplicating
+`AGENTS.md`.
+
`memory/*.md` daily files are **not** part of the normal bootstrap Project Context. On ordinary turns they are accessed on demand via the `memory_search` and `memory_get` tools, so they do not count against the context window unless the model explicitly reads them. Bare `/new` and `/reset` turns are the exception: the runtime can prepend recent daily memory as a one-shot startup-context block for that first turn.
diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md
index 47d91aafaa3..1c23e86c76f 100644
--- a/docs/plugins/codex-harness.md
+++ b/docs/plugins/codex-harness.md
@@ -259,6 +259,20 @@ For live and Docker smoke tests, auth usually comes from the Codex CLI account
or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can
also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present.
+## Workspace bootstrap files
+
+Codex handles `AGENTS.md` itself through native project-doc discovery. OpenClaw
+does not write synthetic Codex project-doc files or depend on Codex fallback
+filenames for persona files, because Codex fallbacks only apply when
+`AGENTS.md` is missing.
+
+For OpenClaw workspace parity, the Codex harness resolves the other bootstrap
+files (`SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`,
+`BOOTSTRAP.md`, and `MEMORY.md` when present) and forwards them through Codex
+config instructions on `thread/start` and `thread/resume`. This keeps
+`SOUL.md` and related workspace persona/profile context visible without
+duplicating `AGENTS.md`.
+
## Add Codex alongside other models
Do not set `agentRuntime.id: "codex"` globally if the same agent should freely switch
diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts
index 6e49a7e558d..3c31b1249ef 100644
--- a/extensions/codex/src/app-server/run-attempt.test.ts
+++ b/extensions/codex/src/app-server/run-attempt.test.ts
@@ -674,6 +674,31 @@ describe("runCodexAppServerAttempt", () => {
);
});
+ it("passes OpenClaw bootstrap files through Codex config instructions", async () => {
+ const sessionFile = path.join(tempDir, "session.jsonl");
+ const workspaceDir = path.join(tempDir, "workspace");
+ await fs.mkdir(workspaceDir, { recursive: true });
+ await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "Follow AGENTS guidance.");
+ await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "Soul voice goes here.");
+ const harness = createStartedThreadHarness();
+
+ const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
+ await harness.waitForMethod("turn/start");
+ await new Promise((resolve) => setImmediate(resolve));
+ await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
+ await run;
+
+ const threadStart = harness.requests.find((request) => request.method === "thread/start");
+ const config = (threadStart?.params as { config?: { instructions?: string } }).config;
+ expect(config).toEqual(
+ expect.objectContaining({
+ instructions: expect.stringContaining("Soul voice goes here."),
+ }),
+ );
+ expect(config?.instructions).toContain("Codex loads AGENTS.md natively");
+ expect(config?.instructions).not.toContain("Follow AGENTS guidance.");
+ });
+
it("fires llm_input, llm_output, and agent_end hooks for codex turns", async () => {
const llmInput = vi.fn();
const llmOutput = vi.fn();
@@ -996,13 +1021,13 @@ describe("runCodexAppServerAttempt", () => {
const startRequest = harness.requests.find((request) => request.method === "thread/start");
expect(startRequest?.params).toEqual(
expect.objectContaining({
- config: {
+ config: expect.objectContaining({
"features.codex_hooks": false,
"hooks.PreToolUse": [],
"hooks.PostToolUse": [],
"hooks.PermissionRequest": [],
"hooks.Stop": [],
- },
+ }),
}),
);
});
diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts
index ba7d528fa35..2f3a0dfbbe1 100644
--- a/extensions/codex/src/app-server/run-attempt.ts
+++ b/extensions/codex/src/app-server/run-attempt.ts
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
+import path from "node:path";
import {
assembleHarnessContextEngine,
bootstrapHarnessContextEngine,
@@ -26,12 +27,14 @@ import {
runAgentHarnessLlmOutputHook,
runHarnessContextEngineMaintenance,
registerNativeHookRelay,
+ resolveBootstrapContextForRun,
setActiveEmbeddedRun,
supportsModelTools,
runAgentCleanupStep,
type AgentMessage,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
+ type EmbeddedContextFile,
type NativeHookRelayEvent,
type NativeHookRelayRegistrationHandle,
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -101,6 +104,16 @@ const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000;
const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000;
const CODEX_STEER_ALL_DEBOUNCE_MS = 500;
const LOG_FIELD_MAX_LENGTH = 160;
+const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
+const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map([
+ ["soul.md", 10],
+ ["identity.md", 20],
+ ["user.md", 30],
+ ["tools.md", 40],
+ ["bootstrap.md", 50],
+ ["memory.md", 60],
+ ["heartbeat.md", 70],
+]);
type OpenClawCodingToolsOptions = NonNullable<
Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0]
@@ -472,6 +485,13 @@ export async function runCodexAppServerAttempt(
messages: historyMessages,
ctx: hookContext,
});
+ const workspaceBootstrapInstructions = await buildCodexWorkspaceBootstrapInstructions({
+ params,
+ resolvedWorkspace,
+ effectiveWorkspace,
+ sessionKey: sandboxSessionKey,
+ sessionAgentId,
+ });
const trajectoryRecorder = createCodexTrajectoryRecorder({
attempt: params,
cwd: effectiveWorkspace,
@@ -506,6 +526,10 @@ export async function runCodexAppServerAttempt(
: options.nativeHookRelay?.enabled === false
? buildCodexNativeHookRelayDisabledConfig()
: undefined;
+ const threadConfig = mergeCodexConfigInstructions(
+ nativeHookRelayConfig,
+ workspaceBootstrapInstructions,
+ );
({ client, thread } = await withCodexStartupTimeout({
timeoutMs: params.timeoutMs,
timeoutFloorMs: options.startupTimeoutFloorMs,
@@ -533,7 +557,7 @@ export async function runCodexAppServerAttempt(
dynamicTools: toolBridge.specs,
appServer,
developerInstructions: promptBuild.developerInstructions,
- config: nativeHookRelayConfig,
+ config: threadConfig,
});
return { client: startupClient, thread: startupThread };
};
@@ -1597,6 +1621,129 @@ async function readMirroredSessionHistoryMessages(
return messages;
}
+async function buildCodexWorkspaceBootstrapInstructions(params: {
+ params: EmbeddedRunAttemptParams;
+ resolvedWorkspace: string;
+ effectiveWorkspace: string;
+ sessionKey: string;
+ sessionAgentId: string;
+}): Promise {
+ try {
+ const { contextFiles } = await resolveBootstrapContextForRun({
+ workspaceDir: params.resolvedWorkspace,
+ config: params.params.config,
+ sessionKey: params.sessionKey,
+ sessionId: params.params.sessionId,
+ agentId: params.params.agentId ?? params.sessionAgentId,
+ warn: (message) => embeddedAgentLog.warn(message),
+ contextMode: params.params.bootstrapContextMode,
+ runKind: params.params.bootstrapContextRunKind,
+ });
+ return renderCodexWorkspaceBootstrapInstructions(
+ contextFiles.map((file) =>
+ remapCodexContextFilePath({
+ file,
+ sourceWorkspaceDir: params.resolvedWorkspace,
+ targetWorkspaceDir: params.effectiveWorkspace,
+ }),
+ ),
+ );
+ } catch (error) {
+ embeddedAgentLog.warn("failed to load codex workspace bootstrap instructions", { error });
+ return undefined;
+ }
+}
+
+function renderCodexWorkspaceBootstrapInstructions(
+ contextFiles: EmbeddedContextFile[],
+): string | undefined {
+ const files = contextFiles
+ .filter((file) => {
+ const baseName = getCodexContextFileBasename(file.path);
+ return baseName && !CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName);
+ })
+ .toSorted(compareCodexContextFiles);
+ if (files.length === 0) {
+ return undefined;
+ }
+ const hasSoulFile = files.some((file) => getCodexContextFileBasename(file.path) === "soul.md");
+ const lines = [
+ "OpenClaw loaded these user-editable workspace files. Treat them as project/user context. Codex loads AGENTS.md natively, so AGENTS.md is not repeated here.",
+ "",
+ "# Project Context",
+ "",
+ "The following project context files have been loaded:",
+ ];
+ if (hasSoulFile) {
+ lines.push(
+ "If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
+ );
+ }
+ lines.push("");
+ for (const file of files) {
+ lines.push(`## ${file.path}`, "", file.content, "");
+ }
+ return lines.join("\n").trim();
+}
+
+function mergeCodexConfigInstructions(
+ config: JsonObject | undefined,
+ instructions: string | undefined,
+): JsonObject | undefined {
+ if (!instructions?.trim()) {
+ return config;
+ }
+ const merged: JsonObject = { ...config };
+ const existingInstructions =
+ typeof merged.instructions === "string" ? merged.instructions.trim() : undefined;
+ merged.instructions = joinPresentSections(existingInstructions, instructions);
+ return merged;
+}
+
+function remapCodexContextFilePath(params: {
+ file: EmbeddedContextFile;
+ sourceWorkspaceDir: string;
+ targetWorkspaceDir: string;
+}): EmbeddedContextFile {
+ const relativePath = path.relative(params.sourceWorkspaceDir, params.file.path);
+ if (
+ !relativePath ||
+ relativePath.startsWith("..") ||
+ path.isAbsolute(relativePath) ||
+ params.sourceWorkspaceDir === params.targetWorkspaceDir
+ ) {
+ return params.file;
+ }
+ return {
+ ...params.file,
+ path: path.join(params.targetWorkspaceDir, relativePath),
+ };
+}
+
+function compareCodexContextFiles(left: EmbeddedContextFile, right: EmbeddedContextFile): number {
+ const leftPath = normalizeCodexContextFilePath(left.path);
+ const rightPath = normalizeCodexContextFilePath(right.path);
+ const leftBase = getCodexContextFileBasename(left.path);
+ const rightBase = getCodexContextFileBasename(right.path);
+ const leftOrder = CODEX_BOOTSTRAP_CONTEXT_ORDER.get(leftBase) ?? Number.MAX_SAFE_INTEGER;
+ const rightOrder = CODEX_BOOTSTRAP_CONTEXT_ORDER.get(rightBase) ?? Number.MAX_SAFE_INTEGER;
+ if (leftOrder !== rightOrder) {
+ return leftOrder - rightOrder;
+ }
+ if (leftBase !== rightBase) {
+ return leftBase.localeCompare(rightBase);
+ }
+ return leftPath.localeCompare(rightPath);
+}
+
+function normalizeCodexContextFilePath(filePath: string): string {
+ return filePath.trim().replaceAll("\\", "/").toLowerCase();
+}
+
+function getCodexContextFileBasename(filePath: string): string {
+ return normalizeCodexContextFilePath(filePath).split("/").pop() ?? "";
+}
+
async function mirrorTranscriptBestEffort(params: {
params: EmbeddedRunAttemptParams;
agentId?: string;
diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts
index fda963b4bbb..e31a7f95cae 100644
--- a/src/plugin-sdk/agent-harness-runtime.ts
+++ b/src/plugin-sdk/agent-harness-runtime.ts
@@ -105,6 +105,8 @@ export {
} from "../agents/runtime-plan/tools.js";
export { normalizeProviderToolSchemas } from "../agents/pi-embedded-runner/tool-schema-runtime.js";
export { resolveSandboxContext } from "../agents/sandbox.js";
+export { resolveBootstrapContextForRun } from "../agents/bootstrap-files.js";
+export type { EmbeddedContextFile } from "../agents/pi-embedded-helpers/types.js";
export { isSubagentSessionKey } from "../routing/session-key.js";
export {
acquireSessionWriteLock,