mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 00:10:44 +00:00
* 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
276 lines
8.6 KiB
TypeScript
276 lines
8.6 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
type CodexAppInventoryCache,
|
|
type CodexAppInventoryRequest,
|
|
} from "./app-inventory-cache.js";
|
|
import { CODEX_PLUGINS_MARKETPLACE_NAME, type ResolvedCodexPluginPolicy } from "./config.js";
|
|
import {
|
|
findOpenAiCuratedPluginSummary,
|
|
pluginReadParams,
|
|
type CodexPluginMarketplaceRef,
|
|
type CodexPluginRuntimeRequest,
|
|
} from "./plugin-inventory.js";
|
|
import type { v2 } from "./protocol.js";
|
|
|
|
export type CodexPluginActivationReason =
|
|
| "already_active"
|
|
| "installed"
|
|
| "disabled"
|
|
| "marketplace_missing"
|
|
| "plugin_missing"
|
|
| "auth_required"
|
|
| "refresh_failed";
|
|
|
|
export type CodexPluginActivationDiagnostic = {
|
|
message: string;
|
|
};
|
|
|
|
export type CodexPluginActivationResult = {
|
|
identity: ResolvedCodexPluginPolicy;
|
|
ok: boolean;
|
|
reason: CodexPluginActivationReason;
|
|
installAttempted: boolean;
|
|
marketplace?: CodexPluginMarketplaceRef;
|
|
installResponse?: v2.PluginInstallResponse;
|
|
diagnostics: CodexPluginActivationDiagnostic[];
|
|
};
|
|
|
|
export type EnsureCodexPluginActivationParams = {
|
|
identity: ResolvedCodexPluginPolicy;
|
|
request: CodexPluginRuntimeRequest;
|
|
appCache?: CodexAppInventoryCache;
|
|
appCacheKey?: string;
|
|
installEvenIfActive?: boolean;
|
|
};
|
|
|
|
export type CodexPluginRuntimeRefreshResult = {
|
|
diagnostics: CodexPluginActivationDiagnostic[];
|
|
};
|
|
|
|
export async function ensureCodexPluginActivation(
|
|
params: EnsureCodexPluginActivationParams,
|
|
): Promise<CodexPluginActivationResult> {
|
|
if (params.identity.marketplaceName !== CODEX_PLUGINS_MARKETPLACE_NAME) {
|
|
return activationFailure(params.identity, "marketplace_missing", {
|
|
message: "Only " + CODEX_PLUGINS_MARKETPLACE_NAME + " plugins can be activated.",
|
|
});
|
|
}
|
|
|
|
const listed = (await params.request("plugin/list", {
|
|
cwds: [],
|
|
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
|
|
const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName);
|
|
if (!resolved) {
|
|
return activationFailure(params.identity, "plugin_missing", {
|
|
message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`,
|
|
});
|
|
}
|
|
|
|
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 installResponse = (await params.request(
|
|
"plugin/install",
|
|
pluginReadParams(
|
|
resolved.marketplace,
|
|
params.identity.pluginName,
|
|
) satisfies v2.PluginInstallParams,
|
|
)) as v2.PluginInstallResponse;
|
|
const refreshDiagnostics: CodexPluginActivationDiagnostic[] = [];
|
|
let refreshFailed = false;
|
|
try {
|
|
const refreshResult = await refreshCodexPluginRuntimeState({
|
|
request: params.request,
|
|
appCache: params.appCache,
|
|
appCacheKey: params.appCacheKey,
|
|
});
|
|
refreshDiagnostics.push(...refreshResult.diagnostics);
|
|
} catch (error) {
|
|
refreshFailed = true;
|
|
refreshDiagnostics.push({
|
|
message: `Codex plugin runtime refresh failed after install: ${
|
|
error instanceof Error ? error.message : String(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.`,
|
|
})),
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function refreshCodexPluginRuntimeState(params: {
|
|
request: CodexPluginRuntimeRequest;
|
|
appCache?: CodexAppInventoryCache;
|
|
appCacheKey?: string;
|
|
}): Promise<CodexPluginRuntimeRefreshResult> {
|
|
const diagnostics: CodexPluginActivationDiagnostic[] = [];
|
|
await params.request("plugin/list", {
|
|
cwds: [],
|
|
} satisfies v2.PluginListParams);
|
|
await params.request("skills/list", {
|
|
cwds: [],
|
|
forceReload: true,
|
|
} satisfies v2.SkillsListParams);
|
|
try {
|
|
await params.request("hooks/list", {
|
|
cwds: [],
|
|
} satisfies v2.HooksListParams);
|
|
} catch (error) {
|
|
diagnostics.push({
|
|
message: `Codex hooks refresh skipped: ${error instanceof Error ? error.message : String(error)}`,
|
|
});
|
|
}
|
|
await params.request("config/mcpServer/reload", undefined);
|
|
|
|
if (params.appCache && params.appCacheKey) {
|
|
params.appCache.invalidate(params.appCacheKey, "Codex plugin activation changed app inventory");
|
|
const request: CodexAppInventoryRequest = async (method, requestParams) =>
|
|
(await params.request(method, requestParams)) as v2.AppsListResponse;
|
|
try {
|
|
await params.appCache.refreshNow({
|
|
key: params.appCacheKey,
|
|
request,
|
|
forceRefetch: true,
|
|
});
|
|
} catch (error) {
|
|
diagnostics.push({
|
|
message: `Codex app inventory refresh skipped: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { diagnostics };
|
|
}
|
|
|
|
export async function ensureCodexAppsSubstrateConfig(params: {
|
|
codexHome: string;
|
|
readFile?: (filePath: string, encoding: "utf8") => Promise<string>;
|
|
writeFile?: (filePath: string, content: string, encoding: "utf8") => Promise<void>;
|
|
mkdir?: (dirPath: string, options: { recursive: true }) => Promise<unknown>;
|
|
}): Promise<{ changed: boolean; configPath: string }> {
|
|
const readFile = params.readFile ?? ((filePath, encoding) => fs.readFile(filePath, encoding));
|
|
const writeFile =
|
|
params.writeFile ??
|
|
((filePath, content, encoding) => fs.writeFile(filePath, content, encoding));
|
|
const mkdir = params.mkdir ?? ((dirPath, options) => fs.mkdir(dirPath, options));
|
|
const configPath = path.join(params.codexHome, "config.toml");
|
|
let current = "";
|
|
try {
|
|
current = await readFile(configPath, "utf8");
|
|
} catch (error) {
|
|
if (!isEnoent(error)) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const next = upsertTomlBoolean(
|
|
upsertTomlBoolean(current, "features", "apps", true),
|
|
"apps._default",
|
|
"enabled",
|
|
true,
|
|
);
|
|
if (next === current) {
|
|
return { changed: false, configPath };
|
|
}
|
|
await mkdir(path.dirname(configPath), { recursive: true });
|
|
await writeFile(configPath, next, "utf8");
|
|
return { changed: true, configPath };
|
|
}
|
|
|
|
export function upsertTomlBoolean(
|
|
source: string,
|
|
section: string,
|
|
key: string,
|
|
value: boolean,
|
|
): string {
|
|
const lines = source.replace(/\r\n/g, "\n").split("\n");
|
|
if (lines.length > 0 && lines.at(-1) === "") {
|
|
lines.pop();
|
|
}
|
|
const sectionHeaderPattern = new RegExp(`^\\s*\\[${escapeRegExp(section)}\\]\\s*(?:#.*)?$`);
|
|
const anySectionPattern = /^\s*\[[^\]]+\]\s*(?:#.*)?$/;
|
|
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
|
const desiredLine = `${key} = ${value ? "true" : "false"}`;
|
|
const sectionStart = lines.findIndex((line) => sectionHeaderPattern.test(line));
|
|
if (sectionStart === -1) {
|
|
const nextLines = [...lines];
|
|
if (nextLines.length > 0 && nextLines.at(-1)?.trim()) {
|
|
nextLines.push("");
|
|
}
|
|
nextLines.push(`[${section}]`, desiredLine);
|
|
return `${nextLines.join("\n")}\n`;
|
|
}
|
|
|
|
let sectionEnd = lines.length;
|
|
for (let index = sectionStart + 1; index < lines.length; index += 1) {
|
|
if (anySectionPattern.test(lines[index] ?? "")) {
|
|
sectionEnd = index;
|
|
break;
|
|
}
|
|
}
|
|
for (let index = sectionStart + 1; index < sectionEnd; index += 1) {
|
|
if (keyPattern.test(lines[index] ?? "")) {
|
|
if (lines[index] === desiredLine) {
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
const nextLines = [...lines];
|
|
nextLines[index] = desiredLine;
|
|
return `${nextLines.join("\n")}\n`;
|
|
}
|
|
}
|
|
const nextLines = [...lines];
|
|
nextLines.splice(sectionEnd, 0, desiredLine);
|
|
return `${nextLines.join("\n")}\n`;
|
|
}
|
|
|
|
function activationFailure(
|
|
identity: ResolvedCodexPluginPolicy,
|
|
reason: CodexPluginActivationReason,
|
|
diagnostic: CodexPluginActivationDiagnostic,
|
|
): CodexPluginActivationResult {
|
|
return {
|
|
identity,
|
|
ok: false,
|
|
reason,
|
|
installAttempted: false,
|
|
diagnostics: [diagnostic],
|
|
};
|
|
}
|
|
|
|
function isEnoent(error: unknown): boolean {
|
|
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|