Compare commits

...

2 Commits

Author SHA1 Message Date
kevinlin-openai
fc8e2196e9 fix(codex): enable native apps in app-server home 2026-05-06 09:40:05 -07:00
kevinlin-openai
e045e45210 feat(codex): use native plugin thread 2026-05-06 04:22:15 -07:00
11 changed files with 519 additions and 6 deletions

View File

@@ -0,0 +1,128 @@
---
title: Codex Native Plugin Apps
description: Milestone specs for removing OpenClaw Codex plugin dynamic tools and relying on Codex app-server native plugin support.
---
Draft implementation specification.
## 1. Milestone feature specs
### 1.1 Remove OpenClaw Codex plugin dynamic tools
Goal: remove the synthetic OpenClaw tool layer that converted configured Codex
plugins into OpenClaw dynamic tools.
User-visible behavior:
- Gateway tool discovery no longer exposes plugin tools for Codex-native apps.
- Codex-mode conversations no longer spawn a second ephemeral Codex thread just
to invoke a Codex plugin.
- Existing Codex app-server turns continue to receive ordinary OpenClaw dynamic
tools that are not native Codex plugin replacements.
Implementation scope:
- Delete the Codex plugin tool registration, inventory, activation, and invoker
modules.
- Remove the Codex plugin wildcard tool contract from the bundled plugin
manifest.
- Remove bridge-specific config schema, UI hints, docs, and tests.
Acceptance criteria:
- No bundled Codex manifest contract declares plugin-derived OpenClaw tools.
- No Codex plugin config key enables a synthetic OpenClaw tool bridge.
- Tool-contract tests keep generic wildcard coverage without referencing Codex
plugins.
Verification:
- Targeted config, manifest, and plugin-tool tests.
- A live dev-gateway proof shows no Codex plugin dynamic tool appears while
native plugin invocation still works.
### 1.2 Invoke plugins in the main Codex session thread
Goal: rely on Codex app-server's native mention handling in the session thread
that OpenClaw already uses for Codex-mode turns.
User-visible behavior:
- Users invoke native Codex plugins with mention syntax such as
`[@Google Calendar](plugin://google-calendar)` inside a Codex-mode message.
- Plugin calls share the same Codex transcript, approval semantics, and app
authorization flow as ordinary Codex app-server plugin use.
- OpenClaw no longer duplicates plugin auth or transcript behavior.
Implementation scope:
- Keep OpenClaw forwarding user text to `turn/start` on the bound Codex thread.
- Document native mention usage in the Codex harness and migration docs.
- Do not add compatibility parsing or translation for the removed OpenClaw tool
names.
Acceptance criteria:
- A TUI-submitted Codex-mode message containing a native plugin mention reaches
the Codex app-server turn on the bound thread.
- The live behavior uses native plugin/app events, not an OpenClaw tool call.
Verification:
- Showboat demo against the dev gateway and TUI with logs or transcript
evidence for the native plugin mention and resulting plugin behavior.
### 1.3 Migration activation
Goal: preserve useful Codex plugin migration by activating selected plugins in
Codex app-server, not in OpenClaw's tool registry.
This milestone is intentionally deferred to the stacked migration PR. The first
PR only lands the native invocation and configuration substrate that migration
uses.
## 2. Implementation plan
### 2.1 Remove the OpenClaw tool bridge as an invocation path
Codex-native apps should not be exposed as OpenClaw `codex_plugin_*` dynamic
tools. The native thread path keeps transcript, approval, and app authorization
inside the Codex app-server session.
### 2.2 Keep app-server plugin/app methods typed
OpenClaw still needs typed JSON-RPC coverage for native Codex plugin/app
configuration surfaces such as `plugin/list`, `plugin/install`, `app/list`, and
`hooks/list`. These methods are app-server control-plane calls, not OpenClaw
tool registrations.
### 2.3 Tolerate app-server permission-profile drift
Live app-server `thread/start` and `thread/resume` responses may contain newer
special filesystem path kinds before OpenClaw updates its generated schema.
Normalize unknown special path kinds to the stable `unknown` shape so native
plugin invocation is not blocked at thread startup.
### 2.4 Invoke native plugins from the bound Codex thread
Users invoke plugins with native Codex mention syntax, for example
`[@Google Calendar](plugin://google-calendar)`, in the same Codex-mode message
that OpenClaw forwards to `turn/start`.
### 2.5 Migrate selected plugins through app-server
Deferred to the stacked migration PR. That PR adds Codex source discovery,
planning, apply-time install/reload behavior, and migration CLI/docs updates.
### 2.6 PR 1 docs, tests, and proof
This PR owns:
- Harness docs for native mention usage and the removal of bridge tool
semantics.
- App-server schema normalization tests for current live `permissionProfile`
responses.
- Generic wildcard plugin-tool contract tests that keep OpenClaw's plugin tool
registry behavior independent of Codex-native plugin ids.
- Showboat/dev-gateway/TUI proof that a native plugin mention reaches the main
Codex app-server thread without a `codex_plugin_*` OpenClaw tool call.

View File

@@ -264,6 +264,24 @@ 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.
## Native Codex plugins and apps
OpenClaw relies on Codex app-server's native plugin and app support for Codex
mode. It does not register synthetic OpenClaw tools for Codex plugins, and it
does not start a separate ephemeral Codex thread to invoke a plugin. Plugin
mentions stay in the main Codex app-server thread for the session.
Use native Codex mention syntax in a Codex-mode turn:
```md
[@Google Calendar](plugin://google-calendar) Find a free slot tomorrow afternoon.
```
Codex plugin migration is covered by the stacked migration PR. The runtime
contract here is the same before and after migration: the Codex app-server owns
plugin activation, app authorization, permission prompts, and transcript
semantics.
## Workspace bootstrap files
Codex handles `AGENTS.md` itself through native project-doc discovery. OpenClaw

View File

@@ -138,12 +138,61 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
await expect(fs.access(codexHome)).resolves.toBeUndefined();
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toBe(
"[features]\napps = true\n\n[apps._default]\nenabled = true\n",
);
expect(startOptions.env).toBeUndefined();
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("upserts native Codex app defaults into an existing app-server config", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const startOptions = createStartOptions();
try {
const codexHome = resolveCodexAppServerHomeDir(agentDir);
await fs.mkdir(codexHome, { recursive: true });
await fs.writeFile(
path.join(codexHome, "config.toml"),
[
'model = "gpt-5.5"',
"",
"[features]",
"apps = false",
"codex_hooks = true",
"",
"[apps._default]",
"enabled = false",
"open_world_enabled = false",
"",
].join("\n"),
);
await bridgeCodexAppServerStartOptions({
startOptions,
agentDir,
});
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toBe(
[
'model = "gpt-5.5"',
"",
"[features]",
"apps = true",
"codex_hooks = true",
"",
"[apps._default]",
"enabled = true",
"open_world_enabled = false",
"",
].join("\n"),
);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const codexHome = path.join(agentDir, "custom-codex-home");
@@ -169,6 +218,9 @@ describe("bridgeCodexAppServerStartOptions", () => {
});
await expect(fs.access(codexHome)).resolves.toBeUndefined();
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toContain(
"[features]\napps = true",
);
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });

View File

@@ -25,6 +25,7 @@ const CODEX_HOME_ENV_VAR = "CODEX_HOME";
const HOME_ENV_VAR = "HOME";
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
const CODEX_APP_SERVER_CONFIG_FILENAME = "config.toml";
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
@@ -111,6 +112,7 @@ async function withAgentCodexHomeEnvironment(
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
await fs.mkdir(codexHome, { recursive: true });
await fs.mkdir(nativeHome, { recursive: true });
await ensureCodexAppServerAppsConfig(codexHome);
const nextStartOptions: CodexAppServerStartOptions = {
...startOptions,
env: {
@@ -128,6 +130,84 @@ async function withAgentCodexHomeEnvironment(
return nextStartOptions;
}
async function ensureCodexAppServerAppsConfig(codexHome: string): Promise<void> {
const configPath = path.join(codexHome, CODEX_APP_SERVER_CONFIG_FILENAME);
let current = "";
try {
current = await fs.readFile(configPath, "utf8");
} catch (error) {
if (!isNodeErrorCode(error, "ENOENT")) {
throw error;
}
}
const next = upsertTomlBoolean(
upsertTomlBoolean(current, "features", "apps"),
"apps._default",
"enabled",
);
if (next !== current) {
await fs.writeFile(configPath, next, "utf8");
}
}
function upsertTomlBoolean(content: string, section: string, key: string): string {
const lines = content.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
const sectionHeader = `[${section}]`;
const sectionStart = lines.findIndex((line) => line.trim() === sectionHeader);
if (sectionStart === -1) {
const nextLines = [...lines];
if (nextLines.length > 0) {
nextLines.push("");
}
nextLines.push(sectionHeader, `${key} = true`);
return `${nextLines.join("\n")}\n`;
}
const sectionEnd = findTomlSectionEnd(lines, sectionStart + 1);
const keyIndex = findTomlKey(lines, key, sectionStart + 1, sectionEnd);
const nextLines = [...lines];
if (keyIndex === -1) {
nextLines.splice(sectionEnd, 0, `${key} = true`);
} else if (nextLines[keyIndex] !== `${key} = true`) {
nextLines[keyIndex] = `${key} = true`;
}
return `${nextLines.join("\n")}\n`;
}
function findTomlSectionEnd(lines: string[], start: number): number {
for (let index = start; index < lines.length; index += 1) {
if (/^\s*\[[^\]]+\]\s*$/.test(lines[index])) {
return index;
}
}
return lines.length;
}
function findTomlKey(lines: string[], key: string, start: number, end: number): number {
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
for (let index = start; index < end; index += 1) {
const line = lines[index];
if (line.trimStart().startsWith("#")) {
continue;
}
if (keyPattern.test(line)) {
return index;
}
}
return -1;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isNodeErrorCode(error: unknown, code: string): boolean {
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
}
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
if (!clearEnv) {
return undefined;

View File

@@ -41,11 +41,19 @@ const validateTurnCompletedNotification = ajv.compile<v2.TurnCompletedNotificati
const validateTurnStartResponse = ajv.compile<CodexTurnStartResponse>(turnStartResponseSchema);
export function assertCodexThreadStartResponse(value: unknown): CodexThreadStartResponse {
return assertCodexShape(validateThreadStartResponse, value, "thread/start response");
return assertCodexShape(
validateThreadStartResponse,
normalizeThreadPermissionProfile(value),
"thread/start response",
);
}
export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse {
return assertCodexShape(validateThreadResumeResponse, value, "thread/resume response");
return assertCodexShape(
validateThreadResumeResponse,
normalizeThreadPermissionProfile(value),
"thread/resume response",
);
}
export function assertCodexTurnStartResponse(value: unknown): CodexTurnStartResponse {
@@ -95,6 +103,95 @@ function readCodexShape<T>(validate: ValidateFunction<T>, value: unknown): T | u
return validate(value) ? value : undefined;
}
function normalizeThreadPermissionProfile(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const response = value as { permissionProfile?: unknown };
const permissionProfile = normalizePermissionProfile(response.permissionProfile);
if (permissionProfile === response.permissionProfile) {
return value;
}
return { ...value, permissionProfile };
}
function normalizePermissionProfile(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const profile = value as { type?: unknown; fileSystem?: unknown };
if (profile.type !== "managed") {
return value;
}
const fileSystem = normalizePermissionProfileFileSystem(profile.fileSystem);
if (fileSystem === profile.fileSystem) {
return value;
}
return { ...value, fileSystem };
}
function normalizePermissionProfileFileSystem(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const fileSystem = value as { type?: unknown; entries?: unknown };
if (fileSystem.type !== "restricted" || !Array.isArray(fileSystem.entries)) {
return value;
}
let changed = false;
const entries = fileSystem.entries.map((entry) => {
const normalized = normalizePermissionProfileFileSystemEntry(entry);
if (normalized !== entry) {
changed = true;
}
return normalized;
});
return changed ? { ...value, entries } : value;
}
function normalizePermissionProfileFileSystemEntry(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const entry = value as { path?: unknown };
const normalizedPath = normalizePermissionProfileFileSystemPath(entry.path);
return normalizedPath === entry.path ? value : { ...value, path: normalizedPath };
}
function normalizePermissionProfileFileSystemPath(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const path = value as { type?: unknown; value?: unknown };
if (path.type !== "special" || !path.value || typeof path.value !== "object") {
return value;
}
const special = path.value as { kind?: unknown; subpath?: unknown };
if (typeof special.kind !== "string" || isKnownPermissionProfileSpecialPathKind(special.kind)) {
return value;
}
return {
...value,
value: {
kind: "unknown",
path: special.kind,
subpath:
typeof special.subpath === "string" || special.subpath === null ? special.subpath : null,
},
};
}
function isKnownPermissionProfileSpecialPathKind(kind: string): boolean {
return (
kind === "root" ||
kind === "minimal" ||
kind === "project_roots" ||
kind === "tmpdir" ||
kind === "slash_tmp" ||
kind === "unknown"
);
}
function normalizeTurn(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;

View File

@@ -89,6 +89,12 @@ type CodexAppServerRequestResultMap = {
"account/read": v2.GetAccountResponse;
"feedback/upload": v2.FeedbackUploadResponse;
"mcpServerStatus/list": v2.ListMcpServerStatusResponse;
"plugin/list": v2.PluginListResponse;
"plugin/install": v2.PluginInstallResponse;
"app/list": v2.AppsListResponse;
"hooks/list": v2.HooksListResponse;
"config/mcpServer/reload": undefined;
"config/batchWrite": undefined;
"model/list": v2.ModelListResponse;
"review/start": v2.ReviewStartResponse;
"skills/list": v2.SkillsListResponse;

View File

@@ -166,4 +166,43 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
});
it("accepts newer permission-profile special filesystem path kinds", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return {
...threadStartResult(),
permissionProfile: {
type: "managed",
network: { enabled: true },
fileSystem: {
type: "restricted",
entries: [
{
path: {
type: "special",
value: { kind: "future_project_roots", subpath: null },
},
access: "write",
},
],
},
},
};
}
throw new Error(`unexpected method: ${method}`);
});
await startOrResumeThread({
client: { request } as never,
params: createParams(sessionFile, workspaceDir),
cwd: workspaceDir,
dynamicTools: [],
appServer: createAppServerOptions(),
});
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object));
});
});

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { findUndeclaredPluginToolNames } from "./tool-contracts.js";
describe("plugin tool contracts", () => {
it("allows explicit prefix wildcard contracts for config-derived tool names", () => {
expect(
findUndeclaredPluginToolNames({
declaredNames: ["derived_tool_*"],
toolNames: ["derived_tool_calendar", "derived_tool_slack"],
}),
).toEqual([]);
expect(
findUndeclaredPluginToolNames({
declaredNames: ["derived_tool_*"],
toolNames: ["memory_search"],
}),
).toEqual(["memory_search"]);
});
});

View File

@@ -22,5 +22,11 @@ export function findUndeclaredPluginToolNames(params: {
toolNames: readonly string[];
}): string[] {
const declared = new Set(normalizePluginToolNames(params.declaredNames));
return normalizePluginToolNames(params.toolNames).filter((name) => !declared.has(name));
const wildcardPrefixes = [...declared]
.filter((name) => name.endsWith("*"))
.map((name) => name.slice(0, -1))
.filter(Boolean);
return normalizePluginToolNames(params.toolNames).filter(
(name) => !declared.has(name) && !wildcardPrefixes.some((prefix) => name.startsWith(prefix)),
);
}

View File

@@ -522,6 +522,53 @@ describe("resolvePluginTools optional tools", () => {
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
});
it("loads a concrete plugin tool selected through a wildcard manifest contract", () => {
const baseContext = createContext();
const config = {
...baseContext.config,
plugins: {
...baseContext.config.plugins,
allow: [...baseContext.config.plugins.allow, "wildcard-demo"],
entries: {
"wildcard-demo": { enabled: true },
},
},
};
const registry = createToolRegistry([
{
pluginId: "wildcard-demo",
optional: true,
source: "/tmp/wildcard-demo.js",
names: ["derived_tool_calendar"],
declaredNames: ["derived_tool_*"],
factory: () => makeTool("derived_tool_calendar"),
},
]);
loadOpenClawPluginsMock.mockReturnValue(registry);
installToolManifestSnapshot({
config,
plugin: {
id: "wildcard-demo",
origin: "bundled",
enabledByDefault: false,
channels: [],
providers: [],
contracts: {
tools: ["derived_tool_*"],
},
},
});
const tools = resolvePluginTools({
context: { ...baseContext, config } as never,
toolAllowlist: ["derived_tool_calendar"],
allowGatewaySubagentBinding: true,
});
expectResolvedToolNames(tools, ["derived_tool_calendar"]);
expectLoaderSelectedOnlyPluginIds(["wildcard-demo"]);
});
it("auto-loads cold registry for path-based config-origin plugins without pre-warming (#76598)", () => {
const context = {
...createContext(),

View File

@@ -179,7 +179,9 @@ function isOptionalToolEntryPotentiallyAllowed(params: {
if (params.names.length === 0) {
return true;
}
return params.names.some((name) => params.allowlist.has(normalizeToolName(name)));
return params.names.some((name) =>
allowlistMatchesToolNameOrContract(params.allowlist, normalizeToolName(name)),
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -348,6 +350,25 @@ function pluginToolNamesMatchAllowlist(params: {
return isOptionalToolEntryPotentiallyAllowed(params);
}
function toolContractPrefix(name: string): string | undefined {
if (!name.endsWith("*")) {
return undefined;
}
const prefix = normalizeToolName(name.slice(0, -1));
return prefix || undefined;
}
function allowlistMatchesToolNameOrContract(allowlist: Set<string>, name: string): boolean {
const normalized = normalizeToolName(name);
if (allowlist.has(normalized)) {
return true;
}
const wildcardPrefix = toolContractPrefix(normalized);
return wildcardPrefix
? [...allowlist].some((allowed) => normalizeToolName(allowed).startsWith(wildcardPrefix))
: false;
}
function listManifestToolNamesForAllowlist(params: {
plugin: PluginManifestRecord;
toolNames: readonly string[];
@@ -365,13 +386,13 @@ function listManifestToolNamesForAllowlist(params: {
return [...params.toolNames];
}
const matchedToolNames = params.toolNames.filter((name) =>
params.allowlist.has(normalizeToolName(name)),
allowlistMatchesToolNameOrContract(params.allowlist, name),
);
if (!allowlistIncludesDefaultPluginTools(params.allowlist)) {
return matchedToolNames;
}
const defaultToolNames = params.toolNames.filter(
(name) => !isManifestToolOptional(params.plugin, name),
(name) => !toolContractPrefix(name) && !isManifestToolOptional(params.plugin, name),
);
return [...new Set([...defaultToolNames, ...matchedToolNames])];
}