diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b60ce8673..0a60a409cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Docs: https://docs.openclaw.ai ## 2026.4.26 +### Fixes + +- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. +- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul. +- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd. +- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex. +- Package: include patched dependency files in the published npm package so downstream installs can resolve `patchedDependencies`. (#69224) Thanks @gucasbrg and @vincentkoc. +- Plugins/channels: treat malformed bundled channel plugin loaders that return `undefined` as unavailable instead of crashing config and help paths. Fixes #69044. Thanks @frankhli843 and @vincentkoc. +- Scripts/watch: show corrupted dependency package-config recovery guidance when `gateway:watch` fails during watcher startup, without double-logging unrelated import failures. (#58780) Thanks @roytong9 and @vincentkoc. +- Signal: read signal-cli RPC, health checks, and SSE events through Node's HTTP client so Node 24/25 fetch regressions do not break Signal sends or inbound events. Fixes #51716 and #53040. Thanks @Barukimang, @minupla, and @vincentkoc. +- Skills/Docker: run npm-backed skill dependency installs with an OpenClaw-managed user prefix so non-root Docker images do not write to `/usr/local`. Fixes #59601. Thanks @chanjarster and @vincentkoc. + ## 2026.4.25 ### Highlights diff --git a/extensions/discord/src/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts index d9cd24fcf74..78e8eb9fc62 100644 --- a/extensions/discord/src/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -87,7 +87,7 @@ describe("discord exec approval monitor helpers", () => { const interaction = createInteraction(); const button = new ExecApprovalButton({ getApprovers: () => ["123"], - resolveApproval: async () => true, + resolveApproval: async () => ({ ok: true }), }); await button.run(interaction, { id: "", action: "" }); @@ -102,7 +102,7 @@ describe("discord exec approval monitor helpers", () => { const interaction = createInteraction({ userId: "999" }); const button = new ExecApprovalButton({ getApprovers: () => ["123"], - resolveApproval: async () => true, + resolveApproval: async () => ({ ok: true }), }); await button.run(interaction, { id: "abc", action: "allow-once" }); @@ -115,7 +115,7 @@ describe("discord exec approval monitor helpers", () => { it("acknowledges and resolves valid approval clicks", async () => { const interaction = createInteraction(); - const resolveApproval = vi.fn(async () => true); + const resolveApproval = vi.fn(async () => ({ ok: true }) as const); const button = new ExecApprovalButton({ getApprovers: () => ["123"], resolveApproval, @@ -132,7 +132,7 @@ describe("discord exec approval monitor helpers", () => { const interaction = createInteraction(); const button = new ExecApprovalButton({ getApprovers: () => ["123"], - resolveApproval: async () => false, + resolveApproval: async () => ({ ok: false, reason: "error" }), }); await button.run(interaction, { id: "abc", action: "deny" }); @@ -144,6 +144,19 @@ describe("discord exec approval monitor helpers", () => { }); }); + it("keeps already-resolved approval clicks quiet", async () => { + const interaction = createInteraction(); + const button = new ExecApprovalButton({ + getApprovers: () => ["123"], + resolveApproval: async () => ({ ok: false, reason: "not-found" }), + }); + + await button.run(interaction, { id: "abc", action: "allow-once" }); + + expect(interaction.acknowledge).toHaveBeenCalled(); + expect(interaction.followUp).not.toHaveBeenCalled(); + }); + it("builds button context from config and routes resolution over gateway", async () => { const cfg = buildConfig({ enabled: true, approvers: ["123"] }); resolveApprovalOverGatewayMock.mockResolvedValue(undefined); @@ -155,7 +168,7 @@ describe("discord exec approval monitor helpers", () => { }); expect(ctx.getApprovers()).toEqual(["123"]); - await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toBe(true); + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ ok: true }); expect(resolveApprovalOverGatewayMock).toHaveBeenCalledWith({ cfg, approvalId: "abc", @@ -173,6 +186,41 @@ describe("discord exec approval monitor helpers", () => { config: { enabled: true, approvers: ["123"] }, }); - await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toBe(false); + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ + ok: false, + reason: "error", + }); + }); + + it("classifies structured approval-not-found gateway errors as stale clicks", async () => { + const err = Object.assign(new Error("unknown or expired approval id"), { + gatewayCode: "INVALID_REQUEST", + details: { reason: "APPROVAL_NOT_FOUND" }, + }); + resolveApprovalOverGatewayMock.mockRejectedValue(err); + const ctx = createDiscordExecApprovalButtonContext({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + accountId: "default", + config: { enabled: true, approvers: ["123"] }, + }); + + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ + ok: false, + reason: "not-found", + }); + }); + + it("keeps message-only approval-not-found errors visible", async () => { + resolveApprovalOverGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")); + const ctx = createDiscordExecApprovalButtonContext({ + cfg: buildConfig({ enabled: true, approvers: ["123"] }), + accountId: "default", + config: { enabled: true, approvers: ["123"] }, + }); + + await expect(ctx.resolveApproval("abc", "allow-once")).resolves.toEqual({ + ok: false, + reason: "error", + }); }); }); diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts index bcec60b8cc8..e4460018a1d 100644 --- a/extensions/discord/src/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -53,9 +53,30 @@ export function parseExecApprovalData( export type ExecApprovalButtonContext = { getApprovers: () => string[]; - resolveApproval: (approvalId: string, decision: ExecApprovalDecision) => Promise; + resolveApproval: ( + approvalId: string, + decision: ExecApprovalDecision, + ) => Promise; }; +type ExecApprovalResolveResult = { ok: true } | { ok: false; reason: "error" | "not-found" }; + +function isStructuredApprovalNotFoundError(err: unknown): boolean { + if (!err || typeof err !== "object") { + return false; + } + const record = err as { + gatewayCode?: unknown; + details?: { reason?: unknown } | null; + }; + if (record.gatewayCode === "APPROVAL_NOT_FOUND") { + return true; + } + return ( + record.gatewayCode === "INVALID_REQUEST" && record.details?.reason === "APPROVAL_NOT_FOUND" + ); +} + export class ExecApprovalButton extends Button { label = "execapproval"; customId = "execapproval:seed=1"; @@ -100,8 +121,8 @@ export class ExecApprovalButton extends Button { await interaction.acknowledge(); } catch {} - const ok = await this.ctx.resolveApproval(parsed.approvalId, parsed.action); - if (!ok) { + const result = await this.ctx.resolveApproval(parsed.approvalId, parsed.action); + if (!result.ok && result.reason !== "not-found") { try { await interaction.followUp({ content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`, @@ -138,9 +159,12 @@ export function createDiscordExecApprovalButtonContext(params: { gatewayUrl: params.gatewayUrl, clientDisplayName: `Discord approval (${params.accountId})`, }); - return true; - } catch { - return false; + return { ok: true }; + } catch (err) { + return { + ok: false, + reason: isStructuredApprovalNotFoundError(err) ? "not-found" : "error", + }; } }, };