mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* 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.
185 lines
6.8 KiB
Go
185 lines
6.8 KiB
Go
// Package daemon implements zero's long-running daemon/server mode: a local
|
|
// control server that supervises a pool of headless `zero exec` worker processes
|
|
// and routes multiple agent sessions to them over an owner-only local socket.
|
|
//
|
|
// It reuses zero's existing building blocks rather than inventing new ones:
|
|
// - internal/background : child-process group setup + cross-platform terminate.
|
|
// - internal/streamjson : the line-based agent event protocol on worker stdio.
|
|
// - internal/cli (exec) : a worker is a `zero exec -i/-o stream-json` process.
|
|
//
|
|
// The control plane (this file) is a small framed codec mirrored from the
|
|
// reference daemon's protocol.js: a 4-byte big-endian length prefix, a 1-byte
|
|
// frame kind, then a JSON control payload, with a 1 MiB frame cap and a
|
|
// version-negotiation handshake. The agent event stream itself stays stream-json.
|
|
package daemon
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
)
|
|
|
|
// Wire constants. Mirrors reference-daemon-code-agent-js/protocol.js
|
|
// (FRAME_DATA/FRAME_CTRL, HEADER_SIZE, MAX_FRAME_SIZE, PROTO_VERSION).
|
|
const (
|
|
// ProtoVersion is the control-protocol version advertised in the handshake.
|
|
ProtoVersion = 1
|
|
// MaxFrameSize caps a single control frame payload at 1 MiB. A larger
|
|
// advertised length is rejected before any allocation (fail closed).
|
|
MaxFrameSize = 1 << 20
|
|
// frameHeaderSize is 4 bytes big-endian length + 1 byte kind.
|
|
frameHeaderSize = 5
|
|
)
|
|
|
|
// FrameKind tags a frame's payload. Mirrors protocol.js FRAME_DATA/FRAME_CTRL.
|
|
type FrameKind uint8
|
|
|
|
const (
|
|
// KindData carries an opaque payload (e.g. a stream-json line). Reserved for
|
|
// future use; the control plane uses KindCtrl.
|
|
KindData FrameKind = 0
|
|
// KindCtrl carries a JSON-encoded control message (see Ctrl).
|
|
KindCtrl FrameKind = 1
|
|
)
|
|
|
|
// ErrFrameTooLarge is returned when a frame's advertised payload length exceeds
|
|
// MaxFrameSize. The reader rejects it without allocating the buffer.
|
|
var ErrFrameTooLarge = errors.New("daemon: control frame exceeds size cap")
|
|
|
|
// ErrUnknownFrameKind is returned for a frame whose kind byte is not recognized.
|
|
var ErrUnknownFrameKind = errors.New("daemon: unknown control frame kind")
|
|
|
|
// WriteFrame writes a single framed message: [uint32BE len][uint8 kind][payload].
|
|
// It rejects an over-cap payload before writing anything so a buggy/hostile caller
|
|
// cannot emit a frame a conforming reader would refuse.
|
|
func WriteFrame(w io.Writer, kind FrameKind, payload []byte) error {
|
|
if len(payload) > MaxFrameSize {
|
|
return fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, len(payload), MaxFrameSize)
|
|
}
|
|
header := make([]byte, frameHeaderSize)
|
|
binary.BigEndian.PutUint32(header[:4], uint32(len(payload)))
|
|
header[4] = byte(kind)
|
|
if _, err := w.Write(header); err != nil {
|
|
return err
|
|
}
|
|
if len(payload) == 0 {
|
|
return nil
|
|
}
|
|
_, err := w.Write(payload)
|
|
return err
|
|
}
|
|
|
|
// ReadFrame reads a single framed message. It validates the advertised length
|
|
// against MaxFrameSize BEFORE allocating, so an oversize or hostile frame is
|
|
// rejected (ErrFrameTooLarge) rather than triggering a huge allocation. An
|
|
// unrecognized kind byte yields ErrUnknownFrameKind after the payload is drained
|
|
// so the stream stays in sync.
|
|
func ReadFrame(r io.Reader) (FrameKind, []byte, error) {
|
|
header := make([]byte, frameHeaderSize)
|
|
if _, err := io.ReadFull(r, header); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
length := binary.BigEndian.Uint32(header[:4])
|
|
if length > MaxFrameSize {
|
|
return 0, nil, fmt.Errorf("%w (%d > %d)", ErrFrameTooLarge, length, MaxFrameSize)
|
|
}
|
|
kind := FrameKind(header[4])
|
|
payload := make([]byte, length)
|
|
if length > 0 {
|
|
if _, err := io.ReadFull(r, payload); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
}
|
|
if kind != KindData && kind != KindCtrl {
|
|
return kind, nil, ErrUnknownFrameKind
|
|
}
|
|
return kind, payload, nil
|
|
}
|
|
|
|
// CtrlType is the discriminator for a control message.
|
|
type CtrlType string
|
|
|
|
const (
|
|
// CtrlHello is the client's opening handshake; Version is the highest control
|
|
// version it speaks.
|
|
CtrlHello CtrlType = "hello"
|
|
// CtrlHelloOK is the daemon's handshake reply with the negotiated Version.
|
|
CtrlHelloOK CtrlType = "hello_ok"
|
|
// CtrlRun asks the daemon to create/route a session and stream its output.
|
|
CtrlRun CtrlType = "run"
|
|
// CtrlAttach asks the daemon to attach the client to a running session's stream.
|
|
CtrlAttach CtrlType = "attach"
|
|
// CtrlStatus requests daemon/worker/session status.
|
|
CtrlStatus CtrlType = "status"
|
|
// CtrlStatusResult carries the status payload (see StatusReport in Status).
|
|
CtrlStatusResult CtrlType = "status_result"
|
|
// CtrlData carries one stream-json line of agent output back to the client.
|
|
CtrlData CtrlType = "data"
|
|
// CtrlAck acknowledges receipt/dispatch of a request (at-least-once dispatch).
|
|
CtrlAck CtrlType = "ack"
|
|
// CtrlEnd marks the end of a session's stream.
|
|
CtrlEnd CtrlType = "end"
|
|
// CtrlShutdown asks the daemon to drain and stop.
|
|
CtrlShutdown CtrlType = "shutdown"
|
|
// CtrlError carries a human-readable error for the client.
|
|
CtrlError CtrlType = "error"
|
|
)
|
|
|
|
// Ctrl is a control message carried in a KindCtrl frame as JSON. Unused fields
|
|
// are omitted so frames stay small.
|
|
type Ctrl struct {
|
|
Type CtrlType `json:"type"`
|
|
Version int `json:"version,omitempty"`
|
|
Session string `json:"session,omitempty"`
|
|
Cwd string `json:"cwd,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
Prompt string `json:"prompt,omitempty"`
|
|
// Line is one stream-json event line (for CtrlData).
|
|
Line string `json:"line,omitempty"`
|
|
// Message is a human-readable detail (for CtrlError / CtrlAck).
|
|
Message string `json:"message,omitempty"`
|
|
// Status is populated on CtrlStatusResult.
|
|
Status *StatusReport `json:"status,omitempty"`
|
|
}
|
|
|
|
// WriteControl marshals a control message into a KindCtrl frame.
|
|
func WriteControl(w io.Writer, msg Ctrl) error {
|
|
payload, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return WriteFrame(w, KindCtrl, payload)
|
|
}
|
|
|
|
// ReadControl reads the next frame and decodes it as a control message. A
|
|
// non-control frame yields ErrUnknownFrameKind so the caller fails closed.
|
|
func ReadControl(r io.Reader) (Ctrl, error) {
|
|
kind, payload, err := ReadFrame(r)
|
|
if err != nil {
|
|
return Ctrl{}, err
|
|
}
|
|
if kind != KindCtrl {
|
|
return Ctrl{}, ErrUnknownFrameKind
|
|
}
|
|
var msg Ctrl
|
|
if err := json.Unmarshal(payload, &msg); err != nil {
|
|
return Ctrl{}, fmt.Errorf("daemon: decode control message: %w", err)
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// NegotiateVersion picks the protocol version both ends can speak: the lower of
|
|
// the client's advertised version and ProtoVersion. A non-positive client
|
|
// version is invalid and yields ok=false so the daemon can reject the connection.
|
|
func NegotiateVersion(clientVersion int) (int, bool) {
|
|
if clientVersion <= 0 {
|
|
return 0, false
|
|
}
|
|
if clientVersion < ProtoVersion {
|
|
return clientVersion, true
|
|
}
|
|
return ProtoVersion, true
|
|
}
|