Files
zero/internal/daemon/server.go
KRATOSandgnanam1990 f7ee189ba5 fix: resolve 15 findings from the 2026-06-20 deep audit (#281)
* docs(audit): 2026-06-20 deep adversarial audit report

Audit-only deliverable (no source changed). Fresh origin/main (bfbdbb1) deep
read across 13 subsystems with per-finding adversarial re-verification, plus
reconciliation of the 2026-06-10/2026-06-13 audits.

Result: build/vet clean; race detector finds 0 data races (67/68 pkgs; the
3 internal/tools failures are -race-only timing flakes). 35 surviving findings
(1 high, 15 medium, 17 low, 2 info); 64 prior findings fixed, 14 partial,
12 still-open.

* fix(providerhealth): strip credentials on cross-host redirect (AUDIT-M10)

The connectivity probe's CheckRedirect re-validated the redirect host against
the SSRF blocklist but never stripped auth headers. Go's stdlib only auto-strips
Authorization/Cookie/WWW-Authenticate on a host change — not x-api-key,
x-goog-api-key, or custom provider headers — so a baseURL that 3xx-redirects to
an attacker-controlled public host received the provider API key verbatim.

Thread the exact set of auth-bearing header names (kind default + profile
AuthHeader + CustomHeaders keys + well-known) into the client and Header.Del
them whenever the redirect target's host:port differs from the original.
Regression test asserts the creds are dropped cross-host and kept same-host.

* fix(sessions): fsync events.jsonl append for crash durability (AUDIT-M12)

appendEventLocked wrote the event into the page cache and returned success, then
durably wrote (fsync'd) the derived metadata. A crash after the metadata fsync
but before the events.jsonl page flushed left metadata.EventCount ahead of the
log — silently dropping the last appended event, including the checkpoint a
/rewind targets. Add file.Sync() before Close so the log is at least as durable
as the metadata derived from it.

(Note: the fsync itself has no portable unit-test seam; covered by the existing
sessions append/durability suite for no-regression.)

* fix(cron): DST fall-back double-fire + claim double-grant (AUDIT-M4, AUDIT-M5)

M4: fireJob/claimFire computed the next slot from the raw sub-second now(), so
schedule.Next's DST fall-back collapse guard (which only engages for a
minute-aligned 'after') never fired — a daily job in the repeated wall-clock hour
ran twice on the fall-back day. Feed sched.Next a minute-truncated instant at the
claim and the post-exec advance (the due-check still uses the real instant so
sub-minute NextRunAt jobs are unaffected).

M5: claimFire left NextRunAt unchanged when the schedule couldn't advance
(unparseable/exhausted spec), so two concurrent schedulers both saw the job due
and both fired it. Pause the job inside the same locked claim instead: the winner
still fires once and the post-exec keeps it paused; the loser sees a non-active
job and is refused.

Tests: claimFire double-claim on an unadvanceable job (second refused, job paused)
and a DST fall-back daily job advancing past the repeat (not re-firing it).

* fix(daemon): bound the control handshake + track bridge conns for Shutdown (AUDIT-M7, M8, I1)

The remote bridge clears the connection deadline before handing the conn to
Server.ServeConn, and the local socket set none, so handleConn's two ReadControl
handshake calls could block forever — an authenticated-but-idle remote peer
pinned a bridge connection slot (32 of them exhaust the bridge), and the local
path had the same unbounded read. Set a handshake deadline in handleConn and
clear it before the (long-lived) streaming dispatch (M7, I1).

ServeConn also bypassed trackConn, so Shutdown's conns-close sweep couldn't close
a remote conn stalled in that handshake read. Track/untrack it like the local
accept loop does (M8).

Tests: handleConn returns under a shortened handshake deadline when the peer
never sends hello; Shutdown closes a ServeConn'd conn well under the 10s deadline.

* fix(swarm): atomic rename-with-verify mailbox stale-lock break (AUDIT-M13)

The stale-break compared two consecutive reads of the SAME lock file
(string(data) == string(stale)), so the equality was always true and gave no
protection: two waiters could both observe a stale lock and both os.Remove it
while a third recreated it via O_EXCL, leaving two live writers to the same
per-agent inbox (split-brain). Replace with the proven rename-with-verify used by
the cron/hooks/oauth locks: rename the stale file aside (only one racer wins the
rename), re-check the moved file's mtime is still stale before deleting, and
rename it back if a holder rotated a fresh lock in the gap.

Tests: a genuinely stale lock is reclaimed (no .stale.* leftover); a fresh held
lock is never broken. (The double-break race itself is not deterministically
unit-testable; the fix mirrors the cron/hooks/oauth pattern.)

* fix(streamjson): anchor api_key redaction so prose isn't mangled (AUDIT-M1)

The api_key secret pattern used ["'=:\s]+ as the delimiter class, so a bare
space after the marker counted — 'the api_key: foo setting' and 'apiKey value
spans' had the following word replaced with [REDACTED] in every emitted
stream-json event (text deltas, tool output, final answer). Require a real
assignment delimiter (= or :) and a credential-length body, mirroring the
already-anchored sk-/bearer patterns. Prose survives; real api_key=... secrets
still redact.

* fix(tools): snapshot exec-session lastUsedAt under its lock to fix prune race (AUDIT-L15)

sessionToPruneLocked sorted on execSession.lastUsedAt while holding only
manager.mu, but touch() writes lastUsedAt under session.mu — a data race on a
time.Time that could torn-read and mis-select the prune victim. Snapshot each
session's lastUsedAt via a session.mu-guarded getter before sorting (lock order
stays manager.mu -> session.mu; no path takes them the other way). Adds a -race
regression test driving touch() concurrently with the prune selection.

* fix(swarm): release per-job context on MaxRuns completion (AUDIT-L13)

Scheduler.run returned on MaxRuns completion without calling job.cancel(), so the
job's derived context (and the goroutine context.WithCancel spawns to propagate
parent cancellation) leaked until the whole scheduler was cancelled at session
end. Add defer job.cancel() (idempotent with Cancel/Close) so it is released on
every exit path.

* fix(agent): route post-compaction and max-turns connects through streamWithReconnect (AUDIT-L1)

The main turn connect already retries transient disconnects via
streamWithReconnect, but the post-compaction retry connect, the reactive
mid-stream reconnect, and finalAnswerAfterMaxTurns called provider.StreamCompletion
directly — a single transient hiccup on those (e.g. the final summary after a long
autonomous/cron run, or the turn right after a context-limit compaction) failed the
whole run with no retry. All three are pre-content connects, so routing them through
the helper carries no OnText-duplication risk.

* fix(cli): reject trailing args after --skip-permissions-unsafe (AUDIT-L3)

zero --skip-permissions-unsafe "fix bug" silently discarded the prompt and
launched the interactive TUI, so a scripted one-shot unsafe invocation appeared
to hang. Reject any trailing positional arg loudly (after checking for a
misplaced --add-dir first, which keeps its specific error), pointing users at
the working form: zero exec --skip-permissions-unsafe -p "...".

* fix(provideroauth): error on present-but-wrong-shape chatgpt account-id claim (AUDIT-L11)

extractChatGPTAccountID returned ("", nil) both when the id_token had no
account-id claim AND when OpenAI's auth namespace was present but the account id
was missing/empty/non-string. The latter is a claim-shape change: the caller only
warns on a non-nil error, so it silently omitted the chatgpt-account-id header and
every Codex call 401'd with no diagnostic, indistinguishable from an expired
token. Return an error for the present-but-unusable case so the login warning
fires; a genuinely absent claim still soft-skips ("", nil).

* test(tools): widen exec-session poll windows so the suite is -race-clean (AUDIT report §2)

Three exec-session timing tests assumed a 250ms-sleep / immediate child would
finish within a fixed 1000ms yield. Under -race the child (the race-instrumented
test binary) starts slowly enough to overrun it, so the tool correctly returned a
still-running session_id instead of an exit_code and the asserts failed — the
non-green -race suite the audit flagged in §2. collect() returns as soon as the
session completes, so widening the poll window to 30s makes them deterministic
under -race without slowing the passing case. Test-only; no behavior change.

* docs(audit): mark remediation status (fixed/deferred/skipped) for the 2026-06-20 audit

* docs(audit): strip trailing whitespace from the report (fix git diff --check)

* fix(tools): drain PTY output before marking an exec session done (AUDIT-M14)

A TTY exec session's stdout was copied into the buffer by a fire-and-forget
io.Copy goroutine, but startSession's Wait goroutine called markDone (and then
removed the session) as soon as command.Wait() returned — before that goroutine
had necessarily drained the master FD. The command's final output chunk (e.g. a
test runner's last PASS/FAIL line) could be lost on exit, and since the exited
session is removed it could not be polled back. This also surfaced as the flaky
TestExecCommandTTYSessionAcceptsInputOnLinux on loaded CI.

Make the PTY cleanup (called after command.Wait, before markDone) join the copy
goroutine before closing the master: the child has exited so the master EOFs once
io.Copy finishes draining; waiting for that (bounded by bashWaitDelay) guarantees
all output is buffered before the session is marked done. Closing the master
first would truncate the unread tail, so the join precedes the Close.

Linux-only path (exec_pty_linux.go); verified via GOOS=linux build+vet (the TTY
test only runs on linux/CI).

* docs(audit): mark M14 fixed (PTY drain) in remediation status

---------

Co-authored-by: gnanam1990 <[email protected]>
2026-06-21 00:44:23 +05:30

374 lines
11 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"sync"
"time"
)
// Server is the daemon control plane. Mirrors reference-daemon-code-agent-js/
// supervisor.js (single-instance lock, status file, lifecycle) + the accept loop
// that routes framed control requests to the SessionManager/Pool. It listens on
// an owner-only local Unix socket and NEVER binds a TCP port.
type Server struct {
opts ServerOptions
startedAt time.Time
mu sync.Mutex // guards listener + conns
listener net.Listener
conns map[net.Conn]struct{} // open connections, closed on Shutdown so blocked reads return
lock *fileLock
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
shutdownOnce sync.Once
done chan struct{}
}
// ServerOptions configures a Server.
type ServerOptions struct {
Paths Paths
Manager *SessionManager
Pool *Pool
Version int
Now func() time.Time
Log func(string)
isAlive func(int) bool // test hook for the single-instance lock
}
// NewServer validates options and builds a Server.
func NewServer(opts ServerOptions) (*Server, error) {
if opts.Manager == nil || opts.Pool == nil {
return nil, errors.New("daemon: server requires a Pool and SessionManager")
}
if opts.Paths.Socket == "" || opts.Paths.Lock == "" || opts.Paths.Status == "" {
return nil, errors.New("daemon: server requires socket, lock, and status paths")
}
if opts.Version <= 0 {
opts.Version = ProtoVersion
}
if opts.Now == nil {
opts.Now = time.Now
}
ctx, cancel := context.WithCancel(context.Background())
return &Server{
opts: opts,
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
conns: map[net.Conn]struct{}{},
}, nil
}
func (s *Server) logf(format string, args ...any) {
if s.opts.Log != nil {
s.opts.Log(fmt.Sprintf(format, args...))
}
}
// Serve acquires the single-instance lock, binds the owner-only control socket,
// writes the status file, and serves connections until Shutdown. It blocks. On
// return it has released the lock and removed the socket/status files.
func (s *Server) Serve() error {
if err := checkSocketPathLength(s.opts.Paths.Socket); err != nil {
return err
}
if err := secureSocketParent(s.opts.Paths.Socket); err != nil {
return err
}
lock, err := acquireLock(s.opts.Paths.Lock, s.opts.isAlive)
if err != nil {
return err
}
s.lock = lock
defer s.cleanup()
// A leftover socket file from an unclean exit would make Listen fail with
// "address already in use"; we hold the lock, so any socket here is stale.
_ = os.Remove(s.opts.Paths.Socket)
listener, err := net.Listen("unix", s.opts.Paths.Socket)
if err != nil {
return fmt.Errorf("daemon: bind control socket: %w", err)
}
s.mu.Lock()
s.listener = listener
s.mu.Unlock()
// If Shutdown already fired during the bind window, close now and bail so a
// shutdown requested at startup is never lost (the accept loop would otherwise
// block forever waiting for a connection that never comes) (D4).
select {
case <-s.done:
_ = listener.Close()
s.wg.Wait()
return nil
default:
}
if err := hardenSocketFile(s.opts.Paths.Socket); err != nil {
return fmt.Errorf("daemon: harden control socket: %w", err)
}
s.startedAt = s.opts.Now()
if err := s.writeStatusFile(); err != nil {
return err
}
s.logf("daemon listening on %s (pid %d)", s.opts.Paths.Socket, os.Getpid())
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-s.done:
s.wg.Wait()
return nil // clean shutdown
default:
// Transient accept error during normal operation.
s.logf("accept error: %v", err)
return fmt.Errorf("daemon: accept: %w", err)
}
}
s.trackConn(conn)
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer s.untrackConn(conn)
s.handleConn(conn)
}()
}
}
// trackConn registers an accepted connection so Shutdown can close it. If a
// shutdown is already in progress it closes the connection immediately rather
// than serving it.
func (s *Server) trackConn(c net.Conn) {
s.mu.Lock()
select {
case <-s.done:
s.mu.Unlock()
_ = c.Close()
return
default:
}
s.conns[c] = struct{}{}
s.mu.Unlock()
}
func (s *Server) untrackConn(c net.Conn) {
s.mu.Lock()
delete(s.conns, c)
s.mu.Unlock()
}
// Shutdown stops accepting connections, cancels in-flight runs, drains the pool,
// and removes the socket/lock/status files. Safe to call multiple times.
func (s *Server) Shutdown() {
s.shutdownOnce.Do(func() {
close(s.done)
s.cancel() // stop in-flight pool runs
s.mu.Lock()
if s.listener != nil {
_ = s.listener.Close()
}
// Close every open connection so a handler blocked on an idle/hostile
// client's read returns at once and wg.Wait() can finish — otherwise a single
// stalled connection wedges shutdown (and SIGTERM cleanup) forever (D3).
for c := range s.conns {
_ = c.Close()
}
s.mu.Unlock()
s.opts.Pool.Drain()
})
}
func (s *Server) cleanup() {
if s.listener != nil {
_ = s.listener.Close()
}
_ = os.Remove(s.opts.Paths.Socket)
_ = os.Remove(s.opts.Paths.Status)
if s.lock != nil {
_ = s.lock.release()
}
}
func (s *Server) writeStatusFile() error {
status := StatusFile{
PID: os.Getpid(),
Socket: s.opts.Paths.Socket,
Version: s.opts.Version,
StartedAt: s.startedAt,
}
data, err := json.MarshalIndent(status, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(s.opts.Paths.Status, data, 0o600); err != nil {
return fmt.Errorf("daemon: write status file: %w", err)
}
return nil
}
// ServeConn runs the control protocol (handshake + one command) on an
// already-established connection, reusing the exact local dispatch path. The
// remote bridge calls it AFTER authenticating a TLS connection, so a remote
// session is handled identically to a local one (same SessionManager/Pool, same
// sandbox/risk model) — remote never bypasses the local controls. It closes conn.
func (s *Server) ServeConn(conn net.Conn) {
// Track the conn so Shutdown can close it: the remote bridge enters here (not via
// the local accept loop), so without this a remote connection stalled in the
// pre-stream handshake read would survive Shutdown's conns-close sweep. (AUDIT-M8)
s.trackConn(conn)
defer s.untrackConn(conn)
s.handleConn(conn)
}
// handleConn performs the handshake then dispatches a single control command.
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
// Bound the handshake (hello + first command). The remote bridge clears the conn
// deadline before handing off and the local socket sets none, so an idle/hostile
// peer that connects but never completes the exchange would otherwise pin a
// handler goroutine (and, on the bridge, a connection slot) forever. Cleared once
// the command is read, before the (long-lived) streaming phase. (AUDIT-M7, AUDIT-I1)
_ = conn.SetDeadline(time.Now().Add(handshakeTimeout))
hello, err := ReadControl(conn)
if err != nil {
return
}
if hello.Type != CtrlHello {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "expected hello"})
return
}
version, ok := NegotiateVersion(hello.Version)
if !ok {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "unsupported protocol version"})
return
}
if err := WriteControl(conn, Ctrl{Type: CtrlHelloOK, Version: version}); err != nil {
return
}
cmd, err := ReadControl(conn)
if err != nil {
return
}
// Handshake complete — the dispatched command may stream indefinitely, so drop
// the deadline before handing off.
_ = conn.SetDeadline(time.Time{})
switch cmd.Type {
case CtrlRun:
s.handleRun(conn, cmd)
case CtrlAttach:
s.handleAttach(conn, cmd)
case CtrlStatus:
s.handleStatus(conn)
case CtrlShutdown:
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Message: "shutting down"})
s.Shutdown()
default:
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: fmt.Sprintf("unknown command %q", cmd.Type)})
}
}
func (s *Server) handleRun(conn net.Conn, cmd Ctrl) {
if cmd.Session == "" {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "run requires a session id"})
return
}
args := cmd.Args
if cmd.Prompt != "" {
args = append(args, "--prompt", cmd.Prompt)
}
sess, err := s.opts.Manager.Start(s.ctx, WorkerSpec{Session: cmd.Session, Cwd: cmd.Cwd, Args: args})
if err != nil {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
return
}
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: sess.ID()})
buffered, live, cancel := sess.Subscribe()
defer cancel()
s.streamToClient(conn, sess, buffered, live)
}
func (s *Server) handleAttach(conn net.Conn, cmd Ctrl) {
if cmd.Session == "" {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: "attach requires a session id"})
return
}
buffered, live, cancel, err := s.opts.Manager.Attach(cmd.Session)
if err != nil {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Message: err.Error()})
return
}
defer cancel()
sess, _ := s.opts.Manager.Get(cmd.Session)
_ = WriteControl(conn, Ctrl{Type: CtrlAck, Session: cmd.Session})
s.streamToClient(conn, sess, buffered, live)
}
// streamToClient writes the buffered history then live lines as CtrlData frames,
// finishing with CtrlEnd (or CtrlError if the session failed). A write error
// (client disconnected) ends the stream without affecting the session.
func (s *Server) streamToClient(conn net.Conn, sess *Session, buffered []string, live <-chan string) {
for _, line := range buffered {
// Honor shutdown during history replay too: a large buffer would otherwise
// keep writing after Shutdown, delaying drain (D5).
select {
case <-s.done:
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
return
default:
}
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
return
}
}
for {
select {
case line, ok := <-live:
if !ok {
s.finishStream(conn, sess)
return
}
if err := WriteControl(conn, Ctrl{Type: CtrlData, Line: line}); err != nil {
return
}
case <-s.done:
_ = WriteControl(conn, Ctrl{Type: CtrlEnd, Message: "daemon shutting down"})
return
}
}
}
func (s *Server) finishStream(conn net.Conn, sess *Session) {
if sess != nil {
if err := sess.Err(); err != nil {
_ = WriteControl(conn, Ctrl{Type: CtrlError, Session: sess.ID(), Message: err.Error()})
return
}
}
_ = WriteControl(conn, Ctrl{Type: CtrlEnd})
}
func (s *Server) handleStatus(conn net.Conn) {
report := StatusReport{
PID: os.Getpid(),
Version: s.opts.Version,
Socket: s.opts.Paths.Socket,
StartedAt: s.startedAt,
PoolSize: s.opts.Pool.Size(),
Workers: s.opts.Pool.WorkerStats(),
Sessions: s.opts.Manager.Statuses(),
QueueDepth: s.opts.Pool.QueueDepth(),
}
_ = WriteControl(conn, Ctrl{Type: CtrlStatusResult, Status: &report})
}