loopctl-mcp-server 2.33.0 → 2.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/index.js +59 -30
- package/lib/witness-sth.js +296 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ Or if installed locally:
|
|
|
63
63
|
| `LOOPCTL_ORCH_KEY` | Orchestrator role API key (verify, reject, review, import) | -- |
|
|
64
64
|
| `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
|
|
65
65
|
| `LOOPCTL_USER_KEY` | User role API key. Required ONLY for destructive admin tools like `knowledge_bulk_publish`. Leave unset if you don't use those tools. | -- |
|
|
66
|
+
| `LOOPCTL_STH_STATE_PATH` | Absolute path for the witness-protocol STH cache file (see [Witness protocol](#witness-protocol-sth)). Optional. | per-(server + key) file under the OS temp dir |
|
|
66
67
|
|
|
67
68
|
Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
|
|
68
69
|
|
|
@@ -322,6 +323,46 @@ loopctl enforces that nobody marks their own work as done. The API returns `409`
|
|
|
322
323
|
|
|
323
324
|
The implementer's final action is `request_review`. All subsequent steps (report, review, verify) must come from different agents.
|
|
324
325
|
|
|
326
|
+
## Witness protocol (STH)
|
|
327
|
+
|
|
328
|
+
Every authenticated request echoes the caller's last-known Signed Tree Head (STH)
|
|
329
|
+
via the `X-Loopctl-Last-Known-STH` header — loopctl's tamper-evident audit chain
|
|
330
|
+
(chain-of-custody v2, §4.4). A brand-new caller has no STH, so its first request
|
|
331
|
+
opts in with `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the
|
|
332
|
+
`x-loopctl-current-sth` response header. **That bootstrap grace is one-time per
|
|
333
|
+
API key** (a deliberate security gate): once consumed, a later request that still
|
|
334
|
+
lacks the header gets `412 witness_bootstrap_already_consumed`.
|
|
335
|
+
|
|
336
|
+
This MCP server handles that transparently, so you never see the 412:
|
|
337
|
+
|
|
338
|
+
- **Retry-once contract (412 only).** Any `412 witness_bootstrap_already_consumed`
|
|
339
|
+
carrying an `x-loopctl-current-sth` header is caught, the STH is cached, and the
|
|
340
|
+
SAME request is retried exactly **once** with `X-Loopctl-Last-Known-STH`. Bounded
|
|
341
|
+
to a single retry (never a loop) and anchored to the response's `error.code`. It
|
|
342
|
+
is safe because the server's witness plug halts *before* the operation runs, so
|
|
343
|
+
the rejected request had no side effect. The 412 body also carries a
|
|
344
|
+
machine-readable `error.remediation.retry` contract describing exactly this.
|
|
345
|
+
- **A `409 witness_divergence` is NOT auto-retried** — deliberately. It means the
|
|
346
|
+
cached STH prefix does not match the server's (the genuine-fork / resync signal,
|
|
347
|
+
custody-01). The client caches the server's STH from the 409 so the *next*
|
|
348
|
+
request self-heals, but the current 409 is surfaced rather than papered over.
|
|
349
|
+
- **Cross-process persistence.** The learned STH is cached to a small state file so
|
|
350
|
+
a **fresh** MCP process (a new Claude session, a script, a CI run) loads it and
|
|
351
|
+
sends a real header on its first request — avoiding the 412 entirely. The file is
|
|
352
|
+
keyed by **(server URL + API key)** so distinct keys/tenants on one host never
|
|
353
|
+
share (or clobber) each other's cache; only a non-secret hash of the key appears
|
|
354
|
+
in the filename (`loopctl-mcp-sth-<hash>.json` under the OS temp dir), never the
|
|
355
|
+
key itself. Override the location with `LOOPCTL_STH_STATE_PATH`. Writes are
|
|
356
|
+
**atomic and symlink-safe** (write to a private `0600` temp file with `O_EXCL`,
|
|
357
|
+
then rename over the target — never write through a symlink), and loads refuse a
|
|
358
|
+
symlinked or foreign-owned file. All file I/O degrades gracefully: a missing,
|
|
359
|
+
corrupt, unwritable, or refused state file falls back to in-memory caching plus
|
|
360
|
+
the retry-once contract above.
|
|
361
|
+
|
|
362
|
+
Dispatch-based (v2) clients that mint a fresh ephemeral key per dispatch are
|
|
363
|
+
unaffected: because the cache is keyed per API key, each fresh key gets its own
|
|
364
|
+
clean one-time bootstrap (no cross-key collision).
|
|
365
|
+
|
|
325
366
|
## Troubleshooting
|
|
326
367
|
|
|
327
368
|
### Connection errors
|
package/index.js
CHANGED
|
@@ -10,15 +10,20 @@ import {
|
|
|
10
10
|
ListToolsRequestSchema,
|
|
11
11
|
CallToolRequestSchema,
|
|
12
12
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { readFileSync, writeFileSync, renameSync, lstatSync, unlinkSync } from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { dirname, join } from "node:path";
|
|
16
|
+
import path, { dirname, join } from "node:path";
|
|
16
17
|
import {
|
|
17
18
|
projectsPath,
|
|
18
19
|
ingestionJobsPath,
|
|
19
20
|
llmUsagePath,
|
|
20
21
|
parseJsonResponseBody,
|
|
21
22
|
} from "./lib/http-helpers.js";
|
|
23
|
+
import {
|
|
24
|
+
createWitnessClient,
|
|
25
|
+
resolveSthStatePath,
|
|
26
|
+
} from "./lib/witness-sth.js";
|
|
22
27
|
|
|
23
28
|
// Single source of truth for the server version: the package.json this file
|
|
24
29
|
// ships with (npm always includes package.json in the published tarball).
|
|
@@ -32,11 +37,51 @@ const SERVER_VERSION = JSON.parse(
|
|
|
32
37
|
// HTTP helper — witness protocol state
|
|
33
38
|
// ---------------------------------------------------------------------------
|
|
34
39
|
|
|
35
|
-
// The witness protocol requires clients to echo back the last-known Signed
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
+
// The witness protocol requires clients to echo back the last-known Signed Tree
|
|
41
|
+
// Head (STH) on every authenticated request. The mechanics live in
|
|
42
|
+
// ./lib/witness-sth.js (shared with the test suite):
|
|
43
|
+
//
|
|
44
|
+
// * On the very first request from a caller that has never seen an STH we send
|
|
45
|
+
// `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the
|
|
46
|
+
// `x-loopctl-current-sth` response header.
|
|
47
|
+
// * That STH is cached AND persisted to a small per-server state file, so a
|
|
48
|
+
// FRESH MCP process (new Claude session) loads it and sends a real
|
|
49
|
+
// `X-Loopctl-Last-Known-STH` on its first request — never tripping the
|
|
50
|
+
// one-time bootstrap-grace 412 (`witness_bootstrap_already_consumed`, #298).
|
|
51
|
+
// * If a request still hits that bootstrap-grace 412 (state file missing or
|
|
52
|
+
// corrupt), the client caches the STH from the 412's `x-loopctl-current-sth`
|
|
53
|
+
// header and RETRIES the SAME request exactly ONCE — transparently — so the
|
|
54
|
+
// tool call succeeds. The witness plug halts before the operation runs, so
|
|
55
|
+
// retrying a witness 412 is side-effect-safe even for POSTs.
|
|
56
|
+
//
|
|
57
|
+
// The state file location defaults to a per-(server + key) file under the OS temp
|
|
58
|
+
// dir and can be overridden with LOOPCTL_STH_STATE_PATH. All file I/O degrades
|
|
59
|
+
// gracefully (missing/corrupt/unwritable/symlinked → in-memory cache + the
|
|
60
|
+
// transparent retry above). The write is atomic + symlink-safe (temp + rename).
|
|
61
|
+
const WITNESS_FS = { readFileSync, writeFileSync, renameSync, lstatSync, unlinkSync };
|
|
62
|
+
|
|
63
|
+
// One witness client PER API KEY (#298 review HIGH-2): the STH is per-tenant and
|
|
64
|
+
// each key resolves to a tenant server-side, so distinct keys must NOT share an
|
|
65
|
+
// in-memory cache or a state file (a collision causes spurious 409s + false
|
|
66
|
+
// divergence telemetry). The state file is keyed by sha256(serverUrl + ":" + key)
|
|
67
|
+
// — a non-secret hash; the key never hits disk in plaintext.
|
|
68
|
+
const witnessClients = new Map();
|
|
69
|
+
|
|
70
|
+
function witnessClientFor(apiKey) {
|
|
71
|
+
let client = witnessClients.get(apiKey);
|
|
72
|
+
if (!client) {
|
|
73
|
+
const statePath = resolveSthStatePath({ env: process.env, os, path, apiKey });
|
|
74
|
+
client = createWitnessClient({
|
|
75
|
+
statePath,
|
|
76
|
+
fs: WITNESS_FS,
|
|
77
|
+
getuid: typeof process.getuid === "function" ? () => process.getuid() : undefined,
|
|
78
|
+
pid: process.pid,
|
|
79
|
+
timeoutMs: 30_000,
|
|
80
|
+
});
|
|
81
|
+
witnessClients.set(apiKey, client);
|
|
82
|
+
}
|
|
83
|
+
return client;
|
|
84
|
+
}
|
|
40
85
|
|
|
41
86
|
function getBaseUrl() {
|
|
42
87
|
return (process.env.LOOPCTL_SERVER || "https://loopctl.com").replace(/\/$/, "");
|
|
@@ -82,26 +127,16 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {
|
|
|
82
127
|
Accept: "application/json",
|
|
83
128
|
};
|
|
84
129
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
headers["X-Loopctl-Last-Known-STH"] = lastKnownSTH;
|
|
88
|
-
} else {
|
|
89
|
-
headers["X-Loopctl-STH-Bootstrap"] = "true";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const options = {
|
|
93
|
-
method,
|
|
94
|
-
headers,
|
|
95
|
-
signal: AbortSignal.timeout(30_000),
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
if (body !== undefined && body !== null) {
|
|
99
|
-
options.body = JSON.stringify(body);
|
|
100
|
-
}
|
|
130
|
+
const serializedBody =
|
|
131
|
+
body !== undefined && body !== null ? JSON.stringify(body) : undefined;
|
|
101
132
|
|
|
133
|
+
// The witness client injects the STH header, caches + persists any STH the
|
|
134
|
+
// server returns, and transparently retries a bootstrap-grace 412 ONCE (see
|
|
135
|
+
// ./lib/witness-sth.js and the module comment above). It returns the final
|
|
136
|
+
// attempt's Response; we keep body parsing / error shaping here.
|
|
102
137
|
let response;
|
|
103
138
|
try {
|
|
104
|
-
response = await
|
|
139
|
+
response = await witnessClientFor(key).send({ url, method, headers, serializedBody });
|
|
105
140
|
} catch (err) {
|
|
106
141
|
if (err.name === "TimeoutError") {
|
|
107
142
|
return { error: true, status: 0, body: "Request timed out after 30s" };
|
|
@@ -110,12 +145,6 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {
|
|
|
110
145
|
return { error: true, status: 0, body: `Network error: ${err.message}${cause}` };
|
|
111
146
|
}
|
|
112
147
|
|
|
113
|
-
// Witness protocol: cache the STH from response for subsequent requests
|
|
114
|
-
const sthHeader = response.headers.get("x-loopctl-current-sth");
|
|
115
|
-
if (sthHeader) {
|
|
116
|
-
lastKnownSTH = sthHeader;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
148
|
if (response.status === 204) {
|
|
120
149
|
return { ok: true };
|
|
121
150
|
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// Witness-protocol (Signed Tree Head) client state — shared by index.js and the
|
|
2
|
+
// test suite so both exercise the SAME retry + persistence logic.
|
|
3
|
+
//
|
|
4
|
+
// Background (loopctl chain-of-custody v2, §4.4): every authenticated request
|
|
5
|
+
// must echo the caller's last-known STH via `X-Loopctl-Last-Known-STH`. A brand
|
|
6
|
+
// new caller has no STH, so its FIRST request opts in with
|
|
7
|
+
// `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the response's
|
|
8
|
+
// `x-loopctl-current-sth` header. That bootstrap grace is ONE-TIME PER API KEY
|
|
9
|
+
// (custody-03 / GHSA-36g5-mcrh-rcrm): once consumed, a later request that still
|
|
10
|
+
// lacks the header gets `412 witness_bootstrap_already_consumed`.
|
|
11
|
+
//
|
|
12
|
+
// The MCP server is a short-lived process: a fresh Claude session spawns a fresh
|
|
13
|
+
// process that historically started with no STH, so — once the key's one-time
|
|
14
|
+
// grace was consumed by an earlier process — its very first tool call hit that
|
|
15
|
+
// 412 (#298). This module fixes that on the CLIENT side, two ways:
|
|
16
|
+
//
|
|
17
|
+
// 1. PERSISTENCE — the learned STH is cached to a small state file keyed by
|
|
18
|
+
// (server URL + API key), so a fresh process loads it and sends a real
|
|
19
|
+
// header on its first request, never tripping the bootstrap 412 at all.
|
|
20
|
+
// 2. TRANSPARENT RETRY — if a request still hits a witness-plug 412 that
|
|
21
|
+
// carries the current STH (e.g. the state file was missing/corrupt), the
|
|
22
|
+
// client caches that STH and retries the SAME request ONCE, so the tool
|
|
23
|
+
// call succeeds instead of surfacing the 412.
|
|
24
|
+
//
|
|
25
|
+
// SAFETY: the witness plug runs early and HALTS the request before the operation
|
|
26
|
+
// executes, so a witness 412/409 means the original request had no side effect —
|
|
27
|
+
// retrying it once is safe even for non-idempotent POSTs. The retry is bounded to
|
|
28
|
+
// a single attempt (never a loop). None of this weakens the server-side one-time
|
|
29
|
+
// grace: it only teaches legit clients to carry/relearn the STH they are entitled
|
|
30
|
+
// to. See mcp-server/README.md → "Witness protocol (STH)".
|
|
31
|
+
//
|
|
32
|
+
// 409 IS NOT AUTO-RETRIED (deliberate): a `409 witness_divergence` means the
|
|
33
|
+
// client's cached STH prefix does not match the server's at that position — the
|
|
34
|
+
// genuine-fork / resync signal (custody-01). The client caches the server's STH
|
|
35
|
+
// from the 409's `x-loopctl-current-sth` header so the NEXT request self-heals,
|
|
36
|
+
// but the current request's 409 is surfaced (not silently retried) so a real
|
|
37
|
+
// divergence is never papered over. Only the bootstrap-grace 412 is retried.
|
|
38
|
+
|
|
39
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
40
|
+
|
|
41
|
+
// Explicit override for the STH state-file location (absolute path). When unset,
|
|
42
|
+
// the file is derived under the OS temp dir, keyed by (server URL + API key).
|
|
43
|
+
export const STH_STATE_PATH_ENV = "LOOPCTL_STH_STATE_PATH";
|
|
44
|
+
|
|
45
|
+
// The `x-loopctl-current-sth` header the client caches / retries with. Shape:
|
|
46
|
+
// `<position>:<22-char base64url signature prefix>`. Validating the shape guards
|
|
47
|
+
// against a corrupt state file feeding a malformed header (which the server would
|
|
48
|
+
// itself reject 412 witness_header_malformed).
|
|
49
|
+
const STH_SHAPE = /^\d+:[A-Za-z0-9_-]{22}$/;
|
|
50
|
+
|
|
51
|
+
// The error code of the ONE 412 that is safe to transparently retry.
|
|
52
|
+
const BOOTSTRAP_CONSUMED_CODE = "witness_bootstrap_already_consumed";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when `sth` is a well-formed `<position>:<22-char base64url prefix>` value.
|
|
56
|
+
* @param {unknown} sth
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isValidSth(sth) {
|
|
60
|
+
return typeof sth === "string" && STH_SHAPE.test(sth);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the STH state-file path. Honors an explicit `LOOPCTL_STH_STATE_PATH`
|
|
65
|
+
* override; otherwise derives a file under the OS temp dir keyed by BOTH the
|
|
66
|
+
* server URL AND the API key, so two keys/tenants on the same host never share
|
|
67
|
+
* (and clobber / spuriously invalidate) each other's cached STH. Only a NON-SECRET
|
|
68
|
+
* sha256 hash of the key goes in the filename — the key itself never hits disk.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ env: Record<string,string|undefined>, os: { tmpdir(): string }, path: { join(...p: string[]): string }, apiKey?: string }} deps
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function resolveSthStatePath({ env, os, path, apiKey = "" }) {
|
|
74
|
+
const override = env[STH_STATE_PATH_ENV];
|
|
75
|
+
if (typeof override === "string" && override.trim() !== "") return override;
|
|
76
|
+
|
|
77
|
+
const baseUrl = (env.LOOPCTL_SERVER || "https://loopctl.com").replace(/\/$/, "");
|
|
78
|
+
// Scope by server URL AND api key (#298 review HIGH-2): distinct keys must not
|
|
79
|
+
// collide on one cache file. The key is hashed (never stored in plaintext).
|
|
80
|
+
const digest = createHash("sha256").update(`${baseUrl}:${apiKey}`).digest("hex").slice(0, 16);
|
|
81
|
+
return path.join(os.tmpdir(), `loopctl-mcp-sth-${digest}.json`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load a persisted STH string from `filePath`, or `null`. NEVER throws — a
|
|
86
|
+
* missing, unreadable, non-JSON, or shape-invalid file degrades to `null` so the
|
|
87
|
+
* caller falls back to the bootstrap+retry path.
|
|
88
|
+
*
|
|
89
|
+
* SYMLINK / OWNERSHIP GUARD (CWE-59, #298 review HIGH-1): on a shared `/tmp` the
|
|
90
|
+
* cache path is predictable, so a co-located user could pre-plant a file/symlink
|
|
91
|
+
* there. Before reading we `lstat` the path and refuse (→ null, in-memory only) if
|
|
92
|
+
* it is a symlink or is not owned by the current uid.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} filePath
|
|
95
|
+
* @param {{ fs: { readFileSync: Function, lstatSync?: Function }, getuid?: () => number }} deps
|
|
96
|
+
* @returns {string|null}
|
|
97
|
+
*/
|
|
98
|
+
export function loadPersistedSth(filePath, { fs, getuid }) {
|
|
99
|
+
try {
|
|
100
|
+
if (typeof fs.lstatSync === "function") {
|
|
101
|
+
const st = fs.lstatSync(filePath);
|
|
102
|
+
if (st.isSymbolicLink()) return null;
|
|
103
|
+
if (typeof getuid === "function" && st.uid !== getuid()) return null;
|
|
104
|
+
}
|
|
105
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
106
|
+
const sth = parsed && typeof parsed.sth === "string" ? parsed.sth : null;
|
|
107
|
+
return isValidSth(sth) ? sth : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Persist an STH string to `filePath`. NEVER throws — a write failure (read-only
|
|
115
|
+
* temp dir, full disk, symlink refusal) degrades to in-memory-only caching.
|
|
116
|
+
* Returns whether the write succeeded.
|
|
117
|
+
*
|
|
118
|
+
* ATOMIC + SYMLINK-SAFE (CWE-59, #298 review HIGH-1): writes to a fresh
|
|
119
|
+
* per-process temp file opened with `wx` (O_CREAT|O_EXCL — refuses a pre-planted
|
|
120
|
+
* symlink at the temp path) and mode 0o600, then `rename`s it over `filePath`.
|
|
121
|
+
* `rename` replaces the destination path ENTRY atomically and never writes THROUGH
|
|
122
|
+
* a symlink at `filePath`, so an attacker's symlink there is replaced, not
|
|
123
|
+
* followed — no arbitrary-file clobber. The rename-over also fixes the torn-file
|
|
124
|
+
* race from a killed mid-write.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} filePath
|
|
127
|
+
* @param {string} sth
|
|
128
|
+
* @param {{ fs: { writeFileSync: Function, renameSync: Function, unlinkSync?: Function }, pid?: number }} deps
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
export function persistSth(filePath, sth, { fs, pid = process.pid }) {
|
|
132
|
+
if (!isValidSth(sth)) return false;
|
|
133
|
+
const tmp = `${filePath}.${pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
134
|
+
try {
|
|
135
|
+
fs.writeFileSync(
|
|
136
|
+
tmp,
|
|
137
|
+
JSON.stringify({ sth, updated_at: new Date().toISOString() }),
|
|
138
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
139
|
+
);
|
|
140
|
+
fs.renameSync(tmp, filePath);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
// Best-effort cleanup of the temp file; ignore any failure.
|
|
144
|
+
try {
|
|
145
|
+
if (typeof fs.unlinkSync === "function") fs.unlinkSync(tmp);
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Decide whether a response should trigger a one-shot transparent retry, and with
|
|
155
|
+
* which STH. Returns the STH to retry with, or `null`.
|
|
156
|
+
*
|
|
157
|
+
* Gated on `412` AND a shape-valid `x-loopctl-current-sth` header — which the
|
|
158
|
+
* witness plug sets ONLY on the `witness_bootstrap_already_consumed` branch (not
|
|
159
|
+
* on the malformed/missing 412s, where a retry couldn't help). This precisely
|
|
160
|
+
* targets the fresh-process bootstrap-grace 412 (#298). The caller additionally
|
|
161
|
+
* confirms the body's `error.code` (see `createWitnessClient`).
|
|
162
|
+
*
|
|
163
|
+
* @param {number} status
|
|
164
|
+
* @param {string|null|undefined} currentSthHeader
|
|
165
|
+
* @returns {string|null}
|
|
166
|
+
*/
|
|
167
|
+
export function bootstrapRetrySth(status, currentSthHeader) {
|
|
168
|
+
return status === 412 && isValidSth(currentSthHeader) ? currentSthHeader : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Create a witness-aware request client that owns the shared STH state (loaded
|
|
173
|
+
* from `statePath` at construction), injects the witness header on every attempt,
|
|
174
|
+
* caches + persists any STH the server returns, coalesces concurrent cold-start
|
|
175
|
+
* bootstraps, and transparently retries a bootstrap-grace 412 ONCE.
|
|
176
|
+
*
|
|
177
|
+
* `send` returns the raw `Response` of the FINAL attempt so the caller keeps full
|
|
178
|
+
* control of body parsing / error shaping. Network errors from `fetchImpl`
|
|
179
|
+
* propagate (the caller's try/catch handles them); a network failure is not
|
|
180
|
+
* retried.
|
|
181
|
+
*
|
|
182
|
+
* @param {{
|
|
183
|
+
* fetchImpl?: typeof fetch,
|
|
184
|
+
* statePath?: string|null,
|
|
185
|
+
* fs?: object,
|
|
186
|
+
* getuid?: () => number,
|
|
187
|
+
* pid?: number,
|
|
188
|
+
* timeoutMs?: number,
|
|
189
|
+
* }} [deps]
|
|
190
|
+
*/
|
|
191
|
+
export function createWitnessClient({
|
|
192
|
+
fetchImpl = fetch,
|
|
193
|
+
statePath = null,
|
|
194
|
+
fs,
|
|
195
|
+
getuid,
|
|
196
|
+
pid,
|
|
197
|
+
timeoutMs = 30_000,
|
|
198
|
+
} = {}) {
|
|
199
|
+
// Load any persisted STH so a FRESH process sends a real header on request #1
|
|
200
|
+
// and skips the bootstrap-grace 412 entirely.
|
|
201
|
+
let lastKnownSTH =
|
|
202
|
+
statePath && fs ? loadPersistedSth(statePath, { fs, getuid }) : null;
|
|
203
|
+
|
|
204
|
+
// Cold-start singleflight (#298 review MEDIUM-6): when several tool calls fire
|
|
205
|
+
// at process start they all observe lastKnownSTH === null; without coalescing
|
|
206
|
+
// each would send its own bootstrap. The FIRST becomes the leader; the rest
|
|
207
|
+
// await its result and then send with the learned header.
|
|
208
|
+
let bootstrapInFlight = null;
|
|
209
|
+
|
|
210
|
+
function remember(sth) {
|
|
211
|
+
if (!isValidSth(sth) || sth === lastKnownSTH) return;
|
|
212
|
+
lastKnownSTH = sth;
|
|
213
|
+
if (statePath && fs) persistSth(statePath, sth, { fs, pid });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function attempt({ url, method, headers, serializedBody }) {
|
|
217
|
+
// Witness protocol: echo the cached STH, or (never seen one) request a
|
|
218
|
+
// one-time bootstrap. A fresh AbortSignal per attempt so the retry gets its
|
|
219
|
+
// own timeout budget.
|
|
220
|
+
const withWitness = { ...headers };
|
|
221
|
+
if (lastKnownSTH) withWitness["X-Loopctl-Last-Known-STH"] = lastKnownSTH;
|
|
222
|
+
else withWitness["X-Loopctl-STH-Bootstrap"] = "true";
|
|
223
|
+
|
|
224
|
+
const options = {
|
|
225
|
+
method,
|
|
226
|
+
headers: withWitness,
|
|
227
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
228
|
+
};
|
|
229
|
+
if (serializedBody !== undefined) options.body = serializedBody;
|
|
230
|
+
|
|
231
|
+
return fetchImpl(url, options);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Confirm a bootstrap-grace 412 against the DOCUMENTED contract before retrying
|
|
235
|
+
// (#298 review LOW-8): status 412 + a valid STH header, AND — when the body is
|
|
236
|
+
// readable — error.code === "witness_bootstrap_already_consumed". A body-read
|
|
237
|
+
// failure falls back to the status+header decision so a transient parse issue
|
|
238
|
+
// never defeats the retry. Reads a CLONE so the caller can still consume the
|
|
239
|
+
// body of a response we end up NOT retrying.
|
|
240
|
+
async function retrySthFor(response) {
|
|
241
|
+
const sth = response.headers.get("x-loopctl-current-sth");
|
|
242
|
+
if (!bootstrapRetrySth(response.status, sth)) return null;
|
|
243
|
+
try {
|
|
244
|
+
const body = await response.clone().json();
|
|
245
|
+
const code = body && body.error && body.error.code;
|
|
246
|
+
if (code && code !== BOOTSTRAP_CONSUMED_CODE) return null;
|
|
247
|
+
} catch {
|
|
248
|
+
/* unreadable body → fall back to status+header */
|
|
249
|
+
}
|
|
250
|
+
return sth;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function performSend(req) {
|
|
254
|
+
let response = await attempt(req);
|
|
255
|
+
remember(response.headers.get("x-loopctl-current-sth"));
|
|
256
|
+
|
|
257
|
+
// Bounded, single transparent retry for the bootstrap-grace 412. `remember`
|
|
258
|
+
// above already cached the STH, so this second attempt sends a real header.
|
|
259
|
+
if (await retrySthFor(response)) {
|
|
260
|
+
response = await attempt(req);
|
|
261
|
+
remember(response.headers.get("x-loopctl-current-sth"));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return response;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function send(req) {
|
|
268
|
+
// Cold-start coalescing: only the leader bootstraps; followers await it and
|
|
269
|
+
// then send with the learned STH. On the (rare) leader failure, followers
|
|
270
|
+
// fall through and each bootstrap — acceptable degradation.
|
|
271
|
+
if (lastKnownSTH === null && bootstrapInFlight) {
|
|
272
|
+
await bootstrapInFlight;
|
|
273
|
+
return performSend(req);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (lastKnownSTH === null) {
|
|
277
|
+
let done;
|
|
278
|
+
bootstrapInFlight = new Promise((resolve) => {
|
|
279
|
+
done = resolve;
|
|
280
|
+
});
|
|
281
|
+
try {
|
|
282
|
+
return await performSend(req);
|
|
283
|
+
} finally {
|
|
284
|
+
done();
|
|
285
|
+
bootstrapInFlight = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return performSend(req);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
send,
|
|
294
|
+
getSTH: () => lastKnownSTH,
|
|
295
|
+
};
|
|
296
|
+
}
|