mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-15 09:40:45 +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
760 lines
24 KiB
TypeScript
760 lines
24 KiB
TypeScript
import {
|
|
embeddedAgentLog,
|
|
type EmbeddedRunAttemptParams,
|
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import { formatCodexDisplayText } from "../command-formatters.js";
|
|
import {
|
|
approvalRequestExplicitlyUnavailable,
|
|
mapExecDecisionToOutcome,
|
|
requestPluginApproval,
|
|
type AppServerApprovalOutcome,
|
|
waitForPluginApprovalDecision,
|
|
} from "./plugin-approval-roundtrip.js";
|
|
import type {
|
|
PluginAppPolicyContext,
|
|
PluginAppPolicyContextEntry,
|
|
} from "./plugin-thread-config.js";
|
|
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
|
|
|
|
type ApprovalPropertyContext = {
|
|
name: string;
|
|
schema: JsonObject;
|
|
required: boolean;
|
|
};
|
|
|
|
type BridgeableApprovalElicitation = {
|
|
title: string;
|
|
description: string;
|
|
requestedSchema: JsonObject;
|
|
meta: JsonObject;
|
|
};
|
|
|
|
type PluginElicitationResolution =
|
|
| { kind: "not_plugin" }
|
|
| { kind: "matched"; entry: PluginAppPolicyContextEntry }
|
|
| { kind: "decline"; reason: string };
|
|
|
|
const MCP_TOOL_APPROVAL_KIND = "mcp_tool_call";
|
|
const MCP_TOOL_APPROVAL_KIND_KEY = "codex_approval_kind";
|
|
const MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY = "connector_name";
|
|
const MCP_TOOL_APPROVAL_TOOL_TITLE_KEY = "tool_title";
|
|
const MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY = "tool_description";
|
|
const MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY = "tool_params_display";
|
|
const PLUGIN_APP_ID_META_KEYS = ["app_id", "appId", "codex_app_id", "codexAppId"];
|
|
const PLUGIN_NAME_META_KEYS = ["plugin_name", "pluginName", "codex_plugin_name", "codexPluginName"];
|
|
const PLUGIN_CONFIG_KEY_META_KEYS = ["config_key", "configKey", "codex_config_key"];
|
|
const PLUGIN_MARKETPLACE_NAME_META_KEYS = [
|
|
"marketplace_name",
|
|
"marketplaceName",
|
|
"codex_marketplace_name",
|
|
"codexMarketplaceName",
|
|
];
|
|
const MAX_DISPLAY_PARAM_ENTRIES = 8;
|
|
const MAX_DISPLAY_PARAM_VALUE_LENGTH = 120;
|
|
const MAX_DISPLAY_VALUE_ARRAY_ITEMS = 8;
|
|
const MAX_DISPLAY_VALUE_OBJECT_KEYS = 8;
|
|
const MAX_DISPLAY_VALUE_DEPTH = 3;
|
|
const DISPLAY_TEXT_SCAN_MAX_LENGTH = 4096;
|
|
const ANSI_OSC_SEQUENCE_RE = new RegExp(
|
|
String.raw`(?:\u001b]|\u009d)[^\u001b\u009c\u0007]*(?:\u0007|\u001b\\|\u009c)`,
|
|
"g",
|
|
);
|
|
const ANSI_CONTROL_SEQUENCE_RE = new RegExp(
|
|
String.raw`(?:\u001b\[[0-?]*[ -/]*[@-~]|\u009b[0-?]*[ -/]*[@-~]|\u001b[@-Z\\-_])`,
|
|
"g",
|
|
);
|
|
const CONTROL_CHARACTER_RE = new RegExp(String.raw`[\u0000-\u001f\u007f-\u009f]+`, "g");
|
|
const INVISIBLE_FORMATTING_CONTROL_RE = new RegExp(
|
|
String.raw`[\u00ad\u034f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\ufe00-\ufe0f\u{e0100}-\u{e01ef}]`,
|
|
"gu",
|
|
);
|
|
const DANGLING_TERMINAL_SEQUENCE_SUFFIX_RE = new RegExp(
|
|
String.raw`(?:\u001b\][^\u001b\u009c\u0007]*|\u009d[^\u001b\u009c\u0007]*|\u001b\[[0-?]*[ -/]*|\u009b[0-?]*[ -/]*|\u001b)$`,
|
|
);
|
|
|
|
export async function handleCodexAppServerElicitationRequest(params: {
|
|
requestParams: JsonValue | undefined;
|
|
paramsForRun: EmbeddedRunAttemptParams;
|
|
threadId: string;
|
|
turnId: string;
|
|
pluginAppPolicyContext?: PluginAppPolicyContext;
|
|
signal?: AbortSignal;
|
|
}): Promise<JsonValue | undefined> {
|
|
const requestParams = isJsonObject(params.requestParams) ? params.requestParams : undefined;
|
|
if (!requestParams) {
|
|
return undefined;
|
|
}
|
|
if (!matchesCurrentThread(requestParams, params.threadId)) {
|
|
return undefined;
|
|
}
|
|
if (turnIdMismatches(requestParams, params.turnId)) {
|
|
return undefined;
|
|
}
|
|
const pluginResolution = resolvePluginElicitation({
|
|
requestParams,
|
|
pluginAppPolicyContext: params.pluginAppPolicyContext,
|
|
});
|
|
if (pluginResolution.kind !== "not_plugin") {
|
|
if (pluginResolution.kind === "decline") {
|
|
logPluginElicitationDecline(pluginResolution.reason, requestParams);
|
|
return declineElicitationResponse();
|
|
}
|
|
if (!hasExactTurnId(requestParams, params.turnId)) {
|
|
logPluginElicitationDecline("missing_active_turn", requestParams);
|
|
return declineElicitationResponse();
|
|
}
|
|
return buildPluginPolicyElicitationResponse(pluginResolution.entry, requestParams);
|
|
}
|
|
|
|
const approvalPrompt = readBridgeableApprovalElicitation(requestParams);
|
|
if (!approvalPrompt) {
|
|
return undefined;
|
|
}
|
|
|
|
const outcome = await requestPluginApprovalOutcome({
|
|
paramsForRun: params.paramsForRun,
|
|
title: approvalPrompt.title,
|
|
description: approvalPrompt.description,
|
|
signal: params.signal,
|
|
});
|
|
return buildElicitationResponse(approvalPrompt.requestedSchema, approvalPrompt.meta, outcome);
|
|
}
|
|
|
|
function matchesCurrentThread(requestParams: JsonObject | undefined, threadId: string): boolean {
|
|
if (!requestParams) {
|
|
return false;
|
|
}
|
|
const requestThreadId = readString(requestParams, "threadId");
|
|
return requestThreadId === threadId;
|
|
}
|
|
|
|
function turnIdMismatches(requestParams: JsonObject | undefined, turnId: string): boolean {
|
|
const rawTurnId = requestParams?.turnId;
|
|
return rawTurnId !== null && rawTurnId !== undefined && rawTurnId !== turnId;
|
|
}
|
|
|
|
function hasExactTurnId(requestParams: JsonObject | undefined, turnId: string): boolean {
|
|
return requestParams?.turnId === turnId;
|
|
}
|
|
|
|
function resolvePluginElicitation(params: {
|
|
requestParams: JsonObject | undefined;
|
|
pluginAppPolicyContext?: PluginAppPolicyContext;
|
|
}): PluginElicitationResolution {
|
|
const requestParams = params.requestParams;
|
|
if (!requestParams) {
|
|
return { kind: "not_plugin" };
|
|
}
|
|
const meta = isJsonObject(requestParams._meta) ? requestParams._meta : {};
|
|
const context = params.pluginAppPolicyContext;
|
|
const entries = context ? Object.values(context.apps) : [];
|
|
|
|
const appId =
|
|
readFirstString(meta, PLUGIN_APP_ID_META_KEYS) ??
|
|
readFirstString(requestParams, PLUGIN_APP_ID_META_KEYS);
|
|
if (appId) {
|
|
if (!context) {
|
|
return { kind: "decline", reason: "missing_policy_context" };
|
|
}
|
|
const entry = context.apps[appId];
|
|
return uniquePluginMatch(entry ? [entry] : [], "app_id");
|
|
}
|
|
|
|
const serverName = readString(requestParams, "serverName");
|
|
if (serverName && context) {
|
|
const matches = entries.filter((entry) => entry.mcpServerNames.includes(serverName));
|
|
if (matches.length > 0) {
|
|
return uniquePluginMatch(matches, "server_name");
|
|
}
|
|
}
|
|
|
|
const metadataResolution = resolvePluginStableMetadataMatch({
|
|
meta,
|
|
requestParams,
|
|
entries,
|
|
context,
|
|
});
|
|
if (metadataResolution.kind !== "not_plugin") {
|
|
return metadataResolution;
|
|
}
|
|
|
|
if (context && hasDisplayNameOnlyPluginMatch(meta, entries)) {
|
|
return { kind: "decline", reason: "display_name_only" };
|
|
}
|
|
|
|
return { kind: "not_plugin" };
|
|
}
|
|
|
|
function resolvePluginStableMetadataMatch(params: {
|
|
meta: JsonObject;
|
|
requestParams: JsonObject;
|
|
entries: PluginAppPolicyContextEntry[];
|
|
context?: PluginAppPolicyContext;
|
|
}): PluginElicitationResolution {
|
|
const pluginName =
|
|
readFirstString(params.meta, PLUGIN_NAME_META_KEYS) ??
|
|
readFirstString(params.requestParams, PLUGIN_NAME_META_KEYS);
|
|
const configKey =
|
|
readFirstString(params.meta, PLUGIN_CONFIG_KEY_META_KEYS) ??
|
|
readFirstString(params.requestParams, PLUGIN_CONFIG_KEY_META_KEYS);
|
|
const marketplaceName =
|
|
readFirstString(params.meta, PLUGIN_MARKETPLACE_NAME_META_KEYS) ??
|
|
readFirstString(params.requestParams, PLUGIN_MARKETPLACE_NAME_META_KEYS);
|
|
if (!pluginName && !configKey) {
|
|
return { kind: "not_plugin" };
|
|
}
|
|
if (!params.context) {
|
|
return { kind: "decline", reason: "missing_policy_context" };
|
|
}
|
|
const matches = params.entries.filter((entry) => {
|
|
if (marketplaceName && entry.marketplaceName !== marketplaceName) {
|
|
return false;
|
|
}
|
|
if (pluginName && entry.pluginName !== pluginName) {
|
|
return false;
|
|
}
|
|
if (configKey && entry.configKey !== configKey) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return uniquePluginMatch(matches, "metadata");
|
|
}
|
|
|
|
function uniquePluginMatch(
|
|
matches: PluginAppPolicyContextEntry[],
|
|
source: string,
|
|
): PluginElicitationResolution {
|
|
if (matches.length === 1 && matches[0]) {
|
|
return { kind: "matched", entry: matches[0] };
|
|
}
|
|
return {
|
|
kind: "decline",
|
|
reason: matches.length === 0 ? `${source}_not_enabled` : `${source}_ambiguous`,
|
|
};
|
|
}
|
|
|
|
function hasDisplayNameOnlyPluginMatch(
|
|
meta: JsonObject,
|
|
entries: PluginAppPolicyContextEntry[],
|
|
): boolean {
|
|
const connectorName = readString(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY);
|
|
if (!connectorName) {
|
|
return false;
|
|
}
|
|
const normalized = normalizePluginIdentityText(connectorName);
|
|
return entries.some(
|
|
(entry) =>
|
|
normalizePluginIdentityText(entry.pluginName) === normalized ||
|
|
normalizePluginIdentityText(entry.configKey) === normalized,
|
|
);
|
|
}
|
|
|
|
function normalizePluginIdentityText(value: string): string {
|
|
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
}
|
|
|
|
function buildPluginPolicyElicitationResponse(
|
|
entry: PluginAppPolicyContextEntry,
|
|
requestParams: JsonObject,
|
|
): JsonValue {
|
|
if (!entry.allowDestructiveActions) {
|
|
logPluginElicitationDecline("destructive_actions_disabled", requestParams);
|
|
return declineElicitationResponse();
|
|
}
|
|
if (
|
|
readString(requestParams, "mode") !== "form" ||
|
|
!isJsonObject(requestParams.requestedSchema)
|
|
) {
|
|
logPluginElicitationDecline("unsupported_schema", requestParams);
|
|
return declineElicitationResponse();
|
|
}
|
|
const meta = isJsonObject(requestParams._meta) ? requestParams._meta : {};
|
|
const response = buildElicitationResponse(requestParams.requestedSchema, meta, "approved-once");
|
|
if (isJsonObject(response) && response.action === "accept") {
|
|
return response;
|
|
}
|
|
logPluginElicitationDecline("unmappable_schema", requestParams);
|
|
return declineElicitationResponse();
|
|
}
|
|
|
|
function declineElicitationResponse(): JsonValue {
|
|
return { action: "decline", content: null, _meta: null };
|
|
}
|
|
|
|
function logPluginElicitationDecline(reason: string, requestParams: JsonObject | undefined): void {
|
|
embeddedAgentLog.debug("codex plugin elicitation declined", {
|
|
reason,
|
|
serverName: readString(requestParams, "serverName"),
|
|
mode: readString(requestParams, "mode"),
|
|
});
|
|
}
|
|
|
|
function readBridgeableApprovalElicitation(
|
|
requestParams: JsonObject | undefined,
|
|
): BridgeableApprovalElicitation | undefined {
|
|
if (
|
|
!requestParams ||
|
|
readString(requestParams, "mode") !== "form" ||
|
|
!isJsonObject(requestParams._meta) ||
|
|
requestParams._meta[MCP_TOOL_APPROVAL_KIND_KEY] !== MCP_TOOL_APPROVAL_KIND ||
|
|
!isJsonObject(requestParams.requestedSchema)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const requestedSchema = requestParams.requestedSchema;
|
|
if (
|
|
readString(requestedSchema, "type") !== "object" ||
|
|
!isJsonObject(requestedSchema.properties)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const title =
|
|
sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex MCP tool approval";
|
|
return {
|
|
title,
|
|
description: buildApprovalDescription({
|
|
title,
|
|
meta: requestParams._meta,
|
|
requestedSchema,
|
|
serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")),
|
|
}),
|
|
requestedSchema,
|
|
meta: requestParams._meta,
|
|
};
|
|
}
|
|
|
|
function buildApprovalDescription(params: {
|
|
title: string;
|
|
meta: JsonObject;
|
|
requestedSchema: JsonObject;
|
|
serverName: string | undefined;
|
|
}): string {
|
|
const connectorName = sanitizeOptionalDisplayText(
|
|
readString(params.meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY),
|
|
);
|
|
const toolTitle = sanitizeOptionalDisplayText(
|
|
readString(params.meta, MCP_TOOL_APPROVAL_TOOL_TITLE_KEY),
|
|
);
|
|
const toolDescription = sanitizeOptionalDisplayText(
|
|
readString(params.meta, MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY),
|
|
);
|
|
const summaryLines = [
|
|
connectorName && `App: ${connectorName}`,
|
|
toolTitle && `Tool: ${toolTitle}`,
|
|
params.serverName && `MCP server: ${params.serverName}`,
|
|
toolDescription,
|
|
].filter((line): line is string => Boolean(line));
|
|
const paramLines = readDisplayParamLines(params.meta);
|
|
const propertyLines = readPropertyDescriptionLines(params.requestedSchema);
|
|
return [
|
|
params.title,
|
|
summaryLines.join("\n"),
|
|
paramLines.length > 0 ? ["Parameters:", ...paramLines].join("\n") : "",
|
|
propertyLines.length > 0 ? ["Fields:", ...propertyLines].join("\n") : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
}
|
|
|
|
function readPropertyDescriptionLines(requestedSchema: JsonObject): string[] {
|
|
const properties = isJsonObject(requestedSchema.properties) ? requestedSchema.properties : {};
|
|
return Object.entries(properties)
|
|
.map(([name, value]) => {
|
|
const schema = isJsonObject(value) ? value : undefined;
|
|
if (!schema) {
|
|
return undefined;
|
|
}
|
|
const propTitle =
|
|
sanitizeDisplayText(readString(schema, "title") ?? "") ||
|
|
sanitizeDisplayText(name) ||
|
|
"field";
|
|
const description = sanitizeOptionalDisplayText(readString(schema, "description"));
|
|
return description ? `- ${propTitle}: ${description}` : `- ${propTitle}`;
|
|
})
|
|
.filter((line): line is string => Boolean(line));
|
|
}
|
|
|
|
function readDisplayParamLines(meta: JsonObject): string[] {
|
|
const displayParams = meta[MCP_TOOL_APPROVAL_TOOL_PARAMS_DISPLAY_KEY];
|
|
if (!Array.isArray(displayParams)) {
|
|
return [];
|
|
}
|
|
const lines = displayParams
|
|
.slice(0, MAX_DISPLAY_PARAM_ENTRIES)
|
|
.map((entry) => {
|
|
const param = isJsonObject(entry) ? entry : undefined;
|
|
if (!param) {
|
|
return undefined;
|
|
}
|
|
const name =
|
|
sanitizeOptionalDisplayText(readString(param, "display_name")) ??
|
|
sanitizeOptionalDisplayText(readString(param, "name"));
|
|
if (!name) {
|
|
return undefined;
|
|
}
|
|
return `- ${name}: ${formatDisplayParamValue(param.value)}`;
|
|
})
|
|
.filter((line): line is string => Boolean(line));
|
|
const remaining = displayParams.length - MAX_DISPLAY_PARAM_ENTRIES;
|
|
return remaining > 0 ? [...lines, `- Additional parameters: ${remaining} more`] : lines;
|
|
}
|
|
|
|
function formatDisplayParamValue(value: JsonValue | undefined): string {
|
|
const formatted = typeof value === "string" ? value : formatDisplayJsonValue(value ?? null);
|
|
return truncateDisplayText(sanitizeDisplayText(formatted), MAX_DISPLAY_PARAM_VALUE_LENGTH);
|
|
}
|
|
|
|
function formatDisplayJsonValue(value: JsonValue, depth = MAX_DISPLAY_VALUE_DEPTH): string {
|
|
if (value === null) {
|
|
return "null";
|
|
}
|
|
if (typeof value === "string") {
|
|
return JSON.stringify(truncateDisplayText(sanitizeDisplayText(value), 80));
|
|
}
|
|
if (typeof value === "number" || typeof value === "boolean") {
|
|
return String(value);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (depth <= 0) {
|
|
return "[truncated]";
|
|
}
|
|
const parts: string[] = [];
|
|
const limit = Math.min(value.length, MAX_DISPLAY_VALUE_ARRAY_ITEMS);
|
|
for (let i = 0; i < limit; i += 1) {
|
|
parts.push(formatDisplayJsonValue(value[i] ?? null, depth - 1));
|
|
}
|
|
if (value.length > MAX_DISPLAY_VALUE_ARRAY_ITEMS) {
|
|
parts.push("...");
|
|
}
|
|
return `[${parts.join(",")}]`;
|
|
}
|
|
if (typeof value === "object") {
|
|
if (depth <= 0) {
|
|
return "{truncated}";
|
|
}
|
|
const parts: string[] = [];
|
|
let count = 0;
|
|
let truncated = false;
|
|
for (const key in value) {
|
|
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
continue;
|
|
}
|
|
if (count >= MAX_DISPLAY_VALUE_OBJECT_KEYS) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
const safeKey = truncateDisplayText(sanitizeDisplayText(key), 80);
|
|
parts.push(
|
|
`${JSON.stringify(safeKey)}:${formatDisplayJsonValue(value[key] ?? null, depth - 1)}`,
|
|
);
|
|
count += 1;
|
|
}
|
|
if (truncated) {
|
|
parts.push("...");
|
|
}
|
|
return `{${parts.join(",")}}`;
|
|
}
|
|
return "null";
|
|
}
|
|
|
|
function sanitizeOptionalDisplayText(value: string | undefined): string | undefined {
|
|
const sanitized = value === undefined ? "" : sanitizeDisplayText(value);
|
|
return sanitized || undefined;
|
|
}
|
|
|
|
function sanitizeDisplayText(value: string): string {
|
|
const scanned = value.slice(0, DISPLAY_TEXT_SCAN_MAX_LENGTH);
|
|
const clipped = value.length > DISPLAY_TEXT_SCAN_MAX_LENGTH;
|
|
const sanitized = scanned
|
|
.replace(ANSI_OSC_SEQUENCE_RE, "")
|
|
.replace(ANSI_CONTROL_SEQUENCE_RE, "")
|
|
.replace(DANGLING_TERMINAL_SEQUENCE_SUFFIX_RE, "")
|
|
.replace(INVISIBLE_FORMATTING_CONTROL_RE, " ")
|
|
.replace(CONTROL_CHARACTER_RE, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const escaped = sanitized ? formatCodexDisplayText(sanitized) : "";
|
|
return clipped && escaped ? `${escaped}...` : escaped;
|
|
}
|
|
|
|
function truncateDisplayText(value: string, maxLength: number): string {
|
|
return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
}
|
|
|
|
async function requestPluginApprovalOutcome(params: {
|
|
paramsForRun: EmbeddedRunAttemptParams;
|
|
title: string;
|
|
description: string;
|
|
signal?: AbortSignal;
|
|
}): Promise<AppServerApprovalOutcome> {
|
|
try {
|
|
const requestResult = await requestPluginApproval({
|
|
paramsForRun: params.paramsForRun,
|
|
title: params.title,
|
|
description: params.description,
|
|
severity: "warning",
|
|
toolName: "codex_mcp_tool_approval",
|
|
});
|
|
|
|
const approvalId = requestResult?.id;
|
|
if (!approvalId) {
|
|
return "unavailable";
|
|
}
|
|
|
|
const decision = approvalRequestExplicitlyUnavailable(requestResult)
|
|
? null
|
|
: await waitForPluginApprovalDecision({ approvalId, signal: params.signal });
|
|
return mapExecDecisionToOutcome(decision);
|
|
} catch {
|
|
return params.signal?.aborted ? "cancelled" : "denied";
|
|
}
|
|
}
|
|
|
|
function buildElicitationResponse(
|
|
requestedSchema: JsonObject,
|
|
meta: JsonObject,
|
|
outcome: AppServerApprovalOutcome,
|
|
): JsonValue {
|
|
if (outcome === "cancelled") {
|
|
return { action: "cancel", content: null, _meta: null };
|
|
}
|
|
if (outcome === "denied" || outcome === "unavailable") {
|
|
return { action: "decline", content: null, _meta: null };
|
|
}
|
|
|
|
const content = buildAcceptedContent(requestedSchema, meta, outcome);
|
|
if (!content) {
|
|
if (hasNoSchemaProperties(requestedSchema)) {
|
|
return {
|
|
action: "accept",
|
|
content: null,
|
|
_meta: buildAcceptedMeta(meta, outcome),
|
|
};
|
|
}
|
|
embeddedAgentLog.warn("codex MCP approval elicitation approved without a mappable response", {
|
|
approvalKind: meta[MCP_TOOL_APPROVAL_KIND_KEY],
|
|
fields: Object.keys(requestedSchema.properties ?? {}),
|
|
outcome,
|
|
});
|
|
return { action: "decline", content: null, _meta: null };
|
|
}
|
|
return { action: "accept", content, _meta: buildAcceptedMeta(meta, outcome) };
|
|
}
|
|
|
|
function buildAcceptedContent(
|
|
requestedSchema: JsonObject,
|
|
meta: JsonObject,
|
|
outcome: AppServerApprovalOutcome,
|
|
): JsonObject | undefined {
|
|
const properties = isJsonObject(requestedSchema.properties)
|
|
? requestedSchema.properties
|
|
: undefined;
|
|
if (!properties) {
|
|
return undefined;
|
|
}
|
|
const required = Array.isArray(requestedSchema.required)
|
|
? new Set(
|
|
requestedSchema.required.filter((entry): entry is string => typeof entry === "string"),
|
|
)
|
|
: new Set<string>();
|
|
const content: JsonObject = {};
|
|
let sawApprovalField = false;
|
|
|
|
for (const [name, value] of Object.entries(properties)) {
|
|
const schema = isJsonObject(value) ? value : undefined;
|
|
if (!schema) {
|
|
continue;
|
|
}
|
|
const property = { name, schema, required: required.has(name) };
|
|
const next =
|
|
readApprovalFieldValue(property, outcome) ??
|
|
readPersistFieldValue(property, meta, outcome) ??
|
|
readFallbackFieldValue(property, outcome);
|
|
|
|
if (next === undefined) {
|
|
if (isApprovalField(property)) {
|
|
sawApprovalField = true;
|
|
}
|
|
if (property.required) {
|
|
return undefined;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isApprovalField(property)) {
|
|
sawApprovalField = true;
|
|
}
|
|
content[name] = next;
|
|
}
|
|
|
|
return sawApprovalField ? content : undefined;
|
|
}
|
|
|
|
function readApprovalFieldValue(
|
|
property: ApprovalPropertyContext,
|
|
outcome: AppServerApprovalOutcome,
|
|
): JsonValue | undefined {
|
|
if (!isApprovalField(property)) {
|
|
return undefined;
|
|
}
|
|
const type = readString(property.schema, "type");
|
|
if (type === "boolean") {
|
|
return true;
|
|
}
|
|
const options = readEnumOptions(property.schema);
|
|
if (options.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const sessionChoice = options.find((option) => isSessionApprovalOption(option));
|
|
const acceptChoice = options.find((option) => isPositiveApprovalOption(option));
|
|
if (outcome === "approved-session") {
|
|
return sessionChoice?.value ?? acceptChoice?.value;
|
|
}
|
|
return acceptChoice?.value ?? sessionChoice?.value;
|
|
}
|
|
|
|
function readPersistFieldValue(
|
|
property: ApprovalPropertyContext,
|
|
meta: JsonObject,
|
|
outcome: AppServerApprovalOutcome,
|
|
): JsonValue | undefined {
|
|
if (!isPersistField(property) || outcome !== "approved-session") {
|
|
return undefined;
|
|
}
|
|
const persistHints = readPersistHints(meta);
|
|
const options = readEnumOptions(property.schema);
|
|
if (options.length === 0) {
|
|
return undefined;
|
|
}
|
|
const preferred = choosePersistHint(persistHints);
|
|
if (preferred) {
|
|
const match = options.find(
|
|
(option) => option.value === preferred || option.label === preferred,
|
|
);
|
|
return match?.value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readDefaultValue(schema: JsonObject): JsonValue | undefined {
|
|
return schema.default as JsonValue | undefined;
|
|
}
|
|
|
|
function readFallbackFieldValue(
|
|
property: ApprovalPropertyContext,
|
|
outcome: AppServerApprovalOutcome,
|
|
): JsonValue | undefined {
|
|
if (outcome === "approved-once" && isPersistField(property)) {
|
|
return undefined;
|
|
}
|
|
return readDefaultValue(property.schema);
|
|
}
|
|
|
|
function isApprovalField(property: ApprovalPropertyContext): boolean {
|
|
const haystack = propertyText(property).toLowerCase();
|
|
return /\b(approve|approval|allow|accept|decision)\b/.test(haystack);
|
|
}
|
|
|
|
function isPersistField(property: ApprovalPropertyContext): boolean {
|
|
const haystack = propertyText(property).toLowerCase();
|
|
return /\b(persist|session|always|scope)\b/.test(haystack);
|
|
}
|
|
|
|
function propertyText(property: ApprovalPropertyContext): string {
|
|
return [
|
|
property.name,
|
|
readString(property.schema, "title"),
|
|
readString(property.schema, "description"),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
function readPersistHints(meta: JsonObject): string[] {
|
|
const raw = meta.persist;
|
|
if (typeof raw === "string") {
|
|
return [raw];
|
|
}
|
|
if (Array.isArray(raw)) {
|
|
return raw.filter((entry): entry is string => typeof entry === "string");
|
|
}
|
|
return ["session", "always"];
|
|
}
|
|
|
|
function buildAcceptedMeta(meta: JsonObject, outcome: AppServerApprovalOutcome): JsonObject | null {
|
|
if (outcome !== "approved-session") {
|
|
return null;
|
|
}
|
|
const persist = choosePersistHint(readPersistHints(meta));
|
|
return persist ? { persist } : null;
|
|
}
|
|
|
|
function choosePersistHint(persistHints: string[]): "always" | "session" | undefined {
|
|
if (persistHints.includes("always")) {
|
|
return "always";
|
|
}
|
|
if (persistHints.includes("session")) {
|
|
return "session";
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function hasNoSchemaProperties(requestedSchema: JsonObject): boolean {
|
|
const properties = isJsonObject(requestedSchema.properties) ? requestedSchema.properties : {};
|
|
return Object.keys(properties).length === 0;
|
|
}
|
|
|
|
function readEnumOptions(schema: JsonObject): Array<{ value: string; label: string }> {
|
|
if (Array.isArray(schema.enum)) {
|
|
const values = schema.enum.filter((entry): entry is string => typeof entry === "string");
|
|
const labels = Array.isArray(schema.enumNames)
|
|
? schema.enumNames.filter((entry): entry is string => typeof entry === "string")
|
|
: [];
|
|
return values.map((value, index) => ({ value, label: labels[index] ?? value }));
|
|
}
|
|
if (Array.isArray(schema.oneOf)) {
|
|
return schema.oneOf
|
|
.map((entry) => {
|
|
const option = isJsonObject(entry) ? entry : undefined;
|
|
const value = readString(option, "const");
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
return { value, label: readString(option, "title") ?? value };
|
|
})
|
|
.filter((entry): entry is { value: string; label: string } => Boolean(entry));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function isPositiveApprovalOption(option: { value: string; label: string }): boolean {
|
|
const haystack = `${option.value} ${option.label}`.toLowerCase();
|
|
return /\b(allow|approve|accept|yes|continue|proceed|true)\b/.test(haystack);
|
|
}
|
|
|
|
function isSessionApprovalOption(option: { value: string; label: string }): boolean {
|
|
const haystack = `${option.value} ${option.label}`.toLowerCase();
|
|
return (
|
|
/\b(session|always|persistent)\b/.test(haystack) && /\b(allow|approve|accept)\b/.test(haystack)
|
|
);
|
|
}
|
|
|
|
function readString(record: JsonObject | undefined, key: string): string | undefined {
|
|
const value = record?.[key];
|
|
return typeof value === "string" && value.trim() ? value : undefined;
|
|
}
|
|
|
|
function readFirstString(record: JsonObject | undefined, keys: string[]): string | undefined {
|
|
for (const key of keys) {
|
|
const value = readString(record, key);
|
|
if (value) {
|
|
return value;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|