Skip to content

Commit 25fbfd6

Browse files
garethxclaude
andcommitted
feat: diagnose a CLI/API-key project mismatch, escalate a dead tunnel
Applying the findings from the Hermes plugin's reviews, which faces the same Hookdeck-shaped problems. Each was checked against this codebase first rather than ported on faith; three applied. The big one: in `cli` transport, "which project" has two independent answers. Provisioning acts on the API key's project while `hookdeck listen` looks for that connection in the CLI session's. Nothing reconciled them, and this plugin deliberately will not force them together — that means `hookdeck ci`, which rewrites the CLI's global config and switches the active project for everything else on the machine. So the split stays and is now visible instead: - `hookdeck_doctor` compares the two, reading the CLI's project from its config file and the key's from `team_id` on any connection it can reach. A CLI with no session, or a key reaching no connections, is reported as unverified rather than as a mismatch: a project can legitimately be empty, and a missing session is a different failure with a different fix. - The supervisor escalates. A tunnel that cannot stay up restarted forever at warn level, which reads as routine churn while nothing reaches the Gateway — and `maxConsecutiveFailures` was never set, so it never even reached the giving-up log. Three consecutive failed runs now log once, with the CLI's own last lines and, where recognisable, the cause. `no connection found matching filter` names the project mismatch; an auth failure names `hookdeck login`. A healthy run re-arms it. Also `projectId`, sent as `X-Team-Id`. A project-scoped key implies its project so this is optional today, but an organisation-scoped one does not, and without it a call can act on whichever project holds a same-named resource. Checked and NOT applicable: a malformed signature answering 500 (ours refuses cleanly and returns 401 — seven malformed inputs verified, including non-ASCII and 100KB; and an unexpected throw is a deliberate 503, never a 500); state cleared only on success (in-flight releases in a `finally`, auto-resume timers are cancelled globally); ambient `HOOKDECK_*` env reads (already config-driven by an earlier fix). `markHealthy` now returns whether it reset, because the supervisor needs "was that run a success?" and the failure count has not yet been incremented at that point — reading it there treated the first failure as a success. Verified live: the CLI's project and the API key's project both read correctly on this machine and agree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ab99800 commit 25fbfd6

17 files changed

Lines changed: 706 additions & 6 deletions

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Every setting, its default, and what it changes.
66
|---|---|---|
77
| `headerPrefix` | `x-hookdeck` | Hookdeck's header prefix is white-labelable per project. Set it if yours differs. |
88
| `signingSecret` || Inline string or a secretRef `{source, provider, id}`. Routes may override. Re-resolved on every request, so rotation needs no restart. |
9+
| `projectId` || Pins API calls to one project via `X-Team-Id`. Optional for a project-scoped key, which implies its project; needed for an organisation-scoped one. |
10+
| `transport.cliConfigPath` | `~/.config/hookdeck/config.toml` | Where the Hookdeck CLI keeps its session. Read by `hookdeck_doctor` to check both point at the same project. |
911
| `apiKey` || Optional. Needed for provisioning, pause/resume, replay, issue management and re-queuing interrupted work. Without it the plugin runs ingress-only. |
1012
| `storage.enabled` | `true` | Persist the ledger and dead-letter log. Off means memory-only — see [Durability](#durability-and-recovery). |
1113
| `storage.deadLetterMaxEntries` | `500` | Dead-letter entries kept before the oldest are dropped. |

docs/transport.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,21 @@ On shutdown the connection is **paused before** the listener is stopped. That or
3232
The `pausedByUs` marker is written *before* the pause call, so a crash in between still leaves the breadcrumb that unpauses on the next start — a connection left paused forever is a silent outage.
3333

3434
`lastDisconnectAt` is written on **every** listener exit, clean or otherwise. It is the only durable evidence of an outage window, and the catch-up replay needs it to bound its query: `bulk/requests/replay` is the only path that can be time-scoped, since `bulk/ignored-events/retry` takes no date filter and there is no project-wide `GET /ignored-events` to enumerate with.
35+
36+
## The two projects problem
37+
38+
In `cli` transport, "which Hookdeck project" has two independent answers:
39+
40+
- `hookdeck_setup` and every API call act on the project the **API key** belongs to.
41+
- `hookdeck listen` looks for that connection in the project the **CLI session** is logged into.
42+
43+
Nothing reconciles them, and the plugin deliberately does not try: forcing them together means `hookdeck ci`, which rewrites the CLI's global config and switches the active project for every other use on the machine.
44+
45+
When they differ, the failure is quiet in the worst way. The Gateway starts, logs that the transport is up, and receives nothing — the tunnel restart-loops on `no connection found matching filter` while every event becomes an ignored `CLI_DISCONNECTED`.
46+
47+
Two things make it visible rather than silent:
48+
49+
- **`hookdeck_doctor` compares them.** It reads the CLI's project from its config file and the API key's from `team_id` on any connection the key can reach, and fails with both ids and the fix. A CLI with no session, or a key that reaches no connections, is reported as unverified rather than as a mismatch — a project can legitimately be empty, and a missing session is its own separate failure.
50+
- **The supervisor escalates a standing failure.** After three consecutive runs that fail to stay up, it logs once — not once per restart — with the CLI's own last lines and, where the output is recognisable, the likely cause. A healthy run re-arms it, so a second outage is not silent.
51+
52+
If your API key is organisation-scoped rather than project-scoped, set `projectId` as well: without it a call can act on whichever project happens to hold a resource of the same name.

index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,14 @@ export default definePluginEntry({
315315
logger: log,
316316
...(keyConfiguredButUnresolvable ? { apiKeyUnresolved: true } : {}),
317317
...(apiKey !== undefined
318-
? { client: createHookdeckClient({ apiKey }) }
318+
? {
319+
client: createHookdeckClient({
320+
apiKey,
321+
...(config.projectId !== undefined
322+
? { projectId: config.projectId }
323+
: {}),
324+
}),
325+
}
319326
: {}),
320327
configWarnings: () => parsed.warnings,
321328
};
@@ -461,7 +468,14 @@ export default definePluginEntry({
461468
return undefined;
462469
});
463470
const client =
464-
apiKey !== undefined ? createHookdeckClient({ apiKey }) : undefined;
471+
apiKey !== undefined
472+
? createHookdeckClient({
473+
apiKey,
474+
...(config.projectId !== undefined
475+
? { projectId: config.projectId }
476+
: {}),
477+
})
478+
: undefined;
465479

466480
// Before serving anything: hand interrupted work back to Hookdeck.
467481
const summary = await reconcileOrphans({

openclaw.plugin.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@
170170
}
171171
}
172172
},
173+
"projectId": {
174+
"type": "string",
175+
"description": "Pins API calls to one Hookdeck project via X-Team-Id. Optional for a project-scoped API key, which implies its project; needed for an organisation-scoped one."
176+
},
173177
"provisioning": {
174178
"type": "object",
175179
"additionalProperties": false,
@@ -621,6 +625,10 @@
621625
"type": "string",
622626
"format": "uri",
623627
"description": "Public base URL of the Gateway, required for http transport provisioning."
628+
},
629+
"cliConfigPath": {
630+
"type": "string",
631+
"description": "Where the Hookdeck CLI keeps its session, used by hookdeck_doctor to check that the CLI and the API key point at the same project. Defaults to ~/.config/hookdeck/config.toml."
624632
}
625633
}
626634
}

src/hookdeck/client.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ export type FetchLike = (
1919

2020
export interface HookdeckClientOptions {
2121
apiKey: string;
22+
/**
23+
* Pins every call to one project, via the same `X-Team-Id` header the
24+
* Hookdeck CLI sends.
25+
*
26+
* A project-scoped key implies its project, so this is optional today. An
27+
* organisation-scoped key does not, and without pinning it would act on
28+
* whichever project happens to hold a matching resource name.
29+
*/
30+
projectId?: string | undefined;
2231
baseUrl?: string;
2332
fetch?: FetchLike;
2433
timeoutMs?: number;
@@ -37,6 +46,8 @@ export type ApiResult<T> =
3746

3847
export interface HookdeckConnection {
3948
id: string;
49+
/** The project this connection belongs to; how we learn the key's project. */
50+
team_id?: string;
4051
/** How a person refers to it. Issues carry only the id. */
4152
name?: string;
4253
paused_at?: string | null;
@@ -125,6 +136,8 @@ export interface HookdeckClient {
125136

126137
getConnection(id: string): Promise<ApiResult<HookdeckConnection>>;
127138

139+
listConnections(limit?: number): Promise<ApiResult<HookdeckConnection[]>>;
140+
128141
/**
129142
* Holds inbound events at status `HOLD` until unpaused, delivered then with
130143
* attempt trigger `UNPAUSE`. Nothing is dropped.
@@ -229,6 +242,9 @@ export function createHookdeckClient(
229242
headers: {
230243
authorization: `Bearer ${options.apiKey}`,
231244
"content-type": "application/json",
245+
...(options.projectId !== undefined
246+
? { "x-team-id": options.projectId }
247+
: {}),
232248
},
233249
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
234250
signal: controller.signal,
@@ -321,6 +337,14 @@ export function createHookdeckClient(
321337
);
322338
},
323339

340+
async listConnections(limit = 1) {
341+
const result = await request<{ models?: HookdeckConnection[] }>(
342+
"GET",
343+
`/connections?limit=${limit}`,
344+
);
345+
return result.ok ? { ok: true, data: result.data.models ?? [] } : result;
346+
},
347+
324348
async pauseConnection(id) {
325349
return request<HookdeckConnection>(
326350
"PUT",

src/plugin/config-parse.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const configSchema = z.object({
8181
headerPrefix: z.string().min(1).default(DEFAULT_HEADER_PREFIX),
8282
signingSecret: secretInputSchema.optional(),
8383
apiKey: secretInputSchema.optional(),
84+
projectId: z.string().min(1).optional(),
8485
tools: z
8586
.object({ allowMutations: z.boolean().default(true) })
8687
.default({ allowMutations: true }),
@@ -106,6 +107,7 @@ const configSchema = z.object({
106107
mode: z.enum(["cli", "http", "none"]).default("none"),
107108
port: z.number().int().positive().max(65535).default(18789),
108109
binaryPath: z.string().min(1).default("hookdeck"),
110+
cliConfigPath: z.string().min(1).optional(),
109111
allowUnsupportedVersion: z.boolean().default(false),
110112
publicUrl: z.string().url().optional(),
111113
})
@@ -374,6 +376,7 @@ export function parseHookdeckConfig(raw: unknown): ConfigParseResult {
374376
maxConcurrent: value.maxConcurrent,
375377
busyRetryAfterSeconds: value.busyRetryAfterSeconds,
376378
deferAttemptLimit: value.deferAttemptLimit,
379+
...(value.projectId !== undefined ? { projectId: value.projectId } : {}),
377380
dedupe: { ttlHours: value.dedupe.ttlHours },
378381
tools: { allowMutations: value.tools.allowMutations },
379382
storage: {
@@ -388,6 +391,9 @@ export function parseHookdeckConfig(raw: unknown): ConfigParseResult {
388391
mode: value.transport.mode,
389392
port: value.transport.port,
390393
binaryPath: value.transport.binaryPath,
394+
...(value.transport.cliConfigPath !== undefined
395+
? { cliConfigPath: value.transport.cliConfigPath }
396+
: {}),
391397
allowUnsupportedVersion: value.transport.allowUnsupportedVersion,
392398
...(value.transport.publicUrl !== undefined
393399
? { publicUrl: value.transport.publicUrl }

src/plugin/config-types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ export interface TransportConfig {
154154
port: number;
155155
/** Resolved explicitly, because a shadowed binary defeats the version gate. */
156156
binaryPath: string;
157+
/**
158+
* Where the Hookdeck CLI keeps its session, for the project-mismatch check.
159+
* Defaults to the CLI's own `~/.config/hookdeck/config.toml`.
160+
*/
161+
cliConfigPath?: string;
157162
/** Downgrade the >=2.4.0 gate to a warning. Sub-2.3.2 silently stops delivering. */
158163
allowUnsupportedVersion: boolean;
159164
/** Public base URL of the Gateway, for `http` mode provisioning. */
@@ -197,6 +202,14 @@ export interface HookdeckPluginConfig {
197202
* cannot be re-queued.
198203
*/
199204
apiKey?: SecretInput;
205+
/**
206+
* Pins API calls to one project, via `X-Team-Id`.
207+
*
208+
* A project-scoped key implies its project, so this is optional. An
209+
* organisation-scoped key does not: without it, a call could act on whichever
210+
* project happens to hold a resource of the same name.
211+
*/
212+
projectId?: string;
200213
ingress: {
201214
/** Route prefix registered on the Gateway. */
202215
basePath: string;

src/tools/deps.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export interface ToolDeps {
5858
* someone to fix a config that is already correct.
5959
*/
6060
apiKeyUnresolved?: boolean;
61+
/** Injectable so `doctor` can read the CLI's config in a test. */
62+
readFile?: (path: string) => Promise<string>;
6163
now?(): number;
6264
}
6365

src/tools/doctor.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { uncoveredStatuses } from "../hookdeck/provision.js";
22
import { RETRYABLE_STATUS_CODES } from "../protocol/outcome.js";
3+
import {
4+
defaultCliConfigPath,
5+
readCliProject,
6+
} from "../transport/cli-project.js";
37
import { type ToolDeps } from "./deps.js";
48
import { reportedPersistence } from "./status.js";
59

@@ -61,6 +65,15 @@ export async function doctorHandler(deps: ToolDeps) {
6165
});
6266
}
6367

68+
// In `cli` transport, "which project" has two independent answers:
69+
// provisioning acts on the API key's project, while `hookdeck listen` looks
70+
// for that connection in whichever project the CLI's session points at. When
71+
// they differ the Gateway reports healthy and receives nothing, so the only
72+
// thing standing between an operator and a silent outage is this check.
73+
if (deps.config.transport.mode === "cli") {
74+
checks.push(await projectMatchCheck(deps));
75+
}
76+
6477
checks.push({
6578
name: "api key",
6679
ok: deps.client !== undefined,
@@ -105,3 +118,71 @@ export async function doctorHandler(deps: ToolDeps) {
105118

106119
return { ok: checks.every((c) => c.ok), checks };
107120
}
121+
122+
interface Check {
123+
name: string;
124+
ok: boolean;
125+
detail: string;
126+
}
127+
128+
async function projectMatchCheck(deps: ToolDeps): Promise<Check> {
129+
const name = "cli/api-key project";
130+
131+
const cli = await readCliProject(
132+
deps.config.transport.cliConfigPath ?? defaultCliConfigPath(),
133+
deps.readFile ??
134+
(async (p) => (await import("node:fs/promises")).readFile(p, "utf8")),
135+
);
136+
137+
if (cli.projectId === undefined) {
138+
// Not a mismatch. A CLI with no session is its own failure — `hookdeck
139+
// listen` cannot start at all — and reporting it as a mismatch would point
140+
// at the wrong fix.
141+
return {
142+
name,
143+
ok: true,
144+
detail:
145+
cli.reason === "no_config"
146+
? "unverified — no Hookdeck CLI config found, so the CLI's project is unknown. Run `hookdeck login`."
147+
: "unverified — the CLI config holds no project, so no session is logged in. Run `hookdeck login`.",
148+
};
149+
}
150+
151+
if (deps.client === undefined) {
152+
return {
153+
name,
154+
ok: true,
155+
detail: `unverified — the CLI forwards from ${cli.projectId}, but with no API key there is nothing to compare it against`,
156+
};
157+
}
158+
159+
const connections = await deps.client.listConnections(1);
160+
if (!connections.ok) {
161+
return { name, ok: true, detail: `unverified — ${connections.message}` };
162+
}
163+
164+
const apiProject = connections.data[0]?.team_id;
165+
if (apiProject === undefined) {
166+
// A project can legitimately be empty, and sending someone to fix that
167+
// would be worse than saying nothing.
168+
return {
169+
name,
170+
ok: true,
171+
detail: `unverified — the API key reaches no connections, so its project cannot be read. The CLI forwards from ${cli.projectId}.`,
172+
};
173+
}
174+
175+
if (apiProject === cli.projectId) {
176+
return { name, ok: true, detail: `both ${apiProject}` };
177+
}
178+
179+
return {
180+
name,
181+
ok: false,
182+
detail:
183+
`MISMATCH: the CLI forwards from ${cli.projectId} but the API key acts on ${apiProject}. ` +
184+
`hookdeck_setup creates connections in the API key's project while \`hookdeck listen\` looks ` +
185+
`for them in the CLI's, so the Gateway will report healthy and receive nothing. Point the CLI ` +
186+
`at the same project with \`hookdeck login\`, or configure an API key belonging to ${cli.projectId}.`,
187+
};
188+
}

src/transport/backoff.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ export interface Backoff {
2222
readonly failures: number;
2323
/** Delay for the next restart, or `undefined` once we have given up. */
2424
next(): number | undefined;
25-
/** Called when the child has been connected long enough to count as healthy. */
26-
markHealthy(connectedForMs: number): void;
25+
/**
26+
* Called when the child has been connected long enough to count as healthy.
27+
* Returns whether that reset the counter, which is also the answer to "was
28+
* that run a success?" — the supervisor needs it to tell a standing failure
29+
* from ordinary churn.
30+
*/
31+
markHealthy(connectedForMs: number): boolean;
2732
reset(): void;
2833
}
2934

@@ -60,7 +65,9 @@ export function createBackoff(options: BackoffOptions = {}): Backoff {
6065
},
6166

6267
markHealthy(connectedForMs) {
63-
if (connectedForMs >= healthyResetMs) failures = 0;
68+
if (connectedForMs < healthyResetMs) return false;
69+
failures = 0;
70+
return true;
6471
},
6572

6673
reset() {

0 commit comments

Comments
 (0)