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

* feat(codex): add native plugin inventory activation

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

* feat(codex): enforce plugin elicitation policy

* feat(codex): migrate native plugins

* docs(codex): document native plugin support

* fix(codex): harden plugin migration refresh

* fix(codex): satisfy plugin activation lint

* fix: stabilize codex plugin app config

* fix: address codex plugin review feedback

* fix: key codex plugin app cache by websocket credentials

* fix: keep codex plugin app fingerprints stable

* fix: refresh codex plugin cache test fixtures

* fix: refresh plugin app readiness after activation

* fix: support remote codex plugin activation

* fix: recover plugin app bindings after cache refresh

* fix: force codex app refresh after plugin activation

* fix: recover partial codex plugin app bindings

* fix: sync codex plugin selection config

* fix: keep codex plugin activation fail closed

* fix: align codex plugin protocol types with main

* fix: refresh partial codex plugin app bindings

* fix: key codex app cache by env api key

* fix: skip failed codex plugin migration config

* test: update codex prompt snapshots

* fix: fail closed on missing codex app inventory entries

* fix(codex): enforce native plugin policy gates

* fix(codex): normalize native plugin policy types

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

* fix(codex): use native plugin destructive policy

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

* fix(codex): drop unshipped plugin fingerprint compat

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

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

* fix(codex): revalidate native plugin app bindings

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

* docs(codex): clarify plugin harness scope

* fix(codex): return activation report state exhaustively

* test(codex): refresh prompt snapshots after rebase

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

226 lines
6.3 KiB
TypeScript

import type { v2 } from "./protocol.js";
export const CODEX_APP_INVENTORY_CACHE_TTL_MS = 60 * 60 * 1_000;
export type CodexAppInventoryRequest = (
method: "app/list",
params: v2.AppsListParams,
) => Promise<v2.AppsListResponse>;
export type CodexAppInventoryCacheKeyInput = {
codexHome?: string;
endpoint?: string;
authProfileId?: string;
accountId?: string;
envApiKeyFingerprint?: string;
appServerVersion?: string;
};
export type CodexAppInventoryCacheDiagnostic = {
message: string;
atMs: number;
};
export type CodexAppInventorySnapshot = {
key: string;
apps: v2.AppInfo[];
fetchedAtMs: number;
expiresAtMs: number;
revision: number;
lastError?: CodexAppInventoryCacheDiagnostic;
};
export type CodexAppInventoryReadState = "fresh" | "stale" | "missing";
export type CodexAppInventoryCacheRead = {
state: CodexAppInventoryReadState;
key: string;
revision: number;
snapshot?: CodexAppInventorySnapshot;
refreshScheduled: boolean;
diagnostic?: CodexAppInventoryCacheDiagnostic;
};
type CacheEntry = CodexAppInventorySnapshot & {
invalidated: boolean;
};
type RefreshParams = {
key: string;
request: CodexAppInventoryRequest;
nowMs?: number;
forceRefetch?: boolean;
};
export class CodexAppInventoryCache {
private readonly ttlMs: number;
private readonly entries = new Map<string, CacheEntry>();
private readonly inFlight = new Map<string, Promise<CodexAppInventorySnapshot>>();
private readonly refreshTokens = new Map<string, number>();
private readonly diagnostics = new Map<string, CodexAppInventoryCacheDiagnostic>();
private revision = 0;
constructor(options: { ttlMs?: number } = {}) {
this.ttlMs = options.ttlMs ?? CODEX_APP_INVENTORY_CACHE_TTL_MS;
}
read(params: RefreshParams): CodexAppInventoryCacheRead {
const nowMs = params.nowMs ?? Date.now();
const entry = this.entries.get(params.key);
if (!entry) {
const refreshScheduled = this.scheduleRefresh(params);
return {
state: "missing",
key: params.key,
revision: this.revision,
refreshScheduled,
...(this.diagnostics.get(params.key)
? { diagnostic: this.diagnostics.get(params.key) }
: {}),
};
}
const state: CodexAppInventoryReadState =
entry.invalidated || entry.expiresAtMs <= nowMs ? "stale" : "fresh";
const refreshScheduled =
state === "fresh" && !params.forceRefetch ? false : this.scheduleRefresh(params);
return {
state,
key: params.key,
revision: entry.revision,
snapshot: stripEntryState(entry),
refreshScheduled,
...(entry.lastError ? { diagnostic: entry.lastError } : {}),
};
}
refreshNow(params: RefreshParams): Promise<CodexAppInventorySnapshot> {
return this.refresh(params);
}
invalidate(key: string, reason: string, nowMs = Date.now()): number {
this.revision += 1;
const diagnostic = { message: reason, atMs: nowMs };
const entry = this.entries.get(key);
if (entry) {
entry.invalidated = true;
entry.lastError = diagnostic;
entry.revision = this.revision;
} else {
this.diagnostics.set(key, diagnostic);
}
return this.revision;
}
clear(): void {
this.entries.clear();
this.inFlight.clear();
this.refreshTokens.clear();
this.diagnostics.clear();
this.revision = 0;
}
getRevision(): number {
return this.revision;
}
private scheduleRefresh(params: RefreshParams): boolean {
if (this.inFlight.has(params.key) && !params.forceRefetch) {
return true;
}
const promise = this.refresh(params);
this.inFlight.set(params.key, promise);
promise.catch(() => undefined);
return true;
}
private async refresh(params: RefreshParams): Promise<CodexAppInventorySnapshot> {
const existing = this.inFlight.get(params.key);
if (existing && !params.forceRefetch) {
return existing;
}
const refreshToken = (this.refreshTokens.get(params.key) ?? 0) + 1;
this.refreshTokens.set(params.key, refreshToken);
const promise = this.refreshUncoalesced(params, refreshToken);
this.inFlight.set(params.key, promise);
try {
return await promise;
} finally {
if (this.inFlight.get(params.key) === promise) {
this.inFlight.delete(params.key);
}
}
}
private async refreshUncoalesced(
params: RefreshParams,
refreshToken: number,
): Promise<CodexAppInventorySnapshot> {
const nowMs = params.nowMs ?? Date.now();
try {
const apps = await listAllApps(params.request, params.forceRefetch ?? false);
this.revision += 1;
const snapshot: CodexAppInventorySnapshot = {
key: params.key,
apps,
fetchedAtMs: nowMs,
expiresAtMs: nowMs + this.ttlMs,
revision: this.revision,
};
if (this.refreshTokens.get(params.key) === refreshToken) {
this.entries.set(params.key, { ...snapshot, invalidated: false });
this.diagnostics.delete(params.key);
}
return snapshot;
} catch (error) {
const diagnostic = {
message: error instanceof Error ? error.message : String(error),
atMs: nowMs,
};
this.diagnostics.set(params.key, diagnostic);
const entry = this.entries.get(params.key);
if (entry) {
entry.lastError = diagnostic;
}
throw error;
}
}
}
export const defaultCodexAppInventoryCache = new CodexAppInventoryCache();
export function buildCodexAppInventoryCacheKey(input: CodexAppInventoryCacheKeyInput): string {
return JSON.stringify({
codexHome: input.codexHome ?? null,
endpoint: input.endpoint ?? null,
authProfileId: input.authProfileId ?? null,
accountId: input.accountId ?? null,
envApiKeyFingerprint: input.envApiKeyFingerprint ?? null,
appServerVersion: input.appServerVersion ?? null,
});
}
async function listAllApps(
request: CodexAppInventoryRequest,
forceRefetch: boolean,
): Promise<v2.AppInfo[]> {
const apps: v2.AppInfo[] = [];
let cursor: string | null | undefined;
do {
const response = await request("app/list", {
cursor,
limit: 100,
forceRefetch,
});
apps.push(...response.data);
cursor = response.nextCursor;
} while (cursor);
return apps;
}
function stripEntryState(entry: CacheEntry): CodexAppInventorySnapshot {
const { invalidated: _invalidated, ...snapshot } = entry;
return snapshot;
}