feat(agentapi): serve /agent/v1 on a unix socket; labctl learns unix:// LAB_URL #208

Merged
dominik.polakovics merged 2 commits from afk/201 into main 2026-07-22 17:02:40 +02:00

Closes #201

What

  • Server: /agent/v1 is now additionally served on a unix domain socket at <state-dir>/agent.sock — the SAME agentapi.Handler() instance the TCP listener mounts, so run-token auth applies identically per transport. New agentapi.SocketPath/agentapi.ListenSocket (stale socket removed before Listen, chmod 0700 — the 0700 state dir covers the pre-chmod window; clean shutdown unlinks via net.UnixListener close). Both servers share the errCh/graceful-shutdown wiring; the startup log line now carries agent_sock. The web/human TCP surface is untouched.
  • labctl: LAB_URL=unix:///abs/path accepted everywhere. One seam in Client (requestBase() + a socket-dialing transport in httpClient()), through which both do() and the hand-built streaming SecretScan request pass — so withClient verbs, secret exec, secret scan, and the pre-push guard hook all inherit it with zero command-layer changes. Relative socket paths (unix://foo.sock) fail fast with a clear error before any dial. http(s) behavior byte-identical.
  • Spawn default: labURL precedence is now --agent-url (verbatim) → unix://<state-dir>/agent.sock. The --base-url/loopback fallbacks are gone — routing agent traffic through the external origin was exactly issue #30's SSO-hairpin failure mode, and the socket always exists.
  • --agent-url also admits unix:///abs/path (config validation; --base-url stays http(s)-only). Small deliberate scope addition: the default LAB_URL is itself a unix URL now, so the explicit override must be able to name a socket too (custom path today, the #205 container mount point tomorrow).
  • NixOS module: services.lab.agentUrl default flips "http://127.0.0.1:<port>"null, so module deployments actually pick up the socket default instead of silently pinning loopback TCP.
  • Docs: new "Agent socket" section in docs/ops.md; option table, config reference, reverse-proxy warning, state-dir layout, and backup-exclusion list all reconciled.

Proof

  • internal/agentapi/socket_test.go: stale-file/orphaned-socket/fresh recreation with 0700+ModeSocket asserts; real Handler() round-trip over the socket (401 without token, 200 with a seeded run token).
  • internal/labctl/uds_test.go: relative-path rejection, unreachable-socket dial error, issue view/issue comment round trip against the real agentapi handler over UDS (view output byte-identical to the TCP fixture), and secret scan (the hand-built streaming request) blocking a seeded leak / passing a clean range over the socket.
  • Config: --agent-url unix:///… accepted, relative rejected, --base-url unix://… still rejected; TestLabURL pins the new precedence.
  • Binary smoke (real bin/lab + bin/labctl, temp state dir): boot → agent.sock appears srwx------; labctl issue list over the socket reaches AuthMiddleware ("invalid run token", identical to TCP); SIGKILL → stale socket left → restart recreates and serves; SIGTERM → graceful shutdown, socket unlinked.
  • Gates: go build ./..., full go test ./..., go vet ./..., gofmt -l clean, and golangci-lint 2.12.2 (CI's exact pin) → 0 issues.

Notes for the maintainer

  • A literally-spawned host-mode run (provider session + forge) isn't reproducible in this sandbox; the UDS tests exercise the same client path pr create uses (do(), tested over the socket via issue comment), and the binary smoke covers the transport with production binaries.
  • Deployment: hosts that set services.lab.agentUrl explicitly (e.g. in Cloonar/nixos host config) will override the socket default until that setting is dropped — worth checking when the rev pin is bumped.

🤖 Generated with Claude Code

Closes #201 ## What - **Server**: `/agent/v1` is now additionally served on a unix domain socket at `<state-dir>/agent.sock` — the SAME `agentapi.Handler()` instance the TCP listener mounts, so run-token auth applies identically per transport. New `agentapi.SocketPath`/`agentapi.ListenSocket` (stale socket removed before Listen, chmod 0700 — the 0700 state dir covers the pre-chmod window; clean shutdown unlinks via `net.UnixListener` close). Both servers share the errCh/graceful-shutdown wiring; the startup log line now carries `agent_sock`. The web/human TCP surface is untouched. - **labctl**: `LAB_URL=unix:///abs/path` accepted everywhere. One seam in `Client` (`requestBase()` + a socket-dialing transport in `httpClient()`), through which both `do()` and the hand-built streaming `SecretScan` request pass — so `withClient` verbs, `secret exec`, `secret scan`, and the pre-push guard hook all inherit it with zero command-layer changes. Relative socket paths (`unix://foo.sock`) fail fast with a clear error before any dial. http(s) behavior byte-identical. - **Spawn default**: `labURL` precedence is now `--agent-url` (verbatim) → `unix://<state-dir>/agent.sock`. The `--base-url`/loopback fallbacks are gone — routing agent traffic through the external origin was exactly issue #30's SSO-hairpin failure mode, and the socket always exists. - **`--agent-url` also admits `unix:///abs/path`** (config validation; `--base-url` stays http(s)-only). Small deliberate scope addition: the default LAB_URL is itself a unix URL now, so the explicit override must be able to name a socket too (custom path today, the #205 container mount point tomorrow). - **NixOS module**: `services.lab.agentUrl` default flips `"http://127.0.0.1:<port>"` → `null`, so module deployments actually pick up the socket default instead of silently pinning loopback TCP. - **Docs**: new "Agent socket" section in docs/ops.md; option table, config reference, reverse-proxy warning, state-dir layout, and backup-exclusion list all reconciled. ## Proof - `internal/agentapi/socket_test.go`: stale-file/orphaned-socket/fresh recreation with 0700+ModeSocket asserts; real `Handler()` round-trip over the socket (401 without token, 200 with a seeded run token). - `internal/labctl/uds_test.go`: relative-path rejection, unreachable-socket dial error, `issue view`/`issue comment` round trip against the real agentapi handler over UDS (view output byte-identical to the TCP fixture), and `secret scan` (the hand-built streaming request) blocking a seeded leak / passing a clean range over the socket. - Config: `--agent-url unix:///…` accepted, relative rejected, `--base-url unix://…` still rejected; `TestLabURL` pins the new precedence. - Binary smoke (real `bin/lab` + `bin/labctl`, temp state dir): boot → `agent.sock` appears `srwx------`; `labctl issue list` over the socket reaches AuthMiddleware ("invalid run token", identical to TCP); SIGKILL → stale socket left → restart recreates and serves; SIGTERM → graceful shutdown, socket unlinked. - Gates: `go build ./...`, full `go test ./...`, `go vet ./...`, `gofmt -l` clean, and golangci-lint **2.12.2 (CI's exact pin)** → 0 issues. ## Notes for the maintainer - A literally-spawned host-mode run (provider session + forge) isn't reproducible in this sandbox; the UDS tests exercise the same client path `pr create` uses (`do()`, tested over the socket via `issue comment`), and the binary smoke covers the transport with production binaries. - Deployment: hosts that set `services.lab.agentUrl` explicitly (e.g. in Cloonar/nixos host config) will override the socket default until that setting is dropped — worth checking when the rev pin is bumped. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(agentapi): serve /agent/v1 on a unix socket; labctl learns unix:// LAB_URL (#201)
Some checks failed
ci / native (pull_request) Successful in 8m33s
ci-nix / flake-check (pull_request) Failing after 18m34s
a440271683
The agent API is now dual-listened: alongside the TCP listener, the same
run-token-authenticated handler is served on <state-dir>/agent.sock (0700,
stale socket removed and recreated on boot, unlinked on clean shutdown).
labctl accepts LAB_URL=unix:///abs/path in every subcommand — one client
seam, so secret exec/scan and the pre-push hook inherit it — with http(s)
behavior unchanged. Spawned runs now default to the socket: labURL drops
the --base-url/loopback fallbacks (the issue #30 SSO-hairpin failure mode),
--agent-url still wins and now also admits unix:///abs/path. The NixOS
module's agentUrl default flips to null so module deployments pick up the
socket default.

Closes #201

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

[autoland] verdict: reject

FAIL — CI aggregate is failure: ci-nix / flake-check red after 18m34s (runs/225). ci / native is green (runs/226, 8m33s), so the Go build/test/vet/lint path is fine; the break is nix-specific and PR-introduced.

Root cause (blocker)

This PR flips the NixOS module default services.lab.agentUrl to null (nix/module.nix:256), and the ExecStart only emits the flag lib.optionals (cfg.agentUrl != null) (nix/module.nix:78-80). Correct. But the flake's nixos-module regression check still asserts the old loopback default and was not updated — and flake.nix is not in this PR's diff at all:

  • flake.nix:232grep '^ExecStart=' "$unitPath" | grep -qF -- '"--agent-url" "http://127.0.0.1:8080"'

The dummy system (flake.nix:104-121) sets baseUrl but no agentUrl, so with the new null default the unit emits no --agent-url at all. Verified locally by evaluating the dummy lab.service ExecStart:

"--base-url" "https://lab.example.com"     # present
(no --agent-url token anywhere)

So the line-232 grep cannot match → the nixos-module check fails → nix flake check fails. That is the flake-check red.

Required fix (author)

Update the flake.nix:227-232 regression block to assert the new #201 socket-default behavior instead of the removed loopback flag — e.g. assert the default unit carries no --agent-url (the socket is lab's internal default, not a CLI flag), and refresh the issue-#30 rationale comment (lines 227-231) to the socket model. Optionally add a dummy that sets agentUrl = "unix:///…"/http to prove the flag still serializes when explicitly set. Re-run nix flake check -L locally (the **/*.nix path filter means any module change re-triggers this gate) before handing back.

Signal relied on: labctl pr checks 208 aggregate = failure; root cause confirmed by local nix eval of the dummy ExecStart.

[autoland] verdict: reject FAIL — CI aggregate is **failure**: `ci-nix / flake-check` red after 18m34s (runs/225). `ci / native` is green (runs/226, 8m33s), so the Go build/test/vet/lint path is fine; the break is nix-specific and PR-introduced. ## Root cause (blocker) This PR flips the NixOS module default `services.lab.agentUrl` to `null` (`nix/module.nix:256`), and the ExecStart only emits the flag `lib.optionals (cfg.agentUrl != null)` (`nix/module.nix:78-80`). Correct. But the flake's `nixos-module` regression check still asserts the **old** loopback default and was not updated — and `flake.nix` is not in this PR's diff at all: - `flake.nix:232` — `grep '^ExecStart=' "$unitPath" | grep -qF -- '"--agent-url" "http://127.0.0.1:8080"'` The dummy system (`flake.nix:104-121`) sets `baseUrl` but no `agentUrl`, so with the new null default the unit emits no `--agent-url` at all. Verified locally by evaluating the dummy `lab.service` ExecStart: "--base-url" "https://lab.example.com" # present (no --agent-url token anywhere) So the line-232 grep cannot match → the `nixos-module` check fails → `nix flake check` fails. That is the flake-check red. ## Required fix (author) Update the `flake.nix:227-232` regression block to assert the new #201 socket-default behavior instead of the removed loopback flag — e.g. assert the default unit carries **no** `--agent-url` (the socket is lab's internal default, not a CLI flag), and refresh the issue-#30 rationale comment (lines 227-231) to the socket model. Optionally add a dummy that sets `agentUrl = "unix:///…"`/http to prove the flag still serializes when explicitly set. Re-run `nix flake check -L` locally (the `**/*.nix` path filter means any module change re-triggers this gate) before handing back. Signal relied on: `labctl pr checks 208` aggregate = failure; root cause confirmed by local `nix eval` of the dummy ExecStart.
fix(nix): update flake nixos-module check for the null agentUrl default
All checks were successful
ci / native (pull_request) Successful in 9m2s
ci-nix / flake-check (pull_request) Successful in 17m50s
fa153846e8
The #201 socket default flipped services.lab.agentUrl to null so the
module emits no --agent-url flag, but the flake's nixos-module check
still asserted the removed loopback default, turning ci-nix/flake-check
red. Assert the default unit carries no --agent-url (the agent socket is
lab's internal default, not a CLI flag), refresh the issue-#30 rationale
comment to the socket model, and add a second text-level dummy proving
an explicitly-set agentUrl still serializes onto ExecStart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

[autoland] verdict: fix-done

[autoland] verdict: fix-done
Author
Owner

[autoland] verdict: pass

[autoland] verdict: pass
Sign in to join this conversation.
No reviewers
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
Cloonar/coding-lab!208
No description provided.