fix(codex): settle plugin activation, align plugin/list with codex 0.144, bound discovery per turn (#108311)

* fix(codex): settle plugin activation, align plugin/list with codex 0.144, bound discovery per turn

- plugin/list curated queries omit cwds and marketplaceKinds (0.144 semantics:
  explicit kinds disable the automatic global remote catalog; cwds:[] never
  suppressed it), and the missing-marketplace check recognizes the current
  openai-curated-remote wire name via the shared predicate (#107305)
- curated plugin/list snapshots settle in a process-local metadata cache
  (coalesced, invalidated on install/identity change/restart, 1h freshness
  window matching the app-inventory cache) so a missing marketplace or plugin
  no longer re-runs blocking discovery RPCs on every embedded-Codex turn;
  fail-open local-only responses (upstream warns without a load error when the
  remote catalog fetch fails) are never cached, and workspace-directory
  queries stay live because external activation has no invalidation signal
- the whole plugin-config build shares one bounded startup deadline with
  remaining-budget propagation per RPC and a deny-all apps fallback, so a hung
  plugin/list cannot consume the turn (#107305)
- guarded thread requests (start/resume/fork under the native-config fence)
  must carry a finite timeout or abort signal, closing the unbounded-fence-hold
  window for raw callers (#106719 hardening)

* chore(codex): keep plugin metadata types and deadline builder module-local

Deadline behavior tests exercise the production provider composition instead
of a test-only export.
This commit is contained in:
Peter Steinberger
2026-07-15 06:40:29 -07:00
committed by GitHub
parent 674dd37cd0
commit 9d4610cbd1
19 changed files with 1397 additions and 69 deletions
+20
View File
@@ -53,6 +53,26 @@ describe("codex plugin", () => {
expect(manifest.enabledByDefault).toBeUndefined();
});
it("does not open plugin state while registering with the base runtime", () => {
const openSyncKeyedStore = vi.fn(() => {
throw new Error("openSyncKeyedStore is only available through the plugin runtime proxy");
});
expect(() =>
plugin.register(
createTestPluginApi({
id: "codex",
name: "Codex",
source: "test",
config: {},
pluginConfig: {},
runtime: { state: { openSyncKeyedStore } } as never,
}),
),
).not.toThrow();
expect(openSyncKeyedStore).not.toHaveBeenCalled();
});
it("registers the codex provider, agent harness, native thread tool, and hosted web search", () => {
const registerAgentHarness = vi.fn();
const registerCommand = vi.fn();
+19 -4
View File
@@ -10,6 +10,7 @@ import {
resolveLivePluginConfigObject,
} from "openclaw/plugin-sdk/plugin-config-runtime";
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { registerCodexCliMetadata } from "./cli-metadata.js";
import { createCodexAppServerAgentHarness } from "./harness.js";
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
@@ -86,13 +87,27 @@ export default definePluginEntry({
return livePluginConfig;
};
const resolveCurrentPluginConfig = () => resolvePluginConfig(resolveCurrentConfig);
const bindingStore = createLazyCodexAppServerBindingStore(
api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
let bindingStateStore: PluginStateSyncKeyedStore<StoredCodexAppServerBinding> | undefined;
const openBindingStateStore = () =>
(bindingStateStore ??= api.runtime.state.openSyncKeyedStore<StoredCodexAppServerBinding>({
namespace: CODEX_APP_SERVER_BINDING_NAMESPACE,
maxEntries: CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
overflowPolicy: "reject-new",
}),
);
}));
// The base registration runtime deliberately rejects state access. Open the
// store only when a proxied runtime performs the first binding operation.
const lazyBindingStateStore: Pick<
PluginStateSyncKeyedStore<StoredCodexAppServerBinding>,
"entries" | "lookup" | "update"
> = {
entries: () => openBindingStateStore().entries(),
lookup: (key) => openBindingStateStore().lookup(key),
get update() {
const store = openBindingStateStore();
return store.update?.bind(store);
},
};
const bindingStore = createLazyCodexAppServerBindingStore(lazyBindingStateStore);
registerCodexCliMetadata(api);
const sessionCatalogControl = createCodexSessionCatalogControl({
getPluginConfig: resolveCurrentPluginConfig,
@@ -11,10 +11,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startCodexAttemptThread } from "./attempt-startup.js";
import { CodexAppServerClient } from "./client.js";
import {
CODEX_PLUGINS_MARKETPLACE_NAME,
type CodexPluginConfig,
resolveCodexAppServerRuntimeOptions,
resolveCodexComputerUseConfig,
} from "./config.js";
import { defaultCodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import {
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
@@ -87,8 +89,12 @@ const bundleMcpThreadConfig = {
const HARNESS_REQUEST_TIMEOUT_MS = 15_000;
function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: string }> {
return writes.map((write) => JSON.parse(write) as { id?: number; method?: string });
function readHarnessMessages(
writes: string[],
): Array<{ id?: number; method?: string; params?: unknown }> {
return writes.map(
(write) => JSON.parse(write) as { id?: number; method?: string; params?: unknown },
);
}
function startThreadWithHarness(
@@ -253,12 +259,14 @@ describe("startCodexAttemptThread", () => {
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
clearSharedCodexAppServerClient();
defaultCodexPluginMetadataCache.clear();
resetCodexTestBindingStore();
});
afterEach(async () => {
vi.useRealTimers();
clearSharedCodexAppServerClient();
defaultCodexPluginMetadataCache.clear();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const root of tempRoots) {
@@ -734,6 +742,46 @@ describe("startCodexAttemptThread", () => {
expect(harness.process.stdin.destroyed).toBe(true);
});
it("continues with a deny-all apps patch when plugin discovery exceeds its shared deadline", async () => {
const deadlinePluginConfig = {
appServer: { command: "codex", requestTimeoutMs: 400 },
codexPlugins: {
enabled: true,
plugins: {
calendar: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "calendar",
},
},
},
} satisfies CodexPluginConfig;
const { harness, run } = startThreadWithHarness(5_000, new AbortController().signal, {
pluginConfig: deadlinePluginConfig,
});
await answerInitialize(harness);
const pluginList = await waitForRequest(harness, "plugin/list");
expect(
readHarnessMessages(harness.writes).find((message) => message.id === pluginList.id),
).toMatchObject({ method: "plugin/list", params: {} });
const threadStart = await waitForThreadStart(harness);
const startMessage = readHarnessMessages(harness.writes).find(
(message) => message.id === threadStart.id,
) as { id?: number; params?: { config?: { apps?: unknown } } } | undefined;
expect(startMessage?.params?.config?.apps).toEqual({
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
});
harness.send({ id: threadStart.id, result: threadStartResult() });
const result = await run;
result.turnRoute.release();
result.releaseSharedClientLease();
});
it("clears the shared app-server when a startup RPC times out", async () => {
const perRpcTimeoutPluginConfig = {
...pluginConfig,
@@ -1,7 +1,6 @@
/**
* Startup orchestration for Codex app-server attempts, including shared-client
* leasing, plugin thread config, sandbox execution environment, and thread
* lifecycle binding.
* leasing, plugin thread config, sandbox environment, and thread lifecycle binding.
*/
import {
embeddedAgentLog,
@@ -11,7 +10,6 @@ import {
type EmbeddedRunAttemptParams,
type resolveSandboxContext,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
CodexAppServerUnsafeSubscriptionError,
@@ -25,14 +23,12 @@ import { isCodexAppServerConnectionClosedError, type CodexAppServerClient } from
import { startCodexComputerUseHealthMonitor } from "./computer-use-health.js";
import { ensureCodexComputerUse } from "./computer-use.js";
import {
resolveCodexPluginsPolicy,
withMcpElicitationsApprovalPolicy,
type CodexAppServerRuntimeOptions,
type CodexPluginConfig,
type ResolvedCodexComputerUseConfig,
} from "./config.js";
import {
disableCodexPluginThreadConfig,
resolveCodexAppServerExecutionCwd,
resolveCodexExternalSandboxPolicyForOpenClawSandbox,
resolveCodexSandboxEnvironmentSelection,
@@ -43,10 +39,12 @@ import {
buildCodexPluginAppCacheKey,
} from "./plugin-app-cache-key.js";
import {
buildCodexPluginThreadConfig,
createCodexPluginThreadConfigStartupProvider,
resolveCodexPluginThreadConfigStartupPolicy,
} from "./plugin-thread-config-deadline.js";
import {
buildCodexPluginThreadConfigInputFingerprint,
mergeCodexThreadConfigs,
shouldBuildCodexPluginThreadConfig,
} from "./plugin-thread-config.js";
import type {
CodexDynamicToolSpec,
@@ -189,26 +187,19 @@ export async function startCodexAttemptThread(params: {
const threadConfig = mergeCodexThreadConfigs(
params.bundleMcpThreadConfig?.configPatch as JsonObject | undefined,
);
const nativeToolSurfaceRestricted = !params.nativeToolSurfaceEnabled;
const pluginThreadConfigRequired =
nativeToolSurfaceRestricted || shouldBuildCodexPluginThreadConfig(params.pluginConfig);
// Restricted runs still need a plugin thread config so thread/start
// carries the explicit apps._default denial patch without app/list.
const pluginThreadConfigPluginConfig = params.nativeToolSurfaceEnabled
? params.pluginConfig
: disableCodexPluginThreadConfig(params.pluginConfig);
const resolvedPluginPolicy = pluginThreadConfigRequired
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
: undefined;
const pluginStartupPolicy = resolveCodexPluginThreadConfigStartupPolicy({
pluginConfig: params.pluginConfig,
nativeToolSurfaceEnabled: params.nativeToolSurfaceEnabled,
});
const {
pluginThreadConfigRequired,
pluginThreadConfigPluginConfig,
resolvedPluginPolicy,
enabledPluginConfigKeys,
} = pluginStartupPolicy;
const computerUseMcpElicitationDelegationRequired = params.computerUseConfig.enabled;
const mcpElicitationDelegationRequired =
resolvedPluginPolicy?.enabled === true || computerUseMcpElicitationDelegationRequired;
const enabledPluginConfigKeys = resolvedPluginPolicy
? resolvedPluginPolicy.pluginPolicies
.filter((plugin) => plugin.enabled)
.map((plugin) => plugin.configKey)
.toSorted()
: undefined;
pluginAppServer = mcpElicitationDelegationRequired
? {
...params.appServer,
@@ -467,23 +458,17 @@ export async function startCodexAttemptThread(params: {
contextEngineProjection: params.contextEngineProjection,
signal,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,
? createCodexPluginThreadConfigStartupProvider({
inputFingerprint: pluginThreadConfigInputFingerprint,
enabledPluginConfigKeys,
build: () =>
buildCodexPluginThreadConfig({
pluginConfig: pluginThreadConfigPluginConfig,
request: (method, requestParams) =>
activeStartupClient.request(method, requestParams, {
timeoutMs: params.appServer.requestTimeoutMs,
signal,
}),
configCwd: startupExecutionCwd,
appCache: defaultCodexAppInventoryCache,
appCacheKey: pluginAppCacheKey,
}),
}
policy: resolvedPluginPolicy,
requestTimeoutMs: params.appServer.requestTimeoutMs,
signal,
pluginConfig: pluginThreadConfigPluginConfig,
client: activeStartupClient,
configCwd: startupExecutionCwd,
appCacheKey: pluginAppCacheKey,
})
: undefined,
}) satisfies Parameters<typeof startOrResumeThread>[0];
try {
@@ -49,6 +49,23 @@ describe("CodexAppServerClient", () => {
expect(outbound.method).toBe("model/list");
});
it("rejects unbounded guarded thread requests before acquiring the fence", async () => {
const harness = createClientHarness();
clients.push(harness.client);
const guard = vi.fn(async () => () => undefined);
harness.client.setThreadSessionRequestGuard(guard);
await expect(harness.client.request("thread/start", {})).rejects.toThrow(
"thread/start requires a positive finite timeout or abort signal",
);
await expect(
harness.client.request("thread/resume", {}, { timeoutMs: Number.POSITIVE_INFINITY }),
).rejects.toThrow("thread/resume requires a positive finite timeout or abort signal");
expect(guard).not.toHaveBeenCalled();
expect(harness.writes).toEqual([]);
});
it("removes unpaired surrogate code units from outbound JSON-RPC strings", async () => {
const harness = createClientHarness();
clients.push(harness.client);
+12
View File
@@ -373,6 +373,18 @@ export class CodexAppServerClient {
? this.threadSessionRequestGuard
: undefined;
if (guard) {
if (
!options.signal &&
!(
options.timeoutMs !== undefined &&
Number.isFinite(options.timeoutMs) &&
options.timeoutMs > 0
)
) {
return Promise.reject(
new TypeError(`${method} requires a positive finite timeout or abort signal`),
);
}
return (async () => {
const guardStartedAt = Date.now();
const timeoutMessage = `${method} timed out`;
@@ -7,6 +7,7 @@ import {
type ResolvedCodexPluginPolicy,
} from "./config.js";
import { ensureCodexPluginActivation } from "./plugin-activation.js";
import { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin activation", () => {
@@ -92,15 +93,23 @@ describe("Codex plugin activation", () => {
it("installs a migration-authorized local curated plugin and refreshes runtime state", async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const appCache = new CodexAppInventoryCache();
const metadataCache = new CodexPluginMetadataCache();
let pluginListCalls = 0;
const result = await ensureCodexPluginActivation({
identity: identity("google-calendar"),
appCache,
appCacheKey: "runtime",
metadataCache,
request: async (method, params) => {
calls.push({ method, params });
if (method === "plugin/list") {
pluginListCalls += 1;
expect(params).toEqual({});
return pluginList([
pluginSummary("google-calendar", { installed: false, enabled: false }),
pluginSummary("google-calendar", {
installed: pluginListCalls > 1,
enabled: pluginListCalls > 1,
}),
]);
}
if (method === "plugin/install") {
@@ -142,6 +151,10 @@ describe("Codex plugin activation", () => {
"config/mcpServer/reload",
"app/list",
]);
expect(pluginListCalls).toBe(2);
expect(
metadataCache.read("runtime", "curated-global")?.response.marketplaces[0]?.plugins[0],
).toMatchObject({ installed: true, enabled: true });
expect(appCache.getRevision()).toBeGreaterThan(0);
});
@@ -286,6 +299,38 @@ describe("Codex plugin activation", () => {
]);
});
it("settles a missing plugin from the remote curated marketplace snapshot", async () => {
const metadataCache = new CodexPluginMetadataCache();
const request = vi.fn(async (_method: string, params: unknown) => {
expect(params).toEqual({});
return {
marketplaces: [
{
name: "openai-curated-remote",
path: null,
interface: null,
plugins: [],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
} satisfies v2.PluginListResponse;
});
const activationParams = {
identity: identity("google-calendar"),
request,
metadataCache,
appCacheKey: "runtime",
};
const first = await ensureCodexPluginActivation(activationParams);
const second = await ensureCodexPluginActivation(activationParams);
expect(first.reason).toBe("plugin_missing");
expect(second.reason).toBe("plugin_missing");
expect(request).toHaveBeenCalledTimes(1);
});
it("requires workspace-directory plugins to be activated outside OpenClaw", async () => {
const request = vi.fn(async () => {
throw new Error("workspace activation must not call app-server");
@@ -10,10 +10,12 @@ import {
} from "./config.js";
import {
findOpenAiCuratedPluginSummary,
isOpenAiCuratedMarketplace,
pluginReadParams,
type CodexPluginMarketplaceRef,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import type { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import type { v2 } from "./protocol.js";
/** Terminal reason reported after trying to activate one Codex plugin policy. */
@@ -48,6 +50,7 @@ type EnsureCodexPluginActivationParams = {
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
metadataCache?: CodexPluginMetadataCache;
installEvenIfActive?: boolean;
targetAppIds?: readonly string[];
};
@@ -68,14 +71,10 @@ export async function ensureCodexPluginActivation(
});
}
const listed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const listed = await listCuratedCodexPluginMetadata(params);
const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName);
if (!resolved) {
const hasCuratedMarketplace = listed.marketplaces.some(
(marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME,
);
const hasCuratedMarketplace = listed.marketplaces.some(isOpenAiCuratedMarketplace);
if (!hasCuratedMarketplace) {
return activationFailure(params.identity, "marketplace_missing", {
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
@@ -106,6 +105,9 @@ export async function ensureCodexPluginActivation(
: params.identity.pluginName,
) satisfies v2.PluginInstallParams,
)) as v2.PluginInstallResponse;
if (params.metadataCache && params.appCacheKey) {
params.metadataCache.invalidate(params.appCacheKey);
}
const refreshDiagnostics: CodexPluginActivationDiagnostic[] = [];
let refreshFailed = false;
try {
@@ -113,6 +115,7 @@ export async function ensureCodexPluginActivation(
request: params.request,
appCache: params.appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
targetAppIds: params.targetAppIds,
});
refreshDiagnostics.push(...refreshResult.diagnostics);
@@ -152,12 +155,11 @@ async function refreshCodexPluginRuntimeState(params: {
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
metadataCache?: CodexPluginMetadataCache;
targetAppIds?: readonly string[];
}): Promise<CodexPluginRuntimeRefreshResult> {
const diagnostics: CodexPluginActivationDiagnostic[] = [];
await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams);
await listCuratedCodexPluginMetadata(params);
await params.request("skills/list", {
cwds: [],
forceReload: true,
@@ -196,6 +198,30 @@ async function refreshCodexPluginRuntimeState(params: {
return { diagnostics };
}
async function listCuratedCodexPluginMetadata(params: {
request: CodexPluginRuntimeRequest;
metadataCache?: CodexPluginMetadataCache;
appCacheKey?: string;
}): Promise<v2.PluginListResponse> {
const requestParams = {} satisfies v2.PluginListParams;
if (!params.metadataCache || !params.appCacheKey) {
return (await params.request("plugin/list", requestParams)) as v2.PluginListResponse;
}
const snapshot = await params.metadataCache.load({
appCacheKey: params.appCacheKey,
queryKind: "curated-global",
requestParams,
request: async (method, listedParams) =>
(await params.request(method, listedParams)) as v2.PluginListResponse,
// Fail-open guard: never settle a curated snapshot that lacks the curated
// marketplace itself (upstream returns local-only on remote fetch failure
// without a load error). See listCodexPluginMetadata in plugin-inventory.
cacheable: (response: v2.PluginListResponse) =>
(response.marketplaces ?? []).some((marketplace) => isOpenAiCuratedMarketplace(marketplace)),
});
return snapshot.response;
}
function activationFailure(
identity: ResolvedCodexPluginPolicy,
reason: CodexPluginActivationReason,
@@ -273,7 +273,7 @@ describe("Codex plugin inventory", () => {
});
expect(calls.slice(0, 2)).toStrictEqual([
{ method: "plugin/list", params: { cwds: [] } },
{ method: "plugin/list", params: {} },
{
method: "plugin/list",
params: { cwds: [], marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME] },
@@ -308,7 +308,7 @@ describe("Codex plugin inventory", () => {
},
});
expect(calls).toStrictEqual([{ cwds: [] }]);
expect(calls).toStrictEqual([{}]);
});
it("fails closed before plugin/read when a workspace summary lacks remotePluginId", async () => {
@@ -429,7 +429,7 @@ describe("Codex plugin inventory", () => {
});
expect(calls).toStrictEqual([
{ method: "plugin/list", params: { cwds: [] } },
{ method: "plugin/list", params: {} },
{
method: "plugin/list",
params: { cwds: [], marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME] },
@@ -17,6 +17,10 @@ import {
type ResolvedCodexPluginPolicy,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
import type {
CodexPluginMetadataCache,
CodexPluginMetadataQueryKind,
} from "./plugin-metadata-cache.js";
import type { v2 } from "./protocol.js";
const CODEX_PLUGINS_REMOTE_MARKETPLACE_NAME = `${CODEX_PLUGINS_MARKETPLACE_NAME}-remote`;
@@ -89,6 +93,7 @@ type ReadCodexPluginInventoryParams = {
request: CodexPluginRuntimeRequest;
appCache?: CodexAppInventoryCache;
appCacheKey?: string;
metadataCache?: CodexPluginMetadataCache;
nowMs?: number;
readPluginDetails?: boolean;
suppressAppInventoryRefresh?: boolean;
@@ -113,9 +118,7 @@ export async function readCodexPluginInventory(
}
const appInventory = readCachedAppInventory(params);
const curatedListed = (await params.request("plugin/list", {
cwds: [],
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
const curatedListed = await listCodexPluginMetadata(params, "curated-global", {});
const shouldListWorkspacePlugins = policy.pluginPolicies.some(
(pluginPolicy) =>
pluginPolicy.enabled &&
@@ -126,10 +129,10 @@ export async function readCodexPluginInventory(
try {
workspaceListResult = {
kind: "listed",
response: (await params.request("plugin/list", {
response: await listCodexPluginMetadata(params, "workspace-directory", {
cwds: [],
marketplaceKinds: [CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME],
} satisfies v2.PluginListParams)) as v2.PluginListResponse,
}),
};
} catch (error) {
if (!(error instanceof CodexAppServerRpcError)) {
@@ -280,6 +283,72 @@ export function pluginReadParams(
};
}
/** Returns configured plugin keys whose current metadata may still recover. */
export function resolveRecoverableCodexPluginConfigKeys(params: {
policy: ResolvedCodexPluginsPolicy;
metadataCache: CodexPluginMetadataCache;
appCacheKey: string;
}): string[] {
return params.policy.pluginPolicies
.filter(
(pluginPolicy) =>
pluginPolicy.enabled &&
!isSettledMissingPluginPolicy({
pluginPolicy,
metadataCache: params.metadataCache,
appCacheKey: params.appCacheKey,
}),
)
.map((pluginPolicy) => pluginPolicy.configKey)
.toSorted();
}
async function listCodexPluginMetadata(
params: ReadCodexPluginInventoryParams,
queryKind: CodexPluginMetadataQueryKind,
requestParams: v2.PluginListParams,
): Promise<v2.PluginListResponse> {
// Workspace-directory plugins are activated OUTSIDE OpenClaw, so a cached
// miss has no invalidation signal; keep those queries live so external
// activation is visible on the next turn (bounded by the build deadline).
if (!params.metadataCache || !params.appCacheKey || queryKind === "workspace-directory") {
return (await params.request("plugin/list", requestParams)) as v2.PluginListResponse;
}
const snapshot = await params.metadataCache.load({
appCacheKey: params.appCacheKey,
queryKind,
requestParams,
request: async (method, listedParams) =>
(await params.request(method, listedParams)) as v2.PluginListResponse,
// Upstream fail-open: with omitted marketplaceKinds a remote catalog fetch
// failure only warns and returns local marketplaces (no load error), which
// is indistinguishable from a genuinely absent plugin. Settle curated
// negatives only when the curated marketplace itself is present.
cacheable: (response: v2.PluginListResponse) =>
(response.marketplaces ?? []).some((marketplace) => isOpenAiCuratedMarketplace(marketplace)),
});
return snapshot.response;
}
function isSettledMissingPluginPolicy(params: {
pluginPolicy: ResolvedCodexPluginPolicy;
metadataCache: CodexPluginMetadataCache;
appCacheKey: string;
}): boolean {
const queryKind: CodexPluginMetadataQueryKind =
params.pluginPolicy.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME
? "workspace-directory"
: "curated-global";
const listed = params.metadataCache.read(params.appCacheKey, queryKind)?.response;
if (!listed) {
return false;
}
if (queryKind === "workspace-directory") {
return !findWorkspaceMarketplacePlugin(listed, params.pluginPolicy.pluginName);
}
return !findOpenAiCuratedMarketplacePlugin(listed, params.pluginPolicy.pluginName);
}
function readCachedAppInventory(
params: ReadCodexPluginInventoryParams,
): CodexAppInventoryCacheRead | undefined {
@@ -464,7 +533,8 @@ function marketplaceRef(
};
}
function isOpenAiCuratedMarketplace(marketplace: v2.PluginMarketplaceEntry): boolean {
/** True for either supported OpenAI curated marketplace wire name. */
export function isOpenAiCuratedMarketplace(marketplace: v2.PluginMarketplaceEntry): boolean {
return (
marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME ||
marketplace.name === CODEX_PLUGINS_REMOTE_MARKETPLACE_NAME
@@ -0,0 +1,307 @@
// Codex tests cover plugin metadata cache behavior.
import { describe, expect, it, vi } from "vitest";
import { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import type { v2 } from "./protocol.js";
describe("Codex plugin metadata cache", () => {
it("coalesces and reuses the full successful snapshot", async () => {
const cache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
const request = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
release = resolve;
}),
);
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
const first = cache.load(params);
const second = cache.load(params);
const response = pluginList("openai-curated-remote", "calendar");
release?.(response);
const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]);
expect(request).toHaveBeenCalledTimes(1);
expect(firstSnapshot).toBe(secondSnapshot);
expect(firstSnapshot.response).toBe(response);
await expect(cache.load(params)).resolves.toBe(firstSnapshot);
expect(request).toHaveBeenCalledTimes(1);
});
it("does not settle snapshots the caller marks uncacheable", async () => {
// Upstream plugin/list fails open for remote catalogs (local-only response,
// empty marketplaceLoadErrors); such a snapshot must not settle negatives.
const cache = new CodexPluginMetadataCache();
const failOpen = pluginList("local-only");
const healthy = pluginList("openai-curated-remote", "calendar");
const request = vi.fn(async () => (request.mock.calls.length > 1 ? healthy : failOpen));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
cacheable: (response: v2.PluginListResponse) =>
response.marketplaces.some((entry) => entry.name === "openai-curated-remote"),
};
await expect(cache.load(params)).resolves.toMatchObject({ response: failOpen });
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
await expect(cache.load(params)).resolves.toMatchObject({ response: healthy });
expect(cache.read("runtime-a", "curated-global")?.response).toBe(healthy);
expect(request).toHaveBeenCalledTimes(2);
});
it("expires settled snapshots after the freshness window", async () => {
// Upstream refreshes its remote catalog in the background; a settled
// negative must not deny a configured plugin for the process lifetime.
let now = 0;
const cache = new CodexPluginMetadataCache(() => now);
const request = vi.fn(async () => pluginList("openai-curated-remote", "calendar"));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
await cache.load(params);
await cache.load(params);
expect(request).toHaveBeenCalledTimes(1);
now = 60 * 60 * 1_000 + 1;
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
await cache.load(params);
expect(request).toHaveBeenCalledTimes(2);
});
it("keeps query kinds and runtime identities separate", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async (_method: string, params: v2.PluginListParams) =>
pluginList(params.marketplaceKinds ? "workspace-directory" : "openai-curated-remote"),
);
await cache.load({
appCacheKey: "runtime-a",
queryKind: "curated-global",
requestParams: {},
request,
});
await cache.load({
appCacheKey: "runtime-a",
queryKind: "workspace-directory",
requestParams: { cwds: [], marketplaceKinds: ["workspace-directory"] },
request,
});
await cache.load({
appCacheKey: "runtime-b",
queryKind: "curated-global",
requestParams: {},
request,
});
expect(request).toHaveBeenCalledTimes(3);
});
it("does not cache failed requests", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi
.fn<() => Promise<v2.PluginListResponse>>()
.mockRejectedValueOnce(new Error("catalog unavailable"))
.mockResolvedValueOnce(pluginList("openai-curated-remote"));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
await expect(cache.load(params)).rejects.toThrow("catalog unavailable");
await expect(cache.load(params)).resolves.toMatchObject({
response: { marketplaces: [{ name: "openai-curated-remote" }] },
});
expect(request).toHaveBeenCalledTimes(2);
});
it("does not cache responses with marketplace load errors", async () => {
const cache = new CodexPluginMetadataCache();
const incomplete = pluginList("openai-curated-remote");
incomplete.marketplaceLoadErrors = [{ message: "catalog unavailable" }];
const request = vi
.fn<() => Promise<v2.PluginListResponse>>()
.mockResolvedValueOnce(incomplete)
.mockResolvedValueOnce(pluginList("openai-curated-remote", "calendar"));
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
await expect(cache.load(params)).resolves.toMatchObject({ response: incomplete });
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
await expect(cache.load(params)).resolves.toMatchObject({
response: { marketplaces: [{ plugins: [{ id: "calendar" }] }] },
});
expect(request).toHaveBeenCalledTimes(2);
});
it("starts a fresh load after invalidation while an older load is pending", async () => {
const cache = new CodexPluginMetadataCache();
const releases: Array<(response: v2.PluginListResponse) => void> = [];
const request = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
releases.push(resolve);
}),
);
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
const beforeInstall = cache.load(params);
await vi.waitFor(() => expect(releases).toHaveLength(1));
cache.invalidate("runtime-a");
const afterInstall = cache.load(params);
await vi.waitFor(() => expect(releases).toHaveLength(2));
const current = pluginList("openai-curated-remote", "calendar");
releases[1]?.(current);
await expect(afterInstall).resolves.toMatchObject({ response: current });
releases[0]?.(pluginList("openai-curated-remote"));
await expect(beforeInstall).resolves.toBeDefined();
expect(cache.read("runtime-a", "curated-global")?.response).toBe(current);
expect(request).toHaveBeenCalledTimes(2);
});
it("retries a joined load with the caller's request after the owner fails", async () => {
const cache = new CodexPluginMetadataCache();
let rejectOwner: ((error: Error) => void) | undefined;
const ownerRequest = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((_resolve, reject) => {
rejectOwner = reject;
}),
);
const params = {
appCacheKey: "runtime-a",
queryKind: "curated-global" as const,
requestParams: {},
};
const owner = cache.load({ ...params, request: ownerRequest });
const ownerResult = owner.catch((error: unknown) => error);
await vi.waitFor(() => expect(rejectOwner).toBeTypeOf("function"));
const joiningRequest = vi.fn(async () => pluginList("openai-curated-remote", "calendar"));
const joining = cache.load({ ...params, request: joiningRequest });
rejectOwner?.(new Error("owner cancelled"));
await expect(ownerResult).resolves.toBeInstanceOf(Error);
await expect(joining).resolves.toMatchObject({
response: { marketplaces: [{ plugins: [{ id: "calendar" }] }] },
});
expect(ownerRequest).toHaveBeenCalledTimes(1);
expect(joiningRequest).toHaveBeenCalledTimes(1);
});
it("reuses a successful workspace snapshot for the process lifetime", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => pluginList("workspace-directory"));
const params = {
appCacheKey: "runtime-a",
queryKind: "workspace-directory" as const,
requestParams: {
cwds: [],
marketplaceKinds: ["workspace-directory"],
} satisfies v2.PluginListParams,
request,
};
const first = await cache.load(params);
await expect(cache.load(params)).resolves.toBe(first);
expect(request).toHaveBeenCalledTimes(1);
});
it("keeps an unrelated runtime load cacheable across invalidation", async () => {
const cache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
const request = vi.fn(
async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
release = resolve;
}),
);
const params = {
appCacheKey: "runtime-b",
queryKind: "curated-global" as const,
requestParams: {},
request,
};
const pending = cache.load(params);
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
cache.invalidate("runtime-a");
const response = pluginList("openai-curated-remote", "calendar");
release?.(response);
await pending;
await expect(cache.load(params)).resolves.toMatchObject({ response });
expect(request).toHaveBeenCalledTimes(1);
});
it("invalidates one runtime and clear resets all snapshots", async () => {
const cache = new CodexPluginMetadataCache();
const request = vi.fn(async () => pluginList("openai-curated-remote"));
const load = (appCacheKey: string) =>
cache.load({
appCacheKey,
queryKind: "curated-global",
requestParams: {},
request,
});
await load("runtime-a");
await load("runtime-b");
cache.invalidate("runtime-a");
await load("runtime-a");
await load("runtime-b");
expect(request).toHaveBeenCalledTimes(3);
cache.clear();
expect(cache.read("runtime-a", "curated-global")).toBeUndefined();
expect(cache.read("runtime-b", "curated-global")).toBeUndefined();
});
});
function pluginList(marketplaceName: string, pluginId?: string): v2.PluginListResponse {
return {
marketplaces: [
{
name: marketplaceName,
path: null,
interface: null,
plugins: pluginId
? [
{
id: pluginId,
name: pluginId,
source: { type: "remote" },
installed: false,
enabled: false,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
]
: [],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
@@ -0,0 +1,166 @@
/**
* Process-local cache for successful Codex plugin/list snapshots.
*/
import type { v2 } from "./protocol.js";
// Matches the sibling app-inventory cache window: upstream refreshes its remote
// catalog in the background, so settled negatives must expire rather than deny
// a configured plugin for the whole process lifetime.
const CODEX_PLUGIN_METADATA_CACHE_TTL_MS = 60 * 60 * 1_000;
/** Plugin catalog query whose request shape affects the returned marketplaces. */
export type CodexPluginMetadataQueryKind = "curated-global" | "workspace-directory";
/** Request callback used to read Codex plugin metadata. */
type CodexPluginMetadataRequest = (
method: "plugin/list",
params: v2.PluginListParams,
) => Promise<v2.PluginListResponse>;
/** Successful plugin metadata snapshot scoped to one app-server runtime. */
type CodexPluginMetadataSnapshot = {
appCacheKey: string;
queryKind: CodexPluginMetadataQueryKind;
response: v2.PluginListResponse;
};
type CachedCodexPluginMetadataEntry = {
snapshot: CodexPluginMetadataSnapshot;
expiresAtMs: number;
};
type LoadCodexPluginMetadataParams = {
appCacheKey: string;
queryKind: CodexPluginMetadataQueryKind;
requestParams: v2.PluginListParams;
request: CodexPluginMetadataRequest;
/**
* Guards against fail-open responses: upstream plugin/list only warns when a
* remote catalog fetch fails with omitted marketplaceKinds, returning local
* marketplaces with empty marketplaceLoadErrors. Such a snapshot must not
* settle for the process lifetime, or configured plugins never recover.
*/
cacheable?: (response: v2.PluginListResponse) => boolean;
};
type InFlightCodexPluginMetadataLoad = {
appCacheKey: string;
promise: Promise<CodexPluginMetadataSnapshot>;
};
/** Process-local plugin metadata cache with coalesced loads per query. */
export class CodexPluginMetadataCache {
private readonly entries = new Map<string, CachedCodexPluginMetadataEntry>();
private readonly inFlight = new Map<string, InFlightCodexPluginMetadataLoad>();
private readonly generations = new Map<string, number>();
private clearGeneration = 0;
constructor(private readonly nowMs: () => number = Date.now) {}
/** Returns a fresh cached snapshot without issuing a request. */
read(
appCacheKey: string,
queryKind: CodexPluginMetadataQueryKind,
): CodexPluginMetadataSnapshot | undefined {
const entryKey = buildMetadataCacheEntryKey(appCacheKey, queryKind);
const entry = this.entries.get(entryKey);
if (!entry) {
return undefined;
}
if (entry.expiresAtMs <= this.nowMs()) {
this.entries.delete(entryKey);
return undefined;
}
return entry.snapshot;
}
/** Returns a fresh cached snapshot or coalesces one plugin/list request. */
async load(params: LoadCodexPluginMetadataParams): Promise<CodexPluginMetadataSnapshot> {
const entryKey = buildMetadataCacheEntryKey(params.appCacheKey, params.queryKind);
const cached = this.read(params.appCacheKey, params.queryKind);
if (cached) {
return cached;
}
const pending = this.inFlight.get(entryKey);
if (pending) {
try {
return await pending.promise;
} catch {
if (this.inFlight.get(entryKey) === pending) {
this.inFlight.delete(entryKey);
}
return await this.load(params);
}
}
const generation = this.generations.get(params.appCacheKey) ?? 0;
const clearGeneration = this.clearGeneration;
const promise = (async () => {
const response = await params.request("plugin/list", params.requestParams);
const snapshot = {
appCacheKey: params.appCacheKey,
queryKind: params.queryKind,
response,
} satisfies CodexPluginMetadataSnapshot;
// Settled snapshots survive until install invalidation, identity change,
// TTL expiry, restart, or test reset — never a per-turn refresh.
if (
generation === (this.generations.get(params.appCacheKey) ?? 0) &&
clearGeneration === this.clearGeneration &&
!hasMarketplaceLoadErrors(response) &&
(params.cacheable?.(response) ?? true)
) {
this.entries.set(entryKey, {
snapshot,
expiresAtMs: this.nowMs() + CODEX_PLUGIN_METADATA_CACHE_TTL_MS,
});
}
return snapshot;
})();
this.inFlight.set(entryKey, { appCacheKey: params.appCacheKey, promise });
try {
return await promise;
} finally {
if (this.inFlight.get(entryKey)?.promise === promise) {
this.inFlight.delete(entryKey);
}
}
}
/** Invalidates all plugin metadata queries for one app-server runtime. */
invalidate(appCacheKey: string): void {
this.generations.set(appCacheKey, (this.generations.get(appCacheKey) ?? 0) + 1);
for (const [entryKey, entry] of this.entries) {
if (entry.snapshot.appCacheKey === appCacheKey) {
this.entries.delete(entryKey);
}
}
for (const [entryKey, pending] of this.inFlight) {
if (pending.appCacheKey === appCacheKey) {
this.inFlight.delete(entryKey);
}
}
}
/** Clears snapshots and prevents late in-flight loads from repopulating them. */
clear(): void {
this.clearGeneration += 1;
this.generations.clear();
this.entries.clear();
this.inFlight.clear();
}
}
/** Shared plugin metadata cache used by Codex app-server runtime paths. */
export const defaultCodexPluginMetadataCache = new CodexPluginMetadataCache();
function hasMarketplaceLoadErrors(response: v2.PluginListResponse): boolean {
return (response.marketplaceLoadErrors?.length ?? 0) > 0;
}
function buildMetadataCacheEntryKey(
appCacheKey: string,
queryKind: CodexPluginMetadataQueryKind,
): string {
return JSON.stringify([appCacheKey, queryKind]);
}
@@ -0,0 +1,232 @@
/** Enforces one bounded startup budget across Codex plugin config discovery. */
import {
defaultCodexAppInventoryCache,
type CodexAppInventoryCache,
} from "./app-inventory-cache.js";
import type { CodexAppServerClient } from "./client.js";
import {
resolveCodexPluginsPolicy,
type CodexPluginConfig,
type ResolvedCodexPluginsPolicy,
} from "./config.js";
import { disableCodexPluginThreadConfig } from "./dynamic-tool-build.js";
import { resolveRecoverableCodexPluginConfigKeys } from "./plugin-inventory.js";
import {
defaultCodexPluginMetadataCache,
type CodexPluginMetadataCache,
} from "./plugin-metadata-cache.js";
import {
buildCodexPluginThreadConfig,
buildCodexPluginThreadConfigTimeoutFallback,
shouldBuildCodexPluginThreadConfig,
type CodexPluginThreadConfig,
} from "./plugin-thread-config.js";
const CODEX_PLUGIN_THREAD_CONFIG_MAX_TIMEOUT_MS = 5_000;
const CODEX_PLUGIN_THREAD_CONFIG_TIMEOUT_DIVISOR = 4;
const CODEX_PLUGIN_THREAD_CONFIG_MIN_TIMEOUT_MS = 100;
type CodexPluginThreadConfigDeadlineRequest = (
method: string,
params: unknown,
options: { timeoutMs: number; signal: AbortSignal },
) => Promise<unknown>;
type BuildCodexPluginThreadConfigWithinDeadlineParams = Omit<
Parameters<typeof buildCodexPluginThreadConfig>[0],
"request"
> & {
requestTimeoutMs: number;
signal: AbortSignal;
request: CodexPluginThreadConfigDeadlineRequest;
};
class CodexPluginThreadConfigDeadlineError extends Error {
constructor() {
super("Codex plugin thread config deadline elapsed");
this.name = "CodexPluginThreadConfigDeadlineError";
}
}
/** Resolves the plugin policy state reused throughout app-server startup. */
export function resolveCodexPluginThreadConfigStartupPolicy(params: {
pluginConfig: CodexPluginConfig;
nativeToolSurfaceEnabled: boolean;
}) {
const pluginThreadConfigRequired =
!params.nativeToolSurfaceEnabled || shouldBuildCodexPluginThreadConfig(params.pluginConfig);
// Restricted runs still need a config so thread/start carries an explicit
// apps._default denial patch without app/list discovery.
const pluginThreadConfigPluginConfig = params.nativeToolSurfaceEnabled
? params.pluginConfig
: disableCodexPluginThreadConfig(params.pluginConfig);
const resolvedPluginPolicy = pluginThreadConfigRequired
? resolveCodexPluginsPolicy(pluginThreadConfigPluginConfig)
: undefined;
return {
pluginThreadConfigRequired,
pluginThreadConfigPluginConfig,
resolvedPluginPolicy,
enabledPluginConfigKeys: resolvedPluginPolicy
? resolvedPluginPolicy.pluginPolicies
.filter((plugin) => plugin.enabled)
.map((plugin) => plugin.configKey)
.toSorted()
: undefined,
};
}
/** Builds plugin config without allowing sequential RPC timeouts to consume the turn. */
async function buildCodexPluginThreadConfigWithinDeadline(
params: BuildCodexPluginThreadConfigWithinDeadlineParams,
): Promise<CodexPluginThreadConfig> {
const { requestTimeoutMs, signal, request, ...buildParams } = params;
const timeoutMs = resolveCodexPluginThreadConfigTimeoutMs(requestTimeoutMs);
// One deadline owns the whole config build; every RPC gets only the remaining
// budget so discovery cannot consume one full request timeout per call.
const deadlineMs = Date.now() + timeoutMs;
try {
return await waitForCodexPluginThreadConfigBuild({
signal,
timeoutMs,
build: () =>
buildCodexPluginThreadConfig({
...buildParams,
request: (method, requestParams) => {
const remainingTimeoutMs = deadlineMs - Date.now();
if (remainingTimeoutMs <= 0) {
throw new CodexPluginThreadConfigDeadlineError();
}
return request(method, requestParams, {
timeoutMs: remainingTimeoutMs,
signal,
});
},
}),
});
} catch (error) {
if (signal.aborted || !isCodexPluginThreadConfigTimeoutError(error)) {
throw error;
}
return buildCodexPluginThreadConfigTimeoutFallback({
pluginConfig: buildParams.pluginConfig,
appCacheKey: buildParams.appCacheKey,
message: `Codex plugin discovery exceeded its ${timeoutMs} ms startup budget; plugin apps were disabled for this turn.`,
});
}
}
function waitForCodexPluginThreadConfigBuild(params: {
signal: AbortSignal;
timeoutMs: number;
build: () => Promise<CodexPluginThreadConfig>;
}): Promise<CodexPluginThreadConfig> {
if (params.signal.aborted) {
return Promise.reject(resolveAbortReason(params.signal));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = () => {
if (settled) {
return false;
}
settled = true;
clearTimeout(timer);
params.signal.removeEventListener("abort", onAbort);
return true;
};
const resolveOnce = (config: CodexPluginThreadConfig) => {
if (finish()) {
resolve(config);
}
};
const rejectOnce = (error: unknown) => {
if (finish()) {
reject(error instanceof Error ? error : new Error(String(error)));
}
};
const onAbort = () => rejectOnce(resolveAbortReason(params.signal));
const timer = setTimeout(
() => rejectOnce(new CodexPluginThreadConfigDeadlineError()),
params.timeoutMs,
);
params.signal.addEventListener("abort", onAbort, { once: true });
params.build().then(resolveOnce, rejectOnce);
});
}
function resolveAbortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("Codex plugin thread config aborted");
}
/** Creates the recovery metadata and bounded builder used by thread startup. */
export function createCodexPluginThreadConfigStartupProvider(params: {
inputFingerprint: string | undefined;
enabledPluginConfigKeys: string[] | undefined;
policy: ResolvedCodexPluginsPolicy | undefined;
requestTimeoutMs: number;
signal: AbortSignal;
pluginConfig?: unknown;
client: Pick<CodexAppServerClient, "request">;
configCwd?: string;
appCache?: CodexAppInventoryCache;
appCacheKey: string;
metadataCache?: CodexPluginMetadataCache;
}) {
const {
client,
policy,
inputFingerprint,
enabledPluginConfigKeys,
appCache,
metadataCache: configuredMetadataCache,
...buildParams
} = params;
const metadataCache = configuredMetadataCache ?? defaultCodexPluginMetadataCache;
return {
enabled: true,
inputFingerprint,
enabledPluginConfigKeys,
accountAppRecoveryEnabled: policy?.allowAllPlugins,
recoverablePluginConfigKeys: policy
? resolveRecoverableCodexPluginConfigKeys({
policy,
metadataCache,
appCacheKey: params.appCacheKey,
})
: undefined,
build: () =>
buildCodexPluginThreadConfigWithinDeadline({
...buildParams,
appCache: appCache ?? defaultCodexAppInventoryCache,
metadataCache,
request: (method, requestParams, options) => client.request(method, requestParams, options),
}),
};
}
function resolveCodexPluginThreadConfigTimeoutMs(requestTimeoutMs: number): number {
const finiteRequestTimeoutMs =
Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0
? requestTimeoutMs
: CODEX_PLUGIN_THREAD_CONFIG_MAX_TIMEOUT_MS * CODEX_PLUGIN_THREAD_CONFIG_TIMEOUT_DIVISOR;
return Math.min(
CODEX_PLUGIN_THREAD_CONFIG_MAX_TIMEOUT_MS,
Math.max(
CODEX_PLUGIN_THREAD_CONFIG_MIN_TIMEOUT_MS,
Math.floor(finiteRequestTimeoutMs / CODEX_PLUGIN_THREAD_CONFIG_TIMEOUT_DIVISOR),
),
);
}
function isCodexPluginThreadConfigTimeoutError(error: unknown): boolean {
return (
error instanceof CodexPluginThreadConfigDeadlineError ||
(error instanceof Error &&
"code" in error &&
error.code === "CODEX_APP_SERVER_LOCAL_REQUEST_CANCELLED" &&
error.message.endsWith(" timed out"))
);
}
@@ -5,10 +5,14 @@ import {
CODEX_PLUGINS_MARKETPLACE_NAME,
CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME,
} from "./config.js";
import { resolveRecoverableCodexPluginConfigKeys } from "./plugin-inventory.js";
import { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import { createCodexPluginThreadConfigStartupProvider } from "./plugin-thread-config-deadline.js";
import {
buildCodexPluginAppsConfigPatchFromPolicyContext,
buildCodexPluginThreadConfig,
buildCodexPluginThreadConfigInputFingerprint,
buildCodexPluginThreadConfigTimeoutFallback,
isCodexPluginThreadBindingStale,
mergeCodexThreadConfigs,
shouldBuildCodexPluginThreadConfig,
@@ -1391,6 +1395,7 @@ describe("Codex plugin thread config", () => {
it("re-reads app readiness after re-enabling an installed plugin", async () => {
const appCache = new CodexAppInventoryCache();
const metadataCache = new CodexPluginMetadataCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
@@ -1445,6 +1450,7 @@ describe("Codex plugin thread config", () => {
},
appCache,
appCacheKey: "runtime",
metadataCache,
nowMs: 1,
request,
});
@@ -1475,7 +1481,6 @@ describe("Codex plugin thread config", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual([
"plugin/list",
"plugin/read",
"plugin/list",
"plugin/install",
"plugin/list",
"skills/list",
@@ -1483,7 +1488,6 @@ describe("Codex plugin thread config", () => {
"config/mcpServer/reload",
"app/list",
"app/list",
"plugin/list",
"plugin/read",
]);
expect(appListParams).toEqual([
@@ -1816,6 +1820,191 @@ describe("Codex plugin thread config", () => {
});
});
it("builds a diagnostic deny-all fallback after plugin config timeout", () => {
const fallback = buildCodexPluginThreadConfigTimeoutFallback({
pluginConfig: { codexPlugins: { enabled: true } },
appCacheKey: "runtime",
message: "Plugin discovery timed out.",
});
expect(fallback.configPatch?.apps).toEqual({
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
});
expect(fallback.diagnostics).toEqual([
{ code: "plugin_config_timeout", message: "Plugin discovery timed out." },
]);
});
it("bounds a coalesced metadata wait by the caller's shared deadline", async () => {
const metadataCache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
const pending = metadataCache.load({
appCacheKey: "runtime",
queryKind: "curated-global",
requestParams: {},
request: async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
release = resolve;
}),
});
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
const request = vi.fn(async () => pluginList([]));
const config = await createCodexPluginThreadConfigStartupProvider({
inputFingerprint: undefined,
enabledPluginConfigKeys: undefined,
policy: undefined,
requestTimeoutMs: 100,
signal: new AbortController().signal,
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
calendar: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "calendar",
},
},
},
},
appCache: new CodexAppInventoryCache(),
appCacheKey: "runtime",
metadataCache,
client: { request },
}).build();
expect(config.diagnostics).toEqual([
expect.objectContaining({ code: "plugin_config_timeout" }),
]);
expect(request).not.toHaveBeenCalled();
release?.(pluginList([]));
await pending;
});
it("propagates an outer abort while waiting on coalesced metadata", async () => {
const metadataCache = new CodexPluginMetadataCache();
let release: ((response: v2.PluginListResponse) => void) | undefined;
const pending = metadataCache.load({
appCacheKey: "runtime",
queryKind: "curated-global",
requestParams: {},
request: async () =>
await new Promise<v2.PluginListResponse>((resolve) => {
release = resolve;
}),
});
await vi.waitFor(() => expect(release).toBeTypeOf("function"));
const controller = new AbortController();
const build = createCodexPluginThreadConfigStartupProvider({
inputFingerprint: undefined,
enabledPluginConfigKeys: undefined,
policy: undefined,
requestTimeoutMs: 1_000,
signal: controller.signal,
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
calendar: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "calendar",
},
},
},
},
appCacheKey: "runtime",
metadataCache,
client: { request: vi.fn(async () => pluginList([])) },
}).build();
controller.abort(new Error("outer abort"));
await expect(build).rejects.toThrow("outer abort");
release?.(pluginList([]));
await pending;
});
it("does not start plugin discovery when the outer signal is already aborted", async () => {
const controller = new AbortController();
controller.abort(new Error("outer abort"));
const request = vi.fn(async () => pluginList([]));
await expect(
createCodexPluginThreadConfigStartupProvider({
inputFingerprint: undefined,
enabledPluginConfigKeys: undefined,
policy: undefined,
requestTimeoutMs: 1_000,
signal: controller.signal,
pluginConfig: { codexPlugins: { enabled: true } },
appCacheKey: "runtime",
client: { request },
}).build(),
).rejects.toThrow("outer abort");
expect(request).not.toHaveBeenCalled();
});
it("settles a missing plugin from one successful metadata snapshot", async () => {
const appCache = new CodexAppInventoryCache();
const metadataCache = new CodexPluginMetadataCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({ data: [], nextCursor: null }),
});
const pluginConfig = {
codexPlugins: {
enabled: true,
plugins: {
calendar: {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "calendar",
},
},
},
};
const request = vi.fn(async (method: string, params: unknown) => {
if (method !== "plugin/list") {
throw new Error(`unexpected request ${method}`);
}
expect(params).toEqual({});
return pluginList([], { name: "openai-curated-remote", path: null });
});
const build = () =>
buildCodexPluginThreadConfig({
pluginConfig,
appCache,
appCacheKey: "runtime",
metadataCache,
nowMs: 1,
request,
});
const first = await build();
const second = await build();
expect(first.diagnostics.map((diagnostic) => diagnostic.code)).toContain("plugin_missing");
expect(second.diagnostics.map((diagnostic) => diagnostic.code)).toContain("plugin_missing");
expect(request).toHaveBeenCalledTimes(1);
expect(
resolveRecoverableCodexPluginConfigKeys({
policy: first.inventory?.policy ?? second.inventory!.policy,
metadataCache,
appCacheKey: "runtime",
}),
).toEqual([]);
expect(second.configPatch?.apps).toEqual({
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
});
});
it("marks missing and changed plugin app bindings stale only when relevant", () => {
expect(
isCodexPluginThreadBindingStale({
@@ -29,6 +29,7 @@ import {
type CodexPluginOwnedApp,
type CodexPluginRuntimeRequest,
} from "./plugin-inventory.js";
import type { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import { isJsonObject, type JsonObject, type JsonValue, type v2 } from "./protocol.js";
/** Policy context for one app id exposed by a configured Codex plugin. */
@@ -67,6 +68,7 @@ type CodexPluginThreadConfigDiagnostic =
| {
code:
| "plugin_activation_failed"
| "plugin_config_timeout"
| "app_not_ready"
| "account_app_inventory_unavailable"
| "approval_overrides_clear_failed";
@@ -92,6 +94,7 @@ type BuildCodexPluginThreadConfigParams = {
configCwd?: string;
appCache?: CodexAppInventoryCache;
appCacheKey: string;
metadataCache?: CodexPluginMetadataCache;
nowMs?: number;
};
@@ -116,6 +119,24 @@ export function buildCodexPluginThreadConfigInputFingerprint(params: {
});
}
/** Builds the deny-all app patch used when plugin discovery exceeds its turn budget. */
export function buildCodexPluginThreadConfigTimeoutFallback(params: {
pluginConfig?: unknown;
appCacheKey: string;
message: string;
}): CodexPluginThreadConfig {
const inputFingerprint = buildCodexPluginThreadConfigInputFingerprint(params);
const fallback = emptyPluginThreadConfig({
enabled: true,
inputFingerprint,
configPatch: buildDisabledAppsConfigPatch(),
});
return {
...fallback,
diagnostics: [{ code: "plugin_config_timeout", message: params.message }],
};
}
/** Builds the Codex apps config patch and policy context for a native thread. */
export async function buildCodexPluginThreadConfig(
params: BuildCodexPluginThreadConfigParams,
@@ -142,6 +163,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
suppressAppInventoryRefresh: true,
})
@@ -164,6 +186,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
});
inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
@@ -182,6 +205,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
targetAppIds: record.ownedAppIds,
});
activationResults.push(activation);
@@ -214,6 +238,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
});
inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
@@ -233,6 +258,7 @@ export async function buildCodexPluginThreadConfig(
request: params.request,
appCache,
appCacheKey: params.appCacheKey,
metadataCache: params.metadataCache,
nowMs: params.nowMs,
});
inputFingerprint = buildCodexPluginThreadConfigInputFingerprint({
+1 -1
View File
@@ -562,7 +562,7 @@ type CodexPluginListMarketplaceKind =
| "created-by-me-remote";
type CodexPluginListParams = {
cwds: string[];
cwds?: string[];
marketplaceKinds?: CodexPluginListMarketplaceKind[];
};
@@ -19,6 +19,7 @@ import type { CodexAppServerClient } from "./client.js";
import { dynamicToolBuildState } from "./dynamic-tool-build-state.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { nativeHookRelayUnregisterQueue } from "./native-hook-relay-state.js";
import { defaultCodexPluginMetadataCache } from "./plugin-metadata-cache.js";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js";
@@ -628,6 +629,7 @@ export function setupRunAttemptTestHooks(): void {
resetGlobalHookRunner();
clearInternalHooks();
defaultCodexAppInventoryCache.clear();
defaultCodexPluginMetadataCache.clear();
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
@@ -1471,6 +1471,153 @@ describe("Codex app-server model provider selection", () => {
});
});
describe("Codex plugin binding recovery", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-plugin-recovery-"));
resetCodexTestBindingStore();
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("does not rebuild a binding whose configured plugin is a settled negative", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(sessionFile, workspaceDir);
const request = vi.fn(async (method: string) => {
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult("thread-settled");
}
throw new Error(`unexpected method: ${method}`);
});
const build = vi.fn(async () => ({
enabled: true,
configPatch: {
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
},
fingerprint: "plugin-config-settled",
inputFingerprint: "plugin-input-settled",
policyContext: { fingerprint: "plugin-policy-settled", apps: {}, pluginAppIds: {} },
diagnostics: [],
}));
const common = {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
};
await startOrResumeThread({
...common,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-input-settled",
enabledPluginConfigKeys: ["calendar"],
recoverablePluginConfigKeys: ["calendar"],
build,
},
});
await startOrResumeThread({
...common,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-input-settled",
enabledPluginConfigKeys: ["calendar"],
recoverablePluginConfigKeys: [],
build,
},
});
expect(build).toHaveBeenCalledTimes(1);
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
it("rebuilds once when a settled negative binding still enables the plugin", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createThreadLifecycleParams(sessionFile, workspaceDir);
const request = vi.fn(async (method: string) => {
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult("thread-settled-transition");
}
throw new Error(`unexpected method: ${method}`);
});
const build = vi
.fn()
.mockResolvedValueOnce({
enabled: true,
configPatch: { apps: { calendar: { enabled: true } } },
fingerprint: "plugin-config-active",
inputFingerprint: "plugin-input-settled",
policyContext: {
fingerprint: "plugin-policy-active",
apps: {
calendar: {
configKey: "calendar",
marketplaceName: "openai-curated" as const,
pluginName: "calendar",
allowDestructiveActions: false,
mcpServerNames: [],
},
},
pluginAppIds: { calendar: ["calendar"] },
},
diagnostics: [],
})
.mockResolvedValue({
enabled: true,
configPatch: { apps: { _default: { enabled: false } } },
fingerprint: "plugin-config-settled",
inputFingerprint: "plugin-input-settled",
policyContext: { fingerprint: "plugin-policy-settled", apps: {}, pluginAppIds: {} },
diagnostics: [],
});
const common = {
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer: createThreadLifecycleAppServerOptions(),
};
await startOrResumeThread({
...common,
pluginThreadConfig: {
enabled: true,
inputFingerprint: "plugin-input-settled",
enabledPluginConfigKeys: ["calendar"],
recoverablePluginConfigKeys: ["calendar"],
build,
},
});
const settledProvider = {
enabled: true,
inputFingerprint: "plugin-input-settled",
enabledPluginConfigKeys: ["calendar"],
recoverablePluginConfigKeys: [],
build,
};
await startOrResumeThread({ ...common, pluginThreadConfig: settledProvider });
await startOrResumeThread({ ...common, pluginThreadConfig: settledProvider });
expect(build).toHaveBeenCalledTimes(2);
expect(request.mock.calls.map(([method]) => method)).toEqual([
"thread/start",
"thread/start",
"thread/resume",
]);
});
});
describe("Codex app-server adopted thread lifecycle", () => {
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-adoption-"));
@@ -153,6 +153,8 @@ type CodexPluginThreadConfigProvider = {
enabled: boolean;
inputFingerprint?: string;
enabledPluginConfigKeys?: readonly string[];
recoverablePluginConfigKeys?: readonly string[];
accountAppRecoveryEnabled?: boolean;
build: () => Promise<CodexPluginThreadConfig>;
};
@@ -2142,8 +2144,27 @@ function shouldRecheckRecoverablePluginBinding(params: {
if (!policyContext) {
return false;
}
const expectedPluginConfigKeys = params.pluginThreadConfig.enabledPluginConfigKeys ?? [];
return Object.keys(policyContext.apps).length === 0 || expectedPluginConfigKeys.length > 0;
const enabledPluginConfigKeys = params.pluginThreadConfig.enabledPluginConfigKeys ?? [];
const recoverablePluginConfigKeys =
params.pluginThreadConfig.recoverablePluginConfigKeys ?? enabledPluginConfigKeys;
const recoverablePluginConfigKeySet = new Set(recoverablePluginConfigKeys);
const settledPluginConfigKeys = enabledPluginConfigKeys.filter(
(configKey) => !recoverablePluginConfigKeySet.has(configKey),
);
const bindingContainsSettledPlugin = settledPluginConfigKeys.some(
(configKey) =>
(policyContext.pluginAppIds[configKey]?.length ?? 0) > 0 ||
Object.values(policyContext.apps).some(
(app) => app.source !== "account" && app.configKey === configKey,
),
);
const accountAppRecoveryEnabled =
params.pluginThreadConfig.accountAppRecoveryEnabled ?? enabledPluginConfigKeys.length === 0;
return (
bindingContainsSettledPlugin ||
(accountAppRecoveryEnabled && Object.keys(policyContext.apps).length === 0) ||
recoverablePluginConfigKeys.length > 0
);
}
export function buildThreadStartParams(