Skip to content

Peers: a peer-scoped key that reads, launches, and controls only the runs a peer launched - #129

Merged
Evanfeenstra merged 3 commits into
mainfrom
feature/peer-scope
Oct 9, 2026
Merged

Evanfeenstra merged 3 commits into
mainfrom
feature/peer-scope

Conversation

@Evanfeenstra

Copy link
Copy Markdown
Contributor

Why

Until now a peer record (plans/federation.md §3) held the other strut's whole deployment key. The local strut that called swarm38's explore on 2026-10-09 did exactly that, and the plan says a laptop must never hold one. This is strut's half of step 4: a narrower credential and the scope behind it.

What

  • STRUT_PEER_KEY. It is presented like STRUT_API_KEY (Bearer or ?key=), but the request it is on is peer-scoped. The scope comes from createStrut({ resolveScope }), the twin of resolveActor; its default, scopeFromKey, says peer for a request carrying the peer key. A host that authenticates requests itself maps its own credential there instead (mcp's lab:peer JWT, next).

  • What a peer may do. The one gate allows any GET/HEAD, a launch (POST /workflows/:name[/:version]/run), and cancel / pause / resume. Anything else is 403, a route a host mounts later included. x-strut-actor is honored with the peer key, so a peer's launch is billed to the person it names (strut/run-workflow sends its run's principal).

  • Only its own runs. The control routes refuse a peer any run no peer launched. That is recorded, not inferred from the forwarded actor: a peer's launch is stamped origin: "peer" on run.start, and a resume keeps it. A run just launched is also marked on its controller, because a caller's cancel right after its launch reaches the peer before run.start is written (5 of 5 test runs without the mark).

  • Not narrowed by workflow (decided 2026-10-09, the plan as written). A peer can launch any workflow, so one whose agent has bash (the lab's seeded job) reaches whatever the server's user can. The scope does keep a peer from:

    • publishing workflows or steps, and the builder chat
    • writing secrets, actor secrets, delegations, peers, owners or caps
    • deleting anything
    • controlling any run it did not launch

    The gap is written into the plan, AGENTS.md and specs/API.md.

With no peer key and no resolveScope, nothing changes for an existing deployment.

Verified

1382 unit tests pass on the branch merged with main; tsc is clean. New:

  • The route sweep with the peer key. Every GET passes, the five launch/control POSTs pass, and every other route is 403, a host-mounted one included.
  • A host's resolveScope over an open gate (mcp's shape).
  • Auth units: read vs write, the actor, an unset key, dev mode.
  • Run control.
    • A peer's own run is cancelled right after launch, then resumed.
    • A run the deployment launched is refused for cancel, pause, resume and durable resume.
    • A version launch is stamped.
  • The two-strut suite over real HTTP on the peer key alone: call, then cancel propagation. This test fails if a peer cannot cancel the run it launched.

The other half

mcp:

  • /mint-token with scope: "lab:peer"
  • labAuth accepting it, with x-strut-actor beside it
  • a resolveScope hook that maps it

Strut already enforces everything else. PR to follow.

…runs a peer launched

plans/federation.md §3, step 4, strut's half. A peer record on another
strut used to hold this strut's whole deployment key.

- STRUT_PEER_KEY: presented like STRUT_API_KEY (Bearer or ?key=), but the
  request it carries is peer-scoped (scopeFromKey, the default of the new
  createStrut({ resolveScope }), the twin of resolveActor; a host such as
  mcp maps its own credential instead). x-strut-actor is honored with it,
  so a peer's launch is billed to the person it names.

- The one gate: a peer may make any GET/HEAD, launch a run
  (POST /workflows/:name[/:version]/run), and cancel / pause / resume.
  Everything else is 403, a route a host mounts later included.
  requireApiKey lets the peer key read.

- "A run it launched" is recorded, not inferred from the forwarded actor:
  a peer's launch is stamped origin: "peer" on run.start (kept by a
  resume), which the control routes check. A run just launched is also
  marked on its controller, because a caller's cancel right after its
  launch reaches the peer before run.start is in the log (5 of 5 test
  runs without the mark).

- Launch is not narrowed by workflow (decided 2026-10-09, the plan as
  written). A peer that can launch a workflow whose agent has bash reaches
  whatever the server's user can. Recorded in the plan, AGENTS.md and
  API.md.

Tests: the route sweep with the peer key (every GET passes, the five
launch/control POSTs pass, every other route is 403), a host's
resolveScope over an open gate, auth units (read vs write, actor, unset,
dev mode), run control (a peer's own run cancelled right after launch and
resumed, a run the deployment launched refused for cancel/pause/resume and
durable resume, the version launch stamped), and the two-strut suite
calling and cancelling over HTTP on the peer key alone.
@Evanfeenstra
Evanfeenstra merged commit 9a1420a into main Oct 9, 2026
1 check passed
Evanfeenstra added a commit to stakwork/stakgraph that referenced this pull request Oct 9, 2026
…rut (#1754)

* lab: a lab:peer token for another strut — strut's peer scope; bump strut

The mcp half of strut plans/federation.md §3, step 4. A strut that has
this swarm on file as a peer used to hold an api JWT or the raw API_TOKEN,
either of which is admin of the lab.

- /mint-token takes `scope`: "api" (the default, unchanged) or "lab:peer",
  which defaults to 60 days. An unknown scope is a 400, and only the raw
  API_TOKEN mints, as before. The handler moved beside signApiToken
  (repo/events.ts, `mintToken`) so it can be tested.
- verifyApiToken accepts `api` unless the caller says otherwise, so a
  lab:peer token opens nothing outside /lab.
- labAuth takes a lab:peer JWT as Bearer only (never ?key=), and stashes
  strut's `peer` scope beside the actor (actor.ts). The actor is the
  token's `sub`, else the caller's x-strut-actor. The dictation upgrade
  refuses a peer.
- createLabStrut passes resolveScope: resolveLabScope. Strut enforces what
  a peer may do: every read, a launch, cancel / pause / resume of a run a
  peer launched, 403 for the rest.

Pins strut at stakwork/strut#129's head (2aac37e), which adds
createStrut({ resolveScope }); re-pin to its merge commit once it lands.

Tests (mount.test.ts):
- labAuth: Bearer vs ?key=, the actor, other credentials still full
- a lab:peer token outside the lab
- a real strut behind labAuth and the real bridge: a read passes, a
  secret write is 403, a launch is stamped origin "peer" with the token's
  person, and the peer cancels its own run but not hive's
- mintToken: defaults, the 60 days, an unknown scope, no renewal by JWT

test:node: 888 pass, 7 skipped, 0 fail.

* bump strut to the #129 merge commit
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant