From 9d4610cbd102746cd01412f02aff442ea6e96743 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 15 Jul 2026 06:40:29 -0700 Subject: [PATCH] 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. --- extensions/codex/index.test.ts | 20 ++ extensions/codex/index.ts | 23 +- .../src/app-server/attempt-startup.test.ts | 52 ++- .../codex/src/app-server/attempt-startup.ts | 63 ++-- .../codex/src/app-server/client.test.ts | 17 + extensions/codex/src/app-server/client.ts | 12 + .../src/app-server/plugin-activation.test.ts | 47 ++- .../codex/src/app-server/plugin-activation.ts | 44 ++- .../src/app-server/plugin-inventory.test.ts | 6 +- .../codex/src/app-server/plugin-inventory.ts | 82 ++++- .../app-server/plugin-metadata-cache.test.ts | 307 ++++++++++++++++++ .../src/app-server/plugin-metadata-cache.ts | 166 ++++++++++ .../plugin-thread-config-deadline.ts | 232 +++++++++++++ .../app-server/plugin-thread-config.test.ts | 193 ++++++++++- .../src/app-server/plugin-thread-config.ts | 26 ++ extensions/codex/src/app-server/protocol.ts | 2 +- .../app-server/run-attempt-test-harness.ts | 2 + .../src/app-server/thread-lifecycle.test.ts | 147 +++++++++ .../codex/src/app-server/thread-lifecycle.ts | 25 +- 19 files changed, 1397 insertions(+), 69 deletions(-) create mode 100644 extensions/codex/src/app-server/plugin-metadata-cache.test.ts create mode 100644 extensions/codex/src/app-server/plugin-metadata-cache.ts create mode 100644 extensions/codex/src/app-server/plugin-thread-config-deadline.ts diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 4def91b41686..a65d37ca9d71 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -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(); diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index 268a4a13ab25..3dbd0951557e 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -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({ + let bindingStateStore: PluginStateSyncKeyedStore | undefined; + const openBindingStateStore = () => + (bindingStateStore ??= api.runtime.state.openSyncKeyedStore({ 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, + "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, diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index f96f6aea9aba..2056dfc92b00 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -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, diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 29490822cc40..a8e1e669d740 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -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[0]; try { diff --git a/extensions/codex/src/app-server/client.test.ts b/extensions/codex/src/app-server/client.test.ts index 0401716463f3..cd45e1c32ea8 100644 --- a/extensions/codex/src/app-server/client.test.ts +++ b/extensions/codex/src/app-server/client.test.ts @@ -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); diff --git a/extensions/codex/src/app-server/client.ts b/extensions/codex/src/app-server/client.ts index a6783551b1bb..5eec26f5961f 100644 --- a/extensions/codex/src/app-server/client.ts +++ b/extensions/codex/src/app-server/client.ts @@ -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`; diff --git a/extensions/codex/src/app-server/plugin-activation.test.ts b/extensions/codex/src/app-server/plugin-activation.test.ts index 0c0e0a826c80..1c74e0503670 100644 --- a/extensions/codex/src/app-server/plugin-activation.test.ts +++ b/extensions/codex/src/app-server/plugin-activation.test.ts @@ -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"); diff --git a/extensions/codex/src/app-server/plugin-activation.ts b/extensions/codex/src/app-server/plugin-activation.ts index f9393598160a..8d118dcb0679 100644 --- a/extensions/codex/src/app-server/plugin-activation.ts +++ b/extensions/codex/src/app-server/plugin-activation.ts @@ -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 { 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 { + 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, diff --git a/extensions/codex/src/app-server/plugin-inventory.test.ts b/extensions/codex/src/app-server/plugin-inventory.test.ts index 93172c3ff979..b30d666c5f6a 100644 --- a/extensions/codex/src/app-server/plugin-inventory.test.ts +++ b/extensions/codex/src/app-server/plugin-inventory.test.ts @@ -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] }, diff --git a/extensions/codex/src/app-server/plugin-inventory.ts b/extensions/codex/src/app-server/plugin-inventory.ts index 2f46eb839c6d..1a2c865d3619 100644 --- a/extensions/codex/src/app-server/plugin-inventory.ts +++ b/extensions/codex/src/app-server/plugin-inventory.ts @@ -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 { + // 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 diff --git a/extensions/codex/src/app-server/plugin-metadata-cache.test.ts b/extensions/codex/src/app-server/plugin-metadata-cache.test.ts new file mode 100644 index 000000000000..e53307407e59 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-metadata-cache.test.ts @@ -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((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>() + .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>() + .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((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((_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((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: [], + }; +} diff --git a/extensions/codex/src/app-server/plugin-metadata-cache.ts b/extensions/codex/src/app-server/plugin-metadata-cache.ts new file mode 100644 index 000000000000..64aa9d298ee6 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-metadata-cache.ts @@ -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; + +/** 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; +}; + +/** Process-local plugin metadata cache with coalesced loads per query. */ +export class CodexPluginMetadataCache { + private readonly entries = new Map(); + private readonly inFlight = new Map(); + private readonly generations = new Map(); + 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 { + 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]); +} diff --git a/extensions/codex/src/app-server/plugin-thread-config-deadline.ts b/extensions/codex/src/app-server/plugin-thread-config-deadline.ts new file mode 100644 index 000000000000..ebb70319ba16 --- /dev/null +++ b/extensions/codex/src/app-server/plugin-thread-config-deadline.ts @@ -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; + +type BuildCodexPluginThreadConfigWithinDeadlineParams = Omit< + Parameters[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 { + 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; +}): Promise { + 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; + 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")) + ); +} diff --git a/extensions/codex/src/app-server/plugin-thread-config.test.ts b/extensions/codex/src/app-server/plugin-thread-config.test.ts index c4e134b506bf..ac0869145fab 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.test.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.test.ts @@ -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((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((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({ diff --git a/extensions/codex/src/app-server/plugin-thread-config.ts b/extensions/codex/src/app-server/plugin-thread-config.ts index 63eb353db55a..3f49e7d22655 100644 --- a/extensions/codex/src/app-server/plugin-thread-config.ts +++ b/extensions/codex/src/app-server/plugin-thread-config.ts @@ -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({ diff --git a/extensions/codex/src/app-server/protocol.ts b/extensions/codex/src/app-server/protocol.ts index b4201cd5c056..476b627c5ef6 100644 --- a/extensions/codex/src/app-server/protocol.ts +++ b/extensions/codex/src/app-server/protocol.ts @@ -562,7 +562,7 @@ type CodexPluginListMarketplaceKind = | "created-by-me-remote"; type CodexPluginListParams = { - cwds: string[]; + cwds?: string[]; marketplaceKinds?: CodexPluginListMarketplaceKind[]; }; diff --git a/extensions/codex/src/app-server/run-attempt-test-harness.ts b/extensions/codex/src/app-server/run-attempt-test-harness.ts index 4a553a85ab71..21a4808694c6 100644 --- a/extensions/codex/src/app-server/run-attempt-test-harness.ts +++ b/extensions/codex/src/app-server/run-attempt-test-harness.ts @@ -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(); diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 9876d11f9367..71ae69a1c760 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -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-")); diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index a316543c5120..c79487044cf4 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -153,6 +153,8 @@ type CodexPluginThreadConfigProvider = { enabled: boolean; inputFingerprint?: string; enabledPluginConfigKeys?: readonly string[]; + recoverablePluginConfigKeys?: readonly string[]; + accountAppRecoveryEnabled?: boolean; build: () => Promise; }; @@ -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(