loopctl-mcp-server 2.33.0 → 2.34.0
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 +90 -4
- package/index.js +154 -50
- package/lib/witness-sth.js +296 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,10 +62,56 @@ Or if installed locally:
|
|
|
62
62
|
| `LOOPCTL_API_KEY` | Global API key override (if set, always used) | -- |
|
|
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
|
-
| `LOOPCTL_USER_KEY` | User role API key. Required
|
|
65
|
+
| `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` — see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)) and for destructive admin tools like `knowledge_bulk_publish`. | -- |
|
|
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
|
|
|
70
|
+
## First-time setup — provision your BYO LLM keys
|
|
71
|
+
|
|
72
|
+
**loopctl is agent-native and strictly BYO (bring-your-own-keys).** loopctl fronts
|
|
73
|
+
**no** LLM cost — the knowledge wiki runs entirely on *your* provider keys, which you
|
|
74
|
+
provision once and which bill you directly. Two SEPARATE keys power two capabilities:
|
|
75
|
+
|
|
76
|
+
| Key | Provider | Powers | Missing ⇒ |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `api_key` | Anthropic | knowledge **ingest** — extraction, classification, merge synthesis | `knowledge_ingest` returns **422** (`code: no_api_key`) |
|
|
79
|
+
| `embedding_api_key` | OpenAI-compatible | article **embeddings + semantic search** | `knowledge_search` silently degrades to **keyword-only** (`meta.fallback_reason: no_embedding_key`) |
|
|
80
|
+
|
|
81
|
+
Both keys are stored **encrypted** and are **never returned** by any tool. You can set
|
|
82
|
+
per-operation model overrides (`extraction_model`, `classification_model`,
|
|
83
|
+
`merge_model`, `embedding_model`); each defaults server-side when omitted.
|
|
84
|
+
|
|
85
|
+
### The smooth path (once, at onboarding)
|
|
86
|
+
|
|
87
|
+
1. **Sign up** and obtain your keys. Tenant signup is anchored to a hardware
|
|
88
|
+
authenticator (WebAuthn) — the one human touch. Signup mints your **user-role**
|
|
89
|
+
API key. Also grab your **agent** and **orchestrator** keys for day-to-day work.
|
|
90
|
+
2. **Set the env** in your `.mcp.json` (see [Configuration](#configuration)):
|
|
91
|
+
`LOOPCTL_SERVER`, `LOOPCTL_USER_KEY` (needed for this step), plus `LOOPCTL_AGENT_KEY`
|
|
92
|
+
/ `LOOPCTL_ORCH_KEY`.
|
|
93
|
+
3. **Provision both keys in one call** (uses `LOOPCTL_USER_KEY`):
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
set_llm_config({ api_key: "sk-ant-...", embedding_api_key: "sk-..." })
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Partial-merge: you can set or rotate one key at a time; omitting a key leaves the
|
|
100
|
+
existing one untouched. Optionally pass model overrides in the same call.
|
|
101
|
+
4. **You're live.** `knowledge_ingest` extracts articles and `knowledge_search`
|
|
102
|
+
(combined/semantic) ranks by meaning. Check status anytime with
|
|
103
|
+
`llm_config` (reports `has_api_key` / `has_embedding_key` + masked last-4 hints,
|
|
104
|
+
never the key).
|
|
105
|
+
|
|
106
|
+
### Self-healing: you can't get stuck
|
|
107
|
+
|
|
108
|
+
If you call `knowledge_ingest` or `knowledge_search` **before** provisioning, the
|
|
109
|
+
tool result **leads with an `ACTION REQUIRED` notice** and a machine-readable
|
|
110
|
+
`remediation` object — naming the `set_llm_config` tool, a copy-paste example, the
|
|
111
|
+
REST endpoint (`PATCH /api/v1/tenants/me/llm-config`), and the docs — so you (or an
|
|
112
|
+
autonomous agent) can self-remediate without a human. Full agent-tenant lifecycle:
|
|
113
|
+
[`docs/onboarding-agent-tenant.md`](../docs/onboarding-agent-tenant.md).
|
|
114
|
+
|
|
69
115
|
## Tools (72)
|
|
70
116
|
|
|
71
117
|
### Project Tools
|
|
@@ -176,7 +222,7 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
|
|
|
176
222
|
| `knowledge_drafts` | List draft (unpublished) knowledge articles with pagination. Optional: `limit` (default 20, max 1000 — over-max → 400, no silent clamp), `offset` (default 0), `project_id`. Returns `meta.total_count`. |
|
|
177
223
|
| `knowledge_lint` | Run a lint check on the knowledge wiki to identify stale or low-coverage articles. Optional: `project_id`, `stale_days`, `min_coverage`, `max_per_category` (default 50, max 500). True totals returned in `summary.total_per_category`. |
|
|
178
224
|
| `knowledge_export` | Export all knowledge articles as an OKF v0.1 bundle (gzipped tar archive, unbounded, bounded-memory streaming, fail-closed). Returns a curl command for direct download — **the download requires `LOOPCTL_USER_KEY`** (an orchestrator key would 403). Pass `format=json` for buffered in-memory JSON (convenience tool for file writers; capped at `export_max_buffered_export_articles` — returns 413 if over-cap). Optional: `project_id`, `format` (`tar.gz` default or `json`). |
|
|
179
|
-
| `knowledge_ingest` | Submit a URL or raw content for knowledge extraction. Enqueues an Oban job. Extracted articles are **drafts by default** (lower-trust LLM output); pass `publish: true` to publish on extraction. Required: `source_type`. One of: `url` or `content`. Optional: `project_id`, `publish`. |
|
|
225
|
+
| `knowledge_ingest` | Submit a URL or raw content for knowledge extraction. Enqueues an Oban job. Extracted articles are **drafts by default** (lower-trust LLM output); pass `publish: true` to publish on extraction. **BYO:** runs on the tenant's own Anthropic key — a keyless tenant gets a 422 whose result leads with an `ACTION REQUIRED` notice pointing at `set_llm_config` (see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)). Required: `source_type`. One of: `url` or `content`. Optional: `project_id`, `publish`. |
|
|
180
226
|
| `knowledge_ingest_batch` | Submit up to 50 ingestion items in a single request. Each item has the same shape as `knowledge_ingest` (incl. `publish`). Returns per-item results. Required: `items`. Optional: batch-level `project_id` / `publish` defaults. |
|
|
181
227
|
| `knowledge_ingestion_jobs` | List recent content ingestion jobs (last 7 days, max 50). |
|
|
182
228
|
|
|
@@ -184,8 +230,8 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
|
|
|
184
230
|
|
|
185
231
|
| Tool | Description |
|
|
186
232
|
|---|---|
|
|
187
|
-
| `llm_config` | Get the tenant's BYO
|
|
188
|
-
| `set_llm_config` | Set/rotate the tenant's OWN Anthropic
|
|
233
|
+
| `llm_config` | **Check your onboarding status.** Get the tenant's BYO LLM config: per-operation models and whether each key is set — `has_api_key` (Anthropic) / `has_embedding_key` (OpenAI embedding) — plus masked last-4 hints. Never returns a key. Requires **user** key. |
|
|
234
|
+
| `set_llm_config` | **First-time setup (do once).** Set/rotate the tenant's OWN **Anthropic `api_key`** (powers ingest) AND **OpenAI `embedding_api_key`** (powers semantic search) — both stored encrypted, never returned — plus the per-operation models (`extraction_model`/`classification_model`/`merge_model`/`embedding_model`). Any subset; partial-merge (omitting a key leaves it untouched). See [First-time setup](#first-time-setup--provision-your-byo-llm-keys). Requires **user** key. |
|
|
189
235
|
| `knowledge_llm_usage` | Per-tenant LLM token-usage summary, grouped by operation + model + source_type + day over an optional `from`/`to` range (defaults to a 90-day lookback; effective window echoed in `meta.from`/`meta.to`), with `limit`/`offset` pagination. Record-only (no budget enforcement). Requires orchestrator key. |
|
|
190
236
|
|
|
191
237
|
### Knowledge Analytics Tools (orchestrator key)
|
|
@@ -322,6 +368,46 @@ loopctl enforces that nobody marks their own work as done. The API returns `409`
|
|
|
322
368
|
|
|
323
369
|
The implementer's final action is `request_review`. All subsequent steps (report, review, verify) must come from different agents.
|
|
324
370
|
|
|
371
|
+
## Witness protocol (STH)
|
|
372
|
+
|
|
373
|
+
Every authenticated request echoes the caller's last-known Signed Tree Head (STH)
|
|
374
|
+
via the `X-Loopctl-Last-Known-STH` header — loopctl's tamper-evident audit chain
|
|
375
|
+
(chain-of-custody v2, §4.4). A brand-new caller has no STH, so its first request
|
|
376
|
+
opts in with `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the
|
|
377
|
+
`x-loopctl-current-sth` response header. **That bootstrap grace is one-time per
|
|
378
|
+
API key** (a deliberate security gate): once consumed, a later request that still
|
|
379
|
+
lacks the header gets `412 witness_bootstrap_already_consumed`.
|
|
380
|
+
|
|
381
|
+
This MCP server handles that transparently, so you never see the 412:
|
|
382
|
+
|
|
383
|
+
- **Retry-once contract (412 only).** Any `412 witness_bootstrap_already_consumed`
|
|
384
|
+
carrying an `x-loopctl-current-sth` header is caught, the STH is cached, and the
|
|
385
|
+
SAME request is retried exactly **once** with `X-Loopctl-Last-Known-STH`. Bounded
|
|
386
|
+
to a single retry (never a loop) and anchored to the response's `error.code`. It
|
|
387
|
+
is safe because the server's witness plug halts *before* the operation runs, so
|
|
388
|
+
the rejected request had no side effect. The 412 body also carries a
|
|
389
|
+
machine-readable `error.remediation.retry` contract describing exactly this.
|
|
390
|
+
- **A `409 witness_divergence` is NOT auto-retried** — deliberately. It means the
|
|
391
|
+
cached STH prefix does not match the server's (the genuine-fork / resync signal,
|
|
392
|
+
custody-01). The client caches the server's STH from the 409 so the *next*
|
|
393
|
+
request self-heals, but the current 409 is surfaced rather than papered over.
|
|
394
|
+
- **Cross-process persistence.** The learned STH is cached to a small state file so
|
|
395
|
+
a **fresh** MCP process (a new Claude session, a script, a CI run) loads it and
|
|
396
|
+
sends a real header on its first request — avoiding the 412 entirely. The file is
|
|
397
|
+
keyed by **(server URL + API key)** so distinct keys/tenants on one host never
|
|
398
|
+
share (or clobber) each other's cache; only a non-secret hash of the key appears
|
|
399
|
+
in the filename (`loopctl-mcp-sth-<hash>.json` under the OS temp dir), never the
|
|
400
|
+
key itself. Override the location with `LOOPCTL_STH_STATE_PATH`. Writes are
|
|
401
|
+
**atomic and symlink-safe** (write to a private `0600` temp file with `O_EXCL`,
|
|
402
|
+
then rename over the target — never write through a symlink), and loads refuse a
|
|
403
|
+
symlinked or foreign-owned file. All file I/O degrades gracefully: a missing,
|
|
404
|
+
corrupt, unwritable, or refused state file falls back to in-memory caching plus
|
|
405
|
+
the retry-once contract above.
|
|
406
|
+
|
|
407
|
+
Dispatch-based (v2) clients that mint a fresh ephemeral key per dispatch are
|
|
408
|
+
unaffected: because the cache is keyed per API key, each fresh key gets its own
|
|
409
|
+
clean one-time bootstrap (no cross-key collision).
|
|
410
|
+
|
|
325
411
|
## Troubleshooting
|
|
326
412
|
|
|
327
413
|
### 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
|
}
|
|
@@ -204,6 +233,51 @@ function toContentCompact(result) {
|
|
|
204
233
|
return toContent(result);
|
|
205
234
|
}
|
|
206
235
|
|
|
236
|
+
/**
|
|
237
|
+
* Surface a server-emitted BYO-LLM `remediation` PROMINENTLY as a leading text
|
|
238
|
+
* block. A stranger agent that calls knowledge_ingest / knowledge_search /
|
|
239
|
+
* knowledge_context BEFORE provisioning its keys then reads the exact next step
|
|
240
|
+
* in the tool result — call set_llm_config — instead of hunting through raw JSON.
|
|
241
|
+
*
|
|
242
|
+
* The remediation lives in one of two places depending on the failure shape:
|
|
243
|
+
* - ingest no-key 422 → result.body.error.remediation (code "no_api_key")
|
|
244
|
+
* - search/context degrade → result.meta.remediation (fallback_reason
|
|
245
|
+
* "no_embedding_key"); the request still returns 200 keyword-only results.
|
|
246
|
+
*
|
|
247
|
+
* Returns a notice string, or null when there is no LLM remediation to surface.
|
|
248
|
+
*/
|
|
249
|
+
function llmRemediationNotice(result) {
|
|
250
|
+
const rem =
|
|
251
|
+
(result &&
|
|
252
|
+
result.error === true &&
|
|
253
|
+
result.body &&
|
|
254
|
+
result.body.error &&
|
|
255
|
+
result.body.error.remediation) ||
|
|
256
|
+
(result && result.meta && result.meta.remediation) ||
|
|
257
|
+
null;
|
|
258
|
+
|
|
259
|
+
if (!rem || rem.action !== "configure_llm") return null;
|
|
260
|
+
|
|
261
|
+
const missing = Array.isArray(rem.missing) ? rem.missing.join(", ") : rem.missing;
|
|
262
|
+
return (
|
|
263
|
+
`ACTION REQUIRED — BYO LLM key not configured (missing: ${missing}). ` +
|
|
264
|
+
`${rem.message || ""} Provision it ONCE with the ${rem.mcp_tool} MCP tool, e.g. ` +
|
|
265
|
+
`${rem.example} (REST: ${rem.api}). Requires your user-role key (LOOPCTL_USER_KEY). ` +
|
|
266
|
+
`Docs: ${rem.docs}`
|
|
267
|
+
).replace(/\s+/g, " ").trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* toContent, but with any BYO-LLM remediation notice prepended so it is the first
|
|
272
|
+
* thing the agent reads. Preserves isError from the underlying result.
|
|
273
|
+
*/
|
|
274
|
+
function withRemediationNotice(result) {
|
|
275
|
+
const base = toContent(result);
|
|
276
|
+
const notice = llmRemediationNotice(result);
|
|
277
|
+
if (!notice) return base;
|
|
278
|
+
return { ...base, content: [{ type: "text", text: notice }, ...base.content] };
|
|
279
|
+
}
|
|
280
|
+
|
|
207
281
|
// ---------------------------------------------------------------------------
|
|
208
282
|
// Tool implementations
|
|
209
283
|
// ---------------------------------------------------------------------------
|
|
@@ -802,7 +876,10 @@ async function knowledgeSearch({ q, project_id, story_id, category, tags, match,
|
|
|
802
876
|
if (offset != null) params.set("offset", String(offset));
|
|
803
877
|
|
|
804
878
|
const result = await apiCall("GET", `/api/v1/knowledge/search?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
|
|
805
|
-
|
|
879
|
+
// If search silently degraded to keyword-only for a missing embedding key, the
|
|
880
|
+
// server attaches meta.remediation — surface it PROMINENTLY so the agent knows to
|
|
881
|
+
// call set_llm_config to enable semantic ranking.
|
|
882
|
+
return withRemediationNotice(result);
|
|
806
883
|
}
|
|
807
884
|
|
|
808
885
|
async function knowledgeList({
|
|
@@ -873,7 +950,8 @@ async function knowledgeContext({
|
|
|
873
950
|
if (conversation_id) params.set("conversation_id", conversation_id);
|
|
874
951
|
|
|
875
952
|
const result = await apiCall("GET", `/api/v1/knowledge/context?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
|
|
876
|
-
|
|
953
|
+
// Same as knowledge_search: surface a missing-embedding-key remediation prominently.
|
|
954
|
+
return withRemediationNotice(result);
|
|
877
955
|
}
|
|
878
956
|
|
|
879
957
|
async function knowledgeCreate({
|
|
@@ -1129,7 +1207,9 @@ async function knowledgeIngest({ url, content, source_type, project_id, publish
|
|
|
1129
1207
|
if (project_id) body.project_id = project_id;
|
|
1130
1208
|
if (publish) body.publish = true;
|
|
1131
1209
|
const result = await apiCall("POST", "/api/v1/knowledge/ingest", body, process.env.LOOPCTL_ORCH_KEY);
|
|
1132
|
-
|
|
1210
|
+
// A keyless tenant gets a 422 (code no_api_key) carrying a remediation — surface it
|
|
1211
|
+
// prominently so a first-time agent knows to call set_llm_config before ingesting.
|
|
1212
|
+
return withRemediationNotice(result);
|
|
1133
1213
|
}
|
|
1134
1214
|
|
|
1135
1215
|
async function knowledgeIngestBatch({ items, project_id, publish }) {
|
|
@@ -1152,7 +1232,9 @@ async function knowledgeIngestBatch({ items, project_id, publish }) {
|
|
|
1152
1232
|
{ items: resolvedItems },
|
|
1153
1233
|
process.env.LOOPCTL_ORCH_KEY
|
|
1154
1234
|
);
|
|
1155
|
-
|
|
1235
|
+
// Batch ingest gates on the Anthropic key up front too — a keyless tenant gets a
|
|
1236
|
+
// 422 with the same remediation; surface it prominently.
|
|
1237
|
+
return withRemediationNotice(result);
|
|
1156
1238
|
}
|
|
1157
1239
|
|
|
1158
1240
|
async function knowledgeIngestionJobs(args = {}) {
|
|
@@ -2589,7 +2671,9 @@ const TOOLS = [
|
|
|
2589
2671
|
"If semantic ranking is unavailable the search transparently degrades to keyword-only " +
|
|
2590
2672
|
"(meta.fallback: true, meta.search_mode: 'keyword_only') and now reports meta.fallback_reason " +
|
|
2591
2673
|
"— a stable tag naming WHY (e.g. no_embedding_key, embedding_circuit_open, " +
|
|
2592
|
-
"embedding_provider_error_<status>, embedding_timeout)."
|
|
2674
|
+
"embedding_provider_error_<status>, embedding_timeout). When the reason is a MISSING " +
|
|
2675
|
+
"embedding key (no_embedding_key), the result leads with an ACTION REQUIRED notice + " +
|
|
2676
|
+
"meta.remediation telling you to provision it with set_llm_config (BYO — do it once).",
|
|
2593
2677
|
inputSchema: {
|
|
2594
2678
|
type: "object",
|
|
2595
2679
|
properties: {
|
|
@@ -3267,7 +3351,10 @@ const TOOLS = [
|
|
|
3267
3351
|
"Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, " +
|
|
3268
3352
|
"and inserts them. Extracted articles are DRAFTS by default (lower-trust LLM output, staged " +
|
|
3269
3353
|
"for review) — unlike knowledge_create which publishes by default. Pass publish:true to " +
|
|
3270
|
-
"publish them on extraction. Requires orchestrator role."
|
|
3354
|
+
"publish them on extraction. Requires orchestrator role. BYO: extraction runs on the " +
|
|
3355
|
+
"tenant's OWN Anthropic key — a keyless tenant gets a 422 (code no_api_key) and the " +
|
|
3356
|
+
"result leads with an ACTION REQUIRED notice telling you to provision it once via " +
|
|
3357
|
+
"set_llm_config.",
|
|
3271
3358
|
inputSchema: {
|
|
3272
3359
|
type: "object",
|
|
3273
3360
|
properties: {
|
|
@@ -3380,29 +3467,44 @@ const TOOLS = [
|
|
|
3380
3467
|
{
|
|
3381
3468
|
name: "llm_config",
|
|
3382
3469
|
description:
|
|
3383
|
-
"
|
|
3384
|
-
"
|
|
3385
|
-
"and
|
|
3386
|
-
"
|
|
3470
|
+
"CHECK your BYO LLM onboarding status. Returns this tenant's per-operation model " +
|
|
3471
|
+
"choices and whether each key is configured — `has_api_key` (Anthropic, powers " +
|
|
3472
|
+
"ingest) and `has_embedding_key` (OpenAI embedding, powers semantic search) — plus " +
|
|
3473
|
+
"masked last-4 hints. NEVER returns a key itself. Call this BEFORE ingest/search to " +
|
|
3474
|
+
"confirm setup, or after set_llm_config to verify it took. Uses your user-role key " +
|
|
3475
|
+
"(LOOPCTL_USER_KEY, obtained at signup); managing tenant secrets is user-only, so an " +
|
|
3476
|
+
"agent/orchestrator key is rejected.",
|
|
3387
3477
|
inputSchema: { type: "object", properties: {}, required: [] },
|
|
3388
3478
|
},
|
|
3389
3479
|
{
|
|
3390
3480
|
name: "set_llm_config",
|
|
3391
3481
|
description:
|
|
3392
|
-
"
|
|
3393
|
-
"
|
|
3394
|
-
"
|
|
3395
|
-
"
|
|
3396
|
-
"
|
|
3397
|
-
"
|
|
3398
|
-
"
|
|
3399
|
-
"(
|
|
3482
|
+
"FIRST-TIME SETUP — do this ONCE, right after signup, to bring the wiki online. " +
|
|
3483
|
+
"Provision your OWN Anthropic + OpenAI embedding keys so knowledge ingest AND " +
|
|
3484
|
+
"semantic search work. loopctl is strictly BYO: it fronts NO LLM cost — your keys " +
|
|
3485
|
+
"bill you directly and are stored ENCRYPTED, never returned. WHY it's required: with " +
|
|
3486
|
+
"no `api_key`, knowledge_ingest returns 422 (code no_api_key); with no " +
|
|
3487
|
+
"`embedding_api_key`, knowledge_search silently degrades to keyword-only " +
|
|
3488
|
+
"(meta.fallback_reason: no_embedding_key) — both responses carry a `remediation` " +
|
|
3489
|
+
"pointing back to THIS tool. WHICH key powers WHAT: `api_key` (Anthropic) → ingest " +
|
|
3490
|
+
"extraction / classification / merge synthesis; `embedding_api_key` " +
|
|
3491
|
+
"(OpenAI-compatible) → article embeddings + semantic ranking. All params are optional " +
|
|
3492
|
+
"and partial-merge — omitting a key leaves the existing one untouched, so you can set " +
|
|
3493
|
+
"or rotate one at a time. The per-operation model overrides " +
|
|
3494
|
+
"(extraction_model / classification_model / merge_model / embedding_model) are " +
|
|
3495
|
+
"free-form (any model the key permits) and default server-side when omitted. Typical " +
|
|
3496
|
+
'onboarding call: set_llm_config({api_key: "sk-ant-...", embedding_api_key: "sk-..."}). ' +
|
|
3497
|
+
"Verify anytime with llm_config (has_api_key / has_embedding_key). REQUIRES your " +
|
|
3498
|
+
"user-role key LOOPCTL_USER_KEY (minted by the human-anchored signup ceremony); if it " +
|
|
3499
|
+
"is unset the tool fails fast telling you to set it.",
|
|
3400
3500
|
inputSchema: {
|
|
3401
3501
|
type: "object",
|
|
3402
3502
|
properties: {
|
|
3403
3503
|
api_key: {
|
|
3404
3504
|
type: "string",
|
|
3405
|
-
description:
|
|
3505
|
+
description:
|
|
3506
|
+
"Anthropic API key — powers knowledge ingest (extraction/classification/merge). " +
|
|
3507
|
+
"Write-only; stored encrypted, never returned.",
|
|
3406
3508
|
},
|
|
3407
3509
|
extraction_model: {
|
|
3408
3510
|
type: "string",
|
|
@@ -3419,7 +3521,9 @@ const TOOLS = [
|
|
|
3419
3521
|
embedding_api_key: {
|
|
3420
3522
|
type: "string",
|
|
3421
3523
|
description:
|
|
3422
|
-
"OpenAI-compatible embedding API key
|
|
3524
|
+
"OpenAI-compatible embedding API key — powers article embeddings + semantic " +
|
|
3525
|
+
"search. Write-only; stored encrypted, never returned. Without it, articles are " +
|
|
3526
|
+
"not vector-searchable and search degrades to keyword-only.",
|
|
3423
3527
|
},
|
|
3424
3528
|
embedding_model: {
|
|
3425
3529
|
type: "string",
|
|
@@ -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
|
+
}
|