Skip to content

Commit facf66e

Browse files
committed
fix(codex): honor OpenClaw app enablement overrides
1 parent 55ad878 commit facf66e

5 files changed

Lines changed: 318 additions & 12 deletions

File tree

docs/plugins/codex-harness-reference.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,13 @@ shorthand before OpenClaw builds app-server start options, and unresolved
155155
structured SecretRefs fail before any token or header is sent. When native Codex
156156
plugins are configured, OpenClaw uses the connected app-server's plugin control
157157
plane to install or refresh those plugins and then refreshes app inventory so
158-
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
159-
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
160-
and app inventory refreshes.
158+
plugin-owned apps are visible to the Codex thread. `app/list` is still the
159+
authoritative inventory and metadata source, but OpenClaw policy decides whether
160+
`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible
161+
app even if Codex currently marks it disabled. Unknown or missing app ids remain
162+
fail-closed; this path only activates marketplace plugins via `plugin/install`
163+
and refreshes inventory. Only connect OpenClaw to remote app-servers that are
164+
trusted to accept OpenClaw-managed plugin installs and app inventory refreshes.
161165

162166
## Approval and sandbox modes
163167

docs/plugins/codex-harness.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,13 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the
465465
remote app-server's own account.
466466
When native Codex plugins are configured, OpenClaw installs or refreshes those
467467
plugins through the connected app-server before exposing plugin-owned apps to
468-
the Codex thread.
468+
the Codex thread. `app/list` remains the source of truth for app ids,
469+
accessibility, and metadata, but OpenClaw owns the per-thread enablement
470+
decision: if policy allows a listed accessible app, OpenClaw sends
471+
`thread/start.config.apps[appId].enabled = true` even when `app/list` currently
472+
reports that app disabled. This path does not invent app installation for
473+
unknown ids; OpenClaw only activates marketplace plugins with `plugin/install`
474+
and then refreshes inventory.
469475

470476
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
471477
time when Codex reports one and tries the next ordered auth profile for the same

extensions/codex/src/app-server/plugin-thread-config.test.ts

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ describe("Codex plugin thread config", () => {
254254
const request = vi.fn(async (method: string, params?: unknown) => {
255255
if (method === "app/list") {
256256
appListParams.push(params as v2.AppsListParams);
257-
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
257+
return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null };
258258
}
259259
if (method === "plugin/list") {
260260
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
@@ -317,6 +317,60 @@ describe("Codex plugin thread config", () => {
317317
]);
318318
});
319319

320+
it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => {
321+
const appCache = new CodexAppInventoryCache();
322+
await appCache.refreshNow({
323+
key: "runtime",
324+
nowMs: 0,
325+
request: async () => ({
326+
data: [appInfo("google-calendar-app", true, false)],
327+
nextCursor: null,
328+
}),
329+
});
330+
331+
const config = await buildCodexPluginThreadConfig({
332+
pluginConfig: {
333+
codexPlugins: {
334+
enabled: true,
335+
plugins: {
336+
"google-calendar": {
337+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
338+
pluginName: "google-calendar",
339+
},
340+
},
341+
},
342+
},
343+
appCache,
344+
appCacheKey: "runtime",
345+
nowMs: 1,
346+
request: async (method) => {
347+
if (method === "plugin/list") {
348+
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
349+
}
350+
if (method === "plugin/read") {
351+
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
352+
}
353+
throw new Error(`unexpected request ${method}`);
354+
},
355+
});
356+
357+
expect(config.inventory?.records[0]?.apps).toStrictEqual([
358+
{
359+
id: "google-calendar-app",
360+
name: "google-calendar-app",
361+
accessible: true,
362+
enabled: false,
363+
needsAuth: false,
364+
},
365+
]);
366+
expect(config.configPatch?.apps).toMatchObject({
367+
"google-calendar-app": {
368+
enabled: true,
369+
},
370+
});
371+
expect(config.diagnostics).toStrictEqual([]);
372+
});
373+
320374
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
321375
const appCache = new CodexAppInventoryCache();
322376
const appListParams: v2.AppsListParams[] = [];
@@ -432,11 +486,59 @@ describe("Codex plugin thread config", () => {
432486
allowDestructiveActions: true,
433487
destructiveApprovalMode: "allow",
434488
},
435-
message: "google-calendar-app is not accessible or enabled for google-calendar.",
489+
message: "google-calendar-app is not accessible for google-calendar.",
436490
},
437491
]);
438492
});
439493

494+
it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => {
495+
const appCache = new CodexAppInventoryCache();
496+
await appCache.refreshNow({
497+
key: "runtime",
498+
nowMs: 0,
499+
request: async () => ({
500+
data: [appInfo("google-calendar-app", true)],
501+
nextCursor: null,
502+
}),
503+
});
504+
505+
const config = await buildCodexPluginThreadConfig({
506+
pluginConfig: {
507+
codexPlugins: {
508+
enabled: true,
509+
plugins: {
510+
"google-calendar": {
511+
enabled: false,
512+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
513+
pluginName: "google-calendar",
514+
},
515+
},
516+
},
517+
},
518+
appCache,
519+
appCacheKey: "runtime",
520+
nowMs: 1,
521+
request: async (method) => {
522+
if (method === "plugin/list") {
523+
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
524+
}
525+
throw new Error(`unexpected request ${method}`);
526+
},
527+
});
528+
529+
expect(config.configPatch).toEqual({
530+
apps: {
531+
_default: {
532+
enabled: false,
533+
destructive_enabled: false,
534+
open_world_enabled: false,
535+
},
536+
},
537+
});
538+
expect(config.policyContext.apps).toStrictEqual({});
539+
expect(config.diagnostics).toStrictEqual([]);
540+
});
541+
440542
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
441543
const appCache = new CodexAppInventoryCache();
442544
await appCache.refreshNow({
@@ -629,9 +731,7 @@ describe("Codex plugin thread config", () => {
629731
let installed = false;
630732
const request = vi.fn(async (method: string, params?: unknown) => {
631733
if (method === "plugin/list") {
632-
return pluginList([
633-
pluginSummary("google-calendar", { installed, enabled: installed }),
634-
]);
734+
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
635735
}
636736
if (method === "plugin/read") {
637737
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -795,6 +895,70 @@ describe("Codex plugin thread config", () => {
795895
]);
796896
});
797897

898+
it("fails closed when app inventory entries are malformed", async () => {
899+
const appCache = new CodexAppInventoryCache();
900+
await appCache.refreshNow({
901+
key: "runtime",
902+
nowMs: 0,
903+
request: async () =>
904+
({
905+
data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[],
906+
nextCursor: null,
907+
}) satisfies v2.AppsListResponse,
908+
});
909+
910+
const config = await buildCodexPluginThreadConfig({
911+
pluginConfig: {
912+
codexPlugins: {
913+
enabled: true,
914+
plugins: {
915+
"google-calendar": {
916+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
917+
pluginName: "google-calendar",
918+
},
919+
},
920+
},
921+
},
922+
appCache,
923+
appCacheKey: "runtime",
924+
nowMs: 1,
925+
request: async (method) => {
926+
if (method === "plugin/list") {
927+
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
928+
}
929+
if (method === "plugin/read") {
930+
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
931+
}
932+
throw new Error(`unexpected request ${method}`);
933+
},
934+
});
935+
936+
expect(config.configPatch).toEqual({
937+
apps: {
938+
_default: {
939+
enabled: false,
940+
destructive_enabled: false,
941+
open_world_enabled: false,
942+
},
943+
},
944+
});
945+
expect(config.policyContext.apps).toStrictEqual({});
946+
expect(config.diagnostics).toStrictEqual([
947+
{
948+
code: "app_not_ready",
949+
plugin: {
950+
configKey: "google-calendar",
951+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
952+
pluginName: "google-calendar",
953+
enabled: true,
954+
allowDestructiveActions: true,
955+
destructiveApprovalMode: "allow",
956+
},
957+
message: "google-calendar-app is not accessible for google-calendar.",
958+
},
959+
]);
960+
});
961+
798962
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
799963
const appCache = new CodexAppInventoryCache();
800964
const first = buildCodexPluginThreadConfigInputFingerprint({

extensions/codex/src/app-server/plugin-thread-config.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,11 @@ export async function buildCodexPluginThreadConfig(
242242
}
243243
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
244244
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
245-
if (!app.accessible || !app.enabled) {
245+
if (!isPluginAppReadyForThreadStart(app)) {
246246
diagnostics.push({
247247
code: "app_not_ready",
248248
plugin: record.policy,
249-
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
249+
message: `${app.id} is not accessible for ${record.policy.pluginName}.`,
250250
});
251251
continue;
252252
}
@@ -438,6 +438,13 @@ function resolveThreadConfigAppsForRecord(params: {
438438
return params.record.apps;
439439
}
440440

441+
function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean {
442+
// `app/list` is the source of truth for inventory and access posture, but
443+
// OpenClaw owns the per-thread enablement decision. A listed app that is
444+
// accessible can be re-enabled for this thread via `config.apps[app.id]`.
445+
return app.accessible;
446+
}
447+
441448
function shouldForceRefreshForNotReadyPluginApps(
442449
params: BuildCodexPluginThreadConfigParams,
443450
policy: ResolvedCodexPluginsPolicy,
@@ -453,7 +460,7 @@ function shouldForceRefreshForNotReadyPluginApps(
453460
(record) =>
454461
record.appOwnership === "proven" &&
455462
record.ownedAppIds.length > 0 &&
456-
(record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)),
463+
(record.apps.length === 0 || record.apps.some((app) => !app.accessible)),
457464
);
458465
}
459466

0 commit comments

Comments
 (0)