Files
zero/internal/daemon/protocol_test.go
KRATOS 135e3c5657 feat(daemon): zero daemon — worker pool + session supervisor over a local control socket (#203)
* feat(daemon): add `zero daemon` — worker pool + session supervisor over a local control socket

Adds internal/daemon + `zero daemon start|stop|status|run|attach`: a long-running
local service that supervises a pool of headless `zero exec` workers and routes
multiple agent sessions to them over an owner-only Unix-domain control socket.
Additive — interactive and one-shot exec are unchanged.

Reuses zero's building blocks:
- internal/background: ConfigureChildProcessGroup (process-group setup) +
  TerminateProcess (cross-platform terminate) for worker kill/drain. The pool
  does its own os/exec and uses background only for those helpers.
- internal/streamjson: the worker speaks stream-json on stdout; the daemon
  forwards those lines to clients.
- internal/cli exec: a worker is `zero exec --output-format stream-json`.

Pieces (reimplemented in Go from a reference daemon):
- protocol.go: framed control codec — 4-byte BE length + 1 kind byte, 1 MiB cap,
  JSON control messages, version negotiation.
- pool.go + backoff.go: bounded worker pool — at-least-once dispatch with
  exponential backoff+jitter retry capped by MaxAttempts, EXIT_TEMPFAIL(75)/
  EXIT_PERMANENT(76) handling, graceful drain + kill-timeout. zero's exec is
  one-shot, so workers are on-demand (one session per worker = the lease), not
  warm long-lived slots (a persistent worker mode is a follow-up).
- session.go: SessionManager — sessionID->worker routing, lease (one active
  request per worker), queue when the pool is full, per-session output buffer for
  attach + metrics.
- server.go + lock.go + socket.go + paths.go: single-instance PID-file lock with
  stale-lock (dead-PID) recovery, status file, owner-only socket, accept loop;
  cleans up lock/socket/status on exit.
- launcher.go: production worker launcher. client.go: control-socket client.

Security model:
- Control socket is LOCAL-ONLY (AF_UNIX loopback file, never a TCP port) and
  owner-only: socket 0600 + parent dir 0700 on POSIX (Windows relies on the
  per-user profile ACL; a dedicated pipe ACL is a follow-up).
- Workers stay sandboxed: the launcher SCRUBS the sandbox re-entrancy markers
  (ZERO_SANDBOXED / ZERO_SANDBOX_BACKEND) from each worker's env so the worker
  re-establishes its OWN sandbox instead of inheriting a pass-through plan, while
  propagating the rest of the policy config/env. The daemon never bypasses the
  sandbox.
- Control frames over the 1 MiB cap are rejected before allocation.

Robustness: a crashed worker never takes down the daemon (backoff+jitter retry,
MaxAttempts cap, EXIT_PERMANENT stops retry); drain gives a grace window then
force-kills stragglers; a stale lock from an unclean exit is reclaimed.

Out of scope (follow-up): remote session-linking; warm pre-spawned persistent
workers; Windows named-pipe ACL.

Tests: protocol round-trip + oversize/unknown-kind rejection; backoff bounds;
pool spawn/retry-backoff/permanent/cap/queue/drain; session lease/route/queue/
attach; lock single-instance + stale recovery; launcher env-scrub (incl. an
end-to-end check that the spawned worker process receives scrubbed env); server
e2e start->run->stream-json->status->attach->stop with a fake worker; CLI
usage/validation/not-running paths. Live-verified start/status/stop (0600 socket,
0700 dir, file cleanup). Platform files behind explicit //go:build tags.

* daemon: address review — help listing, extra-arg rejection, lock write error, bounded sessions

- cli/app.go: list `daemon` in `zero --help` so the command is discoverable.
- cli/daemon: `stop`/`status` reject extra arguments; `attach` rejects more than
  one session id (fail fast on typos instead of silently ignoring tokens).
- daemon/lock: check the PID fmt.Fprintf error — a partial/failed write would
  leave a malformed lock file that another process reads as stale and wrongly
  reclaims, breaking the single-instance guarantee; on write failure remove the
  file and return the error.
- daemon/session: bound the registry with MaxSessions (default 256) — evict the
  oldest FINISHED sessions once past the cap so a long-running daemon doesn't
  accumulate sessions without limit; running/queued sessions are never evicted, so
  attach-after-finish for recent sessions is preserved.

Tests: CLI extra-arg rejection; finished-over-cap eviction + running-never-evicted.
Gates: gofmt, vet, build, test (incl -race), staticcheck (no new), govulncheck (0),
deadcode (no new), GOOS=linux+windows cross-compile.
2026-06-14 22:16:37 +05:30

121 lines
3.4 KiB
Go

package daemon
import (
"bytes"
"encoding/binary"
"errors"
"io"
"strings"
"testing"
)
func TestFrameRoundTrip(t *testing.T) {
var buf bytes.Buffer
payloads := [][]byte{[]byte("hello"), {}, []byte(strings.Repeat("x", 4096))}
for _, p := range payloads {
if err := WriteFrame(&buf, KindCtrl, p); err != nil {
t.Fatalf("WriteFrame: %v", err)
}
}
for i, want := range payloads {
kind, got, err := ReadFrame(&buf)
if err != nil {
t.Fatalf("ReadFrame[%d]: %v", i, err)
}
if kind != KindCtrl {
t.Fatalf("frame[%d] kind = %d, want KindCtrl", i, kind)
}
if !bytes.Equal(got, want) {
t.Fatalf("frame[%d] payload = %q, want %q", i, got, want)
}
}
}
func TestControlRoundTrip(t *testing.T) {
var buf bytes.Buffer
msg := Ctrl{Type: CtrlRun, Session: "s1", Cwd: "/tmp", Args: []string{"--prompt", "hi"}}
if err := WriteControl(&buf, msg); err != nil {
t.Fatalf("WriteControl: %v", err)
}
got, err := ReadControl(&buf)
if err != nil {
t.Fatalf("ReadControl: %v", err)
}
if got.Type != CtrlRun || got.Session != "s1" || got.Cwd != "/tmp" || len(got.Args) != 2 {
t.Fatalf("round-trip mismatch: %+v", got)
}
}
func TestWriteFrameRejectsOversize(t *testing.T) {
var buf bytes.Buffer
err := WriteFrame(&buf, KindCtrl, make([]byte, MaxFrameSize+1))
if !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("WriteFrame oversize err = %v, want ErrFrameTooLarge", err)
}
if buf.Len() != 0 {
t.Fatalf("oversize WriteFrame must not emit any bytes, wrote %d", buf.Len())
}
}
func TestReadFrameRejectsOversizeBeforeAlloc(t *testing.T) {
// A header advertising a 4 GiB payload must be rejected without reading or
// allocating that payload.
header := make([]byte, frameHeaderSize)
binary.BigEndian.PutUint32(header[:4], MaxFrameSize+1)
header[4] = byte(KindCtrl)
_, _, err := ReadFrame(bytes.NewReader(header))
if !errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("ReadFrame oversize err = %v, want ErrFrameTooLarge", err)
}
}
func TestReadFrameUnknownKind(t *testing.T) {
var buf bytes.Buffer
// Hand-write a frame with an unknown kind byte (7) and a small payload.
header := make([]byte, frameHeaderSize)
binary.BigEndian.PutUint32(header[:4], 2)
header[4] = 7
buf.Write(header)
buf.Write([]byte("ab"))
_, _, err := ReadFrame(&buf)
if !errors.Is(err, ErrUnknownFrameKind) {
t.Fatalf("unknown kind err = %v, want ErrUnknownFrameKind", err)
}
}
func TestReadFrameTruncated(t *testing.T) {
// Header promises 10 bytes but only 3 follow → unexpected EOF.
var buf bytes.Buffer
header := make([]byte, frameHeaderSize)
binary.BigEndian.PutUint32(header[:4], 10)
header[4] = byte(KindCtrl)
buf.Write(header)
buf.Write([]byte("abc"))
_, _, err := ReadFrame(&buf)
if err == nil || errors.Is(err, ErrFrameTooLarge) {
t.Fatalf("truncated frame err = %v, want a read error", err)
}
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("truncated frame err = %v, want io.ErrUnexpectedEOF", err)
}
}
func TestNegotiateVersion(t *testing.T) {
cases := []struct {
client int
want int
wantOK bool
}{
{client: 1, want: 1, wantOK: true},
{client: 99, want: ProtoVersion, wantOK: true}, // clamp down to ours
{client: 0, want: 0, wantOK: false},
{client: -3, want: 0, wantOK: false},
}
for _, tc := range cases {
got, ok := NegotiateVersion(tc.client)
if got != tc.want || ok != tc.wantOK {
t.Fatalf("NegotiateVersion(%d) = (%d,%v), want (%d,%v)", tc.client, got, ok, tc.want, tc.wantOK)
}
}
}