fix(approvals): restore queue targeting and plugin id prefixes

This commit is contained in:
Vincent Koc
2026-03-30 07:36:44 +09:00
parent 7043705ef3
commit 0e47ce58bc
5 changed files with 46 additions and 12 deletions

View File

@@ -106,6 +106,8 @@ Docs: https://docs.openclaw.ai
- Telegram/splitting: replace proportional text estimate with verified HTML-length search so long messages split at word boundaries instead of mid-word; gracefully degrade when tag overhead exceeds the limit. (#56595)
- Telegram/delivery: skip whitespace-only and hook-blanked text replies in bot delivery to prevent GrammyError 400 empty-text crashes. (#56620)
- Telegram/send: validate `replyToMessageId` at all four API sinks with a shared normalizer that rejects non-numeric, NaN, and mixed-content strings. (#56587)
- Approvals/UI: keep the newest pending approval at the front of the Control UI queue so approving one request does not accidentally target an older expired id. Thanks @vincentkoc.
- Plugin approvals: accept unique short approval-id prefixes on `plugin.approval.resolve`, matching exec approvals and restoring `/approve` fallback flows on chat approval surfaces. Thanks @vincentkoc.
- Mistral: normalize OpenAI-compatible request flags so official Mistral API runs no longer fail with remaining `422 status code (no body)` chat errors.
- Control UI/config: keep sensitive raw config hidden by default, replace the blank blocked editor with an explicit reveal-to-edit state, and restore raw JSON editing without auto-exposing secrets. Fixes #55322.
- CLI/zsh: defer `compdef` registration until `compinit` is available so zsh completion loads cleanly with plugin managers and manual setups. (#56555)

View File

@@ -431,7 +431,7 @@ describe("createPluginApprovalHandlers", () => {
);
});
it("requires exact id and rejects prefixes", async () => {
it("accepts unique short id prefixes", async () => {
const handlers = createPluginApprovalHandlers(manager);
const record = manager.create({ title: "T", description: "D" }, 60_000, "abcdef-1234");
void manager.register(record, 60_000);
@@ -441,15 +441,8 @@ describe("createPluginApprovalHandlers", () => {
decision: "allow-always",
});
await handlers["plugin.approval.resolve"](opts);
expect(opts.respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
code: "INVALID_REQUEST",
message: expect.stringContaining("unknown or expired"),
details: expect.objectContaining({ reason: "APPROVAL_NOT_FOUND" }),
}),
);
expect(opts.respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot(record.id)?.decision).toBe("allow-always");
});
it("does not leak candidate ids when prefixes are ambiguous", async () => {

View File

@@ -217,7 +217,18 @@ export function createPluginApprovalHandlers(
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const approvalId = p.id.trim();
const resolvedId = manager.lookupPendingId(p.id);
if (resolvedId.kind === "none" || resolvedId.kind === "ambiguous") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
return;
}
const approvalId = resolvedId.id;
const snapshot = manager.getSnapshot(approvalId);
if (!snapshot || snapshot.resolvedAtMs !== undefined) {
respond(

View File

@@ -49,6 +49,9 @@ vi.mock("./gateway.ts", () => ({
}));
const { handleGatewayEvent } = await import("./app-gateway.ts");
const { addExecApproval } = await vi.importActual<typeof import("./controllers/exec-approval.ts")>(
"./controllers/exec-approval.ts",
);
function createHost() {
return {
@@ -120,3 +123,28 @@ describe("handleGatewayEvent sessions.changed", () => {
expect(loadSessionsMock).toHaveBeenCalledWith(host);
});
});
describe("addExecApproval", () => {
it("keeps the newest approval at the front of the queue", () => {
const queue = addExecApproval(
[
{
id: "approval-old",
kind: "exec",
request: { command: "echo old" },
createdAtMs: 1,
expiresAtMs: Date.now() + 120_000,
},
],
{
id: "approval-new",
kind: "exec",
request: { command: "echo new" },
createdAtMs: 2,
expiresAtMs: Date.now() + 120_000,
},
);
expect(queue.map((entry) => entry.id)).toEqual(["approval-new", "approval-old"]);
});
});

View File

@@ -134,7 +134,7 @@ export function addExecApproval(
entry: ExecApprovalRequest,
): ExecApprovalRequest[] {
const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id);
next.push(entry);
next.unshift(entry);
return next;
}