Agent containers: opt-in host/tailnet networking so a carry can reach tailnet-only hosts (kai-tower-3026) #330

Closed
opened 2026-06-25 09:47:59 +00:00 by coilysiren · 4 comments
Owner

Goal

Give a ward agent container an opt-in route to tailnet-only hosts, so a carry can actually reach kai-tower-3026 (and kai-server) and test against them. Today no ward container has any such route. The least-access default stays the default. This is the network escalation flag, off unless asked.

Why (the blocker this unblocks)

The agent-proxy build (coilyco-flight-deck/agent-proxy#1, aosh leg 04) must prove the per-model num_ctx fix against the live tower as it is built. The tower serves ollama only over the tailnet, at an opaque FQDN held in SSM (/coilysiren/kai-tower-3026/tailnet-fqdn), curled as http://$TOWER:11434 (aosh leg 03 preconditions).

Measured from a current container (an explore carry, 2026-06-25):

  • No tailnet - no tailscale CLI, no tailscale0, only the docker bridge (172.17.0.0/16). docker run in dockerArgvHead (cmd/ward/container_compute.go:481) sets no --network, so every carry lands on the bare bridge.
  • No AWS/SSM - --aws is opt-in and off, so the container cannot even resolve the tower's FQDN, let alone route to it.

So a carry cannot live-test any tailnet service. --aws alone is not enough (it hands over the address but no route). This issue adds the route.

The ask - an opt-in network flag (mirror --aws)

Add a network-escalation opt-in alongside the existing --aws BoolFlag. The flag is defined in three places today for --aws (mirror each): cmd/ward/agent.go:343, cmd/ward/agent_sandbox.go:28, cmd/ward/agent_ask.go:33. Thread it through upPlan the same way AWSHome is threaded (cmd/ward/container_compute.go:306-325).

Primary mechanism - host networking (Kai's call, simplest)

A carry runs on a host that is itself on the tailnet. So docker run --network=host makes the container inherit the host's tailscale0 and MagicDNS and reach the tower directly - no in-container tailscaled, no auth key, no minting. Append --network=host in dockerArgvHead/dockerCreateArgv (cmd/ward/container_compute.go:481-521) when the flag is set. This is the whole fix on a tailnet host.

Alternative mechanism - in-container tailscale (portable fallback)

For when the host is not on the tailnet, or host-net's isolation loss is unacceptable, join the container to the tailnet directly:

  • Host-side, mint an ephemeral, tagged, pre-authorized auth key via the surface ward already has - ward ops tailscale exposes createKey on /tailnet/{tailnet}/keys (cmd/ward-kdl/tailscale.openapi.yaml:1886), tagged tag:ward-agent, ephemeral so the node auto-removes.
  • Inject it as TS_AUTHKEY via the existing --env-file secret path - the same host-side-resolve-then-env-file pattern the forgejo push token already uses (cmd/ward/container.go:241-277, appendEnvAndImage in container_compute.go:489-496). Never in argv or audit.
  • The entrypoint (cmd/ward/containerassets/entrypoint.sh) brings up tailscaled (userspace-networking to avoid NET_ADMIN//dev/net/tun, or a real tun if HTTP-client-via-SOCKS is too fiddly) and runs tailscale up --hostname=ward-<repo>-<rand>.
  • Teardown on exit (reaper, cmd/ward/container_reap.go) - ephemeral expiry plus a tailscale logout/deleteDevice belt-and-suspenders so nodes do not pile up.

Pick one mechanism as the implementation, or ship host-net now and leave the in-container route as a documented follow-up. Host-net is the lighter lift and matches Kai's steer.

Pairs with --aws

Even with a route, the tower's address is SSM-only by design (the FQDN is never hardcoded). So live-testing needs both the network flag and --aws (or MagicDNS resolves the host but the carry still reads the opaque FQDN from /coilysiren/kai-tower-3026/tailnet-fqdn). Either make the new flag imply the ~/.aws mount, or document that a tower carry passes both. Recommend implying it - the two are always wanted together for this use.

Security / isolation

  • Host-net drops the cwd-only least-access default (docs/container.md:21-22), so it is opt-in, off by default, and the docs must say what it widens.
  • For the in-container route, the tailnet ACL must scope tag:ward-agent to only the tower (:11434) and kai-server, not the whole tailnet. Pre-authorizing tag:ward-agent and writing that ACL is tailnet-admin config Kai owns - a precondition for the in-container route (not for host-net). Flag it, do not attempt to mint keys or rewrite ACLs from the carry.

Docs to update (the trifecta + the credential docs)

  • docs/container.md - the "least access" model (:14-22) gains the opt-in network escalation.
  • docs/agent-flags.md - the new flag in the work/headless flag list (:8).
  • docs/agent-credentials.md - how the network route (and any auth key) is seeded.
  • docs/FEATURES.md - the new capability entry.

Done-condition

A carry launched with the new flag plus --aws (e.g. warded work coilyco-flight-deck/agent-proxy#1 --host-net --aws) resolves the tower FQDN from SSM and gets a {"version":"0.30.x"} from http://$TOWER:11434/api/version inside the container. Unit tests cover the docker run argv/plan change. Live join is validated on a tailnet host. README / AGENTS / FEATURES trifecta current, committed, pushed to main.

## Goal Give a ward agent container an **opt-in route to tailnet-only hosts**, so a carry can actually reach `kai-tower-3026` (and kai-server) and test against them. Today no ward container has any such route. The least-access default stays the default. This is the network escalation flag, off unless asked. ## Why (the blocker this unblocks) The `agent-proxy` build (`coilyco-flight-deck/agent-proxy#1`, aosh leg 04) must prove the per-model `num_ctx` fix **against the live tower** as it is built. The tower serves ollama only over the tailnet, at an opaque FQDN held in SSM (`/coilysiren/kai-tower-3026/tailnet-fqdn`), curled as `http://$TOWER:11434` (aosh leg 03 preconditions). Measured from a current container (an explore carry, 2026-06-25): - **No tailnet** - no `tailscale` CLI, no `tailscale0`, only the docker bridge (`172.17.0.0/16`). `docker run` in `dockerArgvHead` (`cmd/ward/container_compute.go:481`) sets no `--network`, so every carry lands on the bare bridge. - **No AWS/SSM** - `--aws` is opt-in and off, so the container cannot even **resolve** the tower's FQDN, let alone route to it. So a carry cannot live-test any tailnet service. `--aws` alone is not enough (it hands over the address but no route). This issue adds the route. ## The ask - an opt-in network flag (mirror `--aws`) Add a network-escalation opt-in alongside the existing `--aws` BoolFlag. The flag is defined in three places today for `--aws` (mirror each): `cmd/ward/agent.go:343`, `cmd/ward/agent_sandbox.go:28`, `cmd/ward/agent_ask.go:33`. Thread it through `upPlan` the same way `AWSHome` is threaded (`cmd/ward/container_compute.go:306-325`). ### Primary mechanism - host networking (Kai's call, simplest) A carry runs on a host that is itself on the tailnet. So `docker run --network=host` makes the container **inherit the host's `tailscale0` and MagicDNS** and reach the tower directly - no in-container tailscaled, no auth key, no minting. Append `--network=host` in `dockerArgvHead`/`dockerCreateArgv` (`cmd/ward/container_compute.go:481-521`) when the flag is set. This is the whole fix on a tailnet host. ### Alternative mechanism - in-container tailscale (portable fallback) For when the host is **not** on the tailnet, or host-net's isolation loss is unacceptable, join the container to the tailnet directly: - Host-side, mint an **ephemeral, tagged, pre-authorized** auth key via the surface ward already has - `ward ops tailscale` exposes `createKey` on `/tailnet/{tailnet}/keys` (`cmd/ward-kdl/tailscale.openapi.yaml:1886`), tagged `tag:ward-agent`, ephemeral so the node auto-removes. - Inject it as `TS_AUTHKEY` via the existing `--env-file` secret path - the same host-side-resolve-then-env-file pattern the forgejo push token already uses (`cmd/ward/container.go:241-277`, `appendEnvAndImage` in `container_compute.go:489-496`). Never in argv or audit. - The entrypoint (`cmd/ward/containerassets/entrypoint.sh`) brings up `tailscaled` (userspace-networking to avoid `NET_ADMIN`/`/dev/net/tun`, or a real tun if HTTP-client-via-SOCKS is too fiddly) and runs `tailscale up --hostname=ward-<repo>-<rand>`. - Teardown on exit (reaper, `cmd/ward/container_reap.go`) - ephemeral expiry plus a `tailscale logout`/`deleteDevice` belt-and-suspenders so nodes do not pile up. Pick one mechanism as the implementation, or ship host-net now and leave the in-container route as a documented follow-up. Host-net is the lighter lift and matches Kai's steer. ## Pairs with `--aws` Even with a route, the tower's address is SSM-only by design (the FQDN is never hardcoded). So live-testing needs **both** the network flag and `--aws` (or MagicDNS resolves the host but the carry still reads the opaque FQDN from `/coilysiren/kai-tower-3026/tailnet-fqdn`). Either make the new flag **imply** the `~/.aws` mount, or document that a tower carry passes both. Recommend implying it - the two are always wanted together for this use. ## Security / isolation - Host-net drops the cwd-only least-access default (`docs/container.md:21-22`), so it is **opt-in, off by default**, and the docs must say what it widens. - For the in-container route, the tailnet ACL must scope `tag:ward-agent` to **only** the tower (`:11434`) and kai-server, not the whole tailnet. **Pre-authorizing `tag:ward-agent` and writing that ACL is tailnet-admin config Kai owns** - a precondition for the in-container route (not for host-net). Flag it, do not attempt to mint keys or rewrite ACLs from the carry. ## Docs to update (the trifecta + the credential docs) - `docs/container.md` - the "least access" model (`:14-22`) gains the opt-in network escalation. - `docs/agent-flags.md` - the new flag in the `work`/`headless` flag list (`:8`). - `docs/agent-credentials.md` - how the network route (and any auth key) is seeded. - `docs/FEATURES.md` - the new capability entry. ## Done-condition A carry launched with the new flag plus `--aws` (e.g. `warded work coilyco-flight-deck/agent-proxy#1 --host-net --aws`) resolves the tower FQDN from SSM and gets a `{"version":"0.30.x"}` from `http://$TOWER:11434/api/version` inside the container. Unit tests cover the `docker run` argv/plan change. Live join is validated on a tailnet host. README / AGENTS / FEATURES trifecta current, committed, pushed to `main`.
Author
Owner

🔒 Reserved by ward agent --driver claude — container ward-ward-issue-330-claude-ae585b69 on host 433ede6b295e is carrying this issue (reserved 2026-06-25T09:48:03Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `ward-ward-issue-330-claude-ae585b69` on host `433ede6b295e` is carrying this issue (reserved 2026-06-25T09:48:03Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

Measurement: the current dispatch host is NOT on the tailnet

Probed from a carry's perspective via a throwaway --network=host container against the docker socket that dispatches carries (2026-06-25):

  • ls /sys/class/net on the host shows bond0, many br-*, docker0, dummy0, eth0/1, gre* - no tailscale0.
  • Host resolv.conf is a docker-internal resolver (192.168.65.7), no MagicDNS.

Implication for this issue's primary mechanism. --network=host reaches the tailnet only if the host is on the tailnet. This dispatch host is not, so host-net alone does not unblock the agent-proxy live-test. Do not ship host-net-only and close this.

Two ways to actually reach the tower from a carry here, pick per Kai:

  1. Put the dispatch host on the tailnet (operator action on the host), then --host-net is the whole fix. Lightest code, but moves the requirement onto host provisioning.
  2. Implement the in-container tailscale route (the alternative in the body) - ephemeral tagged auth key minted host-side via ward ops tailscale createKey, TS_AUTHKEY via --env-file, tailscaled + tailscale up in entrypoint.sh. Works regardless of host tailnet membership. Needs the tag:ward-agent ACL pre-authorized (Kai's tailnet-admin action).

Net: option 2 is the portable unblock and should be the shipped mechanism unless Kai commits to putting every dispatch host on the tailnet.

## Measurement: the current dispatch host is NOT on the tailnet Probed from a carry's perspective via a throwaway `--network=host` container against the docker socket that dispatches carries (2026-06-25): - `ls /sys/class/net` on the host shows `bond0`, many `br-*`, `docker0`, `dummy0`, `eth0/1`, `gre*` - **no `tailscale0`**. - Host `resolv.conf` is a docker-internal resolver (`192.168.65.7`), no MagicDNS. **Implication for this issue's primary mechanism.** `--network=host` reaches the tailnet **only if the host is on the tailnet**. This dispatch host is not, so host-net alone does **not** unblock the `agent-proxy` live-test. Do not ship host-net-only and close this. Two ways to actually reach the tower from a carry here, pick per Kai: 1. **Put the dispatch host on the tailnet** (operator action on the host), then `--host-net` is the whole fix. Lightest code, but moves the requirement onto host provisioning. 2. **Implement the in-container tailscale route** (the alternative in the body) - ephemeral tagged auth key minted host-side via `ward ops tailscale createKey`, `TS_AUTHKEY` via `--env-file`, `tailscaled` + `tailscale up` in `entrypoint.sh`. Works regardless of host tailnet membership. Needs the `tag:ward-agent` ACL pre-authorized (Kai's tailnet-admin action). Net: option 2 is the portable unblock and should be the shipped mechanism unless Kai commits to putting every dispatch host on the tailnet.
Author
Owner

Decision from Kai (2026-06-25): ship the host-net mechanism, drop the in-container route

Dispatch hosts will be tailnet nodes. So implement the primary mechanism only:

  • Opt-in flag -> docker run --network=host. The container inherits the host's tailscale0 + MagicDNS and reaches tailnet hosts directly. No in-container tailscaled, no auth-key minting, no tag:ward-agent ACL. Drop the alternative in-container-tailscale route and its ACL precondition - not needed, leave it as a one-line "future, if a non-tailnet dispatch host ever needs it" note at most.
  • Keep it opt-in, off by default. It widens past the cwd-only least-access model and makes the carry inherit the host's full tailnet reach - say so in docs/container.md. Per-carry tag-scoping is an accepted future tightening, not a blocker now.
  • Imply --aws (or document passing both): the tower address is SSM-only (/coilysiren/kai-tower-3026/tailnet-fqdn), so the route plus the FQDN are always wanted together.
  • Flag naming: --host-net is honest about the mechanism; a --tailnet alias communicates the intent. Implementer's call.

Done-condition (unchanged): a carry launched with the flag + --aws on a tailnet-member host resolves the FQDN from SSM and gets {"version":"0.30.x"} from http://$TOWER:11434/api/version inside the container. Unit-test the docker run argv change.

This supersedes the earlier "option 2 is the portable unblock" steer in my prior comment - Kai chose the lighter host-net path.

## Decision from Kai (2026-06-25): ship the host-net mechanism, drop the in-container route Dispatch hosts **will be tailnet nodes**. So implement the **primary mechanism only**: - **Opt-in flag -> `docker run --network=host`.** The container inherits the host's `tailscale0` + MagicDNS and reaches tailnet hosts directly. No in-container `tailscaled`, no auth-key minting, no `tag:ward-agent` ACL. Drop the alternative in-container-tailscale route and its ACL precondition - not needed, leave it as a one-line "future, if a non-tailnet dispatch host ever needs it" note at most. - **Keep it opt-in, off by default.** It widens past the cwd-only least-access model and makes the carry inherit the host's **full** tailnet reach - say so in `docs/container.md`. Per-carry tag-scoping is an accepted future tightening, not a blocker now. - **Imply `--aws`** (or document passing both): the tower address is SSM-only (`/coilysiren/kai-tower-3026/tailnet-fqdn`), so the route plus the FQDN are always wanted together. - Flag naming: `--host-net` is honest about the mechanism; a `--tailnet` alias communicates the intent. Implementer's call. **Done-condition (unchanged):** a carry launched with the flag + `--aws` on a tailnet-member host resolves the FQDN from SSM and gets `{"version":"0.30.x"}` from `http://$TOWER:11434/api/version` inside the container. Unit-test the `docker run` argv change. This supersedes the earlier "option 2 is the portable unblock" steer in my prior comment - Kai chose the lighter host-net path.
Author
Owner

Shipped the host-net mechanism (the lighter lift Kai steered to); left the in-container tailscale join as a documented follow-up. The Go side was clean — the bring-up already threads --aws through a single chokepoint, so --host-net was genuinely a mirror: one shared flag helper, one upPlan field, one line in dockerArgvHead. Making it imply --aws felt right since a route with no SSM resolver is dead weight.

What actually fought back wasn't the code — it was the doc-size pre-commit gate. All four docs the issue asked me to touch (container.md, agent-flags.md, agent-credentials.md, FEATURES.md) were already sitting 1–9 chars under the 4000-char cap, so every addition overflowed. I pulled the detail into a new docs/agent-host-net.md and trimmed each parent doc roughly char-for-char to make room for a pointer. Also had to install trufflehog by hand (missing from this container) to clear the secret-scan hook rather than skip verification.

Fairly confident in the result: argv/plan change is unit-tested both ways and verified live via --print (--network=host present, ~/.aws implied). The one thing I could NOT validate is the actual tower reach — that needs a real tailnet host, which this container isn't. So the done-condition's '{"version":"0.30.x"}' from inside the container is still unproven; worth a real on-tower smoke test before leaning on it. The in-container-tailscale fallback (ephemeral tagged key via ward ops tailscale, ACL scoping owned by Kai) remains a clean follow-up if a non-tailnet host ever needs this.

Shipped the host-net mechanism (the lighter lift Kai steered to); left the in-container tailscale join as a documented follow-up. The Go side was clean — the bring-up already threads --aws through a single chokepoint, so --host-net was genuinely a mirror: one shared flag helper, one upPlan field, one line in dockerArgvHead. Making it imply --aws felt right since a route with no SSM resolver is dead weight. What actually fought back wasn't the code — it was the doc-size pre-commit gate. All four docs the issue asked me to touch (container.md, agent-flags.md, agent-credentials.md, FEATURES.md) were already sitting 1–9 chars under the 4000-char cap, so every addition overflowed. I pulled the detail into a new docs/agent-host-net.md and trimmed each parent doc roughly char-for-char to make room for a pointer. Also had to install trufflehog by hand (missing from this container) to clear the secret-scan hook rather than skip verification. Fairly confident in the result: argv/plan change is unit-tested both ways and verified live via --print (--network=host present, ~/.aws implied). The one thing I could NOT validate is the actual tower reach — that needs a real tailnet host, which this container isn't. So the done-condition's '{"version":"0.30.x"}' from inside the container is still unproven; worth a real on-tower smoke test before leaning on it. The in-container-tailscale fallback (ephemeral tagged key via ward ops tailscale, ACL scoping owned by Kai) remains a clean follow-up if a non-tailnet host ever needs this.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/ward#330
No description provided.