mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
e1b8150a06
* fix(codex): allow owner-approved marketplace plugins * fix(codex): preserve marketplace validation and remove unused exports * docs(codex): clarify already-installed plugin authorization
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* Activates legacy curated Codex plugins while requiring owner-managed
|
|
* installation for every other marketplace.
|
|
*/
|
|
import { coerceErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import type { CodexAppInventoryCache, CodexAppInventoryRequest } from "./app-inventory-cache.js";
|
|
import {
|
|
CODEX_PLUGINS_MARKETPLACE_NAME,
|
|
CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME,
|
|
type ResolvedCodexPluginPolicy,
|
|
} from "./config.js";
|
|
import {
|
|
findCodexMarketplacePluginSummary,
|
|
isOpenAiCuratedMarketplace,
|
|
isOpenAiCuratedMarketplaceName,
|
|
pluginReadParams,
|
|
type CodexPluginMarketplaceRef,
|
|
type CodexPluginRuntimeRequest,
|
|
} from "./plugin-inventory.js";
|
|
import type { CodexPluginMetadataCache } from "./plugin-metadata-cache.js";
|
|
import type { CodexAppServerRequestResult, v2 } from "./protocol.js";
|
|
import { CodexAppServerRpcError } from "./rpc-error.js";
|
|
|
|
/** Terminal reason reported after trying to activate one Codex plugin policy. */
|
|
type CodexPluginActivationReason =
|
|
| "already_active"
|
|
| "installed"
|
|
| "disabled"
|
|
| "marketplace_missing"
|
|
| "plugin_missing"
|
|
| "install_failed"
|
|
| "auth_required"
|
|
| "refresh_failed";
|
|
|
|
/** Human-readable diagnostic emitted during Codex plugin activation. */
|
|
type CodexPluginActivationDiagnostic = {
|
|
message: string;
|
|
};
|
|
|
|
/** Result of ensuring one configured Codex plugin is installed and enabled. */
|
|
export type CodexPluginActivationResult = {
|
|
identity: ResolvedCodexPluginPolicy;
|
|
ok: boolean;
|
|
reason: CodexPluginActivationReason;
|
|
installAttempted: boolean;
|
|
marketplace?: CodexPluginMarketplaceRef;
|
|
installResponse?: v2.PluginInstallResponse;
|
|
diagnostics: CodexPluginActivationDiagnostic[];
|
|
};
|
|
|
|
/** Inputs for activating one resolved Codex plugin policy. */
|
|
type EnsureCodexPluginActivationParams = {
|
|
identity: ResolvedCodexPluginPolicy;
|
|
request: CodexPluginRuntimeRequest;
|
|
appCache?: CodexAppInventoryCache;
|
|
appCacheKey?: string;
|
|
configCwd?: string;
|
|
metadataCache?: CodexPluginMetadataCache;
|
|
installEvenIfActive?: boolean;
|
|
/** Thread setup batches app refresh once after all plugin activations. */
|
|
deferAppInventoryRefresh?: boolean;
|
|
targetAppIds?: readonly string[];
|
|
};
|
|
|
|
/** Diagnostics from refreshing Codex runtime surfaces after plugin activation. */
|
|
type CodexPluginRuntimeRefreshResult = {
|
|
diagnostics: CodexPluginActivationDiagnostic[];
|
|
};
|
|
|
|
/** Activates legacy curated plugins without granting install authority to other marketplaces. */
|
|
export async function ensureCodexPluginActivation(
|
|
params: EnsureCodexPluginActivationParams,
|
|
): Promise<CodexPluginActivationResult> {
|
|
if (params.identity.marketplaceName === CODEX_PLUGINS_WORKSPACE_MARKETPLACE_NAME) {
|
|
return activationFailure(params.identity, "disabled", {
|
|
message:
|
|
"workspace-directory plugins must be installed and enabled outside OpenClaw before use.",
|
|
});
|
|
}
|
|
if (!isOpenAiCuratedMarketplaceName(params.identity.marketplaceName)) {
|
|
const target = params.identity.pluginName.endsWith(`@${params.identity.marketplaceName}`)
|
|
? params.identity.pluginName
|
|
: `${params.identity.pluginName}@${params.identity.marketplaceName}`;
|
|
return activationFailure(params.identity, "disabled", {
|
|
message:
|
|
`${params.identity.marketplaceName} plugins must be installed and enabled by an owner ` +
|
|
`before use. Run /codex plugins install ${target}.`,
|
|
});
|
|
}
|
|
|
|
const listed = await listCuratedCodexPluginMetadata(params);
|
|
const resolved = findCodexMarketplacePluginSummary(
|
|
listed,
|
|
params.identity.marketplaceName,
|
|
params.identity.pluginName,
|
|
);
|
|
if (!resolved) {
|
|
const hasCuratedMarketplace = listed.marketplaces.some((marketplace) =>
|
|
isOpenAiCuratedMarketplace(marketplace),
|
|
);
|
|
if (!hasCuratedMarketplace) {
|
|
return activationFailure(params.identity, "marketplace_missing", {
|
|
message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`,
|
|
});
|
|
}
|
|
return activationFailure(params.identity, "plugin_missing", {
|
|
message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
|
|
});
|
|
}
|
|
|
|
if (resolved.marketplace.remoteMarketplaceName && !resolved.summary.remotePluginId) {
|
|
return activationFailure(params.identity, "plugin_missing", {
|
|
message: `${params.identity.pluginName} detail unavailable: Codex did not return a remote plugin id.`,
|
|
});
|
|
}
|
|
|
|
if (
|
|
resolved.summary.availability === "DISABLED_BY_ADMIN" ||
|
|
resolved.summary.installPolicy === "NOT_AVAILABLE"
|
|
) {
|
|
return activationFailure(params.identity, "disabled", {
|
|
message: `${params.identity.pluginName} was disabled or made unavailable by its marketplace administrator.`,
|
|
});
|
|
}
|
|
|
|
if (resolved.summary.installed && resolved.summary.enabled && !params.installEvenIfActive) {
|
|
return {
|
|
identity: params.identity,
|
|
ok: true,
|
|
reason: "already_active",
|
|
installAttempted: false,
|
|
marketplace: resolved.marketplace,
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
const remotePluginId = resolved.marketplace.remoteMarketplaceName
|
|
? resolved.summary.remotePluginId
|
|
: undefined;
|
|
let installResponse: v2.PluginInstallResponse;
|
|
try {
|
|
installResponse = (await params.request(
|
|
"plugin/install",
|
|
pluginReadParams(
|
|
resolved.marketplace,
|
|
remotePluginId ?? params.identity.pluginName,
|
|
) satisfies v2.PluginInstallParams,
|
|
)) as v2.PluginInstallResponse;
|
|
} catch (error) {
|
|
if (
|
|
!(error instanceof CodexAppServerRpcError) ||
|
|
error.code !== -32600 ||
|
|
!remotePluginId ||
|
|
(error.message !== `remote plugin ${remotePluginId} is disabled by admin` &&
|
|
error.message !== `remote plugin ${remotePluginId} is not available for install`)
|
|
) {
|
|
throw error;
|
|
}
|
|
// The catalog can be stale by install time. Isolate only Codex's exact
|
|
// terminal remote-install contract; unrelated RPC failures abort the turn.
|
|
return {
|
|
identity: params.identity,
|
|
ok: false,
|
|
reason: "install_failed",
|
|
installAttempted: true,
|
|
marketplace: resolved.marketplace,
|
|
diagnostics: [
|
|
{
|
|
message: `Codex plugin install failed: ${coerceErrorMessage(error)}`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (params.metadataCache && params.appCacheKey) {
|
|
params.metadataCache.invalidate(params.appCacheKey);
|
|
}
|
|
const refreshDiagnostics: CodexPluginActivationDiagnostic[] = [];
|
|
let refreshFailed = false;
|
|
try {
|
|
const refreshResult = await refreshCodexPluginRuntimeState({
|
|
request: params.request,
|
|
appCache: params.appCache,
|
|
appCacheKey: params.appCacheKey,
|
|
configCwd: params.configCwd,
|
|
metadataCache: params.metadataCache,
|
|
deferAppInventoryRefresh: params.deferAppInventoryRefresh,
|
|
targetAppIds: params.targetAppIds,
|
|
});
|
|
refreshDiagnostics.push(...refreshResult.diagnostics);
|
|
} catch (error) {
|
|
refreshFailed = true;
|
|
refreshDiagnostics.push({
|
|
message: `Codex plugin runtime refresh failed after install: ${coerceErrorMessage(error)}`,
|
|
});
|
|
}
|
|
const authRequired = installResponse.appsNeedingAuth.length > 0;
|
|
return {
|
|
identity: params.identity,
|
|
ok: !authRequired && !refreshFailed,
|
|
reason: refreshFailed
|
|
? "refresh_failed"
|
|
: authRequired
|
|
? "auth_required"
|
|
: resolved.summary.installed && resolved.summary.enabled
|
|
? "already_active"
|
|
: "installed",
|
|
installAttempted: true,
|
|
marketplace: resolved.marketplace,
|
|
installResponse,
|
|
diagnostics: [
|
|
...refreshDiagnostics,
|
|
...installResponse.appsNeedingAuth.map((app) => ({
|
|
message: `${app.name} requires app authentication before plugin tools are exposed.`,
|
|
})),
|
|
],
|
|
};
|
|
}
|
|
|
|
/** Forces Codex plugin, skill, hook, MCP, and app inventory refreshes after activation. */
|
|
export async function refreshCodexPluginRuntimeState(params: {
|
|
request: CodexPluginRuntimeRequest;
|
|
appCache?: CodexAppInventoryCache;
|
|
appCacheKey?: string;
|
|
configCwd?: string;
|
|
metadataCache?: CodexPluginMetadataCache;
|
|
deferAppInventoryRefresh?: boolean;
|
|
targetAppIds?: readonly string[];
|
|
}): Promise<CodexPluginRuntimeRefreshResult> {
|
|
const diagnostics: CodexPluginActivationDiagnostic[] = [];
|
|
await listCuratedCodexPluginMetadata(params, { forceRefetch: true });
|
|
await (params.request("skills/list", {
|
|
cwds: params.configCwd ? [params.configCwd] : [],
|
|
forceReload: true,
|
|
} satisfies v2.SkillsListParams) as Promise<v2.SkillsListResponse>);
|
|
try {
|
|
await (params.request("hooks/list", {
|
|
cwds: params.configCwd ? [params.configCwd] : [],
|
|
} satisfies v2.HooksListParams) as Promise<v2.HooksListResponse>);
|
|
} catch (error) {
|
|
diagnostics.push({
|
|
message: `Codex hooks refresh skipped: ${coerceErrorMessage(error)}`,
|
|
});
|
|
}
|
|
await params.request("config/mcpServer/reload", undefined);
|
|
|
|
if (params.appCache && params.appCacheKey) {
|
|
// Scope the invalidation to the activated plugin's apps so the follow-up
|
|
// targeted refresh (immediate or deferred union) can revalidate the entry.
|
|
params.appCache.invalidate(
|
|
params.appCacheKey,
|
|
"Codex plugin activation changed app inventory",
|
|
undefined,
|
|
params.targetAppIds,
|
|
);
|
|
if (params.deferAppInventoryRefresh) {
|
|
return { diagnostics };
|
|
}
|
|
const request: CodexAppInventoryRequest = async (method, requestParams) =>
|
|
(await params.request(method, requestParams)) as CodexAppServerRequestResult<typeof method>;
|
|
try {
|
|
await params.appCache.refreshNow({
|
|
key: params.appCacheKey,
|
|
request,
|
|
forceRefetch: true,
|
|
targetAppIds: params.targetAppIds,
|
|
});
|
|
} catch (error) {
|
|
diagnostics.push({
|
|
message: `Codex app inventory refresh skipped: ${coerceErrorMessage(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { diagnostics };
|
|
}
|
|
|
|
async function listCuratedCodexPluginMetadata(
|
|
params: {
|
|
request: CodexPluginRuntimeRequest;
|
|
metadataCache?: CodexPluginMetadataCache;
|
|
appCacheKey?: string;
|
|
configCwd?: string;
|
|
},
|
|
options: { forceRefetch?: boolean } = {},
|
|
): Promise<v2.PluginListResponse> {
|
|
const requestParams = {
|
|
...(params.configCwd ? { cwds: [params.configCwd] } : {}),
|
|
...(options.forceRefetch ? { forceRefetch: true } : {}),
|
|
} 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,
|
|
diagnostic: CodexPluginActivationDiagnostic,
|
|
extraDiagnostics: CodexPluginActivationDiagnostic[] = [],
|
|
): CodexPluginActivationResult {
|
|
return {
|
|
identity,
|
|
ok: false,
|
|
reason,
|
|
installAttempted: false,
|
|
diagnostics: [diagnostic, ...extraDiagnostics],
|
|
};
|
|
}
|