Skip to content

Commit 87a090e

Browse files
garethxclaude
andcommitted
fix: remaining review findings — boot ordering, recovery, limits, docs
Completes the review backlog. Boot ordering (10). Under CLI transport, unpause and catch-up ran before the listeners attached — and an event delivered with no session attached is discarded rather than queued, so recovery replayed the outage window into nothing. Recovery is now driven per route by the listener's `onConnected`, which also means a tunnel that drops and returns recovers its own window instead of waiting for the next boot. Clearing the disconnect cursor on "replay accepted" is sound in that order: with a session attached, a later failure is CLI_UNAVAILABLE and stays in the retry pipeline. Modes with no attach event recover immediately, as before. `start()` is now idempotent — a second call would have orphaned children with no handle left to kill them. Recovery (14) settled the ledger row before asking Hookdeck to redeliver; a crash between the two left a terminal row, no orphan, no Issue and no dead-letter record. The order is inverted, and the over-budget path records its breadcrumb before settling. The in-dispatch capacity guard (12) returned `deferred`, which promises that a background run owns the row and will settle it. No run started, so the row stayed `running` forever, invisible to in-process recovery. Replay protection (15). The HMAC covers the body only: the event id and attempt count are unsigned, the secret is project-level, and no timestamp is signed. The comment claiming otherwise now says exactly what the scheme proves, the README lists it under Limitations, and an implausible attempt count is discarded rather than recorded — otherwise anyone able to replay a captured body could retire every legitimate redelivery of an event as a duplicate. A configured-but-unresolvable API key (16) reported "no API key is configured", sending someone to fix a config that was already correct. A secretRef needs the host secret runtime, which only the Gateway's service start receives, so tools now distinguish "unavailable here" from "absent". Rate limits (17) get their own code and carry Retry-After; a replay batch stops on the first 429 and says how many of how many ran, rather than reporting a generic error per remaining event. Also: the raw-body timeout destroys the stream instead of buffering on; the readiness-timeout kill escalates to SIGKILL like stop() does; backoff jitter is clamped so maxDelayMs is really the maximum; prerelease versions compare per semver, not lexically, so beta.10 is above beta.9; the config-error route honours a custom basePath and still registers hookdeck_status, since a config error is exactly when someone asks whether webhooks are working; and `deliver`, `lane` and `sync` warn at startup that the TaskFlow transport does not carry them. raw-body.ts and node-spawn.ts had no tests; both now have suites, the latter against a fake child_process covering the error-without-exit case. 559 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 040fa4e commit 87a090e

25 files changed

Lines changed: 793 additions & 43 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,9 @@ Signature headers and resolved secrets are redacted from logs.
341341

342342
Not yet implemented:
343343

344-
- **No completion tracking for agent turns.** See [Agent turns](#agent-turns)`sync` and `maxAgentRetries` need a completion hook the TaskFlow transport does not provide.
344+
- **No completion tracking for agent turns.** See [Agent turns](#agent-turns). Agent turns run through TaskFlow `run_task`, which exposes flow state rather than a completion signal, so `ackMode: "sync"` behaves as `async_retry`, and `deliver` and `lane` are recorded but not passed to the turn. Each is warned about at startup rather than failing quietly.
345+
- **A signature authenticates the body, not the headers.** Hookdeck's HMAC covers the raw body only, with a project-level secret and no signed timestamp. So the event id and attempt count arrive unauthenticated, and a captured `(body, signature)` pair stays valid. Deduplication is what provides replay protection, an implausible attempt count is discarded rather than recorded, and provider verification at the Source is the layer that keeps unsigned traffic out in the first place.
346+
- **List endpoints read the first page only.** `hookdeck_issues` and `hookdeck_recent_deliveries` report a real total from the count endpoint, but return one page of results.
345347

346348
## Development
347349

@@ -351,7 +353,7 @@ npm test
351353
npm run typecheck
352354
```
353355

354-
526 tests, no Gateway or Hookdeck account required. Signature vectors are computed independently with `openssl`, `test/http-integration.test.ts` exercises the pipeline over a real socket including multi-byte UTF-8 and multi-chunk bodies, the store suites inject write failures at an exact call to prove the degradation rule, and `test/store-io.test.ts` runs against a real filesystem because that is the only place durability actually lives.
356+
559 tests, no Gateway or Hookdeck account required. Signature vectors are computed independently with `openssl`, `test/http-integration.test.ts` exercises the pipeline over a real socket including multi-byte UTF-8 and multi-chunk bodies, the store suites inject write failures at an exact call to prove the degradation rule, and `test/store-io.test.ts` runs against a real filesystem because that is the only place durability actually lives.
355357

356358
## Shared reliability contract
357359

index.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import { join } from "node:path";
3+
import { jsonResult } from "openclaw/plugin-sdk/core";
34
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
45

56
import { parseHookdeckConfig } from "./src/plugin/config-parse.js";
@@ -95,8 +96,18 @@ export default definePluginEntry({
9596
`config error at ${problem.path}: ${problem.message}`,
9697
);
9798
}
99+
// The configured basePath, not the default: a deployment that moved the
100+
// ingress would otherwise answer 404 on its real path during a config
101+
// error, and the events would be lost rather than held.
102+
const basePath =
103+
typeof (api.pluginConfig as { ingress?: { basePath?: unknown } })
104+
?.ingress?.basePath === "string"
105+
? ((api.pluginConfig as { ingress: { basePath: string } }).ingress
106+
.basePath satisfies string)
107+
: "/hookdeck";
108+
98109
api.registerHttpRoute({
99-
path: "/hookdeck",
110+
path: basePath.startsWith("/") ? basePath : `/${basePath}`,
100111
auth: "plugin",
101112
match: "prefix",
102113
replaceExisting: true,
@@ -114,6 +125,39 @@ export default definePluginEntry({
114125
return true;
115126
},
116127
});
128+
129+
// A config error is exactly when someone asks "are webhooks working?".
130+
// Registering nothing leaves the agent with no way to answer, so the one
131+
// tool that can explain the situation is registered even here.
132+
try {
133+
api.registerTool(
134+
{
135+
name: "hookdeck_status",
136+
label: "Hookdeck Status",
137+
description:
138+
"Reports the Hookdeck plugin's state. Start here. The plugin is currently misconfigured, " +
139+
"so this returns the configuration errors that need fixing.",
140+
parameters: { type: "object", properties: {} },
141+
execute: async () =>
142+
jsonResult({
143+
ok: false,
144+
code: "config_error",
145+
note:
146+
"The Hookdeck plugin could not start: its configuration is invalid. Ingress answers " +
147+
"503 with a Retry-After, so events are held in Hookdeck rather than lost until this " +
148+
"is fixed.",
149+
problems: parsed.problems,
150+
}),
151+
} as never,
152+
{ name: "hookdeck_status" },
153+
);
154+
} catch (err) {
155+
api.logger?.warn?.(
156+
`could not register hookdeck_status during a config error: ${
157+
err instanceof Error ? err.message : String(err)
158+
}`,
159+
);
160+
}
117161
return;
118162
}
119163

@@ -232,18 +276,28 @@ export default definePluginEntry({
232276
const disk = await openDiskState({
233277
ttlHours: config.dedupe.ttlHours,
234278
});
279+
// The host's secret runtime needs the live OpenClawConfig, which
280+
// only the service start receives — and that has not run in this
281+
// process. So a secretRef resolves to nothing here even though the
282+
// key is configured perfectly well, and the difference has to be
283+
// reported as "unavailable", not "absent": one is a deployment fault
284+
// to go and fix, the other is normal.
235285
const apiKey = await resolveSecret(
236286
config.apiKey,
237287
"apiKey",
238288
hostSecrets,
239289
).catch(() => undefined);
290+
const keyConfiguredButUnresolvable =
291+
apiKey === undefined && config.apiKey !== undefined;
292+
240293
return {
241294
config,
242295
source: "disk",
243296
ledger: disk.ledger,
244297
deadLetter: disk.deadLetter,
245298
cursors: disk.cursors,
246299
logger: log,
300+
...(keyConfiguredButUnresolvable ? { apiKeyUnresolved: true } : {}),
247301
...(apiKey !== undefined
248302
? { client: createHookdeckClient({ apiKey }) }
249303
: {}),

src/dispatch/agent.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,13 @@ export function createAgentDispatcher(
196196
}
197197

198198
if (activeRuns >= options.maxConcurrentRuns) {
199+
// `settle: "failed"`, not `"deferred"`. The handler has already written
200+
// the ledger row by the time dispatch runs, and `deferred` means "a
201+
// background run owns this row and will settle it" — but no run
202+
// started, so the row would stay `running` forever, invisible to
203+
// in-process recovery. The response is still a plain retryable defer.
199204
return {
200-
settle: "deferred",
205+
settle: "failed",
201206
plan: deferFor(
202207
503,
203208
"busy",

src/hookdeck/client.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ export interface HookdeckClientOptions {
2626

2727
export type ApiResult<T> =
2828
| { ok: true; data: T }
29-
| { ok: false; status?: number; code: string; message: string };
29+
| {
30+
ok: false;
31+
status?: number;
32+
code: string;
33+
message: string;
34+
/** Present on `rate_limited` when the response said how long to wait. */
35+
retryAfterSeconds?: number;
36+
};
3037

3138
export interface HookdeckConnection {
3239
id: string;
@@ -232,6 +239,29 @@ export function createHookdeckClient(
232239
} catch {
233240
// Non-JSON error body; the status line is enough.
234241
}
242+
243+
// Rate limiting gets its own code and says when to come back. A caller
244+
// looping over events — `hookdeck_replay` does, up to 100 — would
245+
// otherwise report a generic failure per event, which reads as "those
246+
// events are broken" rather than "slow down".
247+
if (response.status === 429) {
248+
const retryAfter = Number.parseInt(
249+
response.headers.get("retry-after") ?? "",
250+
10,
251+
);
252+
return {
253+
ok: false,
254+
status: 429,
255+
code: "rate_limited",
256+
message: Number.isFinite(retryAfter)
257+
? `${message} (rate limited; retry after ${retryAfter}s)`
258+
: `${message} (rate limited)`,
259+
...(Number.isFinite(retryAfter)
260+
? { retryAfterSeconds: retryAfter }
261+
: {}),
262+
};
263+
}
264+
235265
return {
236266
ok: false,
237267
status: response.status,

src/ingress/handler.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
HookdeckPluginConfig,
44
RouteConfig,
55
} from "../plugin/config-types.js";
6-
import { decideAdmission } from "../protocol/admission.js";
6+
import { decideAdmission, plausibleAttempt } from "../protocol/admission.js";
77
import {
88
parseHookdeckDelivery,
99
type HookdeckDelivery,
@@ -392,7 +392,11 @@ async function runPipeline(
392392

393393
// 13. Dispatch, bracketed by ledger writes. `begin` is awaited: it is the
394394
// boundary before which we must not acknowledge anything.
395-
await deps.ledger.begin(eventId, delivery.attemptCount ?? 1, { routeId });
395+
await deps.ledger.begin(
396+
eventId,
397+
plausibleAttempt(delivery.attemptCount) ?? 1,
398+
{ routeId },
399+
);
396400

397401
let outcome: DispatchOutcome;
398402
try {

src/ingress/raw-body.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,21 @@ export interface RawBodySource extends AsyncIterable<
1515
headers?: Record<string, string | string[] | undefined>;
1616
}
1717

18+
/**
19+
* Stops a stream we have finished with.
20+
*
21+
* Optional because the interface is deliberately narrow — tests pass plain
22+
* async iterables — but a real `IncomingMessage` always has it.
23+
*/
24+
function destroy(req: RawBodySource): void {
25+
const destroyable = req as { destroy?: () => void };
26+
try {
27+
destroyable.destroy?.();
28+
} catch {
29+
// Already torn down.
30+
}
31+
}
32+
1833
export interface ReadRawBodyOptions {
1934
maxBytes: number;
2035
timeoutMs: number;
@@ -74,7 +89,14 @@ export async function readRawBody(
7489
})();
7590

7691
try {
77-
return await Promise.race([read, timeout]);
92+
const result = await Promise.race([read, timeout]);
93+
94+
// Destroy on any failure, not just success-by-timeout. Without this the
95+
// `for await` keeps buffering after we have answered, so a slow client can
96+
// hold a request's worth of memory per connection for as long as it likes.
97+
if (!result.ok) destroy(req);
98+
99+
return result;
78100
} finally {
79101
if (timer) clearTimeout(timer);
80102
}

src/plugin/config-parse.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,32 @@ export function parseHookdeckConfig(raw: unknown): ConfigParseResult {
277277
});
278278
}
279279

280-
if (route.dispatch.mode === "agent" && route.dispatch.deliver) {
281-
warnings.push({
282-
path: `routes.${routeId}.dispatch.deliver`,
283-
message:
284-
"deliver is enabled on a webhook-triggered route: an injected payload could cause an outbound message",
285-
});
280+
if (route.dispatch.mode === "agent") {
281+
// Both are carried in the config and neither reaches the runner: agent
282+
// turns go through TaskFlow `run_task`, which takes a goal and nothing
283+
// else. Saying so is better than an operator setting `deliver: false` and
284+
// believing it is doing something.
285+
if (route.dispatch.deliver) {
286+
warnings.push({
287+
path: `routes.${routeId}.dispatch.deliver`,
288+
message:
289+
"deliver has no effect on the TaskFlow transport and is not passed to the agent turn; it is recorded for a future transport that can carry it",
290+
});
291+
}
292+
if (route.dispatch.lane !== undefined) {
293+
warnings.push({
294+
path: `routes.${routeId}.dispatch.lane`,
295+
message:
296+
"lane has no effect on the TaskFlow transport and is not passed to the agent turn",
297+
});
298+
}
299+
if (route.dispatch.ackMode === "sync") {
300+
warnings.push({
301+
path: `routes.${routeId}.dispatch.ackMode`,
302+
message:
303+
"sync needs a completion signal the TaskFlow transport does not provide; this route will behave as async_retry",
304+
});
305+
}
286306
}
287307

288308
routes[routeId] = {

src/protocol/admission.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,23 @@
1313
* When the attempt header is absent, admit only if the previous run for that
1414
* event is recorded as failed.
1515
*
16+
* Both inputs — the event id and the attempt count — arrive in unsigned
17+
* headers, since the HMAC covers only the body. An absurd attempt count would
18+
* otherwise retire every legitimate redelivery of that event as a duplicate,
19+
* so one is treated as no attempt count at all.
20+
*
1621
* Storage is the host's business; this rule is not.
1722
*/
1823

24+
/**
25+
* Above this, an attempt count is not believable.
26+
*
27+
* Hookdeck's own ceiling is 50 automatic attempts, and manual retries and
28+
* replays add few enough that a five-digit count means the header is wrong or
29+
* hostile, not that an event has genuinely been tried that often.
30+
*/
31+
export const MAX_PLAUSIBLE_ATTEMPT = 10_000;
32+
1933
export type LedgerStatus = "running" | "succeeded" | "failed" | "exhausted";
2034

2135
export interface LedgerRow {
@@ -49,10 +63,27 @@ export type AdmissionDecision =
4963
"duplicate_attempt" | "in_flight" | "already_succeeded" | "exhausted";
5064
};
5165

66+
/**
67+
* Discards an attempt count we do not believe.
68+
*
69+
* Applied wherever the header is used, not only when admitting: recording an
70+
* implausible value would raise the bar above every real redelivery of that
71+
* event and retire them all as duplicates. The header is unsigned, so this is
72+
* reachable by anyone who can replay a captured body.
73+
*/
74+
export function plausibleAttempt(
75+
attemptCount: number | undefined,
76+
): number | undefined {
77+
if (attemptCount === undefined) return undefined;
78+
return attemptCount > MAX_PLAUSIBLE_ATTEMPT ? undefined : attemptCount;
79+
}
80+
5281
export function decideAdmission(
5382
row: LedgerRow | undefined,
54-
attemptCount: number | undefined,
83+
rawAttemptCount: number | undefined,
5584
): AdmissionDecision {
85+
const attemptCount = plausibleAttempt(rawAttemptCount);
86+
5687
if (row === undefined) return { admit: true, reason: "first_delivery" };
5788

5889
if (attemptCount !== undefined) {

src/protocol/signature.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,21 @@ import { createHmac, timingSafeEqual } from "node:crypto";
55
*
66
* base64( HMAC-SHA256( raw_body_bytes, signing_secret ) )
77
*
8-
* The secret is project-level, not per-connection. No timestamp is signed, so
9-
* the signature carries no replay protection on its own — deduplication is what
10-
* provides that, and it is mandatory rather than optional.
8+
* What this does and does not prove is worth being exact about, because the
9+
* scheme is narrower than "the request is authentic":
10+
*
11+
* - It proves the BODY came from a project holding the signing secret.
12+
* - It does not cover any header, so the event id, the attempt count and the
13+
* source name all arrive unauthenticated.
14+
* - The secret is project-level, so a signature does not bind a body to a
15+
* particular route or connection.
16+
* - No timestamp is signed, so a captured (body, signature) pair stays valid
17+
* indefinitely.
18+
*
19+
* Deduplication is therefore load-bearing rather than an optimisation, and it
20+
* is deduplication over UNSIGNED inputs: anyone holding one captured pair can
21+
* re-present it with a fresh event id. `MAX_PLAUSIBLE_ATTEMPT` in
22+
* `admission.ts` limits the damage from the worst version of that.
1123
*
1224
* Everything here is pure and operates on bytes. The raw body must be the exact
1325
* octets Hookdeck sent: re-serialising parsed JSON will not reproduce them.

src/recovery.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,10 @@ export async function reconcileOrphans(
7676
const ordered = [...orphans].sort((a, b) => a.updatedAt - b.updatedAt);
7777

7878
for (const [index, row] of ordered.entries()) {
79-
// Settle the row first, whatever happens next. Leaving it `running` would
80-
// make the next boot treat it as an orphan again, forever.
81-
await ledger.settle(row.eventId, "failed");
82-
8379
if (index >= maxEvents || client === undefined) {
80+
// Recorded before the row is settled: a crash between the two would
81+
// otherwise leave a terminal row, no orphan for the next boot, and
82+
// nothing anywhere saying the event was dropped.
8483
summary.skipped += 1;
8584
await deadLetter.record({
8685
eventId: row.eventId,
@@ -94,11 +93,16 @@ export async function reconcileOrphans(
9493
lastAttempt: false,
9594
attemptCount: row.attempt,
9695
});
96+
await ledger.settle(row.eventId, "failed");
9797
continue;
9898
}
9999

100100
const result = await client.retryEvent(row.eventId);
101101
if (result.ok) {
102+
// Settled only once Hookdeck has accepted the redelivery. The other order
103+
// loses the event entirely if the process dies between the two: a
104+
// terminal row, no orphan, no Issue and no dead-letter record.
105+
await ledger.settle(row.eventId, "failed");
102106
summary.retried += 1;
103107
logger.debug(`re-queued interrupted event ${row.eventId}`);
104108
continue;

0 commit comments

Comments
 (0)