mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
674dd37cd0
commit
9d4610cbd1
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user