loopctl-mcp-server 2.91.1 → 2.92.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 CHANGED
@@ -70,7 +70,7 @@ Or if installed locally:
70
70
  | `LOOPCTL_API_KEY` | Global API key override (if set, always used) | -- |
71
71
  | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (verify, reject, review, import) | -- |
72
72
  | `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
73
- | `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`. | -- |
73
+ | `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`, and for the [runner tools](#runner-tools-user-key). | -- |
74
74
  | `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 |
75
75
 
76
76
  Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
@@ -218,7 +218,8 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
218
218
  | Tool | Description |
219
219
  |---|---|
220
220
  | `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. |
221
- | `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. |
221
+ | `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. On a loopctl with claim leases the result leads with the claim's `claim_epoch` and `claimed_until`; keep the epoch for `renew_story_claim`. Both are absent on an older server. |
222
+ | `renew_story_claim` | Renew your claim's lease (`POST /stories/:id/renew-claim`, agent key). A claim not renewed before `claimed_until` (default 24 hours, `STORY_CLAIM_LEASE_SECONDS`) is released back to `pending` under you, so renew well inside it on any story held longer. Required: `story_id`, `claim_epoch` (from the claim). Refusals pass through: 400 epoch missing or malformed, 422 `not_claimed`, 409 `stale_claim_epoch` (the claim has ended: stop working it), 409 `not_claimant`. Needs a loopctl server with claim leases. |
222
223
  | `start_story` | Agent starts work on a claimed story. Transitions assigned -> implementing. |
223
224
  | `request_review` | Agent signals implementation is complete and ready for review. |
224
225
 
@@ -464,6 +465,17 @@ through the same authenticated + witness/STH path as every static read tool. If
464
465
  `/retrieve/tools` fetch fails, listing degrades to the static tools (never errors).
465
466
  The generated-tool count per tenant is bounded by the per-tenant entity cap.
466
467
 
468
+ ### Runner Tools (user key)
469
+
470
+ Enroll the dev machines that run the agent delivery loop, and see which are connected (issue #809). All four require `LOOPCTL_USER_KEY`; enroll and revoke also require a human-anchored tenant.
471
+
472
+ | Tool | Description |
473
+ |---|---|
474
+ | `runner_enroll` | Enroll this machine as a runner (`POST /api/v1/runners`). Required: `name` (the machine name the runner declares when it joins) and `token_file` (absolute, or starting with `~/`). The credential is written to `token_file` with mode 0600 and is **never returned**: the result is only `{ runner: {id, name, inserted_at}, token_file }`, because a tool result lands in the transcript and the token lets its holder join as that machine. The file is created exclusively, so an existing path (or a symlink there) is refused before anything is enrolled; missing parent directories are created 0700. If the token cannot be written after enrollment, the runner is revoked before the error returns. Only a 4xx is treated as a refusal: any other failure (timeout, 5xx, a 2xx that did not parse) may have enrolled the runner, so its response body is never echoed, the runner is revoked when the response proves its id, and otherwise the error points at `runner_list` and `runner_revoke`. 422 (name malformed or taken) and 403 (`custody_tier_required`, `api_key_mint_forbidden`) pass through with their code. |
475
+ | `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
476
+ | `runner_revoke` | Revoke a runner (`DELETE /api/v1/runners/:id`): its credential stops authenticating and its live socket is disconnected. The undo for `runner_enroll`. Idempotent. Required: `id`. |
477
+ | `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `in_flight`, `draining`, `max_sessions`, the latest `sample`, and `live_sockets` (above 1 means more than one process holds the credential). Presence converges only within a cluster. |
478
+
467
479
  ### Dispatch & Chain of Custody (v2) Tools
468
480
 
469
481
  Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys and capability-token recovery. See `docs/chain-of-custody-v2.md`.
package/index.js CHANGED
@@ -44,6 +44,8 @@ import {
44
44
  } from "./lib/generated-tools.js";
45
45
  import { createHandoff } from "./lib/handoff.js";
46
46
  import { readPayloadFile } from "./lib/payload-path.js";
47
+ import { enrollRunner, listRunners, revokeRunner, runnerPool } from "./lib/runners.js";
48
+ import { claimLeaseNotice, renewStoryClaim as renewStoryClaimRequest } from "./lib/claim-lease.js";
47
49
 
48
50
  // Single source of truth for the server version: the package.json this file
49
51
  // ships with (npm always includes package.json in the published tarball).
@@ -1134,7 +1136,19 @@ async function claimStory({ story_id }) {
1134
1136
  );
1135
1137
  // The claim response carries the start_cap that POST /start will require.
1136
1138
  rememberCap(story_id, result && result.capability);
1137
- return toContent(result);
1139
+ return withClaimLeaseNotice(result);
1140
+ }
1141
+
1142
+ // #803/#810: a claim carries a lease and an epoch. Put both at the top of the result, when
1143
+ // the server returns them, so the agent keeps the epoch renew_story_claim needs.
1144
+ function withClaimLeaseNotice(result) {
1145
+ const notice = claimLeaseNotice(result);
1146
+ if (!notice) return toContent(result);
1147
+ return { content: [{ type: "text", text: notice }, ...toContent(result).content] };
1148
+ }
1149
+
1150
+ async function renewStoryClaim(args) {
1151
+ return withClaimLeaseNotice(await renewStoryClaimRequest(args, { apiCall }));
1138
1152
  }
1139
1153
 
1140
1154
  async function startStory({ story_id, capability }) {
@@ -3097,6 +3111,34 @@ async function revokeAuthenticator({ tenant_id, authenticator_id, webauthn_asser
3097
3111
  return toContent(result);
3098
3112
  }
3099
3113
 
3114
+ // Issue #809: runner tools. All four take the EXACT user-role key: a runner credential
3115
+ // is minted by a user key on a human-anchored tenant, and a global LOOPCTL_API_KEY must
3116
+ // never stand in for it. The logic, including how runner_enroll keeps the token out of
3117
+ // the tool result, lives in lib/runners.js.
3118
+ function runnerDeps() {
3119
+ const userKey = process.env.LOOPCTL_USER_KEY;
3120
+ return {
3121
+ userKey,
3122
+ apiCall: (method, path, body) => apiCall(method, path, body, userKey, { exactKey: true }),
3123
+ };
3124
+ }
3125
+
3126
+ async function runnerEnroll(args) {
3127
+ return toContent(await enrollRunner(args, runnerDeps()));
3128
+ }
3129
+
3130
+ async function runnerList(args) {
3131
+ return toContent(await listRunners(args, runnerDeps()));
3132
+ }
3133
+
3134
+ async function runnerRevoke(args) {
3135
+ return toContent(await revokeRunner(args, runnerDeps()));
3136
+ }
3137
+
3138
+ async function runnerPoolRead(args) {
3139
+ return toContent(await runnerPool(args, runnerDeps()));
3140
+ }
3141
+
3100
3142
  // US-26: Signed Tree Head retrieval
3101
3143
  async function getSth({ tenant_id }) {
3102
3144
  const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
@@ -4002,7 +4044,10 @@ const TOOLS = [
4002
4044
  name: "claim_story",
4003
4045
  description:
4004
4046
  "Agent claims a contracted story. Uses pessimistic locking to prevent double-claims. " +
4005
- "Transitions contracted -> assigned. Uses the AGENT key.",
4047
+ "Transitions contracted -> assigned. Uses the AGENT key. On a loopctl with claim leases " +
4048
+ "the result leads with the claim's claim_epoch and claimed_until: keep the epoch, and " +
4049
+ "renew with renew_story_claim before claimed_until (default lease 24 hours) or the story " +
4050
+ "is released back to pending under you.",
4006
4051
  inputSchema: {
4007
4052
  type: "object",
4008
4053
  properties: {
@@ -4014,6 +4059,32 @@ const TOOLS = [
4014
4059
  required: ["story_id"],
4015
4060
  },
4016
4061
  },
4062
+ {
4063
+ name: "renew_story_claim",
4064
+ description:
4065
+ "Renew your claim's lease (POST /api/v1/stories/:id/renew-claim): claimed_until becomes " +
4066
+ "now plus the lease length, measured from NOW. Call it well inside the lease on any story " +
4067
+ "you hold longer than it. Uses the AGENT key, the same key as claim_story. Refusals pass " +
4068
+ "through: 400 claim_epoch missing or not a non-negative integer; 422 not_claimed (the " +
4069
+ "story is not assigned or implementing); 409 stale_claim_epoch (the claim has ENDED: it " +
4070
+ "expired and was reclaimed, released, or claimed again, so stop working it); 409 " +
4071
+ "not_claimant (your key's agent is not the story's assigned agent).",
4072
+ inputSchema: {
4073
+ type: "object",
4074
+ properties: {
4075
+ story_id: {
4076
+ type: "string",
4077
+ description: "The UUID of the story.",
4078
+ },
4079
+ claim_epoch: {
4080
+ type: "integer",
4081
+ minimum: 0,
4082
+ description: "The claim_epoch your claim_story result returned.",
4083
+ },
4084
+ },
4085
+ required: ["story_id", "claim_epoch"],
4086
+ },
4087
+ },
4017
4088
  {
4018
4089
  name: "start_story",
4019
4090
  description:
@@ -7392,6 +7463,81 @@ const TOOLS = [
7392
7463
  },
7393
7464
  },
7394
7465
 
7466
+ // Issue #809: runner enrollment and the Presence pool (user key)
7467
+ {
7468
+ name: "runner_enroll",
7469
+ description:
7470
+ "Enroll this dev machine as a runner of the agent delivery loop (POST /api/v1/runners). " +
7471
+ "The runner's credential is written to `token_file` with mode 0600 and is NEVER returned: " +
7472
+ "whoever holds it can join as this machine and receive its dispatches, and a tool result " +
7473
+ "lands in the transcript. The result is only `{ runner: {id, name, inserted_at}, token_file }`. " +
7474
+ "The file is created exclusively (O_EXCL): an existing path is refused before anything is " +
7475
+ "enrolled, never overwritten. Missing parent directories are created with mode 0700. If the " +
7476
+ "token cannot be written after enrollment, the runner is revoked before the error returns. " +
7477
+ "A failure that may still have enrolled it (timeout, 5xx, a 2xx that did not parse) never " +
7478
+ "echoes the response body: the runner is revoked when the response proves its id, and " +
7479
+ "otherwise the error says to find it with runner_list and revoke it. " +
7480
+ "runner_revoke is the undo for an enrollment. Requires LOOPCTL_USER_KEY (user role) on a " +
7481
+ "human-anchored tenant; errors pass through with their code (422 name malformed or taken, " +
7482
+ "403 custody_tier_required or api_key_mint_forbidden).",
7483
+ inputSchema: {
7484
+ type: "object",
7485
+ properties: {
7486
+ name: {
7487
+ type: "string",
7488
+ description: "The machine name the runner will declare when it joins, e.g. `minis`.",
7489
+ },
7490
+ token_file: {
7491
+ type: "string",
7492
+ description:
7493
+ "Absolute path, or one starting with ~/, for the token file. Must not exist yet.",
7494
+ },
7495
+ },
7496
+ required: ["name", "token_file"],
7497
+ },
7498
+ },
7499
+ {
7500
+ name: "runner_list",
7501
+ description:
7502
+ "List the tenant's enrolled runners (GET /api/v1/runners): id, name, revoked_at, inserted_at. " +
7503
+ "Enrollment only, never tokens; whether a runner is CONNECTED is runner_pool. " +
7504
+ "Requires LOOPCTL_USER_KEY (user role).",
7505
+ inputSchema: {
7506
+ type: "object",
7507
+ properties: {
7508
+ include_revoked: {
7509
+ type: "boolean",
7510
+ description: "Include revoked runners. Default false.",
7511
+ },
7512
+ },
7513
+ },
7514
+ },
7515
+ {
7516
+ name: "runner_revoke",
7517
+ description:
7518
+ "Revoke a runner (DELETE /api/v1/runners/:id): its credential stops authenticating and its " +
7519
+ "live socket is disconnected, which removes it from runner_pool. This is the undo for " +
7520
+ "runner_enroll. Idempotent. Requires LOOPCTL_USER_KEY (user role) on a human-anchored tenant.",
7521
+ inputSchema: {
7522
+ type: "object",
7523
+ properties: {
7524
+ id: { type: "string", description: "The runner UUID (from runner_enroll or runner_list)." },
7525
+ },
7526
+ required: ["id"],
7527
+ },
7528
+ },
7529
+ {
7530
+ name: "runner_pool",
7531
+ description:
7532
+ "The tenant's CONNECTED runners, read from Presence (GET /api/v1/runners/pool): per machine " +
7533
+ "name, runner_id, joined_at, in_flight, draining, max_sessions, the latest health sample, " +
7534
+ "and live_sockets. live_sockets above 1 means more than one process holds that runner's " +
7535
+ "credential. A killed runner disappears once its socket closes. Presence converges only " +
7536
+ "within a cluster, so on an unclustered multi-node deployment a runner on another node is " +
7537
+ "absent. Requires LOOPCTL_USER_KEY (user role).",
7538
+ inputSchema: { type: "object", properties: {} },
7539
+ },
7540
+
7395
7541
  // LCP-1 §9 signed-profile tools
7396
7542
  {
7397
7543
  name: "register_custody_owner_key",
@@ -8130,6 +8276,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8130
8276
  case "claim_story":
8131
8277
  return await claimStory(args);
8132
8278
 
8279
+ case "renew_story_claim":
8280
+ return await renewStoryClaim(args);
8281
+
8133
8282
  case "start_story":
8134
8283
  return await startStory(args);
8135
8284
 
@@ -8396,6 +8545,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8396
8545
  case "dispatch":
8397
8546
  return await createDispatch(args);
8398
8547
 
8548
+ case "runner_enroll":
8549
+ return await runnerEnroll(args);
8550
+
8551
+ case "runner_list":
8552
+ return await runnerList(args);
8553
+
8554
+ case "runner_revoke":
8555
+ return await runnerRevoke(args);
8556
+
8557
+ case "runner_pool":
8558
+ return await runnerPoolRead(args);
8559
+
8399
8560
  case "register_custody_owner_key":
8400
8561
  return await registerCustodyOwnerKey(args);
8401
8562
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Story claim leases (loopctl #803/#810, MCP side under #809).
3
+ *
4
+ * A claim now carries a LEASE (`claimed_until`, default 24h, STORY_CLAIM_LEASE_SECONDS)
5
+ * and a FENCE (`claim_epoch`, bumped by every claim and every release). A claim that is not
6
+ * renewed before `claimed_until` is released back to `pending` under the agent still
7
+ * working it. `renew_story_claim` is the MCP path to `POST /stories/:id/renew-claim`, and
8
+ * `claimLeaseNotice` puts the epoch and the deadline at the top of a `claim_story` result so
9
+ * the agent keeps the epoch the renewal needs.
10
+ *
11
+ * SINGLE SOURCE OF TRUTH. index.js injects `apiCall`; the unit suite runs this code with a
12
+ * recording fake. The KEY is selected here, from the injected env, so the key a renewal
13
+ * travels on is testable behaviour rather than a source pattern.
14
+ *
15
+ * Nothing is validated client-side beyond `story_id`: the server's 400 (missing or
16
+ * non-integer epoch), 422 `not_claimed`, 409 `stale_claim_epoch` and 409 `not_claimant`
17
+ * pass through unchanged, because each one tells the agent something different to do.
18
+ */
19
+
20
+ export function renewClaimPath(storyId) {
21
+ return `/api/v1/stories/${encodeURIComponent(storyId)}/renew-claim`;
22
+ }
23
+
24
+ /**
25
+ * `POST /api/v1/stories/:id/renew-claim {claim_epoch}` on the AGENT key, the same key
26
+ * `claim_story` uses. `claim_epoch` is forwarded exactly as given.
27
+ */
28
+ export async function renewStoryClaim({ story_id, claim_epoch } = {}, { apiCall, env = process.env } = {}) {
29
+ if (typeof story_id !== "string" || story_id.trim() === "") {
30
+ return { error: true, status: 0, body: "`story_id` is required." };
31
+ }
32
+ return apiCall("POST", renewClaimPath(story_id), { claim_epoch }, env.LOOPCTL_AGENT_KEY);
33
+ }
34
+
35
+ /**
36
+ * A leading line for a claim or renewal result naming the claim's epoch and lease, or null
37
+ * when the server returned neither (an older loopctl, or an error).
38
+ */
39
+ export function claimLeaseNotice(result) {
40
+ const story = result && result.error !== true ? result.story : null;
41
+ if (!story || !Number.isInteger(story.claim_epoch)) return null;
42
+
43
+ const lease = story.claimed_until
44
+ ? `claimed_until ${story.claimed_until}: renew with renew_story_claim before then, or the ` +
45
+ "story is released back to pending under you."
46
+ : "no lease (claimed_until is null), so it is never released automatically.";
47
+
48
+ return (
49
+ `CLAIM LEASE: claim_epoch ${story.claim_epoch}, ${lease} Keep claim_epoch; ` +
50
+ "renew_story_claim requires it. A 409 stale_claim_epoch means the claim has ended: stop working it."
51
+ );
52
+ }
package/lib/runners.js ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Runner tools (loopctl issue #809): enroll, list and revoke the runners of the agent
3
+ * delivery loop, and read the tenant's connected pool.
4
+ *
5
+ * WHY ENROLL WRITES A FILE. `POST /api/v1/runners` returns the runner's credential once.
6
+ * Whoever holds it can join as that machine and receive its dispatches. A tool result
7
+ * lands in the session transcript and the audit log, so the token must never be in one.
8
+ * This process runs on the machine being enrolled, so `enrollRunner` writes the token to
9
+ * `token_file` itself and returns only the runner row and the path.
10
+ *
11
+ * The file is RESERVED before the API is called: it is opened O_CREAT|O_EXCL with mode
12
+ * 0600, so an existing path (or a symlink at that path) is refused without enrolling
13
+ * anything, and a missing or unwritable directory fails before a credential exists. What
14
+ * can still fail after enrollment is the write itself (ENOSPC, EIO). That token is then
15
+ * unrecoverable, so the runner is revoked before the error is returned.
16
+ *
17
+ * Only a 4xx is an unambiguous refusal. Every other failure may have enrolled the runner,
18
+ * and a 2xx whose body failed to parse carries the token in the text apiCall kept, so on
19
+ * those paths no response body is echoed at all (`ambiguousEnrollment`).
20
+ *
21
+ * Missing PARENT directories are created with mode 0700. That is safe: `mkdir` with
22
+ * `recursive` leaves every existing directory's mode alone, only ever creates directories
23
+ * this user owns, and 0700 is no wider than the 0600 file inside. Refusing instead would
24
+ * make the first enrollment on a fresh machine fail on `~/.config/loopctl-runner/`.
25
+ *
26
+ * SINGLE SOURCE OF TRUTH. index.js calls these functions with the HTTP call injected, so
27
+ * the unit suite exercises the shipped code with fakes and a real temp directory.
28
+ *
29
+ * Nothing in this module logs. The token lives in one local variable and is only ever
30
+ * passed to the file handle's write.
31
+ */
32
+
33
+ import nodePath from "node:path";
34
+ import os from "node:os";
35
+ import { constants as fsConstants } from "node:fs";
36
+ import defaultFs from "node:fs/promises";
37
+
38
+ export const TOKEN_FILE_MODE = 0o600;
39
+ export const TOKEN_DIR_MODE = 0o700;
40
+ export const TOKEN_FILE_FLAGS = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL;
41
+
42
+ export const RUNNERS_PATH = "/api/v1/runners";
43
+ export const RUNNER_POOL_PATH = "/api/v1/runners/pool";
44
+
45
+ export function runnerPath(id) {
46
+ return `${RUNNERS_PATH}/${encodeURIComponent(id)}`;
47
+ }
48
+
49
+ const MISSING_USER_KEY =
50
+ "No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to use the " +
51
+ "runner tools (runner_enroll, runner_list, runner_revoke, runner_pool).";
52
+
53
+ function refuse(body) {
54
+ return { error: true, status: 0, body };
55
+ }
56
+
57
+ /**
58
+ * Expand a leading `~` (alone or followed by a separator) to `homedir`. `~user` forms are
59
+ * left as they are and then refused as relative.
60
+ */
61
+ export function expandHome(p, homedir = os.homedir()) {
62
+ if (p === "~") return homedir;
63
+ if (p.startsWith("~/")) return nodePath.join(homedir, p.slice(2));
64
+ return p;
65
+ }
66
+
67
+ /**
68
+ * `POST /api/v1/runners {name}`, with the returned token written to `token_file` and
69
+ * never returned. Resolves to `{ runner: {id, name, inserted_at}, token_file }` or an
70
+ * `{ error: true, status, body }` shape. It never throws.
71
+ */
72
+ export async function enrollRunner(
73
+ { name, token_file } = {},
74
+ { userKey, apiCall, fs = defaultFs, homedir = os.homedir() } = {},
75
+ ) {
76
+ if (!userKey) return refuse(MISSING_USER_KEY);
77
+ if (typeof name !== "string" || name.trim() === "") return refuse("`name` is required.");
78
+ if (typeof token_file !== "string" || token_file.trim() === "") {
79
+ return refuse("`token_file` is required: the path the runner's token is written to (mode 0600).");
80
+ }
81
+
82
+ const tokenPath = expandHome(token_file, homedir);
83
+ if (!nodePath.isAbsolute(tokenPath)) {
84
+ return refuse(`token_file must be absolute or start with ~/ (got '${token_file}').`);
85
+ }
86
+
87
+ let handle;
88
+ try {
89
+ await fs.mkdir(nodePath.dirname(tokenPath), { recursive: true, mode: TOKEN_DIR_MODE });
90
+ handle = await fs.open(tokenPath, TOKEN_FILE_FLAGS, TOKEN_FILE_MODE);
91
+ } catch (err) {
92
+ if (err?.code === "EEXIST") {
93
+ return refuse(
94
+ `token_file '${tokenPath}' already exists; refusing to overwrite it. Nothing was enrolled. ` +
95
+ "Choose a new path, or remove the file yourself if its runner is revoked.",
96
+ );
97
+ }
98
+ return refuse(
99
+ `Could not create token_file '${tokenPath}' (${err?.code || "error"}). Nothing was enrolled.`,
100
+ );
101
+ }
102
+
103
+ // The identity of the file this call created, taken BEFORE anything is written, so the
104
+ // file can still be told apart from a replacement if the handle later becomes unusable.
105
+ let opened;
106
+ try {
107
+ opened = await handle.stat();
108
+ } catch (err) {
109
+ await closeQuietly(handle);
110
+ return refuse(
111
+ `Could not stat the new token_file '${tokenPath}' (${err?.code || "error"}). Nothing was ` +
112
+ "enrolled; an empty file may remain at that path.",
113
+ );
114
+ }
115
+
116
+ let result;
117
+ try {
118
+ result = await apiCall("POST", RUNNERS_PATH, { name });
119
+ } catch {
120
+ result = { error: true, status: 0, body: "Enrollment request failed." };
121
+ }
122
+
123
+ // A 4xx is a refusal: nothing was committed, and its body is the server's error, which
124
+ // carries a code the caller needs. Pass it through as it came.
125
+ if (result && result.error === true && result.status >= 400 && result.status < 500) {
126
+ const removed = await discardReservation(fs, handle, tokenPath, opened);
127
+ return removed ? result : { ...result, token_file_not_removed: tokenPath };
128
+ }
129
+
130
+ const runner = result && result.error !== true ? result.runner : undefined;
131
+ const token = result && result.error !== true ? result.token : undefined;
132
+
133
+ if (!runner || typeof runner.id !== "string" || typeof token !== "string" || token === "") {
134
+ const removed = await discardReservation(fs, handle, tokenPath, opened);
135
+ return ambiguousEnrollment(result, runner, apiCall, tokenPath, removed);
136
+ }
137
+
138
+ try {
139
+ await handle.writeFile(token);
140
+ await handle.sync();
141
+ await handle.close();
142
+ } catch (err) {
143
+ const removed = await discardReservation(fs, handle, tokenPath, opened);
144
+ const revoked = await revoke(apiCall, runner.id);
145
+ return refuse(
146
+ `Runner '${runner.name}' (${runner.id}) was enrolled, but its token could not be written ` +
147
+ `to '${tokenPath}' (${err?.code || "error"}). The token cannot be relied on, so ` +
148
+ (revoked.ok
149
+ ? "the runner was revoked. "
150
+ : `revoking the runner FAILED (${revoked.detail}); revoke it with runner_revoke id ` +
151
+ `${runner.id}. `) +
152
+ reservationOutcome(tokenPath, removed),
153
+ );
154
+ }
155
+
156
+ return {
157
+ runner: { id: runner.id, name: runner.name, inserted_at: runner.inserted_at },
158
+ token_file: tokenPath,
159
+ };
160
+ }
161
+
162
+ // Every outcome that is neither a 4xx refusal nor a well-formed enrollment: a timeout, a
163
+ // network error, a 5xx from the edge after the commit, a 3xx, or a 2xx whose body did not
164
+ // parse or lacks the token. The runner MAY exist, so no response body is ever echoed — a
165
+ // 2xx body that failed to parse is the enrollment itself, token included, and apiCall puts
166
+ // its first 200 characters in `body`. The runner is revoked only when this response proves
167
+ // its id; a runner found by name alone could be an earlier, legitimate enrollment.
168
+ async function ambiguousEnrollment(result, runner, apiCall, tokenPath, removed) {
169
+ const status = result && Number.isInteger(result.status) ? result.status : undefined;
170
+ const id = (runner && typeof runner.id === "string" && runner.id) || provenRunnerId(result);
171
+
172
+ const what =
173
+ status === 0 && typeof result.body === "string"
174
+ ? `Enrollment outcome unknown (${result.body}).`
175
+ : `Enrollment outcome unknown (HTTP ${status ?? "?"}; response body withheld: it may contain the token).`;
176
+
177
+ let next;
178
+ if (id) {
179
+ const revoked = await revoke(apiCall, id);
180
+ next = revoked.ok
181
+ ? `The response identified runner ${id}; it was revoked, since its token cannot be recovered.`
182
+ : `The response identified runner ${id}, but revoking it FAILED (${revoked.detail}); revoke it with runner_revoke.`;
183
+ } else {
184
+ next =
185
+ "The runner may have been enrolled with a token nobody holds. Check runner_list for it " +
186
+ "(an active runner with this name and a new inserted_at) and revoke it with runner_revoke " +
187
+ "before enrolling again.";
188
+ }
189
+
190
+ return { error: true, status: status ?? 0, body: `${what} ${next} ${reservationOutcome(tokenPath, removed)}` };
191
+ }
192
+
193
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
194
+
195
+ // The runner id from a 2xx response that did not parse. The id is read from the raw text
196
+ // apiCall kept, is required to be a UUID inside the "runner" object, and never leaves this
197
+ // function as anything but that UUID.
198
+ function provenRunnerId(result) {
199
+ if (!result || result.error !== true || !(result.status >= 200 && result.status < 300)) return null;
200
+ if (typeof result.body !== "string") return null;
201
+ const match = result.body.match(/"runner"\s*:\s*\{[^{}]*?"id"\s*:\s*"([^"]{36})"/);
202
+ return match && UUID.test(match[1]) ? match[1] : null;
203
+ }
204
+
205
+ function reservationOutcome(tokenPath, removed) {
206
+ return removed
207
+ ? `The token_file '${tokenPath}' was removed; the same path can be used again.`
208
+ : `The token_file '${tokenPath}' could NOT be removed: delete it before enrolling again at that path.`;
209
+ }
210
+
211
+ async function revoke(apiCall, id) {
212
+ try {
213
+ const result = await apiCall("DELETE", runnerPath(id), null);
214
+ if (result && result.error) return { ok: false, detail: `status ${result.status}` };
215
+ return { ok: true };
216
+ } catch {
217
+ return { ok: false, detail: "request error" };
218
+ }
219
+ }
220
+
221
+ async function closeQuietly(handle) {
222
+ try {
223
+ await handle.close();
224
+ } catch {
225
+ // already closed, or the close is what failed
226
+ }
227
+ }
228
+
229
+ // Close the handle and remove the file this call created. Removal is by path, so it first
230
+ // checks that the path still names the file identified by `opened`, the stat taken right
231
+ // after the open. Resolves to whether the path no longer holds that file.
232
+ async function discardReservation(fs, handle, tokenPath, opened) {
233
+ await closeQuietly(handle);
234
+
235
+ let current;
236
+ try {
237
+ current = await fs.lstat(tokenPath);
238
+ } catch (err) {
239
+ return err?.code === "ENOENT";
240
+ }
241
+ if (current.ino !== opened.ino || current.dev !== opened.dev) return false;
242
+
243
+ try {
244
+ await fs.unlink(tokenPath);
245
+ return true;
246
+ } catch (err) {
247
+ return err?.code === "ENOENT";
248
+ }
249
+ }
250
+
251
+ /** `GET /api/v1/runners`, optionally with revoked runners. */
252
+ export async function listRunners({ include_revoked } = {}, { userKey, apiCall } = {}) {
253
+ if (!userKey) return refuse(MISSING_USER_KEY);
254
+ const query = include_revoked ? "?include_revoked=true" : "";
255
+ return apiCall("GET", `${RUNNERS_PATH}${query}`, null);
256
+ }
257
+
258
+ /** `DELETE /api/v1/runners/:id`: revokes the runner and disconnects its live socket. */
259
+ export async function revokeRunner({ id } = {}, { userKey, apiCall } = {}) {
260
+ if (!userKey) return refuse(MISSING_USER_KEY);
261
+ if (typeof id !== "string" || id.trim() === "") return refuse("`id` is required.");
262
+ return apiCall("DELETE", runnerPath(id), null);
263
+ }
264
+
265
+ /** `GET /api/v1/runners/pool`: the tenant's connected runners, from Presence. */
266
+ export async function runnerPool(_args = {}, { userKey, apiCall } = {}) {
267
+ if (!userKey) return refuse(MISSING_USER_KEY);
268
+ return apiCall("GET", RUNNER_POOL_PATH, null);
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.91.1",
3
+ "version": "2.92.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",