Files
openclaw/extensions/codex/src/app-server/plugin-inventory.ts
Kevin Lin a1ac559ed7 feat(codex): enable native plugin app support (#78733)
* feat(codex): add native plugin config schema

* feat(codex): add native plugin inventory activation

* feat(codex): configure native plugin apps for threads

* feat(codex): enforce plugin elicitation policy

* feat(codex): migrate native plugins

* docs(codex): document native plugin support

* fix(codex): harden plugin migration refresh

* fix(codex): satisfy plugin activation lint

* fix: stabilize codex plugin app config

* fix: address codex plugin review feedback

* fix: key codex plugin app cache by websocket credentials

* fix: keep codex plugin app fingerprints stable

* fix: refresh codex plugin cache test fixtures

* fix: refresh plugin app readiness after activation

* fix: support remote codex plugin activation

* fix: recover plugin app bindings after cache refresh

* fix: force codex app refresh after plugin activation

* fix: recover partial codex plugin app bindings

* fix: sync codex plugin selection config

* fix: keep codex plugin activation fail closed

* fix: align codex plugin protocol types with main

* fix: refresh partial codex plugin app bindings

* fix: key codex app cache by env api key

* fix: skip failed codex plugin migration config

* test: update codex prompt snapshots

* fix: fail closed on missing codex app inventory entries

* fix(codex): enforce native plugin policy gates

* fix(codex): normalize native plugin policy types

* fix(codex): fail closed on plugin refresh errors

* fix(codex): use native plugin destructive policy

* fix(codex): key plugin cache by api-key profiles

* fix(codex): drop unshipped plugin fingerprint compat

* fix(codex): let native app policy gate plugin tools

* fix(codex): allow open-world plugin app tools

* fix(codex): revalidate native plugin app bindings

* fix(codex): preserve plugin binding on recheck failure

* docs(codex): clarify plugin harness scope

* fix(codex): return activation report state exhaustively

* test(codex): refresh prompt snapshots after rebase

* fix(codex): match namespaced plugin ids
2026-05-07 17:20:28 -07:00

347 lines
10 KiB
TypeScript

import {
type CodexAppInventoryCache,
type CodexAppInventoryCacheRead,
type CodexAppInventoryRequest,
} from "./app-inventory-cache.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
resolveCodexPluginsPolicy,
type ResolvedCodexPluginPolicy,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
import type { v2 } from "./protocol.js";
export type CodexPluginRuntimeRequest = (method: string, params?: unknown) => Promise<unknown>;
export type CodexPluginMarketplaceRef = {
name: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
path?: string;
remoteMarketplaceName?: string;
};
export type CodexPluginInventoryDiagnosticCode =
| "disabled"
| "marketplace_missing"
| "plugin_missing"
| "plugin_disabled"
| "plugin_detail_unavailable"
| "app_inventory_missing"
| "app_inventory_stale"
| "app_ownership_ambiguous";
export type CodexPluginInventoryDiagnostic = {
code: CodexPluginInventoryDiagnosticCode;
plugin?: ResolvedCodexPluginPolicy;
message: string;
};
export type CodexPluginOwnedApp = {
id: string;
name: string;
accessible: boolean;
enabled: boolean;
needsAuth: boolean;
};
export type CodexPluginInventoryRecord = {
policy: ResolvedCodexPluginPolicy;
summary: v2.PluginSummary;
detail?: v2.PluginDetail;
activationRequired: boolean;
authRequired: boolean;
appOwnership: "proven" | "ambiguous" | "none";
ownedAppIds: string[];
apps: CodexPluginOwnedApp[];
};
export type CodexPluginInventory = {
policy: ResolvedCodexPluginsPolicy;
marketplace?: CodexPluginMarketplaceRef;
records: CodexPluginInventoryRecord[];
diagnostics: CodexPluginInventoryDiagnostic[];
appInventory?: CodexAppInventoryCacheRead;
};
export type ReadCodexPluginInventoryParams = {
pluginConfig?: unknown;
policy?: ResolvedCodexPluginsPolicy;
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
nowMs?: number;
readPluginDetails?: boolean;
};
export async function readCodexPluginInventory(
params: ReadCodexPluginInventoryParams,
): Promise<CodexPluginInventory> {
const policy = params.policy ?? resolveCodexPluginsPolicy(params.pluginConfig);
if (!policy.enabled) {
return {
policy,
records: [],
diagnostics: [
{
code: "disabled",
message: "Native Codex plugin support is disabled.",
},
],
};
}
const appInventory = readCachedAppInventory(params);
const listed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
if (!marketplaceEntry) {
return {
policy,
records: [],
diagnostics: policy.pluginPolicies
.filter((pluginPolicy) => pluginPolicy.enabled)
.map((pluginPolicy) => ({
code: "marketplace_missing",
plugin: pluginPolicy,
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
})),
...(appInventory ? { appInventory } : {}),
};
}
const marketplace = marketplaceRef(marketplaceEntry);
const diagnostics: CodexPluginInventoryDiagnostic[] = [];
const records: CodexPluginInventoryRecord[] = [];
if (appInventory?.state === "missing") {
diagnostics.push({
code: "app_inventory_missing",
message: "Cached Codex app inventory is missing; plugin apps are excluded for this setup.",
});
} else if (appInventory?.state === "stale") {
diagnostics.push({
code: "app_inventory_stale",
message: "Cached Codex app inventory is stale; using stale app readiness and refreshing.",
});
}
for (const pluginPolicy of policy.pluginPolicies) {
if (!pluginPolicy.enabled) {
continue;
}
const summary = findPluginSummary(marketplaceEntry, pluginPolicy.pluginName);
if (!summary) {
diagnostics.push({
code: "plugin_missing",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
});
continue;
}
const detail = await readPluginDetail(params, marketplace, pluginPolicy, diagnostics);
const ownedAppIds =
detail?.apps
.map((app) => app.id)
.filter(Boolean)
.toSorted() ?? [];
const appOwnership = resolveAppOwnership({
detail,
appInventory,
summary,
});
if (appOwnership === "ambiguous") {
diagnostics.push({
code: "app_ownership_ambiguous",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} has only display-name app matches; apps are not exposed until ownership is stable.`,
});
}
if (summary.installed && !summary.enabled) {
diagnostics.push({
code: "plugin_disabled",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} is installed in Codex but disabled.`,
});
}
const apps = resolveOwnedApps({
detail,
appInventory,
});
records.push({
policy: pluginPolicy,
summary,
...(detail ? { detail } : {}),
activationRequired: !summary.installed || !summary.enabled,
authRequired: apps.some((app) => app.needsAuth || !app.accessible),
appOwnership,
ownedAppIds,
apps,
});
}
return {
policy,
marketplace,
records,
diagnostics,
...(appInventory ? { appInventory } : {}),
};
}
export function findOpenAiCuratedPluginSummary(
listed: v2.PluginListResponse,
pluginName: string,
): { marketplace: CodexPluginMarketplaceRef; summary: v2.PluginSummary } | undefined {
const marketplaceEntry = listed.marketplaces.find(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
if (!marketplaceEntry) {
return undefined;
}
const summary = findPluginSummary(marketplaceEntry, pluginName);
return summary ? { marketplace: marketplaceRef(marketplaceEntry), summary } : undefined;
}
export function pluginReadParams(
marketplace: CodexPluginMarketplaceRef,
pluginName: string,
): v2.PluginReadParams {
return {
...(marketplace.path ? { marketplacePath: marketplace.path } : {}),
...(marketplace.remoteMarketplaceName
? { remoteMarketplaceName: marketplace.remoteMarketplaceName }
: {}),
pluginName,
};
}
function readCachedAppInventory(
params: ReadCodexPluginInventoryParams,
): CodexAppInventoryCacheRead | undefined {
if (!params.appCache || !params.appCacheKey) {
return undefined;
}
const request: CodexAppInventoryRequest = async (method, requestParams) =>
(await params.request(method, requestParams)) as v2.AppsListResponse;
return params.appCache.read({
key: params.appCacheKey,
request,
nowMs: params.nowMs,
});
}
async function readPluginDetail(
params: ReadCodexPluginInventoryParams,
marketplace: CodexPluginMarketplaceRef,
pluginPolicy: ResolvedCodexPluginPolicy,
diagnostics: CodexPluginInventoryDiagnostic[],
): Promise<v2.PluginDetail | undefined> {
if (params.readPluginDetails === false) {
return undefined;
}
try {
const response = (await params.request(
"plugin/read",
pluginReadParams(marketplace, pluginPolicy.pluginName),
)) as v2.PluginReadResponse;
return response.plugin;
} catch (error) {
diagnostics.push({
code: "plugin_detail_unavailable",
plugin: pluginPolicy,
message: `${pluginPolicy.pluginName} detail unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
});
return undefined;
}
}
function resolveAppOwnership(params: {
detail?: v2.PluginDetail;
appInventory?: CodexAppInventoryCacheRead;
summary: v2.PluginSummary;
}): "proven" | "ambiguous" | "none" {
if (params.detail && params.detail.apps.length > 0) {
return "proven";
}
const apps = params.appInventory?.snapshot?.apps ?? [];
const displayMatches = apps.filter((app) =>
app.pluginDisplayNames.some((displayName) => displayName === params.summary.name),
);
return displayMatches.length > 0 ? "ambiguous" : "none";
}
function resolveOwnedApps(params: {
detail?: v2.PluginDetail;
appInventory?: CodexAppInventoryCacheRead;
}): CodexPluginOwnedApp[] {
const detailApps = params.detail?.apps ?? [];
if (detailApps.length === 0) {
return [];
}
if (params.appInventory?.state === "missing") {
return [];
}
const appInfoById = new Map(
(params.appInventory?.snapshot?.apps ?? []).map((app) => [app.id, app] as const),
);
return detailApps
.map((app) => {
const info = appInfoById.get(app.id);
if (!info) {
return {
id: app.id,
name: app.name,
accessible: false,
enabled: false,
needsAuth: true,
};
}
return {
id: app.id,
name: app.name,
accessible: info.isAccessible,
enabled: info.isEnabled,
needsAuth: app.needsAuth || !info.isAccessible,
};
})
.toSorted((left, right) => left.id.localeCompare(right.id));
}
function findPluginSummary(
marketplace: v2.PluginMarketplaceEntry,
pluginName: string,
): v2.PluginSummary | undefined {
return marketplace.plugins.find(
(plugin) =>
plugin.name === pluginName ||
plugin.id === pluginName ||
plugin.id === `${pluginName}@${marketplace.name}` ||
pluginNameFromPluginId(plugin.id, marketplace.name) === pluginName,
);
}
function pluginNameFromPluginId(pluginId: string, marketplaceName: string): string | undefined {
const trimmed = pluginId.trim();
if (!trimmed) {
return undefined;
}
const marketplaceSuffix = `@${marketplaceName}`;
const withoutMarketplaceSuffix = trimmed.endsWith(marketplaceSuffix)
? trimmed.slice(0, -marketplaceSuffix.length)
: trimmed;
return withoutMarketplaceSuffix.split("/").at(-1)?.trim() || undefined;
}
function marketplaceRef(marketplace: v2.PluginMarketplaceEntry): CodexPluginMarketplaceRef {
return {
name: CODEX_PLUGINS_MARKETPLACE_NAME,
...(marketplace.path ? { path: marketplace.path } : {}),
...(!marketplace.path ? { remoteMarketplaceName: marketplace.name } : {}),
};
}