loopctl-mcp-server 2.91.0 → 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 +14 -2
- package/index.js +167 -57
- package/lib/claim-lease.js +52 -0
- package/lib/payload-path.js +96 -0
- package/lib/runners.js +269 -0
- package/package.json +1 -1
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
|
@@ -43,6 +43,9 @@ import {
|
|
|
43
43
|
GENERATED_TOOL_PREFIX,
|
|
44
44
|
} from "./lib/generated-tools.js";
|
|
45
45
|
import { createHandoff } from "./lib/handoff.js";
|
|
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";
|
|
46
49
|
|
|
47
50
|
// Single source of truth for the server version: the package.json this file
|
|
48
51
|
// ships with (npm always includes package.json in the published tarball).
|
|
@@ -967,13 +970,7 @@ async function importStories({ project_id, payload, payload_path, merge }) {
|
|
|
967
970
|
|
|
968
971
|
// Reads JSON payload from either an inline object or an absolute file path.
|
|
969
972
|
// Returns the object on success, or an { error, body } shape on failure.
|
|
970
|
-
//
|
|
971
|
-
// Security: `payload_path` is read with the MCP process's filesystem
|
|
972
|
-
// privileges. Because agents can set this argument via prompt injection,
|
|
973
|
-
// we validate aggressively:
|
|
974
|
-
// * require absolute path
|
|
975
|
-
// * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
|
|
976
|
-
// * stat first and cap at 5 MiB (server also enforces a body size limit)
|
|
973
|
+
// The file path is validated in lib/payload-path.js (see its security notes).
|
|
977
974
|
async function resolvePayload(inline, payloadPath) {
|
|
978
975
|
if (inline && typeof inline === "object") return inline;
|
|
979
976
|
if (!payloadPath) {
|
|
@@ -983,53 +980,7 @@ async function resolvePayload(inline, payloadPath) {
|
|
|
983
980
|
body: "Must provide either `payload` (object) or `payload_path` (absolute JSON file path).",
|
|
984
981
|
};
|
|
985
982
|
}
|
|
986
|
-
|
|
987
|
-
const nodePath = await import("node:path");
|
|
988
|
-
if (!nodePath.isAbsolute(payloadPath)) {
|
|
989
|
-
return {
|
|
990
|
-
error: true,
|
|
991
|
-
status: 0,
|
|
992
|
-
body: `payload_path must be absolute (got '${payloadPath}').`,
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
const blockedPrefixes = ["/proc/", "/dev/", "/sys/", "/proc", "/dev", "/sys"];
|
|
997
|
-
if (blockedPrefixes.some((p) => payloadPath === p.replace(/\/$/, "") || payloadPath.startsWith(p))) {
|
|
998
|
-
return {
|
|
999
|
-
error: true,
|
|
1000
|
-
status: 0,
|
|
1001
|
-
body: `payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`,
|
|
1002
|
-
};
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
|
|
1006
|
-
const fs = await import("node:fs/promises");
|
|
1007
|
-
|
|
1008
|
-
try {
|
|
1009
|
-
const stat = await fs.stat(payloadPath);
|
|
1010
|
-
if (!stat.isFile()) {
|
|
1011
|
-
return {
|
|
1012
|
-
error: true,
|
|
1013
|
-
status: 0,
|
|
1014
|
-
body: `payload_path '${payloadPath}' is not a regular file.`,
|
|
1015
|
-
};
|
|
1016
|
-
}
|
|
1017
|
-
if (stat.size > MAX_PAYLOAD_BYTES) {
|
|
1018
|
-
return {
|
|
1019
|
-
error: true,
|
|
1020
|
-
status: 0,
|
|
1021
|
-
body: `payload_path '${payloadPath}' is ${stat.size} bytes, exceeds max ${MAX_PAYLOAD_BYTES}.`,
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
const raw = await fs.readFile(payloadPath, "utf8");
|
|
1025
|
-
return JSON.parse(raw);
|
|
1026
|
-
} catch (err) {
|
|
1027
|
-
return {
|
|
1028
|
-
error: true,
|
|
1029
|
-
status: 0,
|
|
1030
|
-
body: `Could not read payload_path '${payloadPath}': ${err.message}`,
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
983
|
+
return readPayloadFile(payloadPath);
|
|
1033
984
|
}
|
|
1034
985
|
|
|
1035
986
|
// --- Story Tools ---
|
|
@@ -1185,7 +1136,19 @@ async function claimStory({ story_id }) {
|
|
|
1185
1136
|
);
|
|
1186
1137
|
// The claim response carries the start_cap that POST /start will require.
|
|
1187
1138
|
rememberCap(story_id, result && result.capability);
|
|
1188
|
-
return
|
|
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 }));
|
|
1189
1152
|
}
|
|
1190
1153
|
|
|
1191
1154
|
async function startStory({ story_id, capability }) {
|
|
@@ -3148,6 +3111,34 @@ async function revokeAuthenticator({ tenant_id, authenticator_id, webauthn_asser
|
|
|
3148
3111
|
return toContent(result);
|
|
3149
3112
|
}
|
|
3150
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
|
+
|
|
3151
3142
|
// US-26: Signed Tree Head retrieval
|
|
3152
3143
|
async function getSth({ tenant_id }) {
|
|
3153
3144
|
const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
|
|
@@ -3927,7 +3918,7 @@ const TOOLS = [
|
|
|
3927
3918
|
payload_path: {
|
|
3928
3919
|
type: "string",
|
|
3929
3920
|
description:
|
|
3930
|
-
"Absolute path to a
|
|
3921
|
+
"Absolute path to a .json file holding the import payload (an object with an `epics` array). Avoids inline size limits for large epics. Ignored if `payload` is also passed.",
|
|
3931
3922
|
},
|
|
3932
3923
|
merge: {
|
|
3933
3924
|
type: "boolean",
|
|
@@ -4053,7 +4044,10 @@ const TOOLS = [
|
|
|
4053
4044
|
name: "claim_story",
|
|
4054
4045
|
description:
|
|
4055
4046
|
"Agent claims a contracted story. Uses pessimistic locking to prevent double-claims. " +
|
|
4056
|
-
"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.",
|
|
4057
4051
|
inputSchema: {
|
|
4058
4052
|
type: "object",
|
|
4059
4053
|
properties: {
|
|
@@ -4065,6 +4059,32 @@ const TOOLS = [
|
|
|
4065
4059
|
required: ["story_id"],
|
|
4066
4060
|
},
|
|
4067
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
|
+
},
|
|
4068
4088
|
{
|
|
4069
4089
|
name: "start_story",
|
|
4070
4090
|
description:
|
|
@@ -7443,6 +7463,81 @@ const TOOLS = [
|
|
|
7443
7463
|
},
|
|
7444
7464
|
},
|
|
7445
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
|
+
|
|
7446
7541
|
// LCP-1 §9 signed-profile tools
|
|
7447
7542
|
{
|
|
7448
7543
|
name: "register_custody_owner_key",
|
|
@@ -8181,6 +8276,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
8181
8276
|
case "claim_story":
|
|
8182
8277
|
return await claimStory(args);
|
|
8183
8278
|
|
|
8279
|
+
case "renew_story_claim":
|
|
8280
|
+
return await renewStoryClaim(args);
|
|
8281
|
+
|
|
8184
8282
|
case "start_story":
|
|
8185
8283
|
return await startStory(args);
|
|
8186
8284
|
|
|
@@ -8447,6 +8545,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
8447
8545
|
case "dispatch":
|
|
8448
8546
|
return await createDispatch(args);
|
|
8449
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
|
+
|
|
8450
8560
|
case "register_custody_owner_key":
|
|
8451
8561
|
return await registerCustodyOwnerKey(args);
|
|
8452
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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Reads an import_stories payload from an absolute file path.
|
|
2
|
+
//
|
|
3
|
+
// Security: `payload_path` is read with the MCP process's filesystem privileges and an
|
|
4
|
+
// agent can set it via prompt injection, so no file CONTENT is ever returned, and a file
|
|
5
|
+
// that is not an import payload is never uploaded. The refusals still say which check
|
|
6
|
+
// failed (missing, not a file, too large, not JSON, wrong shape) for a .json path; that
|
|
7
|
+
// is metadata an agent needs to fix its own call, not content:
|
|
8
|
+
// * require an absolute path ending in .json, checked on the path AND on its realpath,
|
|
9
|
+
// so a symlink named x.json cannot point at /etc/passwd or /proc
|
|
10
|
+
// * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
|
|
11
|
+
// * stat first and cap at 5 MiB (server also enforces a body size limit)
|
|
12
|
+
// * never echo a JSON.parse or fs error message: V8's SyntaxError quotes the file's
|
|
13
|
+
// content ("root:x:0:0"... is not valid JSON), so only err.code is reported
|
|
14
|
+
// * require the Epic 12 import shape (an object with an `epics` array) before returning,
|
|
15
|
+
// so a valid JSON file that is not a payload (a credentials file) is never POSTed
|
|
16
|
+
//
|
|
17
|
+
// Returns the parsed payload on success, or an { error, status, body } shape on failure.
|
|
18
|
+
|
|
19
|
+
import nodePath from "node:path";
|
|
20
|
+
import defaultFs from "node:fs/promises";
|
|
21
|
+
|
|
22
|
+
export const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
const BLOCKED_ROOTS = ["/proc", "/dev", "/sys"];
|
|
25
|
+
|
|
26
|
+
function refuse(body) {
|
|
27
|
+
return { error: true, status: 0, body };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isBlocked(p) {
|
|
31
|
+
return BLOCKED_ROOTS.some((root) => p === root || p.startsWith(root + "/"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasJsonExtension(p) {
|
|
35
|
+
return nodePath.extname(p).toLowerCase() === ".json";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readPayloadFile(payloadPath, { fs = defaultFs } = {}) {
|
|
39
|
+
if (typeof payloadPath !== "string" || !nodePath.isAbsolute(payloadPath)) {
|
|
40
|
+
return refuse(`payload_path must be absolute (got '${payloadPath}').`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (isBlocked(nodePath.resolve(payloadPath))) {
|
|
44
|
+
return refuse(`payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!hasJsonExtension(payloadPath)) {
|
|
48
|
+
return refuse(`payload_path refused: '${payloadPath}' must be a .json file.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let realPath;
|
|
52
|
+
try {
|
|
53
|
+
realPath = await fs.realpath(payloadPath);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return refuse(`Could not read payload_path '${payloadPath}' (${err.code || "error"}).`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isBlocked(realPath)) {
|
|
59
|
+
return refuse(`payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!hasJsonExtension(realPath)) {
|
|
63
|
+
return refuse(`payload_path refused: '${payloadPath}' must be a .json file.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
const stat = await fs.stat(realPath);
|
|
69
|
+
if (!stat.isFile()) {
|
|
70
|
+
return refuse(`payload_path '${payloadPath}' is not a regular file.`);
|
|
71
|
+
}
|
|
72
|
+
if (stat.size > MAX_PAYLOAD_BYTES) {
|
|
73
|
+
return refuse(
|
|
74
|
+
`payload_path '${payloadPath}' exceeds max ${MAX_PAYLOAD_BYTES} bytes.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
raw = await fs.readFile(realPath, "utf8");
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return refuse(`Could not read payload_path '${payloadPath}' (${err.code || "error"}).`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return refuse(`payload_path '${payloadPath}' is not valid JSON.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray(parsed.epics)) {
|
|
90
|
+
return refuse(
|
|
91
|
+
`payload_path '${payloadPath}' is not an import payload (expected an object with an "epics" array).`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
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
|
+
}
|