PRD: Host control server #486

Open
didericis-claude wants to merge 2 commits from prd-new-host-control-server into main
Collaborator

Adds the PRD for #468 — promoting the in-process launch broker into a standalone host control server: the single privileged host component that brokers launches, owns orchestrator lifecycle, and is the sole writer of host-durable state.

The issue explicitly notes "Implementation will need a PRD," so this PR is that PRD (docs/prds/prd-new-host-control-server.md; CI numbers it on merge).

What it covers

  • The four broker gaps between today's tested in-process contract (orchestrator/broker.py) and a real host service:
    1. transport — BrokerClient (POST signed token) + host HTTP server, reusing verify_request;
    2. durable, out-of-band signing secret via the TrustDomain seam (#476), plus a separate key for the host controller's own lifecycle endpoints the orchestrator never holds;
    3. enforced replay protectioniat expiry window + self-trimming jti cache (currently claimed but unread);
    4. a disciplined op vocabulary growth governed by an explicit ids + static-flags-only rule.
  • The three launch/lifecycle paths (console→orchestrator→controller, cli→orchestrator→controller, cli→controller for bootstrap/recovery — the #391 path).
  • State split by owner and lifetime — orchestrator SQLite (operational) / host append-only hash-chained JSONL (historical audit + egress log) / gateway none (post #469).
  • Resolves the three open decisions: stay HS256 symmetric; one daemon with structurally separate handlers and per-handler creds; ids+static-flags schema rule.
  • Removes reconcile's live_source_ips parameter (enumeration becomes internal).

The payoff spelled out throughout: the CLI drops the Docker socket, unblocking an unprivileged Gitea runner user.

Closes #468.

🤖 Generated with Claude Code

Adds the PRD for #468 — promoting the in-process launch broker into a standalone **host control server**: the single privileged host component that brokers launches, owns orchestrator lifecycle, and is the sole writer of host-durable state. The issue explicitly notes "Implementation will need a PRD," so this PR is that PRD (`docs/prds/prd-new-host-control-server.md`; CI numbers it on merge). ## What it covers - **The four broker gaps** between today's tested in-process contract (`orchestrator/broker.py`) and a real host service: 1. transport — `BrokerClient` (POST signed token) + host HTTP server, reusing `verify_request`; 2. durable, out-of-band signing secret via the `TrustDomain` seam (#476), plus a **separate** key for the host controller's own lifecycle endpoints the orchestrator never holds; 3. **enforced replay protection** — `iat` expiry window + self-trimming `jti` cache (currently claimed but unread); 4. a disciplined op vocabulary growth governed by an explicit **ids + static-flags-only** rule. - **The three launch/lifecycle paths** (console→orchestrator→controller, cli→orchestrator→controller, cli→controller for bootstrap/recovery — the #391 path). - **State split by owner and lifetime** — orchestrator SQLite (operational) / host **append-only hash-chained JSONL** (historical audit + egress log) / gateway none (post #469). - Resolves the three open decisions: stay HS256 symmetric; one daemon with structurally separate handlers and per-handler creds; ids+static-flags schema rule. - Removes `reconcile`'s `live_source_ips` parameter (enumeration becomes internal). The payoff spelled out throughout: **the CLI drops the Docker socket**, unblocking an unprivileged Gitea runner user. Closes #468. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Owner

@didericis-claude Replay protection seems a bit extreme for mvp, can move that out of here and into a separate issue.

@didericis-claude Replay protection seems a bit extreme for mvp, can move that out of here and into a separate issue.
Author
Collaborator

Agreed — pulled it out. Filed #494 for the replay-protection work (iat expiry window + self-trimming jti cache) and stripped it from this PRD:

  • four-gap framing → three gaps (transport, durable secret, op vocabulary);
  • removed the enforcement design section and implementation chunk #1 (renumbered the rest);
  • dropped the "replay protection enforced" goal and the jti-cache open question;
  • left a short note in Design pointing at #494 so the wire-replay risk stays visible as a tracked follow-up rather than an oversight.

It was already the one pure in-process chunk with no dependency on the rest, so nothing else in the PRD moves. Pushed as 1487498.

Agreed — pulled it out. Filed #494 for the replay-protection work (`iat` expiry window + self-trimming `jti` cache) and stripped it from this PRD: - four-gap framing → **three gaps** (transport, durable secret, op vocabulary); - removed the enforcement design section and implementation chunk #1 (renumbered the rest); - dropped the "replay protection enforced" goal and the `jti`-cache open question; - left a short note in Design pointing at #494 so the wire-replay risk stays visible as a tracked follow-up rather than an oversight. It was already the one pure in-process chunk with no dependency on the rest, so nothing else in the PRD moves. Pushed as `1487498`.
Owner

@didericis-claude PRD looks good. Implement the feature via a stack off this PR.

@didericis-claude PRD looks good. Implement the feature via a stack off this PR.
didericis force-pushed prd-new-host-control-server from 1487498b23 to a86ad630e2 2026-07-26 02:56:40 -04:00 Compare
didericis changed title from PRD prd-new: Host control server to PRD: Host control server 2026-07-26 03:14:36 -04:00
Author
Collaborator

On it — building the feature as a stack off this branch, one PR per PRD chunk. First one up:

  • Chunk 1 — transport#496 (host-control-server-1-transport, based on this branch). BrokerClient + host control server over HTTP, reusing verify_request + DockerBroker; --broker http wired behind a flag. Tests + pyright/pylint green.

Remaining, each stacked on the previous:

  • Chunk 2 — durable secret (TrustDomain, #476): replace chunk 1's BOT_BOTTLE_BROKER_SECRET stopgap with the provisioned launch-broker key, plus the host controller's own lifecycle-endpoint key the orchestrator never holds.
  • Chunk 3 — op vocabulary: grow the broker one op at a time behind the ids+static-flags rule (list_live first — also removes reconcile's live_source_ips).
  • Chunk 4 — JSONL audit log: host-controller-owned, hash-chained; plain-bearer audit-append handler; redirect the egress traffic log into it.
  • Chunk 5 — drop the CLI Docker socket: once every host-privileged op is a broker op — the payoff that unblocks the unprivileged runner user.

Starting chunk 2 now. I'll link each PR here as it goes up.

On it — building the feature as a stack off this branch, one PR per PRD chunk. First one up: - ✅ **Chunk 1 — transport** → #496 (`host-control-server-1-transport`, based on this branch). `BrokerClient` + host control server over HTTP, reusing `verify_request` + `DockerBroker`; `--broker http` wired behind a flag. Tests + pyright/pylint green. Remaining, each stacked on the previous: - **Chunk 2 — durable secret** (`TrustDomain`, #476): replace chunk 1's `BOT_BOTTLE_BROKER_SECRET` stopgap with the provisioned launch-broker key, plus the host controller's own lifecycle-endpoint key the orchestrator never holds. - **Chunk 3 — op vocabulary**: grow the broker one op at a time behind the ids+static-flags rule (`list_live` first — also removes `reconcile`'s `live_source_ips`). - **Chunk 4 — JSONL audit log**: host-controller-owned, hash-chained; plain-bearer audit-append handler; redirect the egress traffic log into it. - **Chunk 5 — drop the CLI Docker socket**: once every host-privileged op is a broker op — the payoff that unblocks the unprivileged runner user. Starting chunk 2 now. I'll link each PR here as it goes up.
Author
Collaborator

Foundation of the stack is up — two PRs, each tested (full unit suite green, pyright clean, pylint ≥9.8):

  • Chunk 1 — transport#496 (base: this branch). BrokerClient + host control server over HTTP; --broker http behind a flag.
  • Chunk 2 — durable secret#497 (base: #496). LAUNCH_BROKER + HOST_CONTROLLER TrustDomains; the stopgap is gone — signer and verifier resolve the same durable host key, and the orchestrator never holds the controller's own lifecycle key.

Remaining, still to stack on #497:

  • Chunk 3 — op vocabulary: grow the broker one op at a time behind the ids+static-flags rule (list_live first — also removes reconcile's live_source_ips).
  • Chunk 4 — JSONL audit log: host-controller-owned, hash-chained; plain-bearer audit-append handler.
  • Chunk 5 — drop the CLI Docker socket: the payoff, once every host-privileged op is a broker op.

I paused here rather than stacking all five in one go: chunks 1–2 are the seam everything else builds on, and you've been steering the shape of this (replay split, stack-not-monolith), so it's worth a look before I build chunks 3–5 on top. Want me to keep going straight into chunk 3, or review the foundation first? Happy to continue immediately either way.

Foundation of the stack is up — two PRs, each tested (full unit suite green, pyright clean, pylint ≥9.8): - ✅ **Chunk 1 — transport** → #496 (base: this branch). `BrokerClient` + host control server over HTTP; `--broker http` behind a flag. - ✅ **Chunk 2 — durable secret** → #497 (base: #496). `LAUNCH_BROKER` + `HOST_CONTROLLER` `TrustDomain`s; the stopgap is gone — signer and verifier resolve the same durable host key, and the orchestrator never holds the controller's own lifecycle key. Remaining, still to stack on #497: - **Chunk 3 — op vocabulary**: grow the broker one op at a time behind the ids+static-flags rule (`list_live` first — also removes `reconcile`'s `live_source_ips`). - **Chunk 4 — JSONL audit log**: host-controller-owned, hash-chained; plain-bearer audit-append handler. - **Chunk 5 — drop the CLI Docker socket**: the payoff, once every host-privileged op is a broker op. I paused here rather than stacking all five in one go: chunks 1–2 are the seam everything else builds on, and you've been steering the shape of this (replay split, stack-not-monolith), so it's worth a look before I build chunks 3–5 on top. **Want me to keep going straight into chunk 3, or review the foundation first?** Happy to continue immediately either way.
didericis force-pushed prd-new-host-control-server from a86ad630e2 to d6ad956129 2026-07-26 13:20:34 -04:00 Compare
didericis force-pushed prd-new-host-control-server from d6ad956129 to 892299aac5 2026-07-26 13:32:44 -04:00 Compare
Author
Collaborator

Chunk 3 is up — #507 (host-control-server-3-list-live, base: #497).

  • list_live broker op + strict schema (resolves open question 1 → reject unknown claim keys; a query op carries no ids/flags). POST /broker/live + BrokerClient.list_live; DockerBroker enumerates its labelled containers.
  • reconcile invertedOrchestratorCore.reconcile() drops live_source_ips and pulls the live set from the broker; the /reconcile route + client become a bare trigger. Fail-safe: an unavailable/partial live set skips the sweep rather than reaping healthy rows.

Full unit suite green, pyright clean, pylint 10.0 on broker.py.

On "pull chunk 5 forward"

You asked to make launch coherent through the broker on macOS (rather than split the macOS wiring out or build it blind for enumeration only). I kept chunk 3 to the fully-testable seam + core so it stays green, and the next stacked PR does the macOS launch-behind-broker — but I want to flag its one real design knot before I build it, since it can't be exercised in the Linux sandbox:

The macOS launch order inverts around the broker. Today launch.py does the real Apple container run host-side, and the source IP is only knowable after DHCP assigns it — which is why macOS registers after the container is up, and delivers the identity token via container exec. Moving launch behind the host controller means the broker's _launch performs the container run, but the registry bottle_id/attribution flow still needs the post-run IP. So the broker either (a) returns the discovered source IP from _launch (the op grows a return value), or (b) the orchestrator does a list_live-style lookup right after launch to learn it. (a) is cleaner but changes the launch op's contract for every backend; (b) reuses the seam this PR adds.

Leaning (b) — it keeps launch write-only and reuses list_live. Shout if you'd rather (a), otherwise I'll build the macOS host controller (Apple broker: real _launch/_teardown/_list_live, --broker http wiring, container→host vmnet reachability) on (b) as the next PR in the stack.

Chunk 3 is up — **#507** (`host-control-server-3-list-live`, base: #497). - ✅ **`list_live` broker op** + strict schema (resolves open question 1 → reject unknown claim keys; a query op carries no ids/flags). `POST /broker/live` + `BrokerClient.list_live`; `DockerBroker` enumerates its labelled containers. - ✅ **`reconcile` inverted** — `OrchestratorCore.reconcile()` drops `live_source_ips` and pulls the live set from the broker; the `/reconcile` route + client become a bare trigger. **Fail-safe**: an unavailable/partial live set skips the sweep rather than reaping healthy rows. Full unit suite green, pyright clean, pylint 10.0 on `broker.py`. ### On "pull chunk 5 forward" You asked to make launch coherent through the broker on macOS (rather than split the macOS wiring out or build it blind for enumeration only). I kept chunk 3 to the fully-testable seam + core so it stays green, and the **next stacked PR** does the macOS launch-behind-broker — but I want to flag its one real design knot before I build it, since it can't be exercised in the Linux sandbox: **The macOS launch order inverts around the broker.** Today `launch.py` does the real Apple `container run` **host-side**, and the source IP is only knowable *after* DHCP assigns it — which is why macOS registers *after* the container is up, and delivers the identity token via `container exec`. Moving launch behind the host controller means the broker's `_launch` performs the `container run`, but the registry `bottle_id`/attribution flow still needs the post-run IP. So the broker either (a) returns the discovered source IP from `_launch` (the op grows a return value), or (b) the orchestrator does a `list_live`-style lookup right after launch to learn it. (a) is cleaner but changes the launch op's contract for every backend; (b) reuses the seam this PR adds. Leaning **(b)** — it keeps `launch` write-only and reuses `list_live`. Shout if you'd rather (a), otherwise I'll build the macOS host controller (Apple broker: real `_launch`/`_teardown`/`_list_live`, `--broker http` wiring, container→host vmnet reachability) on (b) as the next PR in the stack.
didericis added 2 commits 2026-07-26 18:36:20 -04:00
Promote the in-process launch broker into a standalone host control
server: the single privileged host component that brokers launches, owns
orchestrator lifecycle, and is the sole writer of host-durable state.

Closes the four broker gaps (transport, durable provisioned secret,
replay protection, disciplined op vocabulary) and splits host state by
owner and lifetime (orchestrator SQLite / host JSONL audit / gateway
none). The payoff is dropping the Docker socket from the CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs: defer broker replay protection to its own issue (#494)
prd-number-check / require-numbered-prds (pull_request) Failing after 7s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
refresh-image-locks / refresh (push) Successful in 30s
lint / lint (push) Successful in 1m6s
794e4e662d
Per PR review, replay protection is too heavy for the host control
server MVP. Drop it from the four-gap framing (now three gaps), remove
the enforcement design section and implementation chunk, and track the
iat-window + jti-cache work in #494 as an independent in-process change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
didericis force-pushed prd-new-host-control-server from 892299aac5 to 794e4e662d 2026-07-26 18:36:20 -04:00 Compare
Some optional checks failed
prd-number-check / require-numbered-prds (pull_request) Failing after 7s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
Required
Details
refresh-image-locks / refresh (push) Successful in 30s
lint / lint (push) Successful in 1m6s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin prd-new-host-control-server:prd-new-host-control-server
git checkout prd-new-host-control-server
Sign in to join this conversation.