loopctl-mcp-server 2.100.0 → 2.102.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 +11 -4
- package/index.js +320 -3
- package/lib/intake-sources.js +554 -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: used for every tool except the exact-key ones below, and for the `exact_role: :orchestrator` custody verbs when `LOOPCTL_ORCH_KEY` is unset | -- |
|
|
71
71
|
| `LOOPCTL_ORCH_KEY` | Orchestrator role API key (report, review, import, and the `exact_role: :orchestrator` custody verbs `verify_story` / `reject_story` / `verify_all_in_epic` / `bulk_mark_complete` / `force_unclaim_story` — when it is set, it is the key those five send) | -- |
|
|
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)), for destructive admin tools like `knowledge_bulk_publish`, and for the `runner_*` tools plus `place_dispatch` and `resolve_escalation` in [Runner and delivery-loop tools](#runner-and-delivery-loop-tools). **Not for the whole of that section:** `story_stage` takes `LOOPCTL_AGENT_KEY`, and `force_unclaim_story` is `exact_role: :orchestrator`, where a user key is 403'd like any other non-member. | -- |
|
|
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)), for destructive admin tools like `knowledge_bulk_publish`, and for the `runner_*` and `intake_source_*` tools plus `place_dispatch` and `resolve_escalation` in [Runner and delivery-loop tools](#runner-and-delivery-loop-tools). **Not for the whole of that section:** `story_stage` takes `LOOPCTL_AGENT_KEY`, and `force_unclaim_story` is `exact_role: :orchestrator`, where a user key is 403'd like any other non-member. | -- |
|
|
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`.
|
|
@@ -84,6 +84,7 @@ variable the tool names, so a key you set for a specific role is the key that ge
|
|
|
84
84
|
|---|---|---|
|
|
85
85
|
| `verify_story`, `reject_story`, `verify_all_in_epic`, `bulk_mark_complete`, `force_unclaim_story` | `LOOPCTL_ORCH_KEY` (`exact_role: :orchestrator`) | Pinned **when `LOOPCTL_ORCH_KEY` is set**. With no orchestrator key, `LOOPCTL_API_KEY` is still sent — set it to an orchestrator-role key and these work. |
|
|
86
86
|
| `place_dispatch`, `resolve_escalation` | `LOOPCTL_USER_KEY` (an unlineaged human principal) | Always pinned; `LOOPCTL_API_KEY` is never consulted. |
|
|
87
|
+
| `intake_source_enroll`, `intake_source_list`, `intake_source_update`, `intake_source_revoke` | `LOOPCTL_USER_KEY` | Always pinned; `LOOPCTL_API_KEY` is never consulted. The endpoint is `role: :user` with the hierarchy, but enrolling MINTS the webhook secret — a credential belonging to no dispatch lineage — so the caller must itself be unlineaged (`RequireUnlineagedCaller`) or it is refused 403 `api_key_mint_forbidden`. |
|
|
87
88
|
| `set_llm_config`, `llm_config` and the other secret-managing tools | `LOOPCTL_USER_KEY` | Always pinned; a secret operation must not run under a non-user global key. |
|
|
88
89
|
|
|
89
90
|
Why pin at all: the priority above means `LOOPCTL_API_KEY` beats the variable a tool names,
|
|
@@ -500,18 +501,24 @@ The generated-tool count per tenant is bounded by the per-tenant entity cap.
|
|
|
500
501
|
|
|
501
502
|
### Runner and delivery-loop tools
|
|
502
503
|
|
|
503
|
-
Enroll the dev machines that run the agent delivery loop, see which are connected (issue #809), place work on them and recover a story a runner would not take. The `runner_*` tools require `LOOPCTL_USER_KEY`, and
|
|
504
|
+
Enroll the dev machines that run the agent delivery loop, see which are connected (issue #809), bind the repositories whose issues feed it, place work on them and recover a story a runner would not take. The `runner_*` and `intake_source_*` tools require `LOOPCTL_USER_KEY`, and their writes additionally require a human-anchored tenant. The delivery-loop verbs below do NOT share one key — each row names the key its endpoint's gate demands, and a higher-privileged key does not substitute for an `exact_role` one.
|
|
505
|
+
|
|
506
|
+
**A loop with no intake source has no input.** `intake_source_enroll` is the step that points a GitHub repository at loopctl, and the same row is where `place_dispatch` reads `repo` and `base_branch` from — its 409 `no_intake_source` names exactly this. The webhook secret it returns is written to a file and never appears in a tool result; see that row.
|
|
504
507
|
|
|
505
508
|
| Tool | Description |
|
|
506
509
|
|---|---|
|
|
507
510
|
| `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 `~/`). Optional: `max_sessions` (1..64, default 2) — the CEILING on how many dispatches loopctl will ever keep in flight on this machine at once, and the value it starts at. Since runner contract 1.13.0 every join re-applies the machine's own declared `max_sessions` bounded by this one, so a machine may take itself down below its grant and cannot raise itself above it: to carry FEWER sessions change `control.max_sessions` in the runner's own config and reconnect, to carry MORE `runner_revoke` it first and then enrol it again (nothing widens the ceiling in place, and enrolling the same name while its runner is active is a 422; the revoke invalidates the credential, so the new token file must reach the machine and the runner be restarted). The tenant's total across its runners is capped separately by the server. 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. |
|
|
508
511
|
| `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
|
|
509
512
|
| `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`. |
|
|
510
|
-
| `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `draining`, the latest `sample`, `live_sockets` (above 1 means more than one process holds the credential), and the `node` and `machine_id` (Fly Machine) holding the socket. `in_flight` and `max_sessions` are the capacity loopctl holds in Postgres — what dispatch reserves against — and are null only for a runner revoked while its socket drains; `reported_in_flight` and `reported_max_sessions` are the runner's own last report: `reported_in_flight` is a hint (the runner counts sessions, loopctl counts reservations), while `reported_max_sessions` is what `max_sessions` is copied from on every join (contract 1.13.0), capped at `enrolled_max_sessions` which is also returned. The two can differ in EITHER direction. `max_sessions` BELOW it happens when the machine is declaring above its enrolled ceiling (`enrolled_max_sessions` is what makes that one visible in the payload; the rest are not), when the declaration write has not LANDED — it can fail under lock contention on the runner row and is retried on that socket's 30-second recheck, downward only — or when the socket joined a node older than contract 1.13.0 and has not reconnected since. `max_sessions` ABOVE it happens when the machine declared `0`, held as `1` because the column is 1..64 while this field shows the raw `0`, and again when a declaration write has not landed and the row keeps an older, larger number. A `reported_max_sessions` of 0 means the machine is taking no work and `place_dispatch` refuses a new placement on it, like `draining`. Presence converges only within a cluster. |
|
|
511
|
-
| `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. 409 `runner_declines_work` when the machine declares `draining` or `max_sessions` 0 — refused before the mint, so nothing was claimed; a retry carrying a `dispatch_id` loopctl already holds is re-sent anyway, because its claim is already standing. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `
|
|
513
|
+
| `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `draining`, the latest `sample`, `live_sockets` (above 1 means more than one process holds the credential), and the `node` and `machine_id` (Fly Machine) holding the socket. `in_flight` and `max_sessions` are the capacity loopctl holds in Postgres — what dispatch reserves against — and are null only for a runner revoked while its socket drains; `reported_in_flight` and `reported_max_sessions` are the runner's own last report: `reported_in_flight` is a hint (the runner counts sessions, loopctl counts reservations), while `reported_max_sessions` is what `max_sessions` is copied from on every join (contract 1.13.0), capped at `enrolled_max_sessions` which is also returned. The two can differ in EITHER direction. `max_sessions` BELOW it happens when the machine is declaring above its enrolled ceiling (`enrolled_max_sessions` is what makes that one visible in the payload; the rest are not), when the declaration write has not LANDED — it can fail under lock contention on the runner row and is retried on that socket's 30-second recheck, downward only — or when the socket joined a node older than contract 1.13.0 and has not reconnected since. `max_sessions` ABOVE it happens when the machine declared `0`, held as `1` because the column is 1..64 while this field shows the raw `0`, and again when a declaration write has not landed and the row keeps an older, larger number. A `reported_max_sessions` of 0 means the machine is taking no work and `place_dispatch` refuses a new placement on it, like `draining`. `branch_prefixes` is what the machine declared it ACCEPTS as a branch-name prefix (runner contract 1.14.0): loopctl derives a branch starting with the FIRST entry, and `[]` means it declared no constraint. Read it when `place_dispatch` refuses 409 `no_conforming_branch`, and when a connected machine refuses every dispatch with `branch_not_allowed` — a runner that ENFORCES a prefix and declares none shows `[]` here, which is that misconfiguration made visible. Per-CONNECTION, like `kinds`. Presence converges only within a cluster. |
|
|
514
|
+
| `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. 409 `runner_declines_work` when the machine declares `draining` or `max_sessions` 0 — refused before the mint, so nothing was claimed; a retry carrying a `dispatch_id` loopctl already holds is re-sent anyway, because its claim is already standing. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `base_branch`, `wall_clock_seconds`, `max_turns`, and `branch` — **which you should not send**. Since runner contract 1.14.0 loopctl DERIVES the branch from the story number and an id fragment, behind a prefix the target runner declared it accepts (`branch_prefixes`, visible on `runner_pool`), which is a per-machine fact only the server can read. A branch you name is never rewritten, only judged, and it must satisfy three things: it must be a valid git ref name (422 `invalid_branch_name`, which also refuses a non-string such as `null`), it must start with one of that machine's declared prefixes (422 `branch_not_allowed`), and it must END WITH THIS STORY'S OWN SUFFIX (422 `branch_not_unique`) so that two stories on one repository can never be given one branch — you may choose the prefix, you may not drop the suffix. Nothing is claimed on any of them, and the fix is to drop the field. `base_branch` is judged as a git ref name too (422 `invalid_branch_name`): it reaches git on the runner just as `branch` does. It is NOT story-unique — every dispatch cutting from `master` is the normal case. A retry naming a different `branch` from the one this dispatch was already sent on is 422 `branch_conflict`: the branch is recorded at the first push and re-sent verbatim, because a session may be running on it right now. 409 `no_conforming_branch` is the machine's problem rather than the request's: it declares prefixes and none of them can produce a valid branch name carrying the story number and id fragment that keeps two stories off one branch, so nothing was claimed and an operator has to fix `branch_prefixes` on that box — the body echoes the declared prefixes. A retry carrying a `dispatch_id` loopctl already holds is never refused for either prefix reason: its claim is already standing, so the declaration only steers the name. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
|
|
512
515
|
| `story_stage` | **Where a story is in the delivery machine** (`GET /api/v1/stories/:id/stage`): stage, `claim_epoch`, `lock_version`, `attempts`, the runner holding it, and the escalation reason when parked. `stage: null` means the loop has never touched it. This is how you watch a run, and the only way to see why a stage report was refused `stale_stage`. `escalation_reason` is untrusted session text. Required: `story_id`. |
|
|
513
516
|
| `resolve_escalation` | **Move an escalated story off `escalated`, as a human** (`POST /api/v1/stories/:id/stage/resolve`): `to` is `queued` (work it again), `done` or `failed`. The other half of `escalate_story` — without it a parked story stays parked for ever. Requires `LOOPCTL_USER_KEY` on a key no dispatch minted: a session cannot resolve the escalation it raised. Optional: `reason`. |
|
|
514
517
|
| `force_unclaim_story` | **Take a story back from the agent holding it** (`POST /api/v1/stories/:id/force-unclaim`). Resets it to `agent_status: pending` with `assigned_agent_id` cleared AND makes the delivery stage row follow the release back to `queued`. **It frees the STAGE; it does not make the story placeable.** `place_dispatch` wants `contracted` + `queued` (see its own row in this table), and the release leaves the story at `pending`, whose only transition is `pending -> contracted` — so running `place_dispatch` next answers the identical 409 `invalid_transition`. The remedy is three calls, in this order: `force_unclaim_story`, then `contract_story`, then `place_dispatch`. A story parked at `claimed` with nobody on it is the residue of a compensation that did not complete, not what a refused dispatch normally leaves: placement releases the claim inline when a runner refuses, and the claim lease releases it unattended once `claimed_until` passes — reach for this to get it back now, or when both of those left it held. Requeues from any stage a claim holds; a stage no claim holds keeps its stage and is rebound to the new epoch; `done` and `failed` are untouched. For an ESCALATED story use `resolve_escalation` instead — that one releases AND re-contracts, so its `queued` really is placeable. Requires an orchestrator-ROLE key: the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like any other non-member. Put it in `LOOPCTL_ORCH_KEY` (which is then the key sent, and a global `LOOPCTL_API_KEY` does not displace it) or, with no orchestrator key set, in `LOOPCTL_API_KEY`. The orchestrator key must be linked to a registered agent (400 otherwise) and the tenant must be human-anchored (403 `custody_tier_required` otherwise). Does not touch `verified_status`. Safe to run twice: on an already-pending story the stage row is written only if it is stranded behind the story's claim epoch or still in flight. No request body. Required: `story_id` (refused locally if it is not a UUID). |
|
|
518
|
+
| `intake_source_enroll` | **Bind a GitHub repository to a work project and mint its webhook secret** (`POST /api/v1/intake/sources`) — the step that gives the delivery loop an input, and the row `place_dispatch` reads `repo` and `base_branch` from (its 409 `no_intake_source` means this has not been done; 409 `ambiguous_intake_source` means it has been done twice). **The secret is returned ONCE and can never be read again** — the column is encrypted at rest and redacted on the schema, so no other tool carries it and losing it costs a revoke, a re-enrolment and a reconfigured GitHub webhook. **This tool does not return it either:** a tool result lands in the transcript and the audit log, so it is written to the required `secret_file` with mode 0600 (the handling `runner_enroll` gives a runner credential) and the result carries the source row, the `webhook_url` and that path. The path is reserved before the request, so an existing file or an unwritable directory is refused with nothing enrolled, and any outcome that is not a clean creation withholds the response body — an unparsed 2xx body IS the secret. A 2xx carrying the source and the secret but no webhook path is RECOVERED rather than discarded (the path is derived from the source id, flagged `webhook_url_derived`), and an outcome that proves a source id but no usable secret REVOKES that source rather than leaving it holding the repository's unique slot. Then configure GitHub: Payload URL = the returned `webhook_url`, content type `application/json`, Secret = the contents of the file, and the **Issues** event only — `gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f config[content_type]=json -f config[secret]="$(cat <secret_file>)" -f 'events[]=issues'`. Every later delivery failure is the SAME 401 `invalid_signature` — unknown or revoked source, suspended tenant, missing or wrong signature, or a payload whose `repository.full_name` does not match — so the webhook's Recent Deliveries tab cannot tell you which. `base_branch` is set HERE: it defaults to `master` when the body does not name it, so name `main` for a repository created on GitHub since 2020 or every dispatch is cut from a trunk that does not exist. It is not nullable (a dispatch must name a branch), so null or blank is refused rather than falling back to the default, and `intake_source_update` changes it afterwards. Refusals: 403 `api_key_mint_forbidden` if your key was minted by a dispatch (this mints a credential belonging to no lineage, so only an unlineaged operator key may), 403 `custody_tier_required` on an agent-rooted tenant, 422 for a repository that is not `owner/name`, an ACTIVE source already binding it, a project that is missing / archived / not a work project, or a `target_epic_id` outside that project. Required: `repo_full_name`, `project_id`, `secret_file`. Optional: `base_branch`, and `target_epic_id` — **omitting the epic is not a neutral default**: with no epic every triaged report is escalated to a human and every record stays `pending_triage` until `intake_source_update` names one. Requires `LOOPCTL_USER_KEY`. |
|
|
519
|
+
| `intake_source_list` | List the tenant's intake sources (`GET /api/v1/intake/sources`): `id` (the webhook URL is `/api/v1/intake/github/<id>`), `project_id`, `repo_full_name`, `base_branch`, `target_epic_id`, `revoked_at`, timestamps. **The webhook secret is not here and is not anywhere** — it is returned once by `intake_source_enroll`, so this is not how to recover one. Use it to find the source behind a 409 `no_intake_source` or `ambiguous_intake_source`, to check a repository's `base_branch` before placing work, and to identify a source left behind by an enrolment whose outcome was unknown. Optional: `include_revoked`. Requires `LOOPCTL_USER_KEY`; the only one of the four that does not also need a human-anchored tenant. |
|
|
520
|
+
| `intake_source_update` | **Set where a source's work lands** (`PATCH /api/v1/intake/sources/:id`): `target_epic_id`, the epic triaged stories are created in, and `base_branch`, the branch every dispatch for this repository is cut from. Presence decides — a field you do not name is left exactly as it was, and naming neither is 422 `nothing_to_update`. **Do not send `target_epic_id: null` to mean "not changing this":** an explicit null is the only way to CLEAR the epic, which returns the source to escalating every report instead of filing a story. `base_branch` has no cleared state and a null there is refused locally. This is how a source already pointed at the wrong trunk is corrected — `intake_source_enroll` takes `base_branch` itself, so a `main` repository no longer needs a second call — and the remedy for a source with no epic — every record from it stays `pending_triage` and is retried until one is named, then they promote on the next run with nothing lost. Revoked sources are 404, not a no-op: revoking clears the target so the epic can be deleted, and repointing one would put that block back. 422 when the epic is not in this source's project. Required: `source_id` (refused locally if it is not a UUID — the server answers the same 404 for malformed and unknown). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
|
|
521
|
+
| `intake_source_revoke` | **Revoke an intake source** (`DELETE /api/v1/intake/sources/:id`). The verb is DELETE and the action is a revoke: `revoked_at` is stamped, the row is kept, and `intake_source_list` still returns it with `include_revoked`; nothing already received is discarded. Afterwards every delivery to that URL is refused 401 `invalid_signature` exactly as a wrong secret is, so the GitHub webhook does not know it has been cut off and should be deleted on the repository too. Idempotent — a second revoke answers 200 with the original `revoked_at`. It frees the repository's uniqueness slot (the index is partial on not-yet-revoked), so a source with a lost secret, a wrong project or a typo'd repository is corrected by revoking and enrolling again; and it clears `target_epic_id`, which is what makes that epic deletable and is the remedy an epic-delete refusal names. Required: `source_id` (refused locally if it is not a UUID). Requires `LOOPCTL_USER_KEY` and a human-anchored tenant. |
|
|
515
522
|
|
|
516
523
|
### Dispatch & Chain of Custody (v2) Tools
|
|
517
524
|
|
package/index.js
CHANGED
|
@@ -54,6 +54,12 @@ import {
|
|
|
54
54
|
storyStage as storyStageRequest,
|
|
55
55
|
} from "./lib/delivery-loop.js";
|
|
56
56
|
import { updateStory as updateStoryRequest } from "./lib/story-update.js";
|
|
57
|
+
import {
|
|
58
|
+
enrollIntakeSource,
|
|
59
|
+
listIntakeSources,
|
|
60
|
+
revokeIntakeSource,
|
|
61
|
+
updateIntakeSource,
|
|
62
|
+
} from "./lib/intake-sources.js";
|
|
57
63
|
import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
|
|
58
64
|
import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
|
|
59
65
|
|
|
@@ -3289,6 +3295,48 @@ async function forceUnclaimStory(args) {
|
|
|
3289
3295
|
);
|
|
3290
3296
|
}
|
|
3291
3297
|
|
|
3298
|
+
// #803/#846: GitHub intake sources — the row that makes a webhook possible, and the binding
|
|
3299
|
+
// `place_dispatch` reads `repo` and `base_branch` from. The logic, including how the enrol
|
|
3300
|
+
// path keeps the webhook secret out of the tool result, lives in lib/intake-sources.js.
|
|
3301
|
+
//
|
|
3302
|
+
// THE FACTORY IS DECLARED BELOW THESE FOUR, not above them, and that is load-bearing rather
|
|
3303
|
+
// than a style: several sibling guards read a handler's source as the slice from its own
|
|
3304
|
+
// `async function` to the NEXT one (`delivery_loop_tools.test.js`, `custody_key_pinning.js`,
|
|
3305
|
+
// `mcp_arg_forwarding.js`'s `functionSource`). A plain `function` declaration sitting between
|
|
3306
|
+
// two handlers is swallowed by the earlier one's slice — putting `intakeDeps` above
|
|
3307
|
+
// `intakeSourceEnroll` made `forceUnclaimStory`'s guard read this factory's
|
|
3308
|
+
// `process.env.LOOPCTL_USER_KEY` and correctly fail. Hoisting keeps the call sites valid.
|
|
3309
|
+
async function intakeSourceEnroll(args) {
|
|
3310
|
+
return toContent(await enrollIntakeSource(args, intakeDeps()));
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
async function intakeSourceList(args) {
|
|
3314
|
+
return toContent(await listIntakeSources(args, intakeDeps()));
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
async function intakeSourceUpdate(args) {
|
|
3318
|
+
return toContent(await updateIntakeSource(args, intakeDeps()));
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
async function intakeSourceRevoke(args) {
|
|
3322
|
+
return toContent(await revokeIntakeSource(args, intakeDeps()));
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// All four take the EXACT user-role key, like the `runner_*` family: the controller is
|
|
3326
|
+
// `role: :user` with `RequireHumanAnchor` on the writes, and create additionally requires a
|
|
3327
|
+
// caller NO DISPATCH MINTED (`RequireUnlineagedCaller`) — it mints the webhook secret, a
|
|
3328
|
+
// credential belonging to no lineage — so a global LOOPCTL_API_KEY of another shape must never
|
|
3329
|
+
// stand in for it. `baseUrl` is injected so the webhook URL names the server this process is
|
|
3330
|
+
// actually talking to.
|
|
3331
|
+
function intakeDeps() {
|
|
3332
|
+
const userKey = process.env.LOOPCTL_USER_KEY;
|
|
3333
|
+
return {
|
|
3334
|
+
userKey,
|
|
3335
|
+
baseUrl: getBaseUrl(),
|
|
3336
|
+
apiCall: (method, path, body) => apiCall(method, path, body, userKey, { exactKey: true }),
|
|
3337
|
+
};
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3292
3340
|
// #846.6: correct a filed story. The action is `role: :orchestrator`
|
|
3293
3341
|
// (`story_controller.ex:29-30`) and NOT `exact_role:`, so the hierarchy applies and a `:user`
|
|
3294
3342
|
// or `:superadmin` key passes too. That is why LOOPCTL_ORCH_KEY goes through `resolveKey` as an
|
|
@@ -7948,6 +7996,12 @@ const TOOLS = [
|
|
|
7948
7996
|
"write has not landed and the row keeps an older, larger number. A reported_max_sessions " +
|
|
7949
7997
|
"of 0 means the machine is taking no work and place_dispatch refuses a new placement on " +
|
|
7950
7998
|
"it, like draining. " +
|
|
7999
|
+
"branch_prefixes is what the machine declared it ACCEPTS as a branch-name prefix (runner " +
|
|
8000
|
+
"contract 1.14.0): loopctl derives a branch starting with the FIRST entry, and [] means " +
|
|
8001
|
+
"the machine declared no constraint. Read it when place_dispatch refuses 409 " +
|
|
8002
|
+
"no_conforming_branch, and when a machine refuses dispatches with branch_not_allowed — a " +
|
|
8003
|
+
"runner that ENFORCES a prefix and declares none shows [] here, which is that " +
|
|
8004
|
+
"misconfiguration made visible. Per-CONNECTION, like kinds. " +
|
|
7951
8005
|
"live_sockets " +
|
|
7952
8006
|
"above 1 means more than one process holds that runner's credential. A killed runner " +
|
|
7953
8007
|
"disappears once its socket closes. Presence converges only " +
|
|
@@ -7981,7 +8035,26 @@ const TOOLS = [
|
|
|
7981
8035
|
"NEW dispatch_id. Read `draining` and `reported_max_sessions` from runner_pool before " +
|
|
7982
8036
|
"choosing a machine. It refuses a NEW placement only: retrying with a dispatch_id " +
|
|
7983
8037
|
"loopctl already holds still re-sends that dispatch, because its claim is already " +
|
|
7984
|
-
"standing and a draining machine is asked to finish what it holds, not to take more
|
|
8038
|
+
"standing and a draining machine is asked to finish what it holds, not to take more.\n\n" +
|
|
8039
|
+
"409 `no_conforming_branch` means the machine declares `branch_prefixes` (runner " +
|
|
8040
|
+
"contract 1.14.0) and none of them can produce a valid branch name carrying the story " +
|
|
8041
|
+
"number and id fragment that keeps two stories off one branch. NOTHING WAS CLAIMED. " +
|
|
8042
|
+
"The body echoes the declared prefixes; the fix is on that machine's own configuration, " +
|
|
8043
|
+
"not in this call. 422 `branch_not_allowed` is the other half: you passed a `branch` " +
|
|
8044
|
+
"outside those prefixes, so the machine would have refused the dispatch — omit it and " +
|
|
8045
|
+
"loopctl derives a conforming one. 422 `invalid_branch_name` is a different refusal " +
|
|
8046
|
+
"again: a ref field you passed — `branch` OR `base_branch`, which reaches git on the " +
|
|
8047
|
+
"runner just as `branch` does — is not a string, or is not a valid git ref name, so no " +
|
|
8048
|
+
"machine could create it; nothing was claimed and the remedy is in this call. 422 " +
|
|
8049
|
+
"`branch_not_unique` means the `branch` you passed does not end with this story's own " +
|
|
8050
|
+
"suffix, so two stories on one repository could share a branch and the second session " +
|
|
8051
|
+
"would find the first's work there: keep your prefix and append the suffix the error " +
|
|
8052
|
+
"names, or omit `branch`. 422 `branch_conflict` means a retry named a different branch " +
|
|
8053
|
+
"from the one this dispatch was already sent on — the branch is recorded at the first " +
|
|
8054
|
+
"push and re-sent verbatim, because a session may be running on it right now. Neither " +
|
|
8055
|
+
"prefix refusal can be raised by a RETRY carrying a dispatch_id loopctl already " +
|
|
8056
|
+
"holds: its claim is already standing, so a declaration that changed under you only " +
|
|
8057
|
+
"steers the name and never strands the story.",
|
|
7985
8058
|
inputSchema: {
|
|
7986
8059
|
type: "object",
|
|
7987
8060
|
properties: {
|
|
@@ -8017,8 +8090,30 @@ const TOOLS = [
|
|
|
8017
8090
|
"project bound to NO intake source (409 `no_intake_source`) or to TWO (409 " +
|
|
8018
8091
|
"`ambiguous_intake_source`) — both refusals name these two parameters.",
|
|
8019
8092
|
},
|
|
8020
|
-
branch: {
|
|
8021
|
-
|
|
8093
|
+
branch: {
|
|
8094
|
+
type: "string",
|
|
8095
|
+
description:
|
|
8096
|
+
"Optional, and you almost certainly should NOT pass it. loopctl derives the " +
|
|
8097
|
+
"branch from the story number and an id fragment, behind a prefix the TARGET " +
|
|
8098
|
+
"RUNNER declared it accepts on join (`branch_prefixes`, runner contract 1.14.0) " +
|
|
8099
|
+
"— a per-machine fact only the server can read, and the reason this is derived " +
|
|
8100
|
+
"rather than named. A branch you pass is never rewritten, so one outside that " +
|
|
8101
|
+
"machine's prefixes is refused 422 `branch_not_allowed`, one that is not a valid " +
|
|
8102
|
+
"git ref name (or is not a string at all, such as null) is refused 422 " +
|
|
8103
|
+
"`invalid_branch_name`, and one that does not END WITH THIS STORY'S OWN SUFFIX " +
|
|
8104
|
+
"is refused 422 `branch_not_unique` — you may choose the prefix, you may not " +
|
|
8105
|
+
"drop the suffix, which is what keeps two stories on one repository off one " +
|
|
8106
|
+
"branch. Nothing is claimed on any of them. runner_pool shows each machine's " +
|
|
8107
|
+
"`branch_prefixes`.",
|
|
8108
|
+
},
|
|
8109
|
+
base_branch: {
|
|
8110
|
+
type: "string",
|
|
8111
|
+
description:
|
|
8112
|
+
"Optional. The ref the session cuts FROM; defaults to the project's intake " +
|
|
8113
|
+
"source. Judged as a git ref name exactly as `branch` is (422 " +
|
|
8114
|
+
"`invalid_branch_name`, nothing claimed), but NOT story-unique — every dispatch " +
|
|
8115
|
+
"cutting from master is the normal case.",
|
|
8116
|
+
},
|
|
8022
8117
|
wall_clock_seconds: { type: "integer", description: "Optional. Overrides the default." },
|
|
8023
8118
|
max_turns: { type: "integer", description: "Optional. Overrides the default." },
|
|
8024
8119
|
},
|
|
@@ -8117,6 +8212,216 @@ const TOOLS = [
|
|
|
8117
8212
|
},
|
|
8118
8213
|
},
|
|
8119
8214
|
|
|
8215
|
+
{
|
|
8216
|
+
name: "intake_source_enroll",
|
|
8217
|
+
description:
|
|
8218
|
+
"BIND A GITHUB REPOSITORY TO A WORK PROJECT and mint its webhook secret (POST " +
|
|
8219
|
+
"/api/v1/intake/sources). This is how the agent delivery loop gets an input at all: " +
|
|
8220
|
+
"until a repository has an intake source, no webhook anywhere points at loopctl and no " +
|
|
8221
|
+
"issue can reach a queue. It is also the binding place_dispatch reads — a project bound " +
|
|
8222
|
+
"to no source refuses 409 `no_intake_source`, and one bound to two refuses 409 " +
|
|
8223
|
+
"`ambiguous_intake_source`; this tool is what those two refusals mean by enrolling a " +
|
|
8224
|
+
"source.\n\n" +
|
|
8225
|
+
"THE WEBHOOK SECRET IS RETURNED ONCE AND CAN NEVER BE READ AGAIN. The column is " +
|
|
8226
|
+
"encrypted at rest and redacted on the schema, so neither intake_source_list nor " +
|
|
8227
|
+
"intake_source_update ever carries it; losing it costs a revoke, a second enrolment and " +
|
|
8228
|
+
"a reconfigured GitHub webhook. THIS TOOL DOES NOT RETURN IT EITHER — a tool result " +
|
|
8229
|
+
"lands in the session transcript and the audit log, so the secret is written to " +
|
|
8230
|
+
"`secret_file` with mode 0600 (the same handling runner_enroll gives a runner " +
|
|
8231
|
+
"credential) and the result carries only the source row, the `webhook_url` and that " +
|
|
8232
|
+
"path. The path is reserved before the request, so an existing file or an unwritable " +
|
|
8233
|
+
"directory is refused while there is still nothing to lose, and any outcome that is not " +
|
|
8234
|
+
"a clean creation withholds the response body because an unparsed 2xx body IS the " +
|
|
8235
|
+
"secret. A 2xx that carries the source and the secret but no webhook path is RECOVERED " +
|
|
8236
|
+
"rather than discarded — the path is derived from the source id and the result says so " +
|
|
8237
|
+
"with `webhook_url_derived` — while an outcome that proves a source id but no usable " +
|
|
8238
|
+
"secret REVOKES that source, since it could never authenticate a delivery and would " +
|
|
8239
|
+
"hold the repository's unique slot.\n\n" +
|
|
8240
|
+
"THEN CONFIGURE GITHUB — the tool stops at the loopctl end and this is the other half. " +
|
|
8241
|
+
"On the repository: Settings > Webhooks > Add webhook, Payload URL = the `webhook_url` " +
|
|
8242
|
+
"returned, Content type = application/json, Secret = the contents of `secret_file`, and " +
|
|
8243
|
+
"under 'Let me select individual events' tick ISSUES ONLY. The secret is the HMAC key " +
|
|
8244
|
+
"GitHub signs the RAW body with as `X-Hub-Signature-256`; loopctl verifies it before " +
|
|
8245
|
+
"trusting a byte. In one command, without the secret ever passing through this " +
|
|
8246
|
+
"transcript: gh api repos/OWNER/REPO/hooks -f name=web -f config[url]=<webhook_url> -f " +
|
|
8247
|
+
"config[content_type]=json -f config[secret]=\"$(cat <secret_file>)\" -f 'events[]=issues'\n\n" +
|
|
8248
|
+
"EVERY delivery failure afterwards is the SAME 401 `invalid_signature` — an unknown or " +
|
|
8249
|
+
"revoked source, a suspended tenant, a missing or wrong signature, and a payload whose " +
|
|
8250
|
+
"`repository.full_name` does not match this source's repository. That is deliberate (a " +
|
|
8251
|
+
"valid signature for the wrong repository is a misrouted or replayed delivery), and it " +
|
|
8252
|
+
"means the webhook's own Recent Deliveries tab cannot tell you which of them happened.\n\n" +
|
|
8253
|
+
"REFUSALS. 403 `api_key_mint_forbidden` if your key was minted by a dispatch: this call " +
|
|
8254
|
+
"mints a credential belonging to no lineage, so only a key that is itself unlineaged — " +
|
|
8255
|
+
"the tenant's operator key in LOOPCTL_USER_KEY — may make it, and reaching for a more " +
|
|
8256
|
+
"privileged ephemeral key does not get past it. 403 `custody_tier_required` on an " +
|
|
8257
|
+
"agent-rooted tenant, which may not open an intake surface for itself (an intake source " +
|
|
8258
|
+
"admits outside text into the queue of a project whose stories an implementer will act " +
|
|
8259
|
+
"on). 422 when the repository is not `owner/name`, when an ACTIVE source already binds " +
|
|
8260
|
+
"that repository (revoke it first — the uniqueness is partial on not-yet-revoked), when " +
|
|
8261
|
+
"the project is missing, archived or not a work project, or when `target_epic_id` names " +
|
|
8262
|
+
"an epic outside that project. 429 when rate limited.\n\n" +
|
|
8263
|
+
"NAME `base_branch` AT ENROLMENT for a repository whose trunk is not `master`. It is the " +
|
|
8264
|
+
"branch every dispatch for this repository is cut FROM, it defaults to `master` when the " +
|
|
8265
|
+
"body does not name it, and GitHub has created repositories with `main` since 2020 — so " +
|
|
8266
|
+
"an unnamed branch on a `main` repository sends every dispatch to cut from a branch that " +
|
|
8267
|
+
"does not exist, and the failure arrives after the claim. There is no cleared state for " +
|
|
8268
|
+
"it (a dispatch must name one), so null or blank is refused rather than falling back to " +
|
|
8269
|
+
"the default; intake_source_update changes it afterwards.",
|
|
8270
|
+
inputSchema: {
|
|
8271
|
+
type: "object",
|
|
8272
|
+
properties: {
|
|
8273
|
+
repo_full_name: {
|
|
8274
|
+
type: "string",
|
|
8275
|
+
description:
|
|
8276
|
+
"The repository as `owner/name`, e.g. `mkreyman/home_care_billing`. Not a URL. " +
|
|
8277
|
+
"Matched case-insensitively against each delivery's `repository.full_name`.",
|
|
8278
|
+
},
|
|
8279
|
+
project_id: {
|
|
8280
|
+
type: "string",
|
|
8281
|
+
description:
|
|
8282
|
+
"The ACTIVE WORK project (UUID) whose queue this repository's issues feed. A `kb` " +
|
|
8283
|
+
"scope or an archived project is a 422.",
|
|
8284
|
+
},
|
|
8285
|
+
target_epic_id: {
|
|
8286
|
+
type: "string",
|
|
8287
|
+
description:
|
|
8288
|
+
"Optional. The epic a story triaged from this source's issues is created in; it " +
|
|
8289
|
+
"must belong to the project above. OMITTING IT IS NOT A NEUTRAL DEFAULT: with no " +
|
|
8290
|
+
"epic, a triaged report is ESCALATED to a human instead of becoming a story, and " +
|
|
8291
|
+
"every record from this source stays `pending_triage` and is retried until " +
|
|
8292
|
+
"intake_source_update names one — at which point they all promote on the next run " +
|
|
8293
|
+
"with nothing lost. Unanswered is the safe state rather than a guess.",
|
|
8294
|
+
},
|
|
8295
|
+
base_branch: {
|
|
8296
|
+
type: "string",
|
|
8297
|
+
description:
|
|
8298
|
+
"Optional. The branch every dispatch for this repository is cut FROM, and the " +
|
|
8299
|
+
"`base_branch` an unattended dispatch carries. Omit it for `master`; send `main` " +
|
|
8300
|
+
"for a repository created on GitHub since 2020, or the loop places work against a " +
|
|
8301
|
+
"branch that does not exist. NOT nullable, unlike target_epic_id — there is no " +
|
|
8302
|
+
"unanswered state for a branch a dispatch must name — so null or blank is refused " +
|
|
8303
|
+
"here rather than silently taking the default. Changed later with " +
|
|
8304
|
+
"intake_source_update.",
|
|
8305
|
+
},
|
|
8306
|
+
secret_file: {
|
|
8307
|
+
type: "string",
|
|
8308
|
+
description:
|
|
8309
|
+
"REQUIRED. Absolute path (or starting with `~/`) the webhook secret is written to, " +
|
|
8310
|
+
"mode 0600. It is never returned in the result. Refused if the path already " +
|
|
8311
|
+
"exists, and nothing is enrolled in that case.",
|
|
8312
|
+
},
|
|
8313
|
+
},
|
|
8314
|
+
required: ["repo_full_name", "project_id", "secret_file"],
|
|
8315
|
+
},
|
|
8316
|
+
},
|
|
8317
|
+
{
|
|
8318
|
+
name: "intake_source_list",
|
|
8319
|
+
description:
|
|
8320
|
+
"LIST THE TENANT'S GITHUB INTAKE SOURCES (GET /api/v1/intake/sources): per source its " +
|
|
8321
|
+
"`id` (the webhook URL is /api/v1/intake/github/<id>), `project_id`, `repo_full_name`, " +
|
|
8322
|
+
"`base_branch`, `target_epic_id`, `revoked_at` and timestamps. Active sources only " +
|
|
8323
|
+
"unless `include_revoked` is true.\n\n" +
|
|
8324
|
+
"THE WEBHOOK SECRET IS NOT HERE AND IS NOT ANYWHERE. It is returned once by " +
|
|
8325
|
+
"intake_source_enroll and the column is redacted on the schema, so this is not the way " +
|
|
8326
|
+
"to recover one — revoking and enrolling again is. Use this to find the source behind a " +
|
|
8327
|
+
"409 `no_intake_source` (the project is bound to none) or a 409 " +
|
|
8328
|
+
"`ambiguous_intake_source` (bound to two, so revoke the one that no longer applies), to " +
|
|
8329
|
+
"check a repository's `base_branch` before placing work, and to identify a source left " +
|
|
8330
|
+
"behind by an enrolment whose outcome was unknown. Requires LOOPCTL_USER_KEY; this read " +
|
|
8331
|
+
"is the one action of the four that does not need a human-anchored tenant.",
|
|
8332
|
+
inputSchema: {
|
|
8333
|
+
type: "object",
|
|
8334
|
+
properties: {
|
|
8335
|
+
include_revoked: {
|
|
8336
|
+
type: "boolean",
|
|
8337
|
+
description: "Include revoked sources. Default false.",
|
|
8338
|
+
},
|
|
8339
|
+
},
|
|
8340
|
+
},
|
|
8341
|
+
},
|
|
8342
|
+
{
|
|
8343
|
+
name: "intake_source_update",
|
|
8344
|
+
description:
|
|
8345
|
+
"SET WHERE A SOURCE'S WORK LANDS (PATCH /api/v1/intake/sources/:id): `target_epic_id`, " +
|
|
8346
|
+
"the epic triaged stories are created in, and `base_branch`, the branch every dispatch " +
|
|
8347
|
+
"for this repository is cut FROM and carries.\n\n" +
|
|
8348
|
+
"PRESENCE DECIDES, AND A FIELD YOU DO NOT NAME IS LEFT EXACTLY AS IT WAS. Naming " +
|
|
8349
|
+
"neither is refused 422 `nothing_to_update` rather than being a silent no-op. DO NOT " +
|
|
8350
|
+
"SEND `target_epic_id: null` TO MEAN 'I AM NOT CHANGING THIS' — an explicit null is the " +
|
|
8351
|
+
"only way to CLEAR the epic, and clearing it returns the source to escalating every " +
|
|
8352
|
+
"report to a human instead of filing a story. Leave the field out instead. " +
|
|
8353
|
+
"`base_branch` has no cleared state at all (every dispatch must name a branch to cut " +
|
|
8354
|
+
"from), so a null there is refused.\n\n" +
|
|
8355
|
+
"THIS IS THE FIX FOR A SOURCE ALREADY POINTED AT THE WRONG TRUNK. intake_source_enroll " +
|
|
8356
|
+
"now takes `base_branch` itself, so a `main` repository is enrolled correctly in one " +
|
|
8357
|
+
"call; this is what corrects one that was not — a source enrolled before the parameter " +
|
|
8358
|
+
"existed, or enrolled without it, places work against a trunk that does not exist until " +
|
|
8359
|
+
"it is repointed. It " +
|
|
8360
|
+
"is also the remedy for a source enrolled before it had an epic: until one is named " +
|
|
8361
|
+
"every record from it stays `pending_triage` and is retried, and the moment one is they " +
|
|
8362
|
+
"promote on the next run with nothing lost.\n\n" +
|
|
8363
|
+
"REVOKED SOURCES ARE 404, not a no-op: revoking CLEARS the target epic precisely so that " +
|
|
8364
|
+
"epic can be deleted, and repointing a source that will never report again would put " +
|
|
8365
|
+
"that block back. 422 when the epic is not in this source's project. Requires " +
|
|
8366
|
+
"LOOPCTL_USER_KEY and a human-anchored tenant (403 `custody_tier_required`). A " +
|
|
8367
|
+
"`source_id` that is not a UUID is refused here, before any call — the server answers " +
|
|
8368
|
+
"the same 404 for a malformed id as for an unknown one.",
|
|
8369
|
+
inputSchema: {
|
|
8370
|
+
type: "object",
|
|
8371
|
+
properties: {
|
|
8372
|
+
source_id: {
|
|
8373
|
+
type: "string",
|
|
8374
|
+
description: "The intake source UUID (from intake_source_list).",
|
|
8375
|
+
},
|
|
8376
|
+
target_epic_id: {
|
|
8377
|
+
type: ["string", "null"],
|
|
8378
|
+
description:
|
|
8379
|
+
"The epic triaged stories land in; it must belong to this source's project. An " +
|
|
8380
|
+
"explicit null CLEARS it, which returns the source to escalating every report. " +
|
|
8381
|
+
"Omit the field entirely to leave the current value alone.",
|
|
8382
|
+
},
|
|
8383
|
+
base_branch: {
|
|
8384
|
+
type: "string",
|
|
8385
|
+
description:
|
|
8386
|
+
"The branch dispatches for this repository are cut from, e.g. `main`. 1-255 " +
|
|
8387
|
+
"characters, not nullable. Omit to leave the current value alone.",
|
|
8388
|
+
},
|
|
8389
|
+
},
|
|
8390
|
+
required: ["source_id"],
|
|
8391
|
+
},
|
|
8392
|
+
},
|
|
8393
|
+
{
|
|
8394
|
+
name: "intake_source_revoke",
|
|
8395
|
+
description:
|
|
8396
|
+
"REVOKE A GITHUB INTAKE SOURCE (DELETE /api/v1/intake/sources/:id). The HTTP verb is " +
|
|
8397
|
+
"DELETE and the action is a REVOKE: `revoked_at` is stamped, the row is kept, and " +
|
|
8398
|
+
"intake_source_list still returns it with `include_revoked`. Nothing already received " +
|
|
8399
|
+
"is discarded.\n\n" +
|
|
8400
|
+
"Afterwards every delivery to that webhook URL is refused 401 `invalid_signature`, " +
|
|
8401
|
+
"exactly as a wrong secret is — so the GitHub webhook does not know it has been cut off " +
|
|
8402
|
+
"and should be deleted on the repository too. IDEMPOTENT: revoking twice answers 200 " +
|
|
8403
|
+
"with the original `revoked_at`.\n\n" +
|
|
8404
|
+
"TWO THINGS IT UNBLOCKS. It frees the repository's uniqueness slot, so a source with a " +
|
|
8405
|
+
"lost secret, a wrong project or a typo'd repository is corrected by revoking and " +
|
|
8406
|
+
"enrolling again — the active-repository index is partial on not-yet-revoked, so the " +
|
|
8407
|
+
"422 `an active intake source already binds this repository` clears the moment this " +
|
|
8408
|
+
"returns. And it CLEARS `target_epic_id`, which is what makes that epic deletable: the " +
|
|
8409
|
+
"reference blocks an epic delete, and this is the remedy that refusal names.\n\n" +
|
|
8410
|
+
"Requires LOOPCTL_USER_KEY and a human-anchored tenant (403 `custody_tier_required`). " +
|
|
8411
|
+
"404 for an unknown source or another tenant's; a `source_id` that is not a UUID is " +
|
|
8412
|
+
"refused here, before any call, because the server answers that same 404 for both.",
|
|
8413
|
+
inputSchema: {
|
|
8414
|
+
type: "object",
|
|
8415
|
+
properties: {
|
|
8416
|
+
source_id: {
|
|
8417
|
+
type: "string",
|
|
8418
|
+
description: "The intake source UUID to revoke (from intake_source_list).",
|
|
8419
|
+
},
|
|
8420
|
+
},
|
|
8421
|
+
required: ["source_id"],
|
|
8422
|
+
},
|
|
8423
|
+
},
|
|
8424
|
+
|
|
8120
8425
|
// LCP-1 §9 signed-profile tools
|
|
8121
8426
|
{
|
|
8122
8427
|
name: "register_custody_owner_key",
|
|
@@ -9159,6 +9464,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
9159
9464
|
case "force_unclaim_story":
|
|
9160
9465
|
return await forceUnclaimStory(args);
|
|
9161
9466
|
|
|
9467
|
+
case "intake_source_enroll":
|
|
9468
|
+
return await intakeSourceEnroll(args);
|
|
9469
|
+
|
|
9470
|
+
case "intake_source_list":
|
|
9471
|
+
return await intakeSourceList(args);
|
|
9472
|
+
|
|
9473
|
+
case "intake_source_update":
|
|
9474
|
+
return await intakeSourceUpdate(args);
|
|
9475
|
+
|
|
9476
|
+
case "intake_source_revoke":
|
|
9477
|
+
return await intakeSourceRevoke(args);
|
|
9478
|
+
|
|
9162
9479
|
case "register_custody_owner_key":
|
|
9163
9480
|
return await registerCustodyOwnerKey(args);
|
|
9164
9481
|
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub intake sources — `/api/v1/intake/sources` (loopctl #803, #846).
|
|
3
|
+
*
|
|
4
|
+
* ## The defect these exist for
|
|
5
|
+
*
|
|
6
|
+
* loopctl's agent delivery loop is built end to end — webhook intake, triage, the stage
|
|
7
|
+
* machine, placement, the unattended driver — and until these tools it had never received a
|
|
8
|
+
* GitHub issue, because nothing outside the app could create the one row that makes a
|
|
9
|
+
* webhook possible. All five intake-source routes were declared `gap` in
|
|
10
|
+
* `test/route_coverage.test.js`: no tool reached any of them, and `claude-config`'s
|
|
11
|
+
* `hooks/orchestrator-guardrail.sh` refuses a `curl` carrying `loopctl.com` from every
|
|
12
|
+
* session on the fleet, deliberately. So the loop's own wiring step was reachable by a human
|
|
13
|
+
* with `iex` on the production node and by nothing else — loopctl's "an operator-facing
|
|
14
|
+
* endpoint is not done until an MCP tool calls it" failing at the step the whole loop starts
|
|
15
|
+
* from.
|
|
16
|
+
*
|
|
17
|
+
* An intake source does two things, and the second is why `place_dispatch` cares:
|
|
18
|
+
*
|
|
19
|
+
* 1. It mints the webhook secret and names the URL GitHub must POST to, so issues from one
|
|
20
|
+
* repository reach one project's queue.
|
|
21
|
+
* 2. It BINDS that project to a `repo_full_name` and a `base_branch`, which is where
|
|
22
|
+
* `Loopctl.Delivery.DispatchPayload` reads the repository and trunk from. A project
|
|
23
|
+
* bound to none is the `409 no_intake_source` refusal; bound to two, the `409
|
|
24
|
+
* ambiguous_intake_source` one.
|
|
25
|
+
*
|
|
26
|
+
* ## THE SECRET IS NEVER RETURNED BY THESE TOOLS
|
|
27
|
+
*
|
|
28
|
+
* `POST /api/v1/intake/sources` returns `webhook_secret` ONCE. It cannot be re-read: the
|
|
29
|
+
* column is `Loopctl.Vault.Binary` with `redact: true` and is absent from the schema's
|
|
30
|
+
* `@derive {Jason.Encoder, only: [...]}` list (`lib/loopctl/intake/source.ex`), so neither
|
|
31
|
+
* the list nor the update response carries it. Lose it and the only remedy is to revoke the
|
|
32
|
+
* source, enrol again, and reconfigure the GitHub webhook.
|
|
33
|
+
*
|
|
34
|
+
* A tool result is JSON-stringified into the session transcript AND the audit log, so this
|
|
35
|
+
* follows what `runner_enroll` already does with the runner credential (`lib/runners.js`):
|
|
36
|
+
* `secret_file` is REQUIRED, the secret is written there with mode 0600, and the tool returns
|
|
37
|
+
* the source row, the webhook URL and the path. The secret reaches GitHub from the file —
|
|
38
|
+
* `config[secret]=$(cat <file>)` — so it never has to pass through a transcript at all.
|
|
39
|
+
*
|
|
40
|
+
* The file is RESERVED (`O_CREAT|O_EXCL`, 0600) before the API is called, so an existing path
|
|
41
|
+
* or an unwritable directory is refused while there is still nothing to lose. Every outcome
|
|
42
|
+
* that is not a clean 2xx of the expected shape withholds the response body, because a 2xx
|
|
43
|
+
* whose JSON failed to parse reaches this code as `{error: true, status: 201, body: "<first
|
|
44
|
+
* 200 characters of the raw text>"}` — which is the secret.
|
|
45
|
+
*
|
|
46
|
+
* ## WHERE THIS DIFFERS FROM `runner_enroll`
|
|
47
|
+
*
|
|
48
|
+
* The RECOVERY is the same: an outcome that is not a clean creation revokes the source when
|
|
49
|
+
* the response proved its id, since the secret cannot be re-read and an unusable source still
|
|
50
|
+
* holds the repository's unique slot.
|
|
51
|
+
*
|
|
52
|
+
* What is not the same is the fallback when no id was proved. `enrollRunner` scrapes the
|
|
53
|
+
* runner id out of an unparseable 2xx body, because a runner found by NAME alone might be an
|
|
54
|
+
* earlier, legitimate enrollment. An intake source needs no such scraping:
|
|
55
|
+
* `intake_sources_active_repo_uidx` is unique on `(tenant_id, lower(repo_full_name))` WHERE
|
|
56
|
+
* `revoked_at IS NULL` — CASE-INSENSITIVELY, because GitHub names are — so at most one ACTIVE
|
|
57
|
+
* source ever binds a repository however it was spelled, and `intake_source_list` identifies
|
|
58
|
+
* it exactly. That branch therefore names the pair of tools rather than guessing at an id.
|
|
59
|
+
*
|
|
60
|
+
* MATCH THAT LISTING CASE-INSENSITIVELY. The uniqueness folds case; the STORED value does not
|
|
61
|
+
* — the row keeps the spelling it was enrolled with — so a source enrolled as
|
|
62
|
+
* `MKREYMAN/loopctl` refuses a re-enrolment of `mkreyman/loopctl` with a 422 while a reader
|
|
63
|
+
* comparing the two exactly concludes there is nothing to revoke.
|
|
64
|
+
*
|
|
65
|
+
* A source whose secret nobody holds is also INERT rather than dangerous: no delivery can
|
|
66
|
+
* ever be signed for it, so every POST to its URL is refused `401 invalid_signature`. What it
|
|
67
|
+
* does cost is that unique slot — a second enrolment of the same repository is refused 422
|
|
68
|
+
* until it is revoked — which is why a write failure after a successful create revokes it
|
|
69
|
+
* here rather than leaving it.
|
|
70
|
+
*
|
|
71
|
+
* ## KEY
|
|
72
|
+
*
|
|
73
|
+
* `LOOPCTL_USER_KEY`, for all four, pinned exactly (`exactKey`) the way the `runner_*` family
|
|
74
|
+
* is. The controller mounts `RequireRole, role: :user` — the HIERARCHY form, so a
|
|
75
|
+
* `:superadmin` key would pass too and this package holds no such variable — plus
|
|
76
|
+
* `RequireHumanAnchor` on create/update/delete and `RequireUnlineagedCaller` on create
|
|
77
|
+
* (`lib/loopctl_web/controllers/intake_source_controller.ex:27-29`). That last one is why a
|
|
78
|
+
* global `LOOPCTL_API_KEY` must not stand in: creating a source MINTS A CREDENTIAL that
|
|
79
|
+
* belongs to no dispatch lineage, so a caller whose own key a dispatch minted is refused
|
|
80
|
+
* `403 api_key_mint_forbidden`, exactly as it is on `POST /api/v1/api_keys`.
|
|
81
|
+
*
|
|
82
|
+
* ## `repo_full_name` IS NOT SHAPE-CHECKED HERE, AND THAT IS A DECISION
|
|
83
|
+
*
|
|
84
|
+
* The server's refusal is a 422 naming the field with `must be owner/name`, which is
|
|
85
|
+
* unambiguous and needs no client to disambiguate it — unlike the UUID checks below, which
|
|
86
|
+
* exist because a malformed path id and an unknown one produce the SAME 404
|
|
87
|
+
* (the long comment over `uuid()` in `lib/delivery-loop.js` has the trace). Mirroring
|
|
88
|
+
* `Source.repo_format/0` here would put a second copy of that regex in a second language,
|
|
89
|
+
* free to drift, to catch a refusal the server already words better.
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
import nodePath from "node:path";
|
|
93
|
+
import os from "node:os";
|
|
94
|
+
import defaultFs from "node:fs/promises";
|
|
95
|
+
|
|
96
|
+
import { uuid as uuidRefusal } from "./delivery-loop.js";
|
|
97
|
+
import { expandHome, TOKEN_DIR_MODE, TOKEN_FILE_FLAGS, TOKEN_FILE_MODE } from "./runners.js";
|
|
98
|
+
|
|
99
|
+
export const SOURCES_PATH = "/api/v1/intake/sources";
|
|
100
|
+
|
|
101
|
+
const MISSING_USER_KEY =
|
|
102
|
+
"No user-role API key configured. Set LOOPCTL_USER_KEY to a user-role key to use the " +
|
|
103
|
+
"intake-source tools (intake_source_enroll, intake_source_list, intake_source_update, " +
|
|
104
|
+
"intake_source_revoke). The create path additionally requires a key NO DISPATCH MINTED — " +
|
|
105
|
+
"it mints the webhook secret, which belongs to no lineage — so an ephemeral dispatch key " +
|
|
106
|
+
"is refused 403 api_key_mint_forbidden however privileged it is.";
|
|
107
|
+
|
|
108
|
+
function refuse(body) {
|
|
109
|
+
return { error: true, status: 0, body };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `target_epic_id` when the caller sent SOMETHING for it — the shared `uuid()` check, except
|
|
114
|
+
* for a blank value.
|
|
115
|
+
*
|
|
116
|
+
* `uuid()` answers "`target_epic_id` is required" for an empty or blank string, which is the
|
|
117
|
+
* OPPOSITE of the truth for this field: it is optional everywhere it appears. A caller that
|
|
118
|
+
* has read "an explicit null CLEARS it" and cannot emit a JSON null — a model filling a
|
|
119
|
+
* `type: "string"` schema — sends `""` to mean the clear, is told the optional field is
|
|
120
|
+
* required, and its likeliest next move is to invent an epic id, which is a WRONG epic rather
|
|
121
|
+
* than a refused call.
|
|
122
|
+
*
|
|
123
|
+
* A blank is refused rather than TAKEN AS the clear, and the asymmetry is deliberate: on
|
|
124
|
+
* update, clearing is a real mutation that returns the source to escalating every report to a
|
|
125
|
+
* human, and `""` is equally consistent with a caller whose variable was empty by accident.
|
|
126
|
+
* On enrol, accepting it would silently produce the source with no epic that this tool's own
|
|
127
|
+
* description warns is not a neutral default. Both cases refuse and name the remedy.
|
|
128
|
+
*/
|
|
129
|
+
function epicRefusal(value, { clearable }) {
|
|
130
|
+
if (typeof value === "string" && value.trim() === "") {
|
|
131
|
+
return refuse(
|
|
132
|
+
"`target_epic_id` is OPTIONAL, and an empty string is not how to say so. " +
|
|
133
|
+
(clearable
|
|
134
|
+
? "Send null to CLEAR the epic (which returns the source to escalating every report " +
|
|
135
|
+
"to a human), or leave the field out entirely to leave it exactly as it is."
|
|
136
|
+
: "Leave the field out entirely, which enrols the source with no epic — every " +
|
|
137
|
+
"report from it is then escalated to a human until intake_source_update names " +
|
|
138
|
+
"one.") +
|
|
139
|
+
" Otherwise send the epic's UUID.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return uuidRefusal(value, "target_epic_id");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function sourcePath(sourceId) {
|
|
147
|
+
return `${SOURCES_PATH}/${encodeURIComponent(sourceId)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The source fields this client will put in a tool result, named one by one.
|
|
152
|
+
*
|
|
153
|
+
* AN ALLOWLIST IN THIS PROCESS, not a restatement of the server's. Every tool result is
|
|
154
|
+
* JSON-stringified into the session transcript and into `~/.claude/audit/`, so echoing the
|
|
155
|
+
* server's object verbatim would rest this module's headline property — the webhook secret
|
|
156
|
+
* never enters a transcript — entirely on `@derive {Jason.Encoder, only: [...]}` in
|
|
157
|
+
* `lib/loopctl/intake/source.ex` continuing to exclude it. One commit adding `:webhook_secret`
|
|
158
|
+
* back to that list, or adding a field derived from it, would carry it into every transcript
|
|
159
|
+
* from then on with nothing here to stop it. `enrollRunner` reshapes its response for exactly
|
|
160
|
+
* this reason (`lib/runners.js`), and this is the same defence.
|
|
161
|
+
*
|
|
162
|
+
* The cost is that a genuinely new server field is invisible until this list names it. That is
|
|
163
|
+
* the intended direction of the failure: a missing field is a visible gap, a leaked secret is
|
|
164
|
+
* not.
|
|
165
|
+
*/
|
|
166
|
+
export function publicSource(source) {
|
|
167
|
+
if (!source || typeof source !== "object") return source;
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id: source.id,
|
|
171
|
+
project_id: source.project_id,
|
|
172
|
+
repo_full_name: source.repo_full_name,
|
|
173
|
+
base_branch: source.base_branch,
|
|
174
|
+
target_epic_id: source.target_epic_id,
|
|
175
|
+
revoked_at: source.revoked_at,
|
|
176
|
+
inserted_at: source.inserted_at,
|
|
177
|
+
updated_at: source.updated_at,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The absolute URL to configure on the GitHub webhook, from the `webhook_path` the server
|
|
183
|
+
* returned. The path is joined to the server this process is talking to, because a webhook
|
|
184
|
+
* pointed at a different host than the one holding the source is a delivery that can never
|
|
185
|
+
* authenticate.
|
|
186
|
+
*/
|
|
187
|
+
export function webhookUrl(baseUrl, webhookPath) {
|
|
188
|
+
const base = typeof baseUrl === "string" ? baseUrl.replace(/\/+$/, "") : "";
|
|
189
|
+
return `${base}${webhookPath}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `POST /api/v1/intake/sources` — bind a GitHub repository to a work project and write the
|
|
194
|
+
* webhook secret to `secret_file`, never returning it.
|
|
195
|
+
*
|
|
196
|
+
* Resolves to `{ source, webhook_url, secret_file }` or an `{ error: true, status, body }`
|
|
197
|
+
* shape. It never throws.
|
|
198
|
+
*/
|
|
199
|
+
export async function enrollIntakeSource(
|
|
200
|
+
{ repo_full_name, project_id, target_epic_id, base_branch, secret_file } = {},
|
|
201
|
+
{ userKey, apiCall, baseUrl, fs = defaultFs, homedir = os.homedir() } = {},
|
|
202
|
+
) {
|
|
203
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
204
|
+
|
|
205
|
+
if (typeof repo_full_name !== "string" || repo_full_name.trim() === "") {
|
|
206
|
+
return refuse(
|
|
207
|
+
"`repo_full_name` is required: the repository as `owner/name`, e.g. " +
|
|
208
|
+
"`mkreyman/home_care_billing`. Not a URL.",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const badProject = uuidRefusal(project_id, "project_id");
|
|
213
|
+
if (badProject) return badProject;
|
|
214
|
+
|
|
215
|
+
// OPTIONAL. Absent means the question has not been answered, and a report from a source
|
|
216
|
+
// that names no epic is ESCALATED to a human rather than landing in an epic chosen for it
|
|
217
|
+
// (`lib/loopctl/intake/source.ex`). An explicit null means the same thing on create.
|
|
218
|
+
if (target_epic_id !== undefined && target_epic_id !== null) {
|
|
219
|
+
const badEpic = epicRefusal(target_epic_id, { clearable: false });
|
|
220
|
+
if (badEpic) return badEpic;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// OPTIONAL, and read by PRESENCE at the server: omitted, the column takes its `master`
|
|
224
|
+
// default; named, the value is validated. There is no cleared state for it — every dispatch
|
|
225
|
+
// must name a branch to cut from — so a null or an empty string is a 422 there and is
|
|
226
|
+
// refused here, where the reason can be stated. This is the same shape `updateIntakeSource`
|
|
227
|
+
// refuses, and the two must not disagree about what a valid branch is.
|
|
228
|
+
if (base_branch !== undefined) {
|
|
229
|
+
if (typeof base_branch !== "string" || base_branch.trim() === "") {
|
|
230
|
+
return refuse(
|
|
231
|
+
"`base_branch` must be a non-empty branch name, or must be left out entirely. It " +
|
|
232
|
+
"cannot be null or blank: every dispatch for this repository is cut from it, so " +
|
|
233
|
+
"there is no cleared state for it and the server answers 422. Leave it out for " +
|
|
234
|
+
"`master`, or send `main` for a repository created on GitHub since 2020.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (typeof secret_file !== "string" || secret_file.trim() === "") {
|
|
240
|
+
return refuse(
|
|
241
|
+
"`secret_file` is required: the path the webhook secret is written to (mode 0600). " +
|
|
242
|
+
"The server returns that secret ONCE and it can never be read again, and a tool " +
|
|
243
|
+
"result lands in the session transcript and the audit log — so it is written to a " +
|
|
244
|
+
"file instead, and GitHub is configured from that file.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const secretPath = expandHome(secret_file, homedir);
|
|
249
|
+
if (!nodePath.isAbsolute(secretPath)) {
|
|
250
|
+
return refuse(`secret_file must be absolute or start with ~/ (got '${secret_file}').`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let handle;
|
|
254
|
+
try {
|
|
255
|
+
await fs.mkdir(nodePath.dirname(secretPath), { recursive: true, mode: TOKEN_DIR_MODE });
|
|
256
|
+
handle = await fs.open(secretPath, TOKEN_FILE_FLAGS, TOKEN_FILE_MODE);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
if (err?.code === "EEXIST") {
|
|
259
|
+
return refuse(
|
|
260
|
+
`secret_file '${secretPath}' already exists; refusing to overwrite it. Nothing was ` +
|
|
261
|
+
"enrolled. Choose a new path, or remove the file yourself if its source is revoked.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return refuse(
|
|
265
|
+
`Could not create secret_file '${secretPath}' (${err?.code || "error"}). Nothing was ` +
|
|
266
|
+
"enrolled.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The identity of the file this call created, taken BEFORE anything is written, so the file
|
|
271
|
+
// can still be told apart from a replacement if the handle later becomes unusable.
|
|
272
|
+
let opened;
|
|
273
|
+
try {
|
|
274
|
+
opened = await handle.stat();
|
|
275
|
+
} catch (err) {
|
|
276
|
+
await closeQuietly(handle);
|
|
277
|
+
return refuse(
|
|
278
|
+
`Could not stat the new secret_file '${secretPath}' (${err?.code || "error"}). Nothing ` +
|
|
279
|
+
"was enrolled; an empty file may remain at that path.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const body = { repo_full_name, project_id };
|
|
284
|
+
if (target_epic_id !== undefined && target_epic_id !== null) {
|
|
285
|
+
body.target_epic_id = target_epic_id;
|
|
286
|
+
}
|
|
287
|
+
if (base_branch !== undefined) body.base_branch = base_branch;
|
|
288
|
+
|
|
289
|
+
let result;
|
|
290
|
+
try {
|
|
291
|
+
result = await apiCall("POST", SOURCES_PATH, body);
|
|
292
|
+
} catch {
|
|
293
|
+
result = { error: true, status: 0, body: "Intake-source request failed." };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// A 4xx is a refusal: nothing was committed, and its body is the server's error, which
|
|
297
|
+
// carries the code the caller needs. Pass it through as it came.
|
|
298
|
+
if (result && result.error === true && result.status >= 400 && result.status < 500) {
|
|
299
|
+
const removed = await discardReservation(fs, handle, secretPath, opened);
|
|
300
|
+
return removed ? result : { ...result, secret_file_not_removed: secretPath };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const source = result && result.error !== true ? result.source : undefined;
|
|
304
|
+
const secret = result && result.error !== true ? result.webhook_secret : undefined;
|
|
305
|
+
const webhookPath = result && result.error !== true ? result.webhook_path : undefined;
|
|
306
|
+
|
|
307
|
+
const sourceId =
|
|
308
|
+
source && typeof source.id === "string" && source.id !== "" ? source.id : undefined;
|
|
309
|
+
const sentPath = typeof webhookPath === "string" && webhookPath !== "" ? webhookPath : undefined;
|
|
310
|
+
const usableSecret = typeof secret === "string" && secret !== "";
|
|
311
|
+
|
|
312
|
+
// A 2xx carrying a usable source AND its secret but no `webhook_path` is RECOVERABLE, and
|
|
313
|
+
// discarding it throws away the one thing that cannot be recovered: the secret is returned
|
|
314
|
+
// once. The path is a pure function of the id — `/api/v1/intake/github/<id>`, the route this
|
|
315
|
+
// module's header names — so it is derived rather than lost. A FALLBACK only: what the
|
|
316
|
+
// server sent always wins, and the result says when the URL was derived, because a derived
|
|
317
|
+
// one is a claim this process is making and not something the server answered.
|
|
318
|
+
const derivedPath = sentPath === undefined && sourceId !== undefined;
|
|
319
|
+
const path = derivedPath ? `/api/v1/intake/github/${encodeURIComponent(sourceId)}` : sentPath;
|
|
320
|
+
|
|
321
|
+
if (!sourceId || !usableSecret || path === undefined) {
|
|
322
|
+
const removed = await discardReservation(fs, handle, secretPath, opened);
|
|
323
|
+
return ambiguousEnrollment(result, apiCall, sourceId, repo_full_name, secretPath, removed);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
await handle.writeFile(secret);
|
|
328
|
+
await handle.sync();
|
|
329
|
+
await handle.close();
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const removed = await discardReservation(fs, handle, secretPath, opened);
|
|
332
|
+
const revoked = await revoke(apiCall, source.id);
|
|
333
|
+
return refuse(
|
|
334
|
+
`The intake source for '${repo_full_name}' (${source.id}) was created, but its webhook ` +
|
|
335
|
+
`secret could not be written to '${secretPath}' (${err?.code || "error"}). The secret ` +
|
|
336
|
+
"is returned once and cannot be re-read, so the source is unusable and " +
|
|
337
|
+
(revoked.ok
|
|
338
|
+
? "was revoked. "
|
|
339
|
+
: `revoking it FAILED (${revoked.detail}); revoke it with intake_source_revoke ` +
|
|
340
|
+
`source_id ${source.id} — until you do, a second source for this repository is ` +
|
|
341
|
+
"refused 422. ") +
|
|
342
|
+
reservationOutcome(secretPath, removed),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
source: publicSource(source),
|
|
348
|
+
webhook_url: webhookUrl(baseUrl, path),
|
|
349
|
+
...(derivedPath ? { webhook_url_derived: true } : {}),
|
|
350
|
+
secret_file: secretPath,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Every outcome that is neither a 4xx refusal nor a well-formed creation: a timeout, a
|
|
356
|
+
* network error, a 5xx from the edge after the commit, or a 2xx whose body did not parse or
|
|
357
|
+
* lacks the secret. NO RESPONSE BODY IS EVER ECHOED — a 2xx body that failed to parse is the
|
|
358
|
+
* creation itself, secret included, and `apiCall` puts its first 200 characters in `body`.
|
|
359
|
+
*
|
|
360
|
+
* WHEN THE RESPONSE PROVES AN ID, THE SOURCE IS REVOKED HERE, exactly as `enrollRunner` does:
|
|
361
|
+
* reaching this function means the secret cannot be relied on, so the source can never
|
|
362
|
+
* authenticate a delivery while still holding the repository's unique slot. Leaving that to
|
|
363
|
+
* the operator asked them to do by hand what this process could already prove and do itself.
|
|
364
|
+
*
|
|
365
|
+
* With NO id — an unparseable body, a timeout — the recovery names the tools instead, because
|
|
366
|
+
* it can: an ACTIVE source is unique per repository (`intake_sources_active_repo_uidx`), so
|
|
367
|
+
* the list identifies it exactly.
|
|
368
|
+
*/
|
|
369
|
+
async function ambiguousEnrollment(result, apiCall, sourceId, repoFullName, secretPath, removed) {
|
|
370
|
+
const status = result && Number.isInteger(result.status) ? result.status : undefined;
|
|
371
|
+
|
|
372
|
+
const what =
|
|
373
|
+
status === 0 && typeof result.body === "string"
|
|
374
|
+
? `Intake-source outcome unknown (${result.body}).`
|
|
375
|
+
: `Intake-source outcome unknown (HTTP ${status ?? "?"}; response body withheld: it may ` +
|
|
376
|
+
"contain the webhook secret).";
|
|
377
|
+
|
|
378
|
+
let next;
|
|
379
|
+
if (sourceId) {
|
|
380
|
+
const revoked = await revoke(apiCall, sourceId);
|
|
381
|
+
next = revoked.ok
|
|
382
|
+
? `The response identified source ${sourceId}; it was revoked, since its secret cannot ` +
|
|
383
|
+
"be recovered and an unusable source holds this repository's unique slot. Enrol again."
|
|
384
|
+
: `The response identified source ${sourceId}, but revoking it FAILED ` +
|
|
385
|
+
`(${revoked.detail}); revoke it with intake_source_revoke source_id ${sourceId} — ` +
|
|
386
|
+
"until you do, a second source for this repository is refused 422.";
|
|
387
|
+
} else {
|
|
388
|
+
next =
|
|
389
|
+
`A source for '${repoFullName}' may exist with a secret nobody holds — it can never ` +
|
|
390
|
+
"authenticate a delivery, and it holds that repository's unique slot, so a second " +
|
|
391
|
+
"enrolment is refused 422 until it is gone. Run intake_source_list, and revoke any " +
|
|
392
|
+
`active source whose repo_full_name equals '${repoFullName}' IGNORING CASE with ` +
|
|
393
|
+
"intake_source_revoke before enrolling again — the uniqueness is case-insensitive " +
|
|
394
|
+
"(`lower(repo_full_name)`) while the stored value keeps whatever spelling it was " +
|
|
395
|
+
"enrolled with, so an exact match can miss the row that is blocking you.";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
error: true,
|
|
400
|
+
status: status ?? 0,
|
|
401
|
+
body: `${what} ${next} ${reservationOutcome(secretPath, removed)}`,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** `GET /api/v1/intake/sources`, optionally including revoked ones. */
|
|
406
|
+
export async function listIntakeSources({ include_revoked } = {}, { userKey, apiCall } = {}) {
|
|
407
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
408
|
+
const query = include_revoked ? "?include_revoked=true" : "";
|
|
409
|
+
const result = await apiCall("GET", `${SOURCES_PATH}${query}`, null);
|
|
410
|
+
|
|
411
|
+
// RESHAPED, for the reason in `publicSource`: a listing is the widest surface here — every
|
|
412
|
+
// source the tenant has, in one tool result — so a server-side widening of what a source
|
|
413
|
+
// serialises to would land in the transcript N times rather than once. An error passes
|
|
414
|
+
// through untouched; only the rows are named.
|
|
415
|
+
if (!result || result.error === true || !Array.isArray(result.sources)) return result;
|
|
416
|
+
|
|
417
|
+
return { ...result, sources: result.sources.map(publicSource) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* `PATCH /api/v1/intake/sources/:id` — set the epic triaged stories land in, and/or the
|
|
422
|
+
* branch dispatches for this repository are cut from.
|
|
423
|
+
*
|
|
424
|
+
* PRESENCE DECIDES, on both fields, and the controller reads it with `Map.fetch/2` — see
|
|
425
|
+
* `LoopctlWeb.IntakeSourceController.update/2` and its `put_if_present/4`: a field you do not
|
|
426
|
+
* send is left exactly as it was. So `undefined` here means "not named" and is omitted from
|
|
427
|
+
* the body. (Named by FUNCTION, not by line range: the range this cited rotted inside one PR,
|
|
428
|
+
* when that same PR added `base_branch` to `create/2` above it.)
|
|
429
|
+
*
|
|
430
|
+
* `target_epic_id: null` IS FORWARDED, because null is the endpoint's only way to clear the
|
|
431
|
+
* target and clearing it is a real operation — a source that names no epic escalates every
|
|
432
|
+
* report to a human instead of filing a story. That is the opposite of `update_story`, which
|
|
433
|
+
* refuses a null because the server would drop it and answer 200 having changed nothing
|
|
434
|
+
* (`lib/story-update.js`). The hazard is on the caller and the description says so in the
|
|
435
|
+
* imperative: do not send `target_epic_id: null` to mean "I am not changing this".
|
|
436
|
+
*
|
|
437
|
+
* `base_branch: null` is refused here. It is NOT nullable — every `implement` dispatch must
|
|
438
|
+
* name a branch to cut from, so there is no unanswered state for it — and the server's cast
|
|
439
|
+
* uses `empty_values: []` precisely so that a caller who SENT a value gets an answer about
|
|
440
|
+
* the value it sent, which is a 422.
|
|
441
|
+
*/
|
|
442
|
+
export async function updateIntakeSource(args = {}, { userKey, apiCall } = {}) {
|
|
443
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
444
|
+
|
|
445
|
+
const bad = uuidRefusal(args.source_id, "source_id");
|
|
446
|
+
if (bad) return bad;
|
|
447
|
+
|
|
448
|
+
const body = {};
|
|
449
|
+
|
|
450
|
+
if (args.target_epic_id !== undefined) {
|
|
451
|
+
if (args.target_epic_id !== null) {
|
|
452
|
+
const badEpic = epicRefusal(args.target_epic_id, { clearable: true });
|
|
453
|
+
if (badEpic) return badEpic;
|
|
454
|
+
}
|
|
455
|
+
body.target_epic_id = args.target_epic_id;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (args.base_branch !== undefined) {
|
|
459
|
+
if (args.base_branch === null) {
|
|
460
|
+
return refuse(
|
|
461
|
+
"`base_branch` cannot be null: every dispatch must name a branch to cut from, so " +
|
|
462
|
+
"there is no cleared state for it and the server answers 422. Send the branch you " +
|
|
463
|
+
"want (`master`, `main`, …), or leave the field out to keep the current one.",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
body.base_branch = args.base_branch;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (Object.keys(body).length === 0) {
|
|
470
|
+
return refuse(
|
|
471
|
+
"Nothing to update. Name at least one of `target_epic_id` (null clears it) or " +
|
|
472
|
+
"`base_branch`. A body carrying neither is refused 422 `nothing_to_update`, because a " +
|
|
473
|
+
"field you do not send is left exactly as it was.",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const result = await apiCall("PATCH", sourcePath(args.source_id), body);
|
|
478
|
+
|
|
479
|
+
if (!result || result.error === true || !result.source) return result;
|
|
480
|
+
|
|
481
|
+
return { ...result, source: publicSource(result.source) };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* `DELETE /api/v1/intake/sources/:id` — REVOKE the source. The row is kept.
|
|
486
|
+
*
|
|
487
|
+
* Named for what it does rather than for its HTTP verb: `Loopctl.Intake.revoke_source/3` sets
|
|
488
|
+
* `revoked_at`, clears `target_epic_id` and appends an `intake_source_revoked` audit entry.
|
|
489
|
+
* Nothing is destroyed, deliveries already received are kept, and the row still comes back
|
|
490
|
+
* from `intake_source_list` with `include_revoked`.
|
|
491
|
+
*/
|
|
492
|
+
export async function revokeIntakeSource({ source_id } = {}, { userKey, apiCall } = {}) {
|
|
493
|
+
if (!userKey) return refuse(MISSING_USER_KEY);
|
|
494
|
+
|
|
495
|
+
const bad = uuidRefusal(source_id, "source_id");
|
|
496
|
+
if (bad) return bad;
|
|
497
|
+
|
|
498
|
+
const result = await apiCall("DELETE", sourcePath(source_id), null);
|
|
499
|
+
|
|
500
|
+
// The fourth path that carries a source object back, reshaped for the same reason as the
|
|
501
|
+
// other three. The reviewer named enrol, list and update; revoke returns the revoked row.
|
|
502
|
+
if (!result || result.error === true || !result.source) return result;
|
|
503
|
+
|
|
504
|
+
return { ...result, source: publicSource(result.source) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function revoke(apiCall, sourceId) {
|
|
508
|
+
try {
|
|
509
|
+
const result = await apiCall("DELETE", sourcePath(sourceId), null);
|
|
510
|
+
if (result && result.error) return { ok: false, detail: `status ${result.status}` };
|
|
511
|
+
return { ok: true };
|
|
512
|
+
} catch {
|
|
513
|
+
return { ok: false, detail: "request error" };
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function reservationOutcome(secretPath, removed) {
|
|
518
|
+
return removed
|
|
519
|
+
? `The secret_file '${secretPath}' was removed; the same path can be used again.`
|
|
520
|
+
: `The secret_file '${secretPath}' could NOT be removed: delete it before enrolling again ` +
|
|
521
|
+
"at that path.";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function closeQuietly(handle) {
|
|
525
|
+
try {
|
|
526
|
+
await handle.close();
|
|
527
|
+
} catch {
|
|
528
|
+
// already closed, or the close is what failed
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Close the handle and remove the file this call created. Removal is by path, so it first
|
|
534
|
+
* checks that the path still names the file identified by `opened`, the stat taken right
|
|
535
|
+
* after the open. Resolves to whether the path no longer holds that file.
|
|
536
|
+
*/
|
|
537
|
+
async function discardReservation(fs, handle, secretPath, opened) {
|
|
538
|
+
await closeQuietly(handle);
|
|
539
|
+
|
|
540
|
+
let current;
|
|
541
|
+
try {
|
|
542
|
+
current = await fs.lstat(secretPath);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
return err?.code === "ENOENT";
|
|
545
|
+
}
|
|
546
|
+
if (current.ino !== opened.ino || current.dev !== opened.dev) return false;
|
|
547
|
+
|
|
548
|
+
try {
|
|
549
|
+
await fs.unlink(secretPath);
|
|
550
|
+
return true;
|
|
551
|
+
} catch (err) {
|
|
552
|
+
return err?.code === "ENOENT";
|
|
553
|
+
}
|
|
554
|
+
}
|