Skip to content

Commit a6a4652

Browse files
authored
fix(codex): expose plugin apps after delayed inventory load (#96872)
* fix(codex): refresh missing plugin app inventory * fix(codex): honor OpenClaw app enablement overrides
1 parent 3b292ba commit a6a4652

5 files changed

Lines changed: 403 additions & 21 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: 226 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,117 @@ 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+
374+
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
375+
const appCache = new CodexAppInventoryCache();
376+
const appListParams: v2.AppsListParams[] = [];
377+
let pluginListCalls = 0;
378+
const request = vi.fn(async (method: string, params?: unknown) => {
379+
if (method === "plugin/list") {
380+
pluginListCalls += 1;
381+
const active = pluginListCalls > 1;
382+
return pluginList([
383+
pluginSummary("google-calendar", { installed: active, enabled: active }),
384+
]);
385+
}
386+
if (method === "plugin/read") {
387+
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
388+
}
389+
if (method === "app/list") {
390+
appListParams.push(params as v2.AppsListParams);
391+
return {
392+
data: [appInfo("google-calendar-app", true)],
393+
nextCursor: null,
394+
} satisfies v2.AppsListResponse;
395+
}
396+
throw new Error(`unexpected request ${method}`);
397+
});
398+
399+
const config = await buildCodexPluginThreadConfig({
400+
pluginConfig: {
401+
codexPlugins: {
402+
enabled: true,
403+
plugins: {
404+
"google-calendar": {
405+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
406+
pluginName: "google-calendar",
407+
},
408+
},
409+
},
410+
},
411+
appCache,
412+
appCacheKey: "runtime",
413+
request,
414+
});
415+
416+
expect(config.configPatch?.apps).toMatchObject({
417+
"google-calendar-app": {
418+
enabled: true,
419+
},
420+
});
421+
expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install");
422+
expect(appListParams).toEqual([
423+
{
424+
cursor: undefined,
425+
limit: 100,
426+
forceRefetch: true,
427+
},
428+
]);
429+
});
430+
320431
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
321432
const appCache = new CodexAppInventoryCache();
322433
await appCache.refreshNow({
@@ -375,11 +486,59 @@ describe("Codex plugin thread config", () => {
375486
allowDestructiveActions: true,
376487
destructiveApprovalMode: "allow",
377488
},
378-
message: "google-calendar-app is not accessible or enabled for google-calendar.",
489+
message: "google-calendar-app is not accessible for google-calendar.",
379490
},
380491
]);
381492
});
382493

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+
383542
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
384543
const appCache = new CodexAppInventoryCache();
385544
await appCache.refreshNow({
@@ -572,9 +731,7 @@ describe("Codex plugin thread config", () => {
572731
let installed = false;
573732
const request = vi.fn(async (method: string, params?: unknown) => {
574733
if (method === "plugin/list") {
575-
return pluginList([
576-
pluginSummary("google-calendar", { installed, enabled: installed }),
577-
]);
734+
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
578735
}
579736
if (method === "plugin/read") {
580737
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -738,6 +895,70 @@ describe("Codex plugin thread config", () => {
738895
]);
739896
});
740897

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+
741962
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
742963
const appCache = new CodexAppInventoryCache();
743964
const first = buildCodexPluginThreadConfigInputFingerprint({

0 commit comments

Comments
 (0)