fix(line): preserve webhook object error details (#113606)

* fix(line): preserve webhook object error details

* fix(line): preserve structured errors on the live webhook route

* test(line): provide the complete typed runtime to webhook proof

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sunlit-deng
2026-07-28 10:46:21 +08:00
committed by GitHub
parent f2a12916d7
commit b4e246be01
5 changed files with 128 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
// Line tests cover monitor.lifecycle plugin behavior.
import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
@@ -378,6 +378,102 @@ describe("monitorLineProvider lifecycle", () => {
await secondMonitor.stop();
});
it("redacts structured admission failures from signed HTTP webhook requests", async () => {
const runtimeError = vi.fn<(...args: unknown[]) => void>();
const runtime: RuntimeEnv = {
log: vi.fn<(...args: unknown[]) => void>(),
error: runtimeError,
exit: vi.fn<(code: number) => void>(),
};
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
accountId: "default",
config: {} as OpenClawConfig,
runtime,
});
const route = requireRegisteredRoute();
const server = createServer((req, res) => {
void route.handler(req, res);
});
try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("expected LINE webhook test server to have a TCP address");
}
const webhookUrl = `http://127.0.0.1:${address.port}/line/webhook`;
const payload = JSON.stringify({ events: [{ type: "message" }] });
const rejected = await fetch(webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-line-signature": "invalid-signature",
},
body: payload,
});
expect(rejected.status).toBe(401);
expect(await rejected.json()).toEqual({ error: "Invalid signature" });
const bot = createLineBotMock.mock.results[0]?.value;
if (!bot) {
throw new Error("expected registered LINE webhook bot");
}
expect(bot.handleWebhook).not.toHaveBeenCalled();
const bearerToken = "test_line_access_token_1234567890";
bot.handleWebhook.mockRejectedValueOnce({
code: "LINE_ADMISSION_REJECTED",
retryAfterMs: 250,
authorization: `Bearer ${bearerToken}`,
});
const signature = crypto.createHmac("SHA256", "secret").update(payload).digest("base64");
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-line-signature": signature,
},
body: payload,
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({ error: "Internal server error" });
expect(bot.handleWebhook).toHaveBeenCalledTimes(1);
expect(runtimeError).toHaveBeenCalledTimes(1);
const message = String(runtimeError.mock.calls[0]?.[0]);
expect(message).toContain("line webhook error:");
expect(message).toContain("LINE_ADMISSION_REJECTED");
expect(message).toContain("retryAfterMs");
expect(message).not.toContain("[object Object]");
expect(message).not.toContain(bearerToken);
} finally {
try {
if (server.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
} finally {
await monitor.stop();
}
}
});
it("dispatches a signed POST to a configured trailing-slash webhook path", async () => {
const monitor = await monitorLineProvider({
channelAccessToken: "token",

View File

@@ -2,6 +2,7 @@
import type { webhook } from "@line/bot-sdk";
import { hasFinalInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
import {
danger,
@@ -392,7 +393,7 @@ export async function monitorLineProvider(
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
return;
}
runtime.error?.(danger(`line webhook error: ${String(err)}`));
runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");

View File

@@ -183,18 +183,19 @@ type WebhookPostResult = {
};
type WebhookPostInvoker = (params: {
failWith?: Error;
failWith?: unknown;
rawBody: string;
signed: boolean;
}) => Promise<WebhookPostResult>;
async function invokeNodePostContract(params: {
failWith?: Error;
failWith?: unknown;
rawBody: string;
signed: boolean;
}) {
const dispatched = vi.fn(async () => {
if (params.failWith) {
// oxlint-disable-next-line typescript/only-throw-error -- Webhook boundaries must report non-Error throws from downstream dispatchers.
throw params.failWith;
}
});
@@ -223,13 +224,14 @@ async function invokeNodePostContract(params: {
}
async function invokeMiddlewarePostContract(params: {
failWith?: Error;
failWith?: unknown;
rawBody: string;
signed: boolean;
}) {
const runtime = createRuntimeMock();
const onEvents = vi.fn(async () => {
if (params.failWith) {
// oxlint-disable-next-line typescript/only-throw-error -- Webhook boundaries must report non-Error throws from downstream dispatchers.
throw params.failWith;
}
});
@@ -354,6 +356,26 @@ describe("LINE webhook shared POST contract", () => {
expect(result.runtimeError).toHaveBeenCalledTimes(1);
},
);
it.each(sharedWebhookPostContractCases)(
"$name reports non-Error admission failures without object Object",
async ({ invoke }) => {
const result = await invoke({
failWith: { code: "LINE_ADMISSION_REJECTED", retryAfterMs: 250 },
rawBody: JSON.stringify({ events: [{ type: "message" }] }),
signed: true,
});
expect(result.status).toBe(500);
expect(result.body).toEqual({ error: "Internal server error" });
expect(result.runtimeError).toHaveBeenCalledTimes(1);
const runtimeMessage = String(firstMockCall(result.runtimeError, "runtime error")[0]);
expect(runtimeMessage).toContain("line webhook error:");
expect(runtimeMessage).toContain("LINE_ADMISSION_REJECTED");
expect(runtimeMessage).toContain("retryAfterMs");
expect(runtimeMessage).not.toContain("[object Object]");
},
);
});
describe("createLineNodeWebhookHandler", () => {

View File

@@ -1,6 +1,7 @@
// Line plugin module implements webhook node behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import type { webhook } from "@line/bot-sdk";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import {
isRequestBodyLimitError,
@@ -119,7 +120,7 @@ export function createLineNodeWebhookHandler(params: {
res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
return;
}
params.runtime.error?.(danger(`line webhook error: ${String(err)}`));
params.runtime.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");

View File

@@ -1,6 +1,7 @@
// Line plugin module implements webhook behavior.
import type { webhook } from "@line/bot-sdk";
import type { NextFunction, Request, Response } from "express";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { danger, logVerbose, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { parseLineWebhookBody, validateLineSignature } from "./webhook-utils.js";
@@ -73,7 +74,7 @@ export function createLineWebhookMiddleware(
}
res.status(200).json({ status: "ok" });
} catch (err) {
runtime?.error?.(danger(`line webhook error: ${String(err)}`));
runtime?.error?.(danger(`line webhook error: ${formatErrorMessage(err)}`));
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}