fix(ui): preserve plugin install identity

This commit is contained in:
jesse-merhi
2026-08-13 02:35:12 +10:00
parent c26cd48ffb
commit e4572364a5
5 changed files with 220 additions and 82 deletions
+34 -26
View File
@@ -230,37 +230,35 @@ describe("PluginsPage", () => {
throw new Error(`Unexpected method ${method}`);
});
const harness = createGateway(client);
const lobsterCatalog = createResult(
createPlugin({
id: "lobster",
name: "Lobster",
installed: false,
install: { source: "clawhub", packageName: "@openclaw/lobster" },
}),
);
const lobsterCatalog = createResult();
const { page } = await mountPage(
createContext(harness.gateway),
createPluginsRouteData(harness.gateway, lobsterCatalog),
);
const rowKey = "plugin:lobster";
const rowKey = "clawhub:@openclaw/lobster";
const installIdentity = "plugin:lobster";
const installRequest = {
source: "clawhub",
packageName: "@openclaw/lobster",
} satisfies PluginInstallRequest;
await page.install(rowKey, installRequest);
await page.install(rowKey, installRequest, installIdentity);
expect(page.messages[rowKey]?.installPolicyWarning?.details.acknowledgementToken).toBe(
"approval-token",
);
const pendingRetry = page.install(rowKey, {
...installRequest,
installPolicyWarningAcknowledgement: "approval-token",
});
const pendingRetry = page.install(
rowKey,
{
...installRequest,
installPolicyWarningAcknowledgement: "approval-token",
},
installIdentity,
);
await waitForFast(() =>
expect(request.mock.calls.filter(([method]) => method === "plugins.install")).toHaveLength(2),
);
await page.install("clawhub:@openclaw/lobster", installRequest);
await page.install("plugin:lobster", installRequest, installIdentity);
expect(request.mock.calls.filter(([method]) => method === "plugins.install")).toHaveLength(2);
expect(page.messages[rowKey]?.installPolicyWarning).toBeDefined();
page.messages["plugin:workboard"] = { kind: "success", text: "Unrelated message." };
@@ -272,7 +270,7 @@ describe("PluginsPage", () => {
);
expect(page.messages[rowKey]).toBeUndefined();
expect(page.messages["plugin:workboard"]?.text).toBe("Unrelated message.");
expect(page.installOutcomeReconciliations[rowKey]).toBe("checking");
expect(page.installOutcomeReconciliations[installIdentity]).toBe("checking");
retry.resolve({
ok: true,
@@ -285,7 +283,9 @@ describe("PluginsPage", () => {
createPlugin({ id: "lobster", name: "Lobster", installed: true, enabled: true }),
),
);
await waitForFast(() => expect(page.installOutcomeReconciliations[rowKey]).toBeUndefined());
await waitForFast(() =>
expect(page.installOutcomeReconciliations[installIdentity]).toBeUndefined(),
);
expect(page.messages[rowKey]).toBeUndefined();
expect(page.result?.plugins[0]?.installed).toBe(true);
});
@@ -349,11 +349,15 @@ describe("PluginsPage", () => {
packageName: "@openclaw/lobster",
} satisfies PluginInstallRequest;
await page.install(rowKey, installRequest);
const pendingRetry = page.install(rowKey, {
...installRequest,
installPolicyWarningAcknowledgement: "approval-token",
});
await page.install(rowKey, installRequest, rowKey);
const pendingRetry = page.install(
rowKey,
{
...installRequest,
installPolicyWarningAcknowledgement: "approval-token",
},
rowKey,
);
await waitForFast(() => expect(installCalls).toBe(2));
harness.emit(client, false);
@@ -586,10 +590,14 @@ describe("PluginsPage", () => {
runtimeConfig.patchForm(["pending"], true);
if (action === "install") {
await page.install("search:example-plugin", {
source: "clawhub",
packageName: "example-plugin",
} as PluginInstallRequest);
await page.install(
"search:example-plugin",
{
source: "clawhub",
packageName: "example-plugin",
} as PluginInstallRequest,
"clawhub:example-plugin",
);
} else if (action === "enable") {
await page.updateEnabled("workboard", true);
} else {
+9 -6
View File
@@ -35,7 +35,6 @@ import {
installPlugin,
pluginInstallNeedsRiskAcknowledgement,
readPluginInstallTrustError,
resolvePluginInstallIdentity,
runPluginConfigMutation,
setPluginEnabled,
uninstallPlugin,
@@ -838,8 +837,11 @@ class PluginsPage extends OpenClawLightDomElement {
}
}
private async install(rowKey: string, request: PluginInstallRequest): Promise<void> {
const operationKey = resolvePluginInstallIdentity(request, this.result?.plugins ?? []);
private async install(
rowKey: string,
request: PluginInstallRequest,
installIdentity: string,
): Promise<void> {
await this.runPluginMutation(
rowKey,
(client) => installPlugin(client, request),
@@ -880,8 +882,8 @@ class PluginsPage extends OpenClawLightDomElement {
});
},
{
operationKey,
pendingInstallTarget: operationKey,
operationKey: installIdentity,
pendingInstallTarget: installIdentity,
preserveMessageWhilePending: request.installPolicyWarningAcknowledgement !== undefined,
},
);
@@ -1114,7 +1116,8 @@ class PluginsPage extends OpenClawLightDomElement {
},
onSetEnabled: (pluginId, enabled, rowKey) =>
void this.updateEnabled(pluginId, enabled, rowKey),
onInstall: (rowKey, request) => void this.install(rowKey, request),
onInstall: (rowKey, request, installIdentity) =>
void this.install(rowKey, request, installIdentity),
onDismissMessage: (rowKey) => this.setMessage(rowKey, null),
onRetryInstallOutcome: () => void this.refreshCatalog(),
onRequestUninstall: (rowKey) => this.setPendingRemoval(rowKey, true),
+8 -5
View File
@@ -834,13 +834,19 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: pluginMethods,
methodResponses: pluginMethodResponses(),
methodResponses: {
...pluginMethodResponses(),
"plugins.list": inventory([workboardDisabled, remoteIconPlugin]),
"plugins.search": lobsterSearchResponse,
},
});
try {
await page.goto(`${server.baseUrl}settings/plugins`);
await page.getByRole("tab", { name: /^Discover/u }).click();
const row = page.locator('[data-plugin-id="lobster"]');
await page.getByRole("searchbox", { name: "Search plugins" }).fill("lobster");
await gateway.waitForRequest("plugins.search");
const row = page.locator('[data-package-name="@openclaw/lobster"]');
await row.waitFor({ state: "visible" });
await gateway.deferNext("plugins.install");
@@ -876,9 +882,6 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
);
expect((await gateway.getRequests("plugins.install")).length).toBe(2);
await gateway.setMethodResponse("plugins.search", lobsterSearchResponse);
await page.getByRole("searchbox", { name: "Search plugins" }).fill("lobster");
await gateway.waitForRequest("plugins.search");
const searchRow = page.locator('[data-package-name="@openclaw/lobster"]');
await searchRow.waitFor({ state: "visible" });
await searchRow.getByText("Checking plugin status…", { exact: false }).waitFor();
+66 -18
View File
@@ -441,10 +441,14 @@ describe("renderPlugins", () => {
container
.querySelector<HTMLButtonElement>('[data-plugin-id="tavily"] .plugins-install')
?.click();
expect(onInstall).toHaveBeenCalledWith(pluginRowKey("tavily"), {
source: "official",
pluginId: "tavily",
});
expect(onInstall).toHaveBeenCalledWith(
pluginRowKey("tavily"),
{
source: "official",
pluginId: "tavily",
},
pluginRowKey("tavily"),
);
});
it("renders featured plugins newest-featured first", () => {
@@ -578,10 +582,14 @@ describe("renderPlugins", () => {
expect(normalizedText(result)).toContain("149.3K");
expect(normalizedText(result)).toContain("Code plugin");
result?.querySelector<HTMLButtonElement>('[aria-label="Install Calendar Plus"]')?.click();
expect(onInstall).toHaveBeenCalledWith(clawHubKey("@openclaw/calendar-plus"), {
source: "clawhub",
packageName: "@openclaw/calendar-plus",
});
expect(onInstall).toHaveBeenCalledWith(
clawHubKey("@openclaw/calendar-plus"),
{
source: "clawhub",
packageName: "@openclaw/calendar-plus",
},
clawHubKey("@openclaw/calendar-plus"),
);
});
it("keeps discovery available while disabling all read-only mutations", () => {
@@ -654,12 +662,16 @@ describe("renderPlugins", () => {
expect(row?.getAttribute("aria-busy")).toBe("false");
expect(row?.querySelector('[role="alert"]')?.textContent).toContain("Review required.");
row?.querySelector<HTMLButtonElement>(".plugins-row-message button")?.click();
expect(onInstall).toHaveBeenCalledWith(key, {
source: "clawhub",
packageName,
version: "2.0.0",
acknowledgeClawHubRisk: true,
});
expect(onInstall).toHaveBeenCalledWith(
key,
{
source: "clawhub",
packageName,
version: "2.0.0",
acknowledgeClawHubRisk: true,
},
key,
);
});
it("renders install policy findings with cancel and acknowledged retry actions", () => {
@@ -760,10 +772,14 @@ describe("renderPlugins", () => {
expect(onDismissMessage).toHaveBeenCalledWith(key);
actionButton(alert, "Install anyway")?.click();
expect(onInstall).toHaveBeenCalledWith(key, {
...request,
installPolicyWarningAcknowledgement: "approval-token",
});
expect(onInstall).toHaveBeenCalledWith(
key,
{
...request,
installPolicyWarningAcknowledgement: "approval-token",
},
key,
);
});
it("blocks a repeated install while its reconnect outcome is unresolved", () => {
@@ -821,6 +837,38 @@ describe("renderPlugins", () => {
expect(onRetryInstallOutcome).toHaveBeenCalledOnce();
});
it("preserves a search-only runtime identity when installing", () => {
const onInstall = vi.fn();
const container = mount(
createProps({
activeTab: "discover",
query: "lobster",
result: createResult([]),
searchResults: [
{
score: 1,
package: {
name: "@openclaw/lobster",
displayName: "Lobster",
family: "code-plugin",
channel: "official",
isOfficial: true,
runtimeId: "lobster",
},
},
],
onInstall,
}),
);
actionButton(container, "Install Lobster")?.click();
expect(onInstall).toHaveBeenCalledWith(
"clawhub:@openclaw/lobster",
{ source: "clawhub", packageName: "@openclaw/lobster" },
"plugin:lobster",
);
});
it("keeps the not-installed outcome visible for reason-only policy warnings", () => {
const plugin = createPlugin({
id: "reason-only",
+103 -27
View File
@@ -106,7 +106,7 @@ type PluginsViewProps = {
onIconError: (pluginId: string) => void;
onShowDetails: (pluginId: string | null) => void;
onSetEnabled: (pluginId: string, enabled: boolean, rowKey: string) => void;
onInstall: (rowKey: string, request: PluginInstallRequest) => void;
onInstall: (rowKey: string, request: PluginInstallRequest, installIdentity: string) => void;
onDismissMessage: (rowKey: string) => void;
onRetryInstallOutcome: () => void;
onRequestUninstall: (rowKey: string) => void;
@@ -160,17 +160,26 @@ function clawHubRowKey(packageName: string): string {
return `clawhub:${packageName}`;
}
function resolveInstallIdentity(
props: PluginsViewProps,
request: PluginInstallRequest,
runtimeId?: string,
): string {
return resolvePluginInstallIdentity(request, props.result?.plugins ?? [], runtimeId);
}
function installOperationState(
props: PluginsViewProps,
request: PluginInstallRequest | undefined,
runtimeId?: string,
identity: string | undefined,
): { busy: boolean; outcome?: InstallOutcomeReconciliation } {
if (!request) {
if (!identity) {
return { busy: false };
}
const identity = resolvePluginInstallIdentity(request, props.result?.plugins ?? [], runtimeId);
const outcome = props.installOutcomeReconciliations[identity];
return { busy: Boolean(props.busy[identity] || outcome), ...(outcome ? { outcome } : {}) };
return {
busy: Boolean(props.busy[identity] || outcome),
...(outcome ? { outcome } : {}),
};
}
export function connectorRowKey(connectorId: string): string {
@@ -396,6 +405,17 @@ function originLabel(origin: string): string {
}
}
function requestInstall(
props: PluginsViewProps,
rowKey: string,
request: PluginInstallRequest,
installIdentity?: string,
) {
if (installIdentity) {
props.onInstall(rowKey, request, installIdentity);
}
}
/** Dot-separated plain-text meta line under a row description. */
function renderMetaLine(parts: ReadonlyArray<TemplateResult | string | typeof nothing>) {
const visible = parts.filter((part) => part !== nothing && part !== "");
@@ -416,6 +436,7 @@ function renderRowMessage(
busy: boolean,
props: PluginsViewProps,
installOutcome?: InstallOutcomeReconciliation,
installIdentity?: string,
) {
if (installOutcome) {
return html`
@@ -533,10 +554,15 @@ function renderRowMessage(
title=${props.mutationBlockedReason ?? ""}
?disabled=${busy || !props.canMutate}
@click=${() =>
props.onInstall(key, {
...request,
installPolicyWarningAcknowledgement: details.acknowledgementToken,
})}
requestInstall(
props,
key,
{
...request,
installPolicyWarningAcknowledgement: details.acknowledgementToken,
},
installIdentity,
)}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.installAnyway")}
</button>
@@ -556,12 +582,19 @@ function renderRowMessage(
title=${props.mutationBlockedReason ?? ""}
?disabled=${busy || !props.canMutate}
@click=${() =>
props.onInstall(key, {
source: "clawhub",
packageName: message.acknowledge?.packageName ?? "",
...(message.acknowledge?.version ? { version: message.acknowledge.version } : {}),
acknowledgeClawHubRisk: true,
})}
requestInstall(
props,
key,
{
source: "clawhub",
packageName: message.acknowledge?.packageName ?? "",
...(message.acknowledge?.version
? { version: message.acknowledge.version }
: {}),
acknowledgeClawHubRisk: true,
},
installIdentity,
)}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.acknowledgeRisk")}
</button>
@@ -635,8 +668,9 @@ function renderInstallButton(
key: string,
name: string,
request: PluginInstallRequest,
installIdentity: string,
) {
const { outcome: installOutcome } = installOperationState(props, request);
const installOutcome = props.installOutcomeReconciliations[installIdentity];
return html`
<button
type="button"
@@ -646,7 +680,7 @@ function renderInstallButton(
?disabled=${!props.canMutate || busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onInstall(key, request);
props.onInstall(key, request, installIdentity);
}}
>
${busy
@@ -709,7 +743,14 @@ function renderCatalogActions(
if (!plugin.installed) {
const install = plugin.install;
return install
? renderInstallButton(props, busy, rowKey, plugin.name, install)
? renderInstallButton(
props,
busy,
rowKey,
plugin.name,
install,
resolveInstallIdentity(props, install),
)
: html`<span class="plugins-action-note">${t("pluginsPage.unavailable")}</span>`;
}
return html`
@@ -779,7 +820,10 @@ function renderPluginRow(
includePackageName = false,
): TemplateResult {
const key = pluginRowKey(plugin.id);
const installOperation = installOperationState(props, plugin.install);
const installIdentity = plugin.install
? resolveInstallIdentity(props, plugin.install)
: undefined;
const installOperation = installOperationState(props, installIdentity);
const busy = props.busy[key] || installOperation.busy;
return html`
<article
@@ -829,7 +873,14 @@ function renderPluginRow(
${plugin.error}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
${renderRowMessage(
key,
props.messages[key],
busy,
props,
installOperation.outcome,
installIdentity,
)}
</article>
`;
}
@@ -1047,7 +1098,8 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
const installed = findInstalledSearchPlugin(item, props.result?.plugins ?? []);
const key = clawHubRowKey(pkg.name);
const installRequest = { source: "clawhub", packageName: pkg.name } as const;
const installOperation = installOperationState(props, installRequest, pkg.runtimeId);
const installIdentity = resolveInstallIdentity(props, installRequest, pkg.runtimeId);
const installOperation = installOperationState(props, installIdentity);
const busy = props.busy[key] || installOperation.busy;
const artSlug = pkg.runtimeId ?? pkg.name;
return html`
@@ -1093,9 +1145,16 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
<div class="settings-row__control">
${installed
? html`${rowStateStatus(installed)}${renderCatalogActions(installed, props, busy, key)}`
: renderInstallButton(props, busy, key, pkg.displayName, installRequest)}
: renderInstallButton(props, busy, key, pkg.displayName, installRequest, installIdentity)}
</div>
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
${renderRowMessage(
key,
props.messages[key],
busy,
props,
installOperation.outcome,
installIdentity,
)}
</article>
`;
}
@@ -1218,7 +1277,10 @@ function renderDetailOverlay(props: PluginsViewProps) {
return nothing;
}
const key = pluginRowKey(plugin.id);
const installOperation = installOperationState(props, plugin.install);
const installIdentity = plugin.install
? resolveInstallIdentity(props, plugin.install)
: undefined;
const installOperation = installOperationState(props, installIdentity);
const busy = props.busy[key] || installOperation.busy;
return html`
<openclaw-modal-dialog
@@ -1274,7 +1336,14 @@ function renderDetailOverlay(props: PluginsViewProps) {
</button>
`
: plugin.install
? renderInstallButton(props, busy, key, plugin.name, plugin.install)
? renderInstallButton(
props,
busy,
key,
plugin.name,
plugin.install,
resolveInstallIdentity(props, plugin.install),
)
: nothing}
${plugin.removable
? html`
@@ -1297,7 +1366,14 @@ function renderDetailOverlay(props: PluginsViewProps) {
${plugin.error}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
${renderRowMessage(
key,
props.messages[key],
busy,
props,
installOperation.outcome,
installIdentity,
)}
<div class="plugins-detail__meta">
${plugin.origin
? detailMetaRow(t("pluginsPage.detailOrigin"), originLabel(plugin.origin))