mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
0dbd5c81d5
* feat(plugins): surface plugin capability consent in Control UI and CLI Adds plugins.inspect (declared manifest surface, operator grants, install provenance/integrity, ClawHub trust), a Control UI consent dialog on install and external-plugin enable, a server-side acceptance gate persisted on the install record, artifact-anchored widen diffing, and --accept-capabilities for non-interactive CLI use. NOT READY TO LAND: autoreview found critical gaps (see PR notes) — the declared surface omits 20 of 21 contract families, native plugins always report zero hooks, several install/enable paths bypass the gate, and the acknowledgment is not bound to the reviewed surface. * refactor(plugins): bind capability consent to the reviewed surface Collapses the consent error payload to the fields the client cannot fetch (reviewToken, widened, acceptedAt) and pulls identity/declared/grants/source/ trust from plugins.inspect, shrinking the registry-free protocol reader from 395 to 91 lines and removing its divergence from the closed schema. Acknowledgment now carries the SHA-256 reviewToken of the surface the operator saw; the server recomputes the final staged artifact's surface and rejects any mismatch before persisting acceptance. That closes review-then-swap, laundering of forged acceptance through an unchanged update, and cross-artifact replay. All 22 manifest contract families are now declared, hashed and diffed, so a privileged family such as gatewayMethodDispatch can no longer be added without re-consent. Consent reads the manifest runtime discovery will execute, ambiguous install ownership fails closed, integrity resolution has one owner and no longer labels npm SHA-1 shasums as SHA-256, and code plugins disclose that hooks register at runtime instead of rendering an empty "no hooks" row. * fix(gateway): register plugins.inspect in method inventories and regenerate protocol Adds plugins.inspect to the advertised-method inventories (widening the fixed-size slice windows so older indices stay stable), regenerates the Kotlin protocol bindings, drops an unused exported type, and replaces two nested conditional spreads with a plain conditional. * refactor(plugins): split oversized consent modules and clear lint findings Extracts the MCP controller out of the plugins page, unchanged-install reconciliation out of update-installed, and the install lifecycle suite out of the management-service tests, bringing all three back under the max-lines limit without suppressions. Also renames a shadowed binding, drops an unnecessary generic, removes a spread-to-modify in a map, and types catch callbacks as unknown. * chore(protocol): regenerate Kotlin bindings after rebase * feat(plugins): let chat /plugins install review and accept capabilities The consent gate applies to chat installs too, but the command had no way to give consent, so external installs dead-ended on a CLI-only flag. Chat now replies with the plugin's declared capability surface and the exact command to rerun, and accepts a trailing --accept-capabilities mirroring the existing --force acknowledgement. ClawHub trust acknowledgement stays CLI-only. Staged-artifact verification is unchanged: the reviewToken is still checked against the final artifact before acceptance is recorded. * refactor(plugins): single-source the declared-surface groups and manifest precedence The ordered capability group list was defined independently in the consent engine, the protocol error reader, the CLI formatter and the Control UI, so a new contract family had to be added in four places with nothing enforcing it. All four now derive from one canonical list in the protocol schema with a compile-time exhaustiveness guard. Native-versus-bundle manifest precedence is centralized in one helper that both discovery and staged consent call, so the two cannot drift again — that divergence was a real bug where consent read one manifest and the runtime executed another. Also documents that carrying acceptance forward requires pinned artifact integrity, so integrity-less sources such as local paths ask on every install. * fix(plugins): enforce reviewed consent across activation flows Route setup, repair, linked installs, updates, and chat activation through artifact-bound capability consent. Reuse canonical package discovery and recheck staged activation before config publication. Invalidate stale Control UI review requests on reconnect. Verified focused owner and sibling tests, runtime rebuild, and real isolated CLI/Gateway install, inspect, enable, widening, and stale-token rejection flows. * test(plugins): cover beta installs through capability consent * test(plugins): align consent fixtures with staged artifacts * fix(ui): review staged plugin capabilities once * test(ui): inline the remaining plugin consent confirmation * test(plugins): verify consent with deferred install transactions * refactor(setup): share inference execution plan construction * test(ui): settle applied config before deferring refresh * fix(plugins): protect consent provenance and reuse acceptance
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
import type { TranslationMap, TranslationMemoryEntry } from "./control-ui-i18n-sync-plan.ts";
|
|
|
|
async function importControlUiLocaleModule<T>(filePath: string): Promise<T> {
|
|
const stats = await stat(filePath);
|
|
return (await import(`${pathToFileURL(filePath).href}?ts=${stats.mtimeMs}`)) as T;
|
|
}
|
|
|
|
export async function loadControlUiLocaleCatalog(
|
|
filePath: string,
|
|
exportName: string,
|
|
): Promise<TranslationMap | null> {
|
|
if (!existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
const module = await importControlUiLocaleModule<Record<string, TranslationMap>>(filePath);
|
|
return module[exportName] ?? null;
|
|
}
|
|
|
|
export async function loadControlUiSourceCatalog(
|
|
sourceLocalePath: string,
|
|
activitySourceLocalePath: string,
|
|
sessionPlacementSourceLocalePath: string,
|
|
pluginConsentSourceLocalePath: string,
|
|
): Promise<TranslationMap> {
|
|
const source = await loadControlUiLocaleCatalog(sourceLocalePath, "en");
|
|
const activitySource = (
|
|
await importControlUiLocaleModule<{
|
|
registerActivityEnglish: { catalog: TranslationMap };
|
|
}>(activitySourceLocalePath)
|
|
).registerActivityEnglish.catalog;
|
|
const sessionPlacementSource = (
|
|
await importControlUiLocaleModule<{
|
|
registerSessionPlacementEnglish: { catalog: TranslationMap };
|
|
}>(sessionPlacementSourceLocalePath)
|
|
).registerSessionPlacementEnglish.catalog;
|
|
const pluginConsentSource = (
|
|
await importControlUiLocaleModule<{
|
|
registerPluginConsentEnglish: { catalog: TranslationMap };
|
|
}>(pluginConsentSourceLocalePath)
|
|
).registerPluginConsentEnglish.catalog;
|
|
if (!source || !activitySource || !sessionPlacementSource || !pluginConsentSource) {
|
|
throw new Error("Control UI English source catalogs are incomplete");
|
|
}
|
|
return mergeControlUiTranslationMaps(
|
|
source,
|
|
activitySource,
|
|
sessionPlacementSource,
|
|
pluginConsentSource,
|
|
);
|
|
}
|
|
|
|
export async function readControlUiSourceCatalog(
|
|
sourceLocalePath: string,
|
|
activitySourceLocalePath: string,
|
|
sessionPlacementSourceLocalePath: string,
|
|
pluginConsentSourceLocalePath: string,
|
|
): Promise<string> {
|
|
const sources = await Promise.all(
|
|
[
|
|
sourceLocalePath,
|
|
activitySourceLocalePath,
|
|
sessionPlacementSourceLocalePath,
|
|
pluginConsentSourceLocalePath,
|
|
].map((filePath) => readFile(filePath, "utf8")),
|
|
);
|
|
return sources.join("\n");
|
|
}
|
|
|
|
export function hashControlUiTranslationText(text: string): string {
|
|
return createHash("sha256").update(text.trim().split(/\s+/).join(" ")).digest("hex");
|
|
}
|
|
|
|
export function mergeControlUiTranslationMaps(
|
|
...maps: ReadonlyArray<TranslationMap>
|
|
): TranslationMap {
|
|
const merged: TranslationMap = {};
|
|
const mergeInto = (target: TranslationMap, source: TranslationMap): void => {
|
|
for (const [key, value] of Object.entries(source)) {
|
|
if (typeof value === "string") {
|
|
target[key] = value;
|
|
continue;
|
|
}
|
|
const existing = target[key];
|
|
const nested = typeof existing === "object" ? existing : {};
|
|
target[key] = nested;
|
|
mergeInto(nested, value);
|
|
}
|
|
};
|
|
for (const map of maps) {
|
|
mergeInto(merged, map);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function loadControlUiTranslationMemory(
|
|
filePath: string,
|
|
): Map<string, TranslationMemoryEntry> {
|
|
const entries = new Map<string, TranslationMemoryEntry>();
|
|
if (!existsSync(filePath)) {
|
|
return entries;
|
|
}
|
|
for (const line of readFileSync(filePath, "utf8").split("\n")) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
const entry = JSON.parse(line) as TranslationMemoryEntry;
|
|
if (entry.cache_key && entry.translated.trim()) {
|
|
entries.set(entry.cache_key, entry);
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function setControlUiCatalogValue(catalog: TranslationMap, key: string, value: string): void {
|
|
const parts = key.split(".");
|
|
let current = catalog;
|
|
for (const part of parts.slice(0, -1)) {
|
|
const existing = current[part];
|
|
if (!existing || typeof existing === "string") {
|
|
current[part] = {};
|
|
}
|
|
current = current[part] as TranslationMap;
|
|
}
|
|
current[parts[parts.length - 1]!] = value;
|
|
}
|
|
|
|
export function materializeControlUiLocaleCatalog(
|
|
sourceFlat: ReadonlyMap<string, string>,
|
|
memory: ReadonlyMap<string, TranslationMemoryEntry>,
|
|
): TranslationMap {
|
|
const translations = new Map<string, string>();
|
|
|
|
for (const entry of memory.values()) {
|
|
for (const key of [entry.segment_id, ...(entry.segment_ids ?? [])]) {
|
|
const source = sourceFlat.get(key);
|
|
if (source === undefined || entry.text_hash !== hashControlUiTranslationText(source)) {
|
|
continue;
|
|
}
|
|
translations.set(key, entry.translated);
|
|
}
|
|
}
|
|
|
|
const catalog: TranslationMap = {};
|
|
for (const key of sourceFlat.keys()) {
|
|
const translated = translations.get(key);
|
|
if (translated !== undefined) {
|
|
setControlUiCatalogValue(catalog, key, translated);
|
|
}
|
|
}
|
|
return catalog;
|
|
}
|