fix(ui): retire stale install policy reviews

This commit is contained in:
jesse-merhi
2026-08-12 21:29:06 +10:00
parent b033fe2fa3
commit ab769f167b
4 changed files with 122 additions and 0 deletions
@@ -19,6 +19,7 @@ import {
type ApplicationContextProvider,
} from "../../test-helpers/application-context.ts";
import type { PluginsRouteData } from "./plugins-page.ts";
import type { PluginRowMessage } from "./view.ts";
import "./plugins-page.ts";
type RequestHandler = (method: string, params: unknown) => Promise<unknown>;
@@ -34,6 +35,7 @@ type TestPluginsPage = HTMLElement & {
result: PluginListResult | null;
loading: boolean;
busy: Record<string, boolean>;
messages: Record<string, PluginRowMessage>;
activeTab: "installed" | "discover";
searchResults: PluginSearchResult[] | null;
applyMutationResult: (result: PluginMutationResult) => void;
+68
View File
@@ -2,12 +2,14 @@
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayRequestError } from "../../api/gateway.ts";
import type { ApplicationContext } from "../../app/context.ts";
import { i18n } from "../../i18n/index.ts";
import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts";
import type {
PluginInstallRequest,
PluginListResult,
PluginMutationResult,
PluginSearchResult,
} from "../../lib/plugins/index.ts";
import { waitForFast } from "../../test-helpers/wait-for.ts";
@@ -199,6 +201,72 @@ describe("PluginsPage", () => {
);
});
it("retires a pending install-policy review across a same-client reconnect", async () => {
const retry = deferred<PluginMutationResult>();
let installCalls = 0;
const { client, request } = createClient(async (method) => {
if (method === "plugins.install") {
installCalls += 1;
if (installCalls === 1) {
throw new GatewayRequestError({
code: "INVALID_REQUEST",
message: "install requires review",
details: {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "@openclaw/lobster",
targetType: "plugin",
requestMode: "install",
reason: "Review this plugin.",
acknowledgementToken: "approval-token",
},
});
}
return retry.promise;
}
if (method === "plugins.list") {
return createResult();
}
throw new Error(`Unexpected method ${method}`);
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(harness.gateway),
createPluginsRouteData(harness.gateway),
);
const rowKey = "plugin:@openclaw/lobster";
const installRequest = {
source: "clawhub",
packageName: "@openclaw/lobster",
} satisfies PluginInstallRequest;
await page.install(rowKey, installRequest);
expect(page.messages[rowKey]?.installPolicyWarning?.details.acknowledgementToken).toBe(
"approval-token",
);
const pendingRetry = page.install(rowKey, {
...installRequest,
installPolicyWarningAcknowledgement: "approval-token",
});
await waitForFast(() =>
expect(request.mock.calls.filter(([method]) => method === "plugins.install")).toHaveLength(2),
);
expect(page.messages[rowKey]?.installPolicyWarning).toBeDefined();
harness.emit(client, false);
harness.emit(client, true);
await page.updateComplete;
expect(page.messages[rowKey]).toBeUndefined();
retry.resolve({
ok: true,
plugin: createPlugin({ id: "lobster", name: "Lobster" }),
restartRequired: true,
});
await pendingRetry;
expect(page.messages[rowKey]).toBeUndefined();
});
it("debounces two-character ClawHub searches and cancels stale input", async () => {
vi.useFakeTimers();
const { client, request } = createClient(async (method) => {
+5
View File
@@ -363,6 +363,11 @@ class PluginsPage extends OpenClawLightDomElement {
void this.configTask.run([null, this.context.runtimeConfig]);
void this.searchTask.run([null, ""]);
this.mutationTokens.clear();
// A connection-epoch change makes the one-shot approval outcome unknown;
// retire its action so reconnect cannot resend a spent or process-stale token.
this.messages = Object.fromEntries(
Object.entries(this.messages).filter(([, message]) => !message.installPolicyWarning),
);
}
private replaceResult(result: PluginListResult | null, preserveIcons = false) {
+47
View File
@@ -813,6 +813,53 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
}
});
it("retires a pending install-policy review after reconnect", async () => {
const context = await newContext();
const page = await context.newPage();
const gateway = await installMockGateway(page, {
featureMethods: pluginMethods,
methodResponses: pluginMethodResponses(),
});
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 row.waitFor({ state: "visible" });
await gateway.deferNext("plugins.install");
await row.getByRole("button", { name: "Install Lobster", exact: true }).click();
await gateway.waitForRequest("plugins.install");
await gateway.rejectDeferred("plugins.install", {
code: "INVALID_REQUEST",
message: "install requires review",
details: installPolicyWarning,
});
const review = row.getByRole("alert");
await review.waitFor({ state: "visible" });
const installCountBeforeRetry = (await gateway.getRequests("plugins.install")).length;
await gateway.deferNext("plugins.install");
await review.getByRole("button", { name: "Install anyway", exact: true }).click();
await waitForNextRequest(gateway, "plugins.install", installCountBeforeRetry);
await review.getByRole("button", { name: "Installing…", exact: true }).waitFor();
const socketsBeforeReconnect = await gateway.getSocketCount();
await gateway.closeLatest(1001, "install-policy reconnect proof");
await expect
.poll(() => gateway.getSocketCount(), { timeout: 10_000 })
.toBeGreaterThan(socketsBeforeReconnect);
await review.waitFor({ state: "detached" });
await row.getByRole("button", { name: "Install Lobster", exact: true }).waitFor();
expect(await row.getByRole("button", { name: "Install anyway", exact: true }).count()).toBe(
0,
);
} finally {
await context.close();
}
});
it("keeps plugin mutations unavailable to read-only operators while browse and search work", async () => {
const context = await newContext();
const page = await context.newPage();