Compare commits

..
Author SHA1 Message Date
Gnanam 8b7b8296ac tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019)
The mouse classification helpers checked both the current Button/Action pair and
the deprecated msg.Type / tea.Mouse* constants. Bubble Tea's parser always
populates Button+Action and only DERIVES Type from them, so the Type fallbacks
were pure redundancy (a left-button drag is Action==Motion, already covered by
mouseMotion). Drop them — clears all 11 SA1019 findings in internal/tui with no
behavior change. The left-drag test now asserts the Action==Motion path directly.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(SA1019 0)/
deadcode(no new) all pass.
2026-06-15 19:08:26 +05:30
KRATOS f857c4afb4 feat(daemon): TLS remote bridge — authenticated network access to the daemon (#211)
* feat(daemon): TLS remote bridge — authenticated network access to the daemon

Add internal/daemon/remote: an OPT-IN, TLS-only network bridge on top of the
local daemon. A remote client drives the SAME SessionManager/Pool over the
network behind bearer-token auth, reusing the daemon's framing and dispatch
unchanged. The local Unix-socket daemon stays the default and is unchanged.

- Bridge: per accepted TLS connection, authenticate THEN hand to the daemon's
  control dispatch via the new Server.ServeConn seam — a remote session runs
  under the same sandbox/risk model as a local one (never bypasses it).
- Authenticator + TokenAuthenticator: constant-time bearer-token check, token
  from $ZERO_DAEMON_REMOTE_TOKEN or a file; optional attestation hook (no-op
  default). Auth happens before any control frame; failure closes the connection
  after a small backoff; tokens are never logged.
- RemoteClient (DialRemote): verified TLS dial (never InsecureSkipVerify) + auth
  handshake, then the existing daemon.Client via the new NewClientConn seam, so
  remote and local share one protocol.
- Fail-closed: TLS mandatory (refuses without cert/key), a configurable
  min-protocol-version floor, the 1 MiB frame cap reused, and a bounded
  concurrent-connection cap that refuses overflow.
- CLI: `zero daemon serve-remote --addr --tls-cert --tls-key`; `run`/`attach`
  gain `--remote <addr> [--token --ca-cert --server-name]`.

Daemon seams added (additive, behavior unchanged): Server.ServeConn,
NewClientConn. SessionLink (event POST) and git-bundle repo transfer are
documented follow-ups.

Tests cover auth accept/reject (env + file), TLS-required, cert verification,
version floor, run/status/attach round-trip, re-attach resumption, connection
cap, and ListenAndServeTLS/Close. Gates: gofmt / vet / build (host+linux+
windows) / test -race / staticcheck (no new) / govulncheck (0) / deadcode
(no new) all pass.

* swarm: de-flake TestMailboxConcurrentSends on Windows CI

The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)

* remote: address CodeRabbit re-review (TLS dial + Close idempotency)

- client: replace the deprecated, non-cancellation-aware tls.DialWithDialer with
  a context-aware tls.Dialer.DialContext (resolves the golangci-lint noctx CI
  blocker; the context bounds the dial + TLS handshake).
- bridge: Close now clears b.listener under the lock, so a repeat Close is a
  no-op instead of returning a closed-listener error (matches its doc). Test
  asserts a double Close succeeds.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
2026-06-15 18:48:07 +05:30
KRATOS bb7de29c80 feat(oauth): reusable provider OAuth engine + zero auth CLI (#210)
* feat(oauth): reusable provider OAuth engine + `zero auth` CLI

Add internal/oauth: a transport/identity-agnostic OAuth 2.0 engine that powers
provider login alongside the MCP-server OAuth zero already has. It is additive
and provider-neutral — NO providers, endpoints, or client identities are baked
into the binary; every provider is configured entirely via ZERO_OAUTH_<NAME>_*
env vars, so the engine works with any OAuth 2.0 / OIDC server.

Engine (internal/oauth):
- PKCE (S256 mandatory; "plain" refused) + per-flow CSRF state.
- Authorization-code flow: BuildAuthorizationURL / ExchangeCode / Refresh, with
  an https-only token-endpoint guard (loopback exempt) and error bodies that
  carry only error/error_description (never token material).
- Loopback callback server: 127.0.0.1-only, OS-assigned port, single use, state
  verified, then closed.
- Device-authorization grant (RFC 8628): RequestDeviceCode + PollDeviceToken
  honoring authorization_pending / slow_down / interval / expiry (headless/SSH).
- RFC 8414 + OIDC discovery (issuer-driven endpoint resolution).
- Namespaced token store ("provider:<name>" / "mcp:<name>"), 0600, file-locked
  (ownership-aware), atomic writes, malformed-fails-closed; GetFresh (on-demand
  refresh) + Handle401 (forced refresh).
- Opt-in proactive RefreshScheduler.

CLI (zero auth):
- login <provider> [--device] [--scope ...], logout, status [provider],
  refresh <provider> [--watch]. Status/output never print token material.
- Registered in app.go dispatch + top-level help.

MCP OAuth (internal/mcp) is left byte-for-byte unchanged (zero regression);
sharing the engine with MCP and encrypted-at-rest/keyring storage are documented
follow-ups. Tests cover every path (PKCE, auth URL, https-guard + redaction,
exchange/refresh, loopback capture + state-mismatch + timeout, device pending/
slow_down/expired, store namespacing/0600/locking, provider env resolution,
manager login (loopback+device)/GetFresh/Handle401, scheduler). Gates: gofmt /
vet / build (host+linux+windows) / test -race / staticcheck (no new) /
govulncheck (0) / deadcode (no new) all pass.

* oauth: fix Windows smoke — use OS-appropriate path in store-path test

TestResolveStorePathHonorsOverride hardcoded a unix "/tmp/custom/tok.json"
literal, which is not absolute on Windows (no drive), so ResolveStorePath
correctly resolves it against the current drive and the verbatim comparison
failed (D:\tmp\custom\tok.json). The production code is correct; the test now
builds the override with filepath.Join(t.TempDir(), ...) so it is absolute and
clean on every OS.

* oauth: address CodeRabbit review (11 findings)

Security / correctness:
- flow: caller-supplied ExtraAuthParams/extraParams can no longer override the
  reserved OAuth/PKCE fields (state, redirect_uri, code_challenge[_method],
  response_type, client_id) — e.g. forcing method=plain.
- device: a device-authorization response without expires_in now gets a bounded
  default lifetime (fail closed), and the poll loop re-checks expiry AFTER the
  interval sleep so it never polls past the deadline (e.g. after slow_down).
- lock: handle WriteString/Close errors on the token lock file — a partial write
  no longer strands an undeletable lock.
- store: a non-nil env map is now authoritative (hermetic) — a controlled map
  never falls back to ambient ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME.
- scheduler: a transient loadForKey error backs off and retries (bounded) instead
  of permanently stopping proactive refresh.
- cli auth: reject flags a subcommand does not accept (e.g. login --watch,
  status --device) and reject empty --scope values — fail fast.

Lint blockers:
- manager: restructured the discovery fallback so the non-nil-err branch no longer
  returns nil (nilerr).
- providers: dropped the ineffectual flow initializer (ineffassign).

Tests: assert Save/Load errors in the scheduler test; the slow_down device test
no longer depends on post-expiry polling. Added tests for reserved-param
stripping, hermetic env, device default-expiry, and CLI flag validation. The
store-path test was already made OS-portable in the Windows-smoke fix.

Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.

* swarm: de-flake TestMailboxConcurrentSends on Windows CI

The 200-way concurrent-send stress test inherited from the swarm package timed
out on Windows CI (103/200 sends hit the 10s lock timeout): the lock's 20ms
retry sleep starves waiters when Windows file ops are slow under heavy
contention. Shorten the retry to 2ms (a freed lock is re-acquired promptly) and
raise the test's lock timeout to 60s so a legitimately-slow CI never times a send
out. Passes repeatedly under -race. (CI de-flake of a test that surfaced on
#210's Windows smoke; functionally unrelated to the OAuth changes.)

* oauth: address CodeRabbit re-review (4 findings)

- loopback (security): NewLoopbackListener refuses an empty CSRF state — an empty
  state would match a callback carrying no state at all, defeating the check.
- device (correctness): RequestDeviceCode now sends client_secret when set, so a
  confidential client authenticates on the device-authorization endpoint too
  (consistent with the token poll).
- cli auth: document --watch in the help Flags section.
- scheduler test: assert the seed Save error instead of ignoring it.

Also scrubbed a stray third-party reference from a loopback.go comment.
Tests added: empty-state rejection, device client_secret is sent. Gates:
gofmt/vet/build/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new)
all pass.

* mcp(oauth): delegate the OAuth engine to internal/oauth (dedup) (#212)

OAuth Phase 2: the MCP OAuth code now reuses the shared internal/oauth engine
instead of carrying its own copy of the transport/identity-agnostic primitives.
internal/mcp/oauth.go drops ~110 lines (526 -> 418): newPKCE, newState,
discoverAuthorizationServer, the authorize-URL builder, token exchange/refresh,
the token-response parser, and joinWellKnown now delegate to internal/oauth.

Behavior-preserving: MCP keeps its own OAuthConfig/StoredToken types,
LoginOptions/Login orchestration, loopback handling, registerClient (dynamic
client registration — not part of the shared engine), CLI output, and on-disk
token format (mcp-oauth-tokens.json, raw server-name keys). The existing mcp
oauth tests pass UNCHANGED. The only behavioral delta is a hardening inherited
from the shared engine: the token endpoint must be https (loopback exempt) —
a credential is no longer sent to a plaintext non-loopback endpoint.

Stacks on #210 (the internal/oauth package). Gates: gofmt/vet/build(host+
linux+windows)/test -race/staticcheck(no new)/govulncheck(0)/deadcode(no new
vs base) all pass; `zero mcp oauth status` output is identical.
2026-06-15 18:47:43 +05:30
de5d402e1f web_search: add domains allowlist + score field (#209)
* web_search: add domains allowlist + score field

The web_search tool now accepts an optional 'domains' array that filters
results to the listed hostnames before they reach the model. This is the
zero-only prompt-injection defense that none of the major CLIs ship: a
model that the user has constrained to a known-good set of domains cannot
be tricked into fetching arbitrary pages from search results.

Also surfaces the provider's optional 'score' field on each result row
when present (zero/absent scores are not rendered, to keep the common
case tidy). The 'score' field is parsed from the generic JSON shape
already understood by the httpSearchBackend.

Filters are applied after the search returns, before the empty-result
short-circuit, so a non-empty allowlist that ate every hit surfaces as a
clear 'no results matched domains' error rather than a misleading
'no results' message.

Co-Authored-By: Claude <[email protected]>

* web_search: fail closed on all-invalid domains, strip :port on canonicalize

CodeRabbit review fixes:

1. canonicalizeWebSearchHost: switch parsed.Host -> parsed.Hostname() so
   an allowlist entry like 'https://react.dev:443/path' normalizes the same
   as 'react.dev'. The previous code left ':443' in the string, which
   silently broke every match against a result URL on the default port.

2. stringListArgWebSearch: return a 'provided' bool so Run can distinguish
   'no allowlist' from 'allowlist that normalized to empty'. The previous
   code returned len(domains)==0 in both cases, and Run fell through to
   the unfiltered path — defeating the prompt-injection defense the
   parameter exists to provide. Now we error fail-closed.

3. Tests:
   - TestWebSearchDomainsFilterRejectsAllInvalidInputs: locks in the
     fail-closed contract for all-whitespace, all-empty, embedded-space,
     and mixed-invalid allowlists.
   - TestWebSearchDomainsFilterAcceptsHostPort: locks in the
     Hostname()-strips-port fix for the scheme'd case and asserts that
     scheme-less 'host:port' is rejected.
   - TestCanonicalizeWebSearchHostStripsPort: the direct unit-level
     regression test for canonicalizeWebSearchHost.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-06-15 18:27:21 +05:30
KRATOS 9ddef0e957 feat(swarm): multi-agent swarm over the specialist sub-agent (#207)
* feat(swarm): multi-agent swarm over the specialist sub-agent

Add internal/swarm so an orchestrator can spawn and coordinate MULTIPLE
specialist members concurrently under one team, communicate via per-agent
mailboxes, hand work off, and adopt orphaned tasks. Additive and opt-in: the
single Task tool is unchanged; swarm tools only act when invoked.

- Registry/Definition: agent roster (built-in teammate/subagent + user-defined).
- Mailbox: per-team/per-agent JSON inbox, owner-only 0600, file-locked with
  stale-break, size-capped, path-confined, and malformed-fails-closed.
- Coordinator: in-memory task registry with terminal-state guards and stable
  per-agent colors.
- Team: bounded concurrent members per team with a FIFO overflow queue that
  drains one-per-slot.
- Lifecycle: spawn / handoff / orphan-adoption with bounded relaunch on
  temporary failures; members run under the Swarm's base context so they
  outlive the spawn call.
- Tools: swarm_spawn, swarm_send, swarm_inbox, swarm_status, swarm_handoff,
  swarm_collect.
- Wiring: each member is launched through specialist.Executor and inherits the
  orchestrator's model + permission mode (never widened) and runs under the same
  sandbox/policy. Mailbox state lives under the workspace so writes fall within
  the sandbox write rules. Swarm lifetime is paired with the specialist runtime.

Scheduling (cron/wakeup) and daemon-pool-backed members are deferred follow-ups.

* swarm: address CodeRabbit review (7 findings)

- coordinator: SetStatus rejects unknown TaskStatus values (fail closed) so a
  task can never enter a state Summarize/lifecycle don't reason about.
- permission inheritance ENFORCED end-to-end (was resolved but discarded):
  thread PermissionMode through specialist.TaskRunOptions -> BuildArgs/
  BuildResumeArgs. A headless specialist child now runs "--auto high" (unsafe)
  ONLY when the parent is unsafe or no mode is given (preserves the Task tool's
  behavior); any other parent mode yields non-unsafe "--auto low", so a member
  never gains more authority than its orchestrator. Also fixed the swarm
  permission vocabulary to the real agent runtime modes (ask/spec-draft/auto/
  unsafe) instead of TUI display names, and the launcher substitutes "auto" for
  an empty mode so a member can't fall into the empty-means-high path.
- mailbox: symlink-aware confinement (EvalSymlinks) rejects an inbox dir that
  resolves outside BaseDir; dirs tightened to 0700 even if pre-existing with
  broad perms; lock release is ownership-token-aware so a stale-broken writer
  can't delete a newer writer's lock (split-brain).
- member: recover panics inside a member goroutine -> fails that member only,
  never crashes the orchestrator.
- tools: collapse() truncates on rune boundaries (no invalid UTF-8).

Tests added for every fix. Existing specialist behavior is unchanged (empty
PermissionMode keeps --auto high). Gates: gofmt/vet/build (host+linux)/test
-race/staticcheck(no new)/govulncheck(0)/deadcode(no new) all pass.

* swarm: address review round 2 (Windows lock + non-blocking notes)

Blocking (Vasanth): mailbox acquireLock now treats Windows ERROR_ACCESS_DENIED
/ ERROR_SHARING_VIOLATION as lock-contended (retry/wait) instead of a fatal
error, via a platform-aware isLockContended (lock_windows.go / lock_other.go).
A contended lock under Windows file-sharing semantics no longer fails a
send/read spuriously. TestMailboxConcurrentSends raised to 200 goroutines with a
10s lock timeout and now asserts zero send failures (regression guard).

Non-blocking notes addressed:
- Removed third-party reference-project names from doc comments (clean-room +
  provider-neutral); the Go code is original.
- Documented the inbox-file size bound's ×2 (pretty-print overhead) constant.
- Documented Send's O(N) rewrite cost + when to switch to append-only.
- permissionRank: spec-draft now ranks below ask (it never widens authority).
- Documented watch's per-member-id retry binding (Member.ID == MemberSpec.ID).
- handoff Safety.Reason now also mentions the inbox write.

Gates: gofmt · vet · build (host+linux+windows) · test -race · staticcheck
(no new) · govulncheck (0) · deadcode (no new) all pass.
2026-06-15 15:10:21 +05:30
KRATOS ba8fbb8b71 feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection (#206)
* feat(sandbox): complete sandbox breadth — WSL fallback + opt-in MITM TLS inspection

Adds the two remaining sandbox features on top of the strict fail-closed core
(the other four — SOCKS5 egress, re-entrancy guard, fine-grained path lists,
sandbox-aware ripgrep — already shipped and are untouched). Both new features are
opt-in and the DEFAULT policy is unchanged (enforce + network deny, InspectTLS
off; AllowPolicyOnlyRunner keeps its existing default).

1) WSL detection + safe fail-closed fallback
   - parseWSL/detectWSL (wsl.go + wsl_linux.go reading /proc/version; wsl_other.go
     stub) classify WSL/WSL2.
   - SelectBackend: on Linux without bubblewrap, if running under WSL it selects a
     new BackendWSL instead of silently degrading to a plain policy-only runner.
   - BuildCommandPlan(BackendWSL): FAILS CLOSED unless Policy.AllowPolicyOnlyRunner
     is set (errWSLPolicyOnlyDisabled explains how to opt in). When allowed it runs
     the policy-only fallback that still routes egress through the filtering proxy
     (Backend.ProxyEgress => EnforcesScopedEgress, best-effort HTTP+SOCKS), sets
     ZERO_SANDBOXED=1 / ZERO_SANDBOX_BACKEND=wsl, and records an auditable
     least-privilege downgrade note (CommandPlan.Notes). Never runs unsandboxed
     silently.

2) Optional gated MITM TLS inspection (Policy.InspectTLS, default OFF)
   - Off => the egress proxy is a pure CONNECT passthrough, byte-for-byte
     unchanged (no CA generated).
   - On => the proxy terminates TLS toward the sandboxed client with an EPHEMERAL,
     locally generated ECDSA CA (mitm.go), minting per-host leaves on the fly, so
     it can re-check the DECRYPTED request Host through the SAME
     authorize()/domainAllowed() gate (deny-wins, empty allowlist => deny) — MITM
     widens visibility, never authority. The UPSTREAM dial is ALWAYS validated
     against the system roots (never InsecureSkipVerify; tests inject a known root
     pool to exercise validation). The CA's PUBLIC cert is written owner-only and
     surfaced via ZERO_SANDBOX_CA_CERT; MITM only runs on backends that already
     permit reading it (sandbox-exec allows file-read; WSL shares the host fs), so
     no new bind path is introduced. Trust implications documented loudly on the
     field. (No "raw passthrough only" flag exists in zero, so the mutual-exclusion
     case is N/A.)

Tests: parseWSL table; WSL fail-closed-without-opt-in and policy-only-with-proxy
(markers + ProxyEnvWithSocks) + deny-mode markers-no-proxy; MITM passthrough-by-
default (no CA), CA mints a chaining leaf, full TLS-terminate-and-forward of an
allowlisted host (upstream validated via an explicit root pool), and decrypted-
Host deny. Gates: gofmt, go vet (host + GOOS=linux), go build (host/linux/
windows), go test (incl -race), staticcheck (no new, host + linux), govulncheck
(0), deadcode -test=false under GOOS=linux (no new unreachable funcs).

* sandbox: address review on #206 (WSL + MITM)

- mitm: bound the upstream transport's TCP connect (DialContext 30s) so a
  blackholed upstream can't park a goroutine; disable upstream HTTP/2 and
  normalize the response to HTTP/1.1 before writing to the HTTP/1.1 client.
- mitm: mint leaves for the AUTHORIZED CONNECT host (not arbitrary client SNI)
  and cap the leaf cache (512) so SNI churn can't drive unbounded keygen/memory.
- mitm: re-check the decrypted Host with the FULL authorize gate
  (authorizeTarget) so a DomainPrompt-allowed host is honored consistently with
  the CONNECT check, not just domainAllowed.
- runner (WSL): preserve the caller's env in the direct-run WSL plan (it is not
  OS-wrapped), appending the sandbox markers last; the downgrade note says
  "with proxy egress" only when a proxy was actually started.
- tests: WSL caller-env preservation; deny-mode asserts no HTTP/HTTPS/ALL_PROXY
  leak + the downgrade note; MITM forward asserts HTTP 200; mitmConnect sets an
  I/O deadline.

Gates: gofmt, vet (host+linux), build (host/linux/windows), test (incl -race),
staticcheck (no new), govulncheck (0), deadcode -test=false (GOOS=linux) no new.
2026-06-15 11:38:14 +05:30
Vasanth TandClaude 265aa52ba7 Polish /permissions command output into a bounded card (#205)
* Convert /permissions output to bounded command card

Replace status: ok style with commandCard format used by /tools and /context.

Summary shows mode and grant count; grants render as bullet rows.

Co-Authored-By: Claude <[email protected]>

* Address CodeRabbit nitpick: add error path test for /permissions

Adds TestPermissionsCommandCardHandlesGrantListError and a grantLister

interface so tests can inject a stub store without a real file path.

Co-Authored-By: Claude <[email protected]>

* Fix gofmt formatting in permissions card

Tabs alignment only; no functional change.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
2026-06-15 11:37:51 +05:30
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
KRATOS 355b76cc78 sandbox: exempt first-party network tools from the Evaluate network-deny too (#202)
Follow-up to the EnforceToolNetwork change: web_search/web_fetch were still
hard-denied under NetworkDeny because the engine-level gate in Evaluate
(netMode==NetworkDeny && risk has "network") fired BEFORE the tool's
RunWithSandbox/NetworkHostAllowed ever ran. The earlier change only relaxed
NetworkHostAllowed, so the user-facing path (Evaluate -> registry) still blocked
the tool with "network access is blocked by sandbox policy".

Fix: Evaluate now skips the network-deny for a request that declares
SideEffectNetwork (the first-party in-process tools, web_search/web_fetch) when
EnforceToolNetwork is off — mirroring the NetworkHostAllowed exemption so the two
gates agree. A SHELL command merely classified as network (SideEffectShell) is
NOT exempt, so shell egress stays blocked under deny. EnforceToolNetwork restores
the strict deny for the tools.

Tests: TestEvaluateExemptsNetworkToolsFromDenyByDefault covers the full path
(default-exempt, granted->allow, EnforceToolNetwork->deny, shell stays denied);
the two existing gate tests now opt into EnforceToolNetwork to keep testing the
strict path. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new),
govulncheck (0), deadcode (no new unreachable funcs).
2026-06-14 20:36:51 +05:30
Anandan 6a9f44a716 Polish TUI transcript and reasoning display (#200)
* Polish TUI transcript and reasoning display

* Address provider and transcript review fixes

* Gate inline think tag parsing

* Cover disabled think tag parsing config
2026-06-14 18:55:18 +05:30
Vasanth T e1f1b111a1 Add command dashboard cards (#201)
* Add context command dashboard card

* Render tools command as dashboard card

* Render command cards as styled TUI cards

* Fix command card action rendering

* fix: harden command card edge cases
2026-06-14 18:34:46 +05:30
Vasanth T 284a948f00 feat: add doctor fix flow (#198)
* feat: add doctor fix flow

* fix: animate doctor connectivity status

* fix: make doctor report problem first

* fix: address doctor review feedback
2026-06-14 17:54:00 +05:30
KRATOS 671af594fb feat(sandbox): harden internal/sandbox — re-entrancy guard, SOCKS5 egress, sandbox-aware search, fine-grained path lists (#199)
* feat(sandbox): harden internal/sandbox with four opt-in capabilities

All four are off by default and fail closed; existing behavior is unchanged
when the new policy fields are empty / ZERO_SANDBOXED is unset.

1. Re-entrancy guard — ZERO_SANDBOXED=1 marks every wrapped command; a nested
   BuildCommandPlan returns a pass-through plan (no double bwrap/sandbox-exec,
   no second egress proxy). New: EnvSandboxed, IsAlreadySandboxed().

2. SOCKS5 egress — the scoped-egress proxy also serves SOCKS5 CONNECT on a
   second loopback port through the SAME authorize()/domainAllowed() gate.
   ProxyEnvWithSocks points ALL_PROXY at socks5://<addr>; HTTP(S)_PROXY stay on
   the HTTP listener; the sandbox-exec profile allows both proxy ports.
   Bubblewrap scoped still collapses to deny.

3. Sandbox-aware search — ReadExclusionGlobs(policy, scope) returns ripgrep
   --glob '!<pat>' for DenyRead subtrees; grep/glob skip read-denied subtrees
   at walk time via Engine.ReadPathExcluded/ReadDirExcluded when sandboxed.

4. Fine-grained path lists — Policy.AllowRead/DenyRead/AllowWrite/DenyWrite.
   Read = deny-then-allow (more-specific AllowRead re-includes); Write =
   DenyWrite > workspace/scope > AllowWrite > deny. AllowWrite is reflected in
   the OS write binds; DenyWrite is emitted as sandbox-exec deny rules. Each
   entry is home-expanded, made absolute, and symlink-resolved.

Tests: internal/sandbox/{reentrancy,egress_socks,pathlists}_test.go and
internal/tools/read_exclusions_test.go.
Gates: gofmt, go vet, go build, go test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode -test=false (no new unreachable funcs).

* sandbox: address review — SOCKS5 method negotiation + cache read-exclusion roots

- egress_socks: respect SOCKS5 method negotiation (RFC 1928). The proxy now
  inspects the client's offered methods and replies 0xFF ("no acceptable
  methods") when no-auth was not offered, instead of forcing no-auth.
- pathlists/engine: resolve DenyRead/AllowRead roots ONCE per search walk via a
  new ReadExclusions matcher (Engine.ReadExclusions), replacing the per-path
  ReadPathExcluded/ReadDirExcluded that re-ran Abs/EvalSymlinks for every visited
  path during a grep/glob walk.

Re-entrancy guard left as-is by design: ZERO_SANDBOXED is an internal marker set
only on commands zero wraps; cross-process re-entrancy detection legitimately
relies on the inherited env, and a manually-exported flag is a self-opt-out, not
an external bypass.

Tests: SOCKS no-acceptable-methods rejection; nested-AllowRead descent via the
matcher. Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new
findings), govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: fix windows smoke test + symlink-resolve DirExcluded prefix

- pathlists_test: TestSandboxExecProfileEmitsDenyWriteRule now builds the
  expected deny clause via sandboxProfileString(secret), so it matches the
  profile's own escaping (Windows doubles backslashes). Fixes the
  windows-latest smoke failure; macOS/Linux unaffected (slash paths, no-op
  escaping).
- pathlists: ReadExclusions.DirExcluded now EvalSymlinks-resolves the candidate
  path before the nested-AllowRead prefix check, matching the resolution applied
  to allowRoots — otherwise a denied dir reached THROUGH a symlink fails to match
  a nested AllowRead root and is wrongly skipped, dropping a re-included subtree.
  Added TestDirExcludedResolvesSymlinkPrefix (skips where symlinks are
  unavailable).

Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: strengthen re-entrancy guard with a corroborating backend marker

Addresses review feedback that the re-entrancy guard trusted a single ambient
env var. IsAlreadySandboxed now requires BOTH correlated markers that
sandboxEnvironment sets together on every wrapped command — ZERO_SANDBOXED=1 AND
a non-empty ZERO_SANDBOX_BACKEND (new EnvSandboxBackend const) — so a lone
stray/hand-exported ZERO_SANDBOXED=1 no longer forces an unsandboxed
pass-through. Pass-through (direct) plans set neither marker, so genuine nesting
still detected and double-wrapping still avoided.

Tests updated: TestIsAlreadySandboxed now asserts each marker alone is
insufficient and both together are required; the re-entrancy and wrapping plan
tests set/clear both markers explicitly.

Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: don't subject first-party web tools to the network policy by default

The sandbox was reported as too strict: web_search (and web_fetch) were blocked
with "network access is disabled by the sandbox policy" because the default
policy is network=deny and the in-process web tools were gated by the same
NetworkHostAllowed check as the sandboxed shell's egress.

That network policy exists to confine the sandboxed SHELL's egress, which these
in-process tools don't use. New Policy.EnforceToolNetwork (off by default) makes
NetworkHostAllowed exempt web_search/web_fetch unless an operator opts in. The
sandboxed-shell egress decision (Evaluate / effectiveNetworkMode) is unchanged,
and the tools keep their own SSRF/private-IP, port, redirect, and redaction
safeguards. Set EnforceToolNetwork to hold the tools to the allow/scoped/deny
policy as before.

Tests: engine default-exempt + enforced-table cases; web_search default-allows
and enforced-when-flag; web_fetch enforced cases set the flag. Gates: gofmt, vet,
build, test (incl. -race), staticcheck (no new findings), govulncheck (0),
deadcode (no new unreachable funcs).

* sandbox: re-review fixes — disabled policy inert; path lists independent of EnforceWorkspace

- engine: ReadExclusions() / ReadExclusionGlobs() now return inert results under
  ModeDisabled, so a disabled policy never filters grep/glob (parity with
  Evaluate, which already allows everything when disabled).
- engine/pathlists: the fine-grained path lists (DenyRead/DenyWrite, with
  AllowRead/AllowWrite) now apply in Evaluate whenever the sandbox is enforcing,
  independent of EnforceWorkspace — matching the grep/glob path that already
  honors DenyRead directly. The workspace boundary (scope.validate) stays gated
  by EnforceWorkspace via a param threaded through validatePathWithPolicy /
  validateWritePath; DenyWrite wins regardless.
- reentrancy_test: TestSandboxEnvironmentMarksSandboxed now asserts BOTH
  correlated markers (ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND) so dropping
  either would fail the test.

Tests added: path lists enforced with EnforceWorkspace off; ReadExclusions inert
under ModeDisabled. Gates: gofmt, vet, build, test (incl. -race), staticcheck
(no new findings), govulncheck (0), deadcode (no new unreachable funcs).

* sandbox: enforce path lists even without a workspace root

Re-review follow-up: the Evaluate path-validation loop was still guarded by
request.WorkspaceRoot != "", so an engine built without a workspace root skipped
DenyRead/DenyWrite/AllowRead/AllowWrite even for absolute paths. The loop now
runs unconditionally; the workspace boundary (scope.validate) stays gated on
having a root (enforceWorkspace = EnforceWorkspace && WorkspaceRoot != ""), while
the path lists match absolute paths regardless. An unanchorable relative path
(no workspace root) fails closed when anything is configured to enforce, and is a
no-op otherwise (unchanged from prior behavior).

Test: TestEvaluatePathListsWithoutWorkspaceRoot. Gates: gofmt, vet, build, test
(incl. -race), staticcheck (no new findings), govulncheck (0), deadcode (no new
unreachable funcs).

* sandbox: base no-workspace fail-closed on normalized (enforceable) path lists

policyHasPathLists now resolves the lists (resolvePolicyPaths/
resolveWriteRootPaths) instead of counting raw config, matching how the rest of
the file normalizes them. A typo or non-existent entry (dropped during
resolution) therefore no longer makes validatePathWithPolicy fail-close every
relative request when there is no workspace root.

Gates: gofmt, vet, build, test (incl. -race), staticcheck (no new findings),
govulncheck (0), deadcode (no new unreachable funcs).
2026-06-14 17:37:40 +05:30
Vasanth T 7153ec7a7d feat: add TUI diagnostics center (#197) 2026-06-14 13:18:43 +05:30
KRATOS 73eb8d3376 Solidify sandbox: harden command analysis + wire/clean dormant policy surfaces (#196)
* Harden sandbox command risk by wiring the AST analyzer

The AST-based command analyzer (sandbox/analyzer.go) was built and tested but never
reached at runtime — the live risk classifier used only the regex detectors. Wire
AnalyzeCommand into classifyWithScope as a second opinion. It walks the parsed shell tree,
so it catches destructive/network programs the regexes miss (shred, fdisk, parted; telnet,
ftp, sftp; and commands hidden behind sudo/env wrappers or a sh -c launcher) and flags an
unparseable (obfuscated) script as elevated risk.

Purely additive — it only ADDs risk categories, so a benign, parseable command classifies
exactly as before, and a program name inside a quoted argument is not flagged (AST
precision). Revives the dormant analyzer (sandbox unreachable funcs 13 -> 6). New tests cover
the regex-missed destructive/network programs, nested launchers, the unparseable signal, the
quoted-name no-false-positive case, and benign-stays-clean. Full suite + -race green.

* Wire sandbox auto-allow-bash env opt-in; drop orphaned batch-autonomy

Two dormant sandbox surfaces resolved:

- WIRE: AutoAllowBashWhenSandboxed was enforced live (engine.go) but never set — no
  config field, and the ZERO_SANDBOX_AUTO_ALLOW_BASH env overlay (ApplyAutoAllowBashEnv)
  was unwired, so the documented opt-in did nothing. Wire it through the central policy
  builder (applyConfiguredSandboxPolicy, used by all engine constructions). Fail-safe: only
  turns ON when the env is truthy, only honored when the shell sandbox is actually active,
  default stays prompt, and an explicit config opt-in is preserved. Revives
  AutoAllowBashEnvEnabled + ApplyAutoAllowBashEnv; adds a policy-builder test.

- DELETE: RequiredAutonomyForBatch/riskToAutonomy (batch.go + test) had no consumer — the
  agent loop enforces autonomy per tool call (loop.go), so a batch-ceiling helper was
  speculative and redundant. Wiring it would have meant fabricating a batch model.

Sandbox now has no dormant funcs; the only remaining unreachable entries are the
cross-platform seccomp pair (unixSocketBlockFilter is live on Linux; ApplyUnixSocketBlock
is the intentional non-Linux no-op stub).

* Assert risk level (and add wrapper cases) in AST analyzer tests

Per review (Vasanth + CodeRabbit): the AST hardening tests checked only the risk category,
not the escalation level, so a regression that downgraded destructive/network shell commands
while preserving the category would slip through. Assert risk.Level too — Critical for
destructive/network, High for unparseable — matching the pattern used throughout the file.
Add sudo/env wrapper cases (sudo/env shred, sudo telnet) to cover effectiveProgram's wrapper
unwrapping.
2026-06-14 13:13:16 +05:30
Vasanth T 0444eb0e6f feat: add backend doctor diagnostics (#194) 2026-06-14 11:57:10 +05:30
144 changed files with 17813 additions and 547 deletions

No files matched your search

+67 -19
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
@@ -23,6 +24,7 @@ import (
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/swarm"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/tui"
"github.com/Gitlawb/zero/internal/update"
@@ -240,6 +242,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
case "exec":
// Forward leading --add-dir occurrences so exec's own parser collects them.
return runExec(append(addDirFlagArgs(addDirs), args[1:]...), stdout, stderr, deps)
case "daemon":
return runDaemon(args[1:], stdout, stderr, deps)
case "config":
return runConfig(args[1:], stdout, stderr, deps)
case "models":
@@ -274,6 +278,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runHooks(args[1:], stdout, stderr, deps)
case "mcp":
return runMCP(args[1:], stdout, stderr, deps)
case "auth":
return runAuth(args[1:], stdout, stderr, deps)
case "sandbox":
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
@@ -415,6 +421,12 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if err != nil {
return writeAppError(stderr, "failed to resolve user config path: "+err.Error(), 1)
}
doctorUserConfigPath := ""
projectConfigPath := ""
if resolveOptions, optErr := config.DefaultResolveOptions(workspaceRoot); optErr == nil {
doctorUserConfigPath = resolveOptions.UserConfigPath
projectConfigPath = resolveOptions.ProjectConfigPath
}
needsSetup := setupRequired(resolved)
setupVisible := forceSetup || needsSetup
@@ -507,20 +519,24 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
})
lastKnownMCPConfig := mcpConfig
return deps.runTUI(context.Background(), tui.Options{
Cwd: workspaceRoot,
UserConfigPath: userConfigPath,
ProviderName: resolved.Provider.Name,
ModelName: resolved.Provider.Model,
ProviderProfile: resolved.Provider,
FavoriteModels: resolved.Preferences.FavoriteModels,
Provider: provider,
NewProvider: deps.newProvider,
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
Cwd: workspaceRoot,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
ProviderName: resolved.Provider.Name,
ModelName: resolved.Provider.Model,
ProviderProfile: resolved.Provider,
FavoriteModels: resolved.Preferences.FavoriteModels,
Provider: provider,
NewProvider: deps.newProvider,
ProbeProviderHealth: deps.probeProviderHealth,
UserAgent: userAgent(),
Registry: registry,
SessionStore: deps.newSessionStore(),
SandboxStore: sandboxStore,
MCPConfig: mcpConfig,
MCPPermissionStore: mcpPermissionStore,
MCPTokenStore: mcpTokenStore,
MCPCommand: func(ctx context.Context, args []string) tui.MCPCommandResult {
if ctx == nil {
ctx = context.Background()
@@ -582,12 +598,37 @@ func newCoreRegistryScoped(workspaceRoot string, scope tools.PathScope) *tools.R
return registry
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*specialist.Runtime, error) {
// agentToolRuntime bundles the specialist runtime with the swarm it backs so
// their lifetimes (and shutdown) stay paired: registerSpecialistTools brings up
// both, closeSpecialistRuntime tears down both.
type agentToolRuntime struct {
specialist *specialist.Runtime
swarm *swarm.Swarm
}
func registerSpecialistTools(registry *tools.Registry, workspaceRoot string) (*agentToolRuntime, error) {
paths, err := specialist.DefaultPaths(workspaceRoot)
if err != nil {
return nil, err
}
return specialist.RegisterTools(registry, specialist.Executor{Paths: paths})
executor := specialist.Executor{Paths: paths}
runtime, err := specialist.RegisterTools(registry, executor)
if err != nil {
return nil, err
}
// The swarm reuses the same specialist executor to launch each member, so
// every member runs under the orchestrator's sandbox + policy. Mailbox state
// lives under the workspace so its files fall within the sandbox write rules.
sw, err := swarm.New(swarm.Options{
BaseDir: filepath.Join(workspaceRoot, ".zero", "swarm"),
Launcher: swarm.NewSpecialistLauncher(executor),
})
if err != nil {
runtime.Close()
return nil, err
}
swarm.RegisterTools(registry, sw)
return &agentToolRuntime{specialist: runtime, swarm: sw}, nil
}
func shouldRegisterExecSpecialistTools(options execOptions) bool {
@@ -609,12 +650,17 @@ func closeMCPRuntime(stderr io.Writer, runtime mcpToolRuntime) {
}
}
func closeSpecialistRuntime(stderr io.Writer, runtime *specialist.Runtime) {
func closeSpecialistRuntime(stderr io.Writer, runtime *agentToolRuntime) {
if runtime == nil {
return
}
if err := runtime.Close(); err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
if runtime.swarm != nil {
runtime.swarm.Close()
}
if runtime.specialist != nil {
if err := runtime.specialist.Close(); err != nil {
_, _ = fmt.Fprintf(stderr, "[zero] specialist_cleanup_error: %s\n", err)
}
}
}
@@ -637,6 +683,7 @@ Usage:
Commands:
exec Run a one-shot prompt through the Go agent runtime
daemon Manage the local background worker daemon (start/stop/status/run/attach)
setup Guide first-run provider setup
config Inspect resolved Go configuration without leaking secrets
models List Zero model registry entries
@@ -655,6 +702,7 @@ Commands:
tools Scaffold and list local Zero plugin-tools
hooks Inspect Zero hook configuration
mcp Manage MCP backend settings
auth Log in to model providers via OAuth
sandbox Inspect sandbox policy and persistent grants
update Check for Zero CLI updates
worktrees Prepare isolated git worktrees
+35
View File
@@ -8,6 +8,7 @@ import (
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -59,10 +60,38 @@ func TestRunPrintsHelp(t *testing.T) {
}
}
func setCLIUserConfigRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
switch runtime.GOOS {
case "windows":
t.Setenv("APPDATA", root)
case "darwin":
t.Setenv("HOME", root)
default:
t.Setenv("XDG_CONFIG_HOME", root)
}
configRoot, err := os.UserConfigDir()
if err != nil {
t.Fatalf("UserConfigDir() error = %v", err)
}
return configRoot
}
func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
projectConfigPath := filepath.Join(cwd, ".zero", "config.json")
if err := os.MkdirAll(filepath.Dir(projectConfigPath), 0o700); err != nil {
t.Fatalf("create project config parent: %v", err)
}
if err := os.WriteFile(projectConfigPath, []byte("{}"), 0o600); err != nil {
t.Fatalf("write project config: %v", err)
}
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
@@ -104,6 +133,12 @@ func TestRunNoArgsLaunchesSetupTUIWithNilProviderWhenNoProviderConfigured(t *tes
if launchedOptions.UserConfigPath != userConfigPath {
t.Fatalf("UserConfigPath = %q, want %q", launchedOptions.UserConfigPath, userConfigPath)
}
if launchedOptions.DoctorUserConfigPath != "" {
t.Fatalf("DoctorUserConfigPath = %q, want empty for missing user config", launchedOptions.DoctorUserConfigPath)
}
if launchedOptions.ProjectConfigPath != projectConfigPath {
t.Fatalf("ProjectConfigPath = %q, want %q", launchedOptions.ProjectConfigPath, projectConfigPath)
}
if launchedOptions.Provider != nil {
t.Fatalf("Provider = %#v, want nil", launchedOptions.Provider)
}
+344
View File
@@ -0,0 +1,344 @@
package cli
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
func runAuth(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) == 0 {
return writeExecUsageError(stderr, "auth subcommand required. Use `zero auth status`.")
}
if args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
if err := writeAuthHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
switch args[0] {
case "login":
return runAuthLogin(args[1:], stdout, stderr, deps)
case "logout":
return runAuthLogout(args[1:], stdout, stderr, deps)
case "status":
return runAuthStatus(args[1:], stdout, stderr, deps)
case "refresh":
return runAuthRefresh(args[1:], stdout, stderr, deps)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown auth subcommand %q", args[0]))
}
}
// authArgs is the parsed form of an auth subcommand's arguments.
type authArgs struct {
positional []string
json bool
device bool
watch bool
scopes []string
help bool
}
func parseAuthArgs(sub string, args []string) (authArgs, error) {
var parsed authArgs
addScope := func(scope string) error {
scope = strings.TrimSpace(scope)
if scope == "" {
return fmt.Errorf("--scope requires a non-empty value")
}
parsed.scopes = append(parsed.scopes, scope)
return nil
}
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "-h" || arg == "--help" || arg == "help":
parsed.help = true
case arg == "--json":
parsed.json = true
case arg == "--device":
parsed.device = true
case arg == "--watch":
parsed.watch = true
case arg == "--scope":
if i+1 >= len(args) {
return authArgs{}, fmt.Errorf("--scope requires a value")
}
i++
if err := addScope(args[i]); err != nil {
return authArgs{}, err
}
case strings.HasPrefix(arg, "--scope="):
if err := addScope(strings.TrimPrefix(arg, "--scope=")); err != nil {
return authArgs{}, err
}
case strings.HasPrefix(arg, "-"):
return authArgs{}, fmt.Errorf("unknown flag %q", arg)
default:
parsed.positional = append(parsed.positional, arg)
}
}
if parsed.help {
return parsed, nil // help short-circuits flag validation
}
if err := validateAuthFlags(sub, parsed); err != nil {
return authArgs{}, err
}
return parsed, nil
}
// validateAuthFlags rejects flags a subcommand does not accept, so an ambiguous
// invocation fails fast instead of silently ignoring a flag.
func validateAuthFlags(sub string, a authArgs) error {
allowed := map[string]map[string]bool{
"login": {"device": true, "scope": true},
"logout": {"json": true},
"status": {"json": true},
"refresh": {"watch": true},
}[sub]
bad := func(name string) error { return fmt.Errorf("zero auth %s does not accept %s", sub, name) }
if a.json && !allowed["json"] {
return bad("--json")
}
if a.device && !allowed["device"] {
return bad("--device")
}
if a.watch && !allowed["watch"] {
return bad("--watch")
}
if len(a.scopes) > 0 && !allowed["scope"] {
return bad("--scope")
}
return nil
}
// newAuthManager builds an oauth.Manager backed by the file store, printing the
// authorization URL / device code to stdout. The store path honors
// ZERO_OAUTH_TOKENS_PATH (env), so callers/tests can redirect it.
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
store, err := oauth.NewStore(oauth.StoreOptions{Now: deps.now})
if err != nil {
return nil, err
}
return oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
Now: deps.now,
Out: out,
// The opener prints the URL so headless shells can copy it; the URL
// carries no token material. A real browser launch is intentionally not
// performed (the sandbox/headless contexts make printing the safer default).
OpenBrowser: func(string) error { return nil },
})
}
func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
parsed, err := parseAuthArgs("login", args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if parsed.help {
_ = writeAuthHelp(stdout)
return exitSuccess
}
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: parsed.positional[0],
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
return exitSuccess
}
func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
parsed, err := parseAuthArgs("logout", args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if parsed.help {
_ = writeAuthHelp(stdout)
return exitSuccess
}
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth logout <provider>")
}
provider := parsed.positional[0]
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
removed, err := manager.Logout(provider)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if parsed.json {
payload := struct {
Provider string `json:"provider"`
Removed bool `json:"removed"`
}{Provider: provider, Removed: removed}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
msg := fmt.Sprintf("No OAuth login stored for %s.\n", provider)
if removed {
msg = fmt.Sprintf("Logged out of %s.\n", provider)
}
if _, err := fmt.Fprint(stdout, msg); err != nil {
return exitCrash
}
return exitSuccess
}
func runAuthStatus(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
parsed, err := parseAuthArgs("status", args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if parsed.help {
_ = writeAuthHelp(stdout)
return exitSuccess
}
if len(parsed.positional) > 1 {
return writeExecUsageError(stderr, "usage: zero auth status [provider]")
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
statuses, err := manager.StatusAll()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if len(parsed.positional) == 1 {
statuses = filterAuthStatuses(statuses, parsed.positional[0])
}
if parsed.json {
payload := struct {
Logins []oauth.Status `json:"logins"`
}{Logins: statuses}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, oauth.FormatStatuses(statuses)); err != nil {
return exitCrash
}
return exitSuccess
}
func runAuthRefresh(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
parsed, err := parseAuthArgs("refresh", args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if parsed.help {
_ = writeAuthHelp(stdout)
return exitSuccess
}
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth refresh <provider>")
}
provider := parsed.positional[0]
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
key := oauth.ProviderKey(provider)
if parsed.watch {
return runAuthRefreshWatch(manager, key, provider, stdout, stderr)
}
if _, err := manager.Handle401(context.Background(), key); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if _, err := fmt.Fprintf(stdout, "Refreshed OAuth token for %s.\n", provider); err != nil {
return exitCrash
}
return exitSuccess
}
// runAuthRefreshWatch keeps a provider's token fresh in the foreground until
// interrupted. This is the opt-in proactive-refresh scheduler surface (for a
// long-running external process that reads the token file). It validates a
// refreshable token exists first, then schedules refreshes before each expiry.
func runAuthRefreshWatch(manager *oauth.Manager, key, provider string, stdout io.Writer, stderr io.Writer) int {
ctx, stop := signalContext()
defer stop()
// Validate (and refresh-if-needed) once up front so a missing/non-refreshable
// token fails fast instead of silently watching nothing.
if _, err := manager.GetFresh(ctx, key); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
scheduler := oauth.NewRefreshScheduler()
scheduler.Start(ctx, manager, key)
defer scheduler.Stop()
if _, err := fmt.Fprintf(stdout, "Watching %s — refreshing before expiry. Press Ctrl+C to stop.\n", provider); err != nil {
return exitCrash
}
<-ctx.Done()
return exitSuccess
}
func filterAuthStatuses(statuses []oauth.Status, provider string) []oauth.Status {
want := oauth.ProviderKey(provider)
filtered := make([]oauth.Status, 0, 1)
for _, st := range statuses {
if st.Key == want {
filtered = append(filtered, st)
}
}
return filtered
}
func writeAuthHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero auth <command>
Commands:
login <provider> [--device] [--scope <scope>] Log in to a provider via OAuth
logout <provider> Delete a provider's stored login
status [provider] Show login presence/expiry (never the token)
refresh <provider> [--watch] Force a token refresh (--watch keeps it fresh)
A provider is any OAuth 2.0 / OIDC server you configure via env (no providers are
built in). For a provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
ZERO_OAUTH_<NAME>_DEVICE_URL ZERO_OAUTH_<NAME>_ISSUER_URL (for discovery)
ZERO_OAUTH_<NAME>_SCOPES ZERO_OAUTH_<NAME>_FLOW (loopback|device)
Endpoint URLs must be https (loopback exempt).
Flags:
--device Use the device-code flow (headless/SSH; no browser)
--scope Add an OAuth scope (repeatable)
--watch Keep the token fresh in the foreground (refresh only)
--json Print result as JSON (status/logout)
-h, --help Show this help
`)
return err
}
+129
View File
@@ -0,0 +1,129 @@
package cli
import (
"bytes"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/oauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
return path
}
func TestRunAuthStatusEmpty(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "No OAuth provider logins are stored.") {
t.Fatalf("status output = %q", stdout.String())
}
}
func TestRunAuthStatusReportsLoginWithoutSecret(t *testing.T) {
path := withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
if err := store.Save(oauth.ProviderKey("demo"), oauth.Token{
AccessToken: "super-secret", RefreshToken: "super-secret-rt", Account: "[email protected]",
}); err != nil {
t.Fatalf("Save: %v", err)
}
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "status"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "demo") || !strings.Contains(out, "[email protected]") {
t.Fatalf("status should show provider + account: %q", out)
}
if strings.Contains(out, "super-secret") {
t.Fatalf("status leaked token material: %q", out)
}
}
func TestRunAuthLogoutNothing(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "logout", "demo"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "No OAuth login stored for demo") {
t.Fatalf("logout output = %q", stdout.String())
}
}
func TestRunAuthLoginValidation(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
// Missing provider.
if code := runWithDeps([]string{"auth", "login"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("login with no provider should fail")
}
// --json is rejected for the interactive login.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "demo", "--json"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("login --json should be rejected")
}
}
func TestRunAuthLoginUnknownProvider(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "does-not-exist"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("unknown provider login should fail")
}
if !strings.Contains(stderr.String(), "not configured") {
t.Fatalf("stderr = %q, want not-configured error", stderr.String())
}
}
func TestRunAuthRefreshNoToken(t *testing.T) {
withAuthStore(t)
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "client") // so config resolves; refresh still fails (no token)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "refresh", "demo"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("refresh with no stored token should fail")
}
}
func TestRunAuthRejectsWrongFlags(t *testing.T) {
withAuthStore(t)
cases := [][]string{
{"auth", "login", "demo", "--watch"}, // watch is refresh-only
{"auth", "login", "demo", "--json"}, // json not for interactive login
{"auth", "status", "demo", "--device"}, // device is login-only
{"auth", "logout", "demo", "--scope", "x"}, // scope is login-only
{"auth", "refresh", "demo", "--json"}, // json not for refresh
{"auth", "login", "demo", "--scope", ""}, // empty scope rejected
}
for _, args := range cases {
var stdout, stderr bytes.Buffer
if code := runWithDeps(args, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Errorf("args %v should be rejected, got success", args)
}
}
}
func TestRunAuthHelp(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "--help"}, &stdout, &stderr, appDeps{}); code != exitSuccess {
t.Fatalf("exit = %d", code)
}
for _, want := range []string{"zero auth", "login", "logout", "status", "refresh", "--device"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("help missing %q:\n%s", want, stdout.String())
}
}
}
+122
View File
@@ -17,6 +17,13 @@ type backendStatusOptions struct {
}
func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
if len(args) > 0 {
switch args[0] {
case "doctor":
return runBackendsDoctor(args[1:], stdout, stderr, deps)
}
}
options, help, err := parseBackendsArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
@@ -44,6 +51,44 @@ func runBackends(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return exitSuccess
}
type backendDoctorOptions struct {
json bool
}
func runBackendsDoctor(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseBackendsDoctorArgs(args)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeBackendsDoctorHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
report, err := backendDoctorReport(deps)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, report); err != nil {
return exitCrash
}
if !report.OK {
return exitProvider
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, redaction.RedactString(formatBackendDoctorReport(report), redaction.Options{})); err != nil {
return exitCrash
}
if !report.OK {
return exitProvider
}
return exitSuccess
}
func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
options := backendStatusOptions{}
for _, arg := range args {
@@ -59,6 +104,21 @@ func parseBackendsArgs(args []string) (backendStatusOptions, bool, error) {
return options, false, nil
}
func parseBackendsDoctorArgs(args []string) (backendDoctorOptions, bool, error) {
options := backendDoctorOptions{}
for _, arg := range args {
switch arg {
case "-h", "--help", "help":
return options, true, nil
case "--json":
options.json = true
default:
return options, false, execUsageError{fmt.Sprintf("unknown backends doctor flag %q", arg)}
}
}
return options, false, nil
}
func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapshot, error) {
cwd, err := deps.getwd()
if err != nil {
@@ -86,6 +146,32 @@ func backendLifecycleSnapshot(deps appDeps) (zerocommands.BackendLifecycleSnapsh
return zerocommands.NewBackendLifecycleSnapshot(servers, hookResult.Config.Hooks, pluginResult.Plugins), nil
}
func backendDoctorReport(deps appDeps) (zerocommands.BackendDoctorReport, error) {
cwd, err := deps.getwd()
if err != nil {
return zerocommands.BackendDoctorReport{}, fmt.Errorf("failed to resolve workspace: %w", err)
}
cfg, err := deps.resolveMCPConfig(cwd)
if err != nil {
return zerocommands.BackendDoctorReport{}, err
}
hookResult, err := deps.loadHooks(hooks.LoadOptions{Cwd: cwd})
if err != nil {
return zerocommands.BackendDoctorReport{}, err
}
pluginResult, err := deps.loadPlugins(plugins.LoadOptions{Cwd: cwd})
if err != nil {
return zerocommands.BackendDoctorReport{}, err
}
return zerocommands.NewBackendDoctorReport(zerocommands.BackendDoctorInput{
MCP: cfg,
Hooks: hookResult,
Plugins: pluginResult,
}), nil
}
func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapshot) string {
lines := []string{"Zero Backends:"}
lines = append(lines, fmt.Sprintf(" MCP servers: %d", len(snapshot.MCPServers)))
@@ -135,16 +221,52 @@ func formatBackendLifecycleSnapshot(snapshot zerocommands.BackendLifecycleSnapsh
return strings.Join(lines, "\n")
}
func formatBackendDoctorReport(report zerocommands.BackendDoctorReport) string {
lines := []string{
"Zero backend doctor",
"Overall: " + string(report.Status),
}
for _, check := range report.Checks {
target := ""
if check.Target != "" {
target = " " + check.Target
}
lines = append(lines, fmt.Sprintf("[%s] %s%s - %s", check.Status, check.ID, target, check.Message))
if check.Action != "" {
lines = append(lines, " action: "+check.Action)
}
}
return strings.Join(lines, "\n")
}
func writeBackendsHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero backends [flags]
zero backends doctor [flags]
Inspect MCP, hook, and plugin backend lifecycle state without connecting to
external MCP servers.
Commands:
doctor Diagnose MCP, hook, and plugin backend setup
Flags:
--json Print backend lifecycle data as JSON
-h, --help Show this help
`)
return err
}
func writeBackendsDoctorHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero backends doctor [flags]
Diagnose MCP, hook, and plugin backend setup without connecting to external MCP
servers or executing hooks/plugins.
Flags:
--json Print backend diagnostics as JSON
-h, --help Show this help
`)
return err
}
+222
View File
@@ -5,7 +5,12 @@ import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -153,3 +158,220 @@ func TestRunBackendsTextAndHelp(t *testing.T) {
}
}
}
func TestRunBackendsDoctorJSONAndTextWithoutConnectingMCP(t *testing.T) {
cwd := t.TempDir()
secret := "sk-proj-" + strings.Repeat("b", 24)
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(workspaceRoot string) (config.MCPConfig, error) {
if workspaceRoot != cwd {
t.Fatalf("workspaceRoot = %q, want %q", workspaceRoot, cwd)
}
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {
Type: "http",
URL: "https://api.example.com/mcp?token=" + secret,
},
"broken": {
Type: "http",
},
}}, nil
},
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
if options.Cwd != cwd {
t.Fatalf("hook Cwd = %q, want %q", options.Cwd, cwd)
}
return hooks.LoadResult{Diagnostics: []hooks.Diagnostic{{
Kind: hooks.DiagnosticSchema,
Message: "schema failed " + secret,
HookID: "zero.bad",
}}}, nil
},
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
if options.Cwd != cwd {
t.Fatalf("plugin Cwd = %q, want %q", options.Cwd, cwd)
}
return plugins.LoadResult{Plugins: []plugins.LoadedPlugin{{
ID: "zero.docs",
Name: "Docs",
Enabled: true,
Source: plugins.SourceProject,
}}}, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return nil, errors.New("zero backends doctor must not connect to MCP servers")
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("exitCode = %d, want %d stderr=%s stdout=%s", exitCode, exitProvider, stderr.String(), stdout.String())
}
if strings.Contains(stdout.String(), secret) || strings.Contains(stdout.String(), "sk-proj-") {
t.Fatalf("backend doctor JSON leaked secret material:\n%s", stdout.String())
}
var payload zerocommands.BackendDoctorReport
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("backend doctor JSON failed to decode: %v\n%s", err, stdout.String())
}
if payload.OK {
t.Fatalf("payload.OK = true, want false for invalid MCP/hook diagnostic: %#v", payload.Checks)
}
if payload.Status != zerocommands.BackendDoctorStatusFail {
t.Fatalf("payload.Status = %q, want %q", payload.Status, zerocommands.BackendDoctorStatusFail)
}
assertBackendDoctorPayloadCheck(t, payload, "backend.mcp.invalid", "broken")
assertBackendDoctorPayloadCheck(t, payload, "backend.hooks.diagnostic", "zero.bad")
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"backends", "doctor"}, &stdout, &stderr, deps)
if exitCode != exitProvider {
t.Fatalf("text exitCode = %d, want %d stderr=%s", exitCode, exitProvider, stderr.String())
}
for _, want := range []string{"Zero backend doctor", "[fail] backend.mcp.invalid", "zero mcp add broken", "[fail] backend.hooks.diagnostic"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("backend doctor text missing %q:\n%s", want, stdout.String())
}
}
}
func TestRunBackendsDoctorHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"backends", "doctor", "--help"}, &stdout, &stderr, appDeps{})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s", exitCode, stderr.String())
}
for _, want := range []string{"Usage:", "zero backends doctor", "--json"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("backend doctor help missing %q:\n%s", want, stdout.String())
}
}
}
func TestRunBackendsDoctorDoesNotConnectOrExecuteConfiguredBackends(t *testing.T) {
cwd := t.TempDir()
hookSentinel := filepath.Join(cwd, "hook-ran")
pluginToolSentinel := filepath.Join(cwd, "plugin-tool-ran")
pluginHookSentinel := filepath.Join(cwd, "plugin-hook-ran")
helperCommand := os.Args[0]
helperArgs := func(path string) []string {
return []string{"-test.run=TestBackendDoctorHelperProcess", "--", "--zero-backend-doctor-sentinel", path}
}
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
hookConfigPath := filepath.Join(cwd, ".zero", "hooks.json")
writeBackendDoctorJSON(t, hookConfigPath, map[string]any{
"enabled": true,
"hooks": []any{map[string]any{
"id": "zero.sentinel",
"event": "sessionStart",
"command": helperCommand,
"args": helperArgs(hookSentinel),
}},
})
pluginDir := filepath.Join(cwd, ".zero", "plugins", "sentinel")
writeBackendDoctorJSON(t, filepath.Join(pluginDir, "plugin.json"), map[string]any{
"schemaVersion": 1,
"id": "zero.sentinel",
"name": "Sentinel",
"version": "1.0.0",
"tools": []any{map[string]any{
"name": "sentinel_tool",
"command": helperCommand,
"args": helperArgs(pluginToolSentinel),
}},
"hooks": []any{map[string]any{
"name": "sentinel_hook",
"event": "sessionStart",
"command": helperCommand,
"args": helperArgs(pluginHookSentinel),
}},
})
deps := appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveMCPConfig: func(string) (config.MCPConfig, error) {
return config.MCPConfig{Servers: map[string]config.MCPServerConfig{
"remote": {Type: "http", URL: server.URL + "/mcp"},
}}, nil
},
loadHooks: func(options hooks.LoadOptions) (hooks.LoadResult, error) {
return hooks.LoadConfig(hooks.LoadOptions{
Cwd: options.Cwd,
UserConfigPath: filepath.Join(cwd, "missing-user-hooks.json"),
ProjectConfigPath: hookConfigPath,
})
},
loadPlugins: func(options plugins.LoadOptions) (plugins.LoadResult, error) {
return plugins.Load(plugins.LoadOptions{
Roots: []plugins.Root{{Source: plugins.SourceProject, Path: filepath.Join(cwd, ".zero", "plugins")}},
Cwd: options.Cwd,
})
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return nil, errors.New("zero backends doctor must not register MCP tools")
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"backends", "doctor", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stderr=%s stdout=%s", exitCode, stderr.String(), stdout.String())
}
if got := atomic.LoadInt32(&hits); got != 0 {
t.Fatalf("backend doctor connected to MCP HTTP server %d time(s)", got)
}
for _, sentinel := range []string{hookSentinel, pluginToolSentinel, pluginHookSentinel} {
if _, err := os.Stat(sentinel); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("backend doctor executed configured backend command, sentinel=%s err=%v", sentinel, err)
}
}
}
func assertBackendDoctorPayloadCheck(t *testing.T, report zerocommands.BackendDoctorReport, id string, target string) {
t.Helper()
for _, check := range report.Checks {
if check.ID == id && check.Target == target {
return
}
}
t.Fatalf("check %s/%s not found in %#v", id, target, report.Checks)
}
func writeBackendDoctorJSON(t *testing.T, path string, value any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("create dir for %s: %v", path, err)
}
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
t.Fatalf("marshal %s: %v", path, err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestBackendDoctorHelperProcess(t *testing.T) {
for index, arg := range os.Args {
if arg != "--zero-backend-doctor-sentinel" || index+1 >= len(os.Args) {
continue
}
if err := os.WriteFile(os.Args[index+1], []byte("executed"), 0o600); err != nil {
os.Exit(2)
}
os.Exit(0)
}
}
+587
View File
@@ -0,0 +1,587 @@
package cli
import (
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/Gitlawb/zero/internal/background"
"github.com/Gitlawb/zero/internal/daemon"
"github.com/Gitlawb/zero/internal/daemon/remote"
)
// runDaemon dispatches the `zero daemon ...` subcommands. The daemon supervises a
// pool of headless `zero exec` workers and routes sessions to them over an
// owner-only local control socket. It is an ADDITIVE surface — interactive and
// one-shot exec are unchanged.
func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int {
if len(args) == 0 {
return writeDaemonUsage(stderr, exitUsage)
}
sub, rest := args[0], args[1:]
switch sub {
case "start":
return runDaemonStart(rest, stdout, stderr)
case "stop":
return runDaemonStop(rest, stdout, stderr)
case "status":
return runDaemonStatus(rest, stdout, stderr)
case "run":
return runDaemonRun(rest, stdout, stderr)
case "attach":
return runDaemonAttach(rest, stdout, stderr)
case "serve-remote":
return runDaemonServeRemote(rest, stdout, stderr)
case "-h", "--help", "help":
return writeDaemonUsage(stdout, exitSuccess)
default:
fmt.Fprintf(stderr, "unknown daemon subcommand %q\n", sub)
return writeDaemonUsage(stderr, exitUsage)
}
}
func writeDaemonUsage(w io.Writer, code int) int {
fmt.Fprint(w, `Usage: zero daemon <command>
Commands:
start [--foreground] Start the daemon (background by default).
stop Gracefully stop the running daemon.
status Show daemon / pool / session status.
run --session <id> [--cwd <dir>] [--prompt <text>] [exec flags...]
Create/route a session and stream its output.
attach <session> Attach to a running session's stream.
serve-remote --addr <host:port> --tls-cert <f> --tls-key <f>
Serve an opt-in, TLS-only network bridge to this
daemon. Requires a bearer token in $ZERO_DAEMON_REMOTE_TOKEN
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE).
run and attach accept --remote <host:port> [--token <t>] [--ca-cert <f>]
[--server-name <name>] to drive a remote daemon over the bridge instead of the
local socket.
`)
return code
}
// runDaemonStart starts the daemon. Without --foreground it spawns a detached
// background process running the foreground daemon and returns once it is up.
func runDaemonStart(args []string, stdout io.Writer, stderr io.Writer) int {
foreground := false
for _, a := range args {
switch a {
case "--foreground", "-f":
foreground = true
case "-h", "--help":
return writeDaemonUsage(stdout, exitSuccess)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon start", a))
}
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if foreground {
return runDaemonForeground(paths, stdout, stderr)
}
return runDaemonStartDetached(paths, stdout, stderr)
}
// runDaemonForeground runs the daemon in this process until SIGINT/SIGTERM. Each
// worker is a headless `zero exec` child with the sandbox re-entrancy markers
// scrubbed (NewExecLauncher) so it establishes its own sandbox.
func runDaemonForeground(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigCh
srv.Shutdown()
}()
if err := srv.Serve(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return exitSuccess
}
// runDaemonStartDetached spawns the foreground daemon as a detached background
// process (its own process group, output to a log file) and waits for it to bind.
func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writer) int {
if daemonReachable(paths) {
fmt.Fprintln(stdout, "zero daemon is already running")
return exitSuccess
}
exe, err := os.Executable()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if err := os.MkdirAll(filepath.Dir(paths.Socket), 0o700); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logPath := filepath.Join(filepath.Dir(paths.Socket), "daemon.log")
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
defer logFile.Close()
cmd := exec.Command(exe, "daemon", "start", "--foreground")
cmd.Stdout = logFile
cmd.Stderr = logFile
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
if err := cmd.Start(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
_ = cmd.Process.Release()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if daemonReachable(paths) {
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
time.Sleep(25 * time.Millisecond)
}
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
}
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
if helpRequested(args) {
return writeDaemonUsage(stdout, exitSuccess)
}
if len(args) > 0 {
return writeExecUsageError(stderr, "daemon stop takes no arguments")
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
client, err := daemon.Dial(paths.Socket)
if err != nil {
fmt.Fprintln(stdout, "zero daemon is not running")
return exitSuccess
}
defer client.Close()
if err := client.Shutdown(); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintln(stdout, "zero daemon stopped")
return exitSuccess
}
func runDaemonStatus(args []string, stdout io.Writer, stderr io.Writer) int {
if helpRequested(args) {
return writeDaemonUsage(stdout, exitSuccess)
}
if len(args) > 0 {
return writeExecUsageError(stderr, "daemon status takes no arguments")
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
client, err := daemon.Dial(paths.Socket)
if err != nil {
fmt.Fprintln(stdout, "zero daemon is not running")
return exitSuccess
}
defer client.Close()
report, err := client.Status()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "daemon pid=%d version=%d socket=%s\n", report.PID, report.Version, report.Socket)
fmt.Fprintf(stdout, "pool size=%d busy=%d queue=%d\n", report.PoolSize, len(report.Workers), report.QueueDepth)
for _, s := range report.Sessions {
fmt.Fprintf(stdout, " session %s state=%s lines=%d\n", s.ID, s.State, s.Lines)
}
return exitSuccess
}
func runDaemonRun(args []string, stdout io.Writer, stderr io.Writer) int {
session, cwd, prompt := "", "", ""
var remoteFlags remoteDialFlags
var forward []string
for i := 0; i < len(args); i++ {
a := args[i]
value := func() (string, bool) {
if i+1 >= len(args) {
return "", false
}
i++
return args[i], true
}
switch {
case a == "-h" || a == "--help":
return writeDaemonUsage(stdout, exitSuccess)
case a == "--remote":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--remote requires a value")
}
remoteFlags.Addr = v
case strings.HasPrefix(a, "--remote="):
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
case a == "--token":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--token requires a value")
}
remoteFlags.Token = v
case strings.HasPrefix(a, "--token="):
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
case a == "--ca-cert":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--ca-cert requires a value")
}
remoteFlags.CACert = v
case strings.HasPrefix(a, "--ca-cert="):
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
case a == "--server-name":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--server-name requires a value")
}
remoteFlags.ServerName = v
case strings.HasPrefix(a, "--server-name="):
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
case a == "--session":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--session requires a value")
}
session = v
case strings.HasPrefix(a, "--session="):
session = strings.TrimPrefix(a, "--session=")
case a == "--cwd":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--cwd requires a value")
}
cwd = v
case strings.HasPrefix(a, "--cwd="):
cwd = strings.TrimPrefix(a, "--cwd=")
case a == "--prompt" || a == "-p":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--prompt requires a value")
}
prompt = v
case strings.HasPrefix(a, "--prompt="):
prompt = strings.TrimPrefix(a, "--prompt=")
default:
// Forwarded verbatim to the worker `zero exec` (reuses exec's own flag
// parsing for per-session run options).
forward = append(forward, a)
}
}
if strings.TrimSpace(session) == "" {
return writeExecUsageError(stderr, "daemon run requires --session <id>")
}
if prompt == "" && len(forward) == 0 {
return writeExecUsageError(stderr, "daemon run requires --prompt <text> or exec args")
}
client, err := dialForCLI(remoteFlags)
if err != nil {
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
}
defer client.Close()
if err := client.Run(session, cwd, prompt, forward, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return exitSuccess
}
func runDaemonAttach(args []string, stdout io.Writer, stderr io.Writer) int {
session := ""
var remoteFlags remoteDialFlags
extra := 0
for i := 0; i < len(args); i++ {
a := args[i]
value := func() (string, bool) {
if i+1 >= len(args) {
return "", false
}
i++
return args[i], true
}
switch {
case a == "-h" || a == "--help":
return writeDaemonUsage(stdout, exitSuccess)
case a == "--remote":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--remote requires a value")
}
remoteFlags.Addr = v
case strings.HasPrefix(a, "--remote="):
remoteFlags.Addr = strings.TrimPrefix(a, "--remote=")
case a == "--token":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--token requires a value")
}
remoteFlags.Token = v
case strings.HasPrefix(a, "--token="):
remoteFlags.Token = strings.TrimPrefix(a, "--token=")
case a == "--ca-cert":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--ca-cert requires a value")
}
remoteFlags.CACert = v
case strings.HasPrefix(a, "--ca-cert="):
remoteFlags.CACert = strings.TrimPrefix(a, "--ca-cert=")
case a == "--server-name":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--server-name requires a value")
}
remoteFlags.ServerName = v
case strings.HasPrefix(a, "--server-name="):
remoteFlags.ServerName = strings.TrimPrefix(a, "--server-name=")
case session == "":
session = a
default:
extra++
}
}
if strings.TrimSpace(session) == "" {
return writeExecUsageError(stderr, "daemon attach requires a <session> id")
}
if extra > 0 {
return writeExecUsageError(stderr, "daemon attach accepts exactly one <session> id")
}
client, err := dialForCLI(remoteFlags)
if err != nil {
return writeAppError(stderr, daemonDialError(remoteFlags, err), exitCrash)
}
defer client.Close()
if err := client.Attach(session, func(line string) { fmt.Fprintln(stdout, line) }); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
return exitSuccess
}
// daemonDialError tailors the connect-failure message for local vs remote.
func daemonDialError(flags remoteDialFlags, err error) string {
if strings.TrimSpace(flags.Addr) != "" {
return "failed to reach remote daemon at " + flags.Addr + ": " + err.Error()
}
return "zero daemon is not running (start it with `zero daemon start`)"
}
// runDaemonServeRemote starts the local daemon plus an opt-in, TLS-only network
// bridge. TLS and a bearer token are mandatory (fail closed): it refuses to
// start without a cert/key pair and a token from the environment.
func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int {
addr, certFile, keyFile := "", "", ""
minVersion, maxConns := 0, 0
for i := 0; i < len(args); i++ {
a := args[i]
value := func() (string, bool) {
if i+1 >= len(args) {
return "", false
}
i++
return args[i], true
}
switch {
case a == "-h" || a == "--help":
return writeDaemonUsage(stdout, exitSuccess)
case a == "--addr":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--addr requires a value")
}
addr = v
case strings.HasPrefix(a, "--addr="):
addr = strings.TrimPrefix(a, "--addr=")
case a == "--tls-cert":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--tls-cert requires a value")
}
certFile = v
case strings.HasPrefix(a, "--tls-cert="):
certFile = strings.TrimPrefix(a, "--tls-cert=")
case a == "--tls-key":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--tls-key requires a value")
}
keyFile = v
case strings.HasPrefix(a, "--tls-key="):
keyFile = strings.TrimPrefix(a, "--tls-key=")
case a == "--min-version":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--min-version requires a value")
}
minVersion = atoiOrZero(v)
case a == "--max-conns":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--max-conns requires a value")
}
maxConns = atoiOrZero(v)
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
}
}
if strings.TrimSpace(addr) == "" {
return writeExecUsageError(stderr, "daemon serve-remote requires --addr <host:port>")
}
// Fail closed: TLS cert/key + a bearer token are mandatory.
tlsConfig, err := remote.ServerTLSConfig(certFile, keyFile)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
token, err := remote.TokenFromEnv()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
auth, err := remote.NewTokenAuthenticator(token)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
paths, err := daemon.DefaultPaths()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
launcher, err := daemon.NewExecLauncher(daemon.ExecLauncherConfig{})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
logf := func(line string) { fmt.Fprintln(stderr, "[daemon] "+line) }
pool, err := daemon.NewPool(daemon.PoolOptions{Launcher: launcher, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
srv, err := daemon.NewServer(daemon.ServerOptions{Paths: paths, Manager: mgr, Pool: pool, Log: logf})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
bridge, err := remote.NewBridge(remote.BridgeOptions{
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, Log: logf,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Serve the local control socket too, so local clients keep working.
go func() {
if serveErr := srv.Serve(); serveErr != nil {
logf("local serve error: " + serveErr.Error())
}
}()
serveErr := make(chan error, 1)
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
fmt.Fprintf(stdout, "zero daemon remote bridge listening on %s (TLS)\n", addr)
select {
case <-sigCh:
srv.Shutdown()
_ = bridge.Close()
<-serveErr // wait for the accept loop to unwind
return exitSuccess
case err := <-serveErr:
// Bind/serve failed before any signal (e.g. address in use).
srv.Shutdown()
return writeAppError(stderr, err.Error(), exitCrash)
}
}
// remoteDialFlags holds the optional flags that redirect run/attach to a remote
// daemon. When Addr is empty the local control socket is used.
type remoteDialFlags struct {
Addr string
Token string
CACert string
ServerName string
}
// dialForCLI returns a daemon.Client for either the local socket (Addr empty) or
// a remote bridge (Addr set). For remote, the token falls back to the env.
func dialForCLI(flags remoteDialFlags) (*daemon.Client, error) {
if strings.TrimSpace(flags.Addr) == "" {
paths, err := daemon.DefaultPaths()
if err != nil {
return nil, err
}
return daemon.Dial(paths.Socket)
}
token := strings.TrimSpace(flags.Token)
if token == "" {
token, _ = remote.TokenFromEnv() // best effort; DialRemote rejects an empty token
}
return remote.DialRemote(remote.RemoteConfig{
Address: flags.Addr,
Token: token,
CACertFile: flags.CACert,
ServerName: flags.ServerName,
})
}
func atoiOrZero(s string) int {
n := 0
for _, r := range strings.TrimSpace(s) {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
return n
}
// daemonReachable reports whether a daemon is accepting connections (a successful
// handshake). Used for the single-instance check and start-up wait.
func daemonReachable(paths daemon.Paths) bool {
client, err := daemon.Dial(paths.Socket)
if err != nil {
return false
}
_ = client.Close()
return true
}
func helpRequested(args []string) bool {
for _, a := range args {
if a == "-h" || a == "--help" {
return true
}
}
return false
}
+123
View File
@@ -0,0 +1,123 @@
package cli
import (
"bytes"
"strings"
"testing"
)
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
// a real daemon on the dev machine.
func isolateDaemonPaths(t *testing.T) {
t.Helper()
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
}
func runDaemonCLI(t *testing.T, args ...string) (int, string, string) {
t.Helper()
var out, errb bytes.Buffer
code := runDaemon(args, &out, &errb, appDeps{})
return code, out.String(), errb.String()
}
func TestDaemonUsage(t *testing.T) {
code, _, _ := runDaemonCLI(t)
if code != exitUsage {
t.Fatalf("no-args exit = %d, want exitUsage", code)
}
code, out, _ := runDaemonCLI(t, "--help")
if code != exitSuccess || !strings.Contains(out, "Usage: zero daemon") {
t.Fatalf("--help exit=%d out=%q", code, out)
}
}
func TestDaemonUnknownSubcommand(t *testing.T) {
code, _, errb := runDaemonCLI(t, "frobnicate")
if code != exitUsage {
t.Fatalf("unknown subcommand exit = %d, want exitUsage", code)
}
if !strings.Contains(errb, "unknown daemon subcommand") {
t.Fatalf("stderr = %q, want unknown-subcommand message", errb)
}
}
func TestDaemonRunRequiresSession(t *testing.T) {
isolateDaemonPaths(t)
code, _, errb := runDaemonCLI(t, "run", "--prompt", "hi")
if code != exitUsage {
t.Fatalf("run without --session exit = %d, want exitUsage", code)
}
if !strings.Contains(errb, "--session") {
t.Fatalf("stderr = %q, want a --session hint", errb)
}
}
func TestDaemonRunRequiresPromptOrArgs(t *testing.T) {
isolateDaemonPaths(t)
code, _, errb := runDaemonCLI(t, "run", "--session", "s1")
if code != exitUsage {
t.Fatalf("run without prompt/args exit = %d, want exitUsage", code)
}
if !strings.Contains(errb, "--prompt") {
t.Fatalf("stderr = %q, want a --prompt hint", errb)
}
}
func TestDaemonStopWhenNotRunning(t *testing.T) {
isolateDaemonPaths(t)
code, out, _ := runDaemonCLI(t, "stop")
if code != exitSuccess {
t.Fatalf("stop (not running) exit = %d, want exitSuccess", code)
}
if !strings.Contains(out, "not running") {
t.Fatalf("stop output = %q, want 'not running'", out)
}
}
func TestDaemonStatusWhenNotRunning(t *testing.T) {
isolateDaemonPaths(t)
code, out, _ := runDaemonCLI(t, "status")
if code != exitSuccess {
t.Fatalf("status (not running) exit = %d, want exitSuccess", code)
}
if !strings.Contains(out, "not running") {
t.Fatalf("status output = %q, want 'not running'", out)
}
}
func TestDaemonAttachRequiresSession(t *testing.T) {
isolateDaemonPaths(t)
code, _, errb := runDaemonCLI(t, "attach")
if code != exitUsage {
t.Fatalf("attach without session exit = %d, want exitUsage", code)
}
if !strings.Contains(errb, "session") {
t.Fatalf("stderr = %q, want a session hint", errb)
}
}
func TestDaemonRunWhenNotRunning(t *testing.T) {
isolateDaemonPaths(t)
code, _, errb := runDaemonCLI(t, "run", "--session", "s1", "--prompt", "hello")
if code != exitCrash {
t.Fatalf("run (no daemon) exit = %d, want exitCrash", code)
}
if !strings.Contains(errb, "not running") {
t.Fatalf("stderr = %q, want 'not running'", errb)
}
}
func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
isolateDaemonPaths(t)
cases := [][]string{
{"stop", "oops"},
{"status", "oops"},
{"attach", "s1", "extra"},
}
for _, args := range cases {
code, _, errb := runDaemonCLI(t, args...)
if code != exitUsage {
t.Fatalf("%v exit = %d, want exitUsage (reject extra args); stderr=%q", args, code, errb)
}
}
}
+9 -3
View File
@@ -19,7 +19,6 @@ import (
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/specialist"
"github.com/Gitlawb/zero/internal/specmode"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
@@ -163,7 +162,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if options.allowEscalation {
registry.Register(tools.NewEscalateModelTool())
}
var specialistRuntime *specialist.Runtime
var specialistRuntime *agentToolRuntime
if shouldRegisterExecSpecialistTools(options) {
var err error
specialistRuntime, err = registerSpecialistTools(registry, workspaceRoot)
@@ -704,7 +703,8 @@ func buildExecSandboxEngine(workspaceRoot string, resolved config.ResolvedConfig
// depth — it guarantees that even a direct/programmatic caller passing a typo
// can never WIDEN the ceiling back to the High default.
// applyConfiguredSandboxPolicy overlays every config-sourced sandbox knob onto
// the default policy: the autonomy ceiling and the network mode.
// the default policy (the autonomy ceiling and the network mode), plus the
// ZERO_SANDBOX_AUTO_ALLOW_BASH environment opt-in.
func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfig) sandbox.Policy {
policy = applyConfiguredAutonomyCeiling(policy, cfg.MaxAutonomy)
if network := strings.TrimSpace(cfg.Network); network != "" {
@@ -721,6 +721,12 @@ func applyConfiguredSandboxPolicy(policy sandbox.Policy, cfg config.SandboxConfi
if cfg.MonitorDenials {
policy.MonitorDenials = true
}
// ZERO_SANDBOX_AUTO_ALLOW_BASH opts into auto-allowing a *sandboxed* shell
// command without a prompt — the active sandbox is the safety boundary, and
// the engine only honors this when the shell sandbox is actually active. Like
// the flags above it only ever turns the opt-in ON; unset/blank stays off
// (prompt), and an explicit config opt-in is preserved.
policy = sandbox.ApplyAutoAllowBashEnv(policy)
return policy
}
+13
View File
@@ -662,6 +662,19 @@ func TestApplyConfiguredSandboxPolicyHardeningFlags(t *testing.T) {
}
}
func TestApplyConfiguredSandboxPolicyHonorsAutoAllowBashEnv(t *testing.T) {
// Unset/blank: the opt-in stays off so a sandboxed bash command still prompts.
t.Setenv(sandbox.EnvAutoAllowBash, "")
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); got.AutoAllowBashWhenSandboxed {
t.Fatal("auto-allow-bash must be off when ZERO_SANDBOX_AUTO_ALLOW_BASH is unset")
}
// Truthy: the env opt-in is wired through the central policy builder.
t.Setenv(sandbox.EnvAutoAllowBash, "true")
if got := applyConfiguredSandboxPolicy(sandbox.DefaultPolicy(), config.SandboxConfig{}); !got.AutoAllowBashWhenSandboxed {
t.Fatal("ZERO_SANDBOX_AUTO_ALLOW_BASH=true must enable AutoAllowBashWhenSandboxed")
}
}
func newSandboxTestStore(t *testing.T) *sandbox.GrantStore {
t.Helper()
store, err := sandbox.NewGrantStore(sandbox.StoreOptions{
+26 -1
View File
@@ -1094,7 +1094,8 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
"auth_scheme": "raw",
"auth_header_value": "header-secret",
"custom_headers": {"X-Zero": "1"},
"model_id": "custom-model"
"model_id": "custom-model",
"parse_think_tags": true
}]
}`)
@@ -1119,6 +1120,30 @@ func TestResolveProviderProfileExtendedJSONAliases(t *testing.T) {
if profile.CustomHeaders["X-Zero"] != "1" {
t.Fatalf("CustomHeaders = %#v, want X-Zero header", profile.CustomHeaders)
}
if profile.ParseThinkTags == nil || !*profile.ParseThinkTags {
t.Fatalf("ParseThinkTags = %#v, want true", profile.ParseThinkTags)
}
}
func TestResolveProviderProfileParseThinkTagsFalseAlias(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
"providers": [{
"name": "custom",
"provider_kind": "openai-compatible",
"base_url": "https://custom.example/v1",
"model_id": "custom-model",
"parse_think_tags": false
}]
}`)
resolved, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.Provider.ParseThinkTags == nil || *resolved.Provider.ParseThinkTags {
t.Fatalf("ParseThinkTags = %#v, want explicit false", resolved.Provider.ParseThinkTags)
}
}
func TestResolveRejectsUnknownProviderCatalogID(t *testing.T) {
+14
View File
@@ -34,6 +34,7 @@ type ProviderProfile struct {
AuthHeaderValue string `json:"authHeaderValue,omitempty"`
CustomHeaders map[string]string `json:"customHeaders,omitempty"`
Model string `json:"model,omitempty"`
ParseThinkTags *bool `json:"parseThinkTags,omitempty"`
Description string `json:"description,omitempty"`
}
@@ -51,6 +52,7 @@ func HasProviderProfile(profile ProviderProfile) bool {
strings.TrimSpace(profile.AuthHeaderValue) != "" ||
profile.CustomHeaders != nil ||
strings.TrimSpace(profile.Model) != "" ||
profile.ParseThinkTags != nil ||
strings.TrimSpace(profile.Description) != ""
}
@@ -304,6 +306,8 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
CustomHeadersSnake map[string]string `json:"custom_headers"`
Model string `json:"model"`
ModelID string `json:"model_id"`
ParseThinkTags *bool `json:"parseThinkTags"`
ParseThinkTagsSnake *bool `json:"parse_think_tags"`
Description string `json:"description"`
}
@@ -325,6 +329,7 @@ func (profile *ProviderProfile) UnmarshalJSON(data []byte) error {
profile.AuthHeaderValue = firstNonEmpty(raw.AuthHeaderValue, raw.AuthHeaderValueSnake)
profile.CustomHeaders = firstNonNilMap(raw.CustomHeaders, raw.CustomHeadersSnake)
profile.Model = strings.TrimSpace(firstNonEmpty(raw.Model, raw.ModelID))
profile.ParseThinkTags = firstNonNilBool(raw.ParseThinkTags, raw.ParseThinkTagsSnake)
profile.Description = strings.TrimSpace(raw.Description)
return nil
}
@@ -348,6 +353,15 @@ func firstNonNilMap(values ...map[string]string) map[string]string {
return nil
}
func firstNonNilBool(values ...*bool) *bool {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func copyStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
+41
View File
@@ -0,0 +1,41 @@
package daemon
import (
"math/rand"
"time"
)
// Backoff bounds. Mirrors reference-daemon-code-agent-js/worker-manager.js:
// backoffMs(consecutive) = jitter(min(1000 * 2**consecutive, 300000)).
const (
backoffUnit = time.Second
backoffCap = 5 * time.Minute
)
// backoffBase returns the un-jittered exponential delay for a 1-based restart
// count: min(1s * 2^attempt, 5m). attempt<=0 yields the unit delay.
func backoffBase(attempt int) time.Duration {
if attempt <= 0 {
return backoffUnit
}
// Shift carefully: cap before overflow. 2^attempt * 1s exceeds the cap well
// before any int64 overflow (cap is 5m), so clamp once it would exceed it.
d := backoffUnit
for i := 0; i < attempt; i++ {
d *= 2
if d >= backoffCap {
return backoffCap
}
}
return d
}
// BackoffWithJitter returns backoffBase(attempt) scaled by a uniform random
// factor in [0.5, 1.5), matching worker-manager.js jitter(base) =
// base*(0.5 + random()). The jitter de-synchronizes simultaneous restarts so a
// fleet of workers does not thunder back at the same instant.
func BackoffWithJitter(attempt int) time.Duration {
base := backoffBase(attempt)
factor := 0.5 + rand.Float64() //nolint:gosec // jitter, not security-sensitive
return time.Duration(float64(base) * factor)
}
+38
View File
@@ -0,0 +1,38 @@
package daemon
import (
"testing"
"time"
)
func TestBackoffBaseExponentialAndCap(t *testing.T) {
cases := []struct {
attempt int
want time.Duration
}{
{0, 1 * time.Second},
{1, 2 * time.Second},
{2, 4 * time.Second},
{3, 8 * time.Second},
{20, backoffCap}, // far past the cap
}
for _, tc := range cases {
if got := backoffBase(tc.attempt); got != tc.want {
t.Fatalf("backoffBase(%d) = %s, want %s", tc.attempt, got, tc.want)
}
}
}
func TestBackoffWithJitterBounds(t *testing.T) {
for attempt := 1; attempt <= 6; attempt++ {
base := backoffBase(attempt)
lo := time.Duration(float64(base) * 0.5)
hi := time.Duration(float64(base) * 1.5)
for i := 0; i < 200; i++ {
got := BackoffWithJitter(attempt)
if got < lo || got >= hi {
t.Fatalf("BackoffWithJitter(%d) = %s, want in [%s,%s)", attempt, got, lo, hi)
}
}
}
}
+130
View File
@@ -0,0 +1,130 @@
package daemon
import (
"fmt"
"net"
"time"
)
// Client is a control-socket client used by the `zero daemon run|attach|status|
// stop` subcommands. It performs the version handshake on Dial.
type Client struct {
conn net.Conn
}
// Dial connects to the daemon control socket and completes the handshake.
func Dial(socketPath string) (*Client, error) {
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
if err != nil {
return nil, err
}
c := &Client{conn: conn}
if err := c.handshake(); err != nil {
_ = conn.Close()
return nil, err
}
return c, nil
}
// NewClientConn completes the control handshake on an already-connected
// transport (e.g. a TLS connection the remote bridge has authenticated) and
// returns a Client ready for Run/Attach/Status. It takes ownership of conn and
// closes it on handshake failure.
func NewClientConn(conn net.Conn) (*Client, error) {
c := &Client{conn: conn}
if err := c.handshake(); err != nil {
_ = conn.Close()
return nil, err
}
return c, nil
}
func (c *Client) handshake() error {
if err := WriteControl(c.conn, Ctrl{Type: CtrlHello, Version: ProtoVersion}); err != nil {
return err
}
reply, err := ReadControl(c.conn)
if err != nil {
return err
}
if reply.Type != CtrlHelloOK {
return fmt.Errorf("daemon: handshake rejected: %s", reply.Message)
}
return nil
}
// Close closes the connection.
func (c *Client) Close() error { return c.conn.Close() }
// Run starts/routes a session and streams its stream-json output lines to onLine
// until the session ends. It returns the session's terminal error, if any.
func (c *Client) Run(session, cwd, prompt string, args []string, onLine func(string)) error {
if err := WriteControl(c.conn, Ctrl{Type: CtrlRun, Session: session, Cwd: cwd, Prompt: prompt, Args: args}); err != nil {
return err
}
return c.streamLoop(onLine)
}
// Attach streams a running session's output (buffered history + live) to onLine.
func (c *Client) Attach(session string, onLine func(string)) error {
if err := WriteControl(c.conn, Ctrl{Type: CtrlAttach, Session: session}); err != nil {
return err
}
return c.streamLoop(onLine)
}
func (c *Client) streamLoop(onLine func(string)) error {
for {
msg, err := ReadControl(c.conn)
if err != nil {
return err
}
switch msg.Type {
case CtrlAck:
// dispatch acknowledged; keep reading
case CtrlData:
if onLine != nil {
onLine(msg.Line)
}
case CtrlEnd:
return nil
case CtrlError:
return fmt.Errorf("daemon: %s", msg.Message)
default:
return fmt.Errorf("daemon: unexpected message %q", msg.Type)
}
}
}
// Status requests the daemon/worker/session status report.
func (c *Client) Status() (*StatusReport, error) {
if err := WriteControl(c.conn, Ctrl{Type: CtrlStatus}); err != nil {
return nil, err
}
msg, err := ReadControl(c.conn)
if err != nil {
return nil, err
}
if msg.Type == CtrlError {
return nil, fmt.Errorf("daemon: %s", msg.Message)
}
if msg.Type != CtrlStatusResult || msg.Status == nil {
return nil, fmt.Errorf("daemon: unexpected status reply %q", msg.Type)
}
return msg.Status, nil
}
// Shutdown asks the daemon to drain and stop.
func (c *Client) Shutdown() error {
if err := WriteControl(c.conn, Ctrl{Type: CtrlShutdown}); err != nil {
return err
}
msg, err := ReadControl(c.conn)
if err != nil {
return err
}
if msg.Type == CtrlError {
return fmt.Errorf("daemon: %s", msg.Message)
}
return nil
}
+165
View File
@@ -0,0 +1,165 @@
package daemon
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/Gitlawb/zero/internal/background"
"github.com/Gitlawb/zero/internal/sandbox"
)
// reentrancyMarkers are the env vars zero sets on a command it has ALREADY
// wrapped in a sandbox. The daemon MUST strip them from every worker's
// environment: a worker that inherited them would trip zero's re-entrancy guard
// (sandbox.IsAlreadySandboxed needs BOTH) and run with a pass-through plan —
// i.e. UNSANDBOXED — silently defeating the sandbox. Stripping them forces each
// worker to (re)establish its own sandbox. This is the daemon's #1 security
// invariant: it must NOT bypass the sandbox.
var reentrancyMarkers = []string{sandbox.EnvSandboxed, sandbox.EnvSandboxBackend}
// scrubWorkerEnv returns env with the sandbox re-entrancy markers removed. Every
// other variable is preserved — including the ZERO_SANDBOX_* policy config the
// worker reads to rebuild its own policy — so the sandbox policy is PROPAGATED,
// not bypassed. The caller's slice is never mutated.
func scrubWorkerEnv(env []string) []string {
out := make([]string, 0, len(env))
for _, kv := range env {
drop := false
for _, marker := range reentrancyMarkers {
if strings.HasPrefix(kv, marker+"=") {
drop = true
break
}
}
if !drop {
out = append(out, kv)
}
}
return out
}
// execWorker is a WorkerHandle backed by a `zero exec` child process speaking
// stream-json on stdout.
type execWorker struct {
cmd *exec.Cmd
stdout io.ReadCloser
lines *scannerLines
pid int
}
func (w *execWorker) Stdout() Lines { return w.lines }
func (w *execWorker) Pid() int { return w.pid }
func (w *execWorker) Wait() (int, error) {
err := w.cmd.Wait()
if err == nil {
return 0, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), nil
}
return -1, err
}
func (w *execWorker) Kill() error {
if w.cmd.Process == nil {
return nil
}
// background.TerminateProcess is the cross-platform terminate (kills the
// process group on POSIX, taskkill /T on Windows).
return background.TerminateProcess(w.cmd.Process.Pid)
}
// scannerLines adapts a bufio.Scanner to the Lines interface.
type scannerLines struct {
sc *bufio.Scanner
}
func (s *scannerLines) Next() (string, bool, error) {
if s.sc.Scan() {
return s.sc.Text(), true, nil
}
if err := s.sc.Err(); err != nil {
return "", false, err
}
return "", false, nil
}
// ExecLauncherConfig configures the production worker launcher.
type ExecLauncherConfig struct {
// Executable is the zero binary to spawn (defaults to os.Executable()).
Executable string
// BaseArgs are prepended before the per-session args (defaults to the headless
// stream-json exec invocation `exec --output-format stream-json`).
BaseArgs []string
// Env is the base environment for workers (defaults to os.Environ()). The
// re-entrancy markers are always scrubbed from it.
Env []string
}
// NewExecLauncher builds a Launcher that spawns `zero exec` workers which speak
// stream-json on stdout. Each worker:
// - runs with the re-entrancy markers SCRUBBED from its env (so it establishes
// its own sandbox; the daemon never bypasses the sandbox), while the rest of
// the policy config/env is propagated;
// - is placed in its own process group (background.ConfigureChildProcessGroup)
// so Kill / drain can terminate it and any children cleanly;
// - has its per-session working directory (spec.Cwd) and flags (spec.Args)
// applied. The pool does the os/exec itself and uses internal/background only
// for process-group setup + cross-platform terminate.
func NewExecLauncher(cfg ExecLauncherConfig) (Launcher, error) {
exe := cfg.Executable
if exe == "" {
resolved, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("daemon: locate zero executable: %w", err)
}
exe = resolved
}
baseArgs := cfg.BaseArgs
if baseArgs == nil {
baseArgs = []string{"exec", "--output-format", "stream-json"}
}
baseEnv := cfg.Env
if baseEnv == nil {
baseEnv = os.Environ()
}
env := scrubWorkerEnv(baseEnv)
return func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error) {
args := make([]string, 0, len(baseArgs)+len(spec.Args))
args = append(args, baseArgs...)
args = append(args, spec.Args...)
cmd := exec.CommandContext(ctx, exe, args...)
cmd.Dir = spec.Cwd
cmd.Env = env
cmd.Stdin = nil // the prompt is passed via flags; no streamed stdin input
background.ConfigureChildProcessGroup(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("daemon: worker stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("daemon: start worker: %w", err)
}
scanner := bufio.NewScanner(stdout)
// Allow long stream-json lines up to the control-frame cap.
scanner.Buffer(make([]byte, 0, 64*1024), MaxFrameSize)
return &execWorker{
cmd: cmd,
stdout: stdout,
lines: &scannerLines{sc: scanner},
pid: cmd.Process.Pid,
}, nil
}, nil
}
+102
View File
@@ -0,0 +1,102 @@
package daemon
import (
"context"
"runtime"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sandbox"
)
func TestScrubWorkerEnvRemovesReentrancyMarkers(t *testing.T) {
in := []string{
"PATH=/bin",
sandbox.EnvSandboxed + "=1",
"ZERO_SANDBOX_AUTO_ALLOW_BASH=1", // policy config — must be PRESERVED
sandbox.EnvSandboxBackend + "=bubblewrap",
"HOME=/home/u",
}
out := scrubWorkerEnv(in)
for _, kv := range out {
if strings.HasPrefix(kv, sandbox.EnvSandboxed+"=") || strings.HasPrefix(kv, sandbox.EnvSandboxBackend+"=") {
t.Fatalf("re-entrancy marker not scrubbed: %q", kv)
}
}
want := map[string]bool{"PATH=/bin": true, "ZERO_SANDBOX_AUTO_ALLOW_BASH=1": true, "HOME=/home/u": true}
got := map[string]bool{}
for _, kv := range out {
got[kv] = true
}
for w := range want {
if !got[w] {
t.Fatalf("scrub dropped a non-marker var: %q", w)
}
}
if len(out) != len(want) {
t.Fatalf("scrub result = %v, want exactly %d preserved vars", out, len(want))
}
if len(in) != 5 {
t.Fatal("scrubWorkerEnv must not mutate the caller's slice")
}
}
// TestExecLauncherScrubsEnvEndToEnd proves the spawned WORKER PROCESS actually
// receives an environment without the re-entrancy markers — the security
// invariant. It uses /bin/sh to dump the worker env, so it is POSIX-only.
func TestExecLauncherScrubsEnvEndToEnd(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses the POSIX env dumper /bin/sh -c env")
}
launcher, err := NewExecLauncher(ExecLauncherConfig{
Executable: "/bin/sh",
BaseArgs: []string{"-c", "env"},
Env: append([]string{"KEEP=yes"},
sandbox.EnvSandboxed+"=1",
sandbox.EnvSandboxBackend+"=bubblewrap"),
})
if err != nil {
t.Fatalf("NewExecLauncher: %v", err)
}
h, err := launcher(context.Background(), WorkerSpec{})
if err != nil {
t.Fatalf("launch: %v", err)
}
lines := h.Stdout()
var got []string
for {
line, ok, lerr := lines.Next()
if lerr != nil {
t.Fatalf("read worker stdout: %v", lerr)
}
if !ok {
break
}
got = append(got, line)
}
if _, err := h.Wait(); err != nil {
t.Fatalf("worker wait: %v", err)
}
keepSeen := false
for _, l := range got {
if strings.HasPrefix(l, sandbox.EnvSandboxed+"=") || strings.HasPrefix(l, sandbox.EnvSandboxBackend+"=") {
t.Fatalf("worker process still has a re-entrancy marker in its env: %q", l)
}
if l == "KEEP=yes" {
keepSeen = true
}
}
if !keepSeen {
t.Fatalf("worker env lost a non-marker var (KEEP=yes); got %v", got)
}
}
func TestNewExecLauncherDefaults(t *testing.T) {
// With no Executable it resolves the test binary's own path (os.Executable),
// and constructs without error.
if _, err := NewExecLauncher(ExecLauncherConfig{}); err != nil {
t.Fatalf("NewExecLauncher with defaults: %v", err)
}
}
+87
View File
@@ -0,0 +1,87 @@
package daemon
import (
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
)
// Single-instance lock. Mirrors reference-daemon-code-agent-js/supervisor.js's
// lock file: a PID file created with O_EXCL. A second start fails; a STALE lock
// left by a dead daemon (the recorded PID is no longer alive) is reclaimed so the
// daemon recovers from an unclean shutdown without manual cleanup.
// ErrAlreadyRunning is returned when a live daemon already holds the lock.
var ErrAlreadyRunning = errors.New("daemon: another instance is already running")
// fileLock is an acquired single-instance lock.
type fileLock struct {
path string
}
// processAlive reports whether pid is a live process. Implemented per-platform
// (lock_posix.go / lock_windows.go). It is a package var so tests can stub it.
var processAlive = osProcessAlive
// acquireLock takes the single-instance lock at path, reclaiming a stale lock
// whose recorded PID is dead. isAlive overrides the liveness check (tests pass a
// stub); nil uses the real processAlive.
func acquireLock(path string, isAlive func(pid int) bool) (*fileLock, error) {
if isAlive == nil {
isAlive = processAlive
}
// At most two passes: create, or detect-stale-then-retry once.
for attempt := 0; attempt < 2; attempt++ {
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
// A failed PID write would leave a malformed lock file that another
// process reads as stale (unparsable PID) and wrongly reclaims, breaking
// the single-instance guarantee — so on write failure, remove it and fail.
if _, werr := fmt.Fprintf(f, "%d\n", os.Getpid()); werr != nil {
_ = f.Close()
_ = os.Remove(path)
return nil, werr
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(path)
return nil, cerr
}
return &fileLock{path: path}, nil
}
if !errors.Is(err, fs.ErrExist) {
return nil, err
}
pid, perr := readPidFile(path)
if perr == nil && pid > 0 && isAlive(pid) {
return nil, fmt.Errorf("%w (pid %d)", ErrAlreadyRunning, pid)
}
// Stale lock (dead PID or unreadable) — remove and retry once.
if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) {
return nil, rmErr
}
}
return nil, ErrAlreadyRunning
}
// release removes the lock file. Safe to call once.
func (l *fileLock) release() error {
if l == nil || l.path == "" {
return nil
}
if err := os.Remove(l.path); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
// readPidFile reads and parses the PID recorded in a lock file.
func readPidFile(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(data)))
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !windows
package daemon
import (
"errors"
"syscall"
)
// osProcessAlive reports whether pid is a live process on POSIX. Signal 0 does
// not deliver a signal; it only checks existence/permission. ESRCH means no such
// process (dead); EPERM means it exists but we may not signal it (alive).
func osProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
if err == nil {
return true
}
return errors.Is(err, syscall.EPERM)
}
+77
View File
@@ -0,0 +1,77 @@
package daemon
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestLockSingleInstance(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
alive := func(int) bool { return true }
l1, err := acquireLock(path, alive)
if err != nil {
t.Fatalf("first acquireLock: %v", err)
}
// Second acquire while the holder is "alive" must be refused.
if _, err := acquireLock(path, alive); !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("second acquireLock err = %v, want ErrAlreadyRunning", err)
}
// After release, a new acquire succeeds.
if err := l1.release(); err != nil {
t.Fatalf("release: %v", err)
}
l2, err := acquireLock(path, alive)
if err != nil {
t.Fatalf("acquire after release: %v", err)
}
_ = l2.release()
}
func TestLockStaleRecovery(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
// Simulate a stale lock from a crashed daemon: a PID file whose process is
// dead.
if err := os.WriteFile(path, []byte("4242\n"), 0o600); err != nil {
t.Fatalf("write stale lock: %v", err)
}
dead := func(int) bool { return false }
l, err := acquireLock(path, dead)
if err != nil {
t.Fatalf("stale-lock recovery failed: %v", err)
}
// The lock now records OUR pid, not the stale one.
data, _ := os.ReadFile(path)
if strings.TrimSpace(string(data)) != strconv.Itoa(os.Getpid()) {
t.Fatalf("reclaimed lock pid = %q, want %d", strings.TrimSpace(string(data)), os.Getpid())
}
_ = l.release()
}
func TestLockReleaseRemovesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "daemon.lock")
l, err := acquireLock(path, func(int) bool { return true })
if err != nil {
t.Fatalf("acquireLock: %v", err)
}
if err := l.release(); err != nil {
t.Fatalf("release: %v", err)
}
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("lock file still present after release: %v", err)
}
}
func TestProcessAliveSelfAndDead(t *testing.T) {
if !osProcessAlive(os.Getpid()) {
t.Fatal("osProcessAlive(self) = false, want true")
}
// PID 0 / negative are never live.
if osProcessAlive(0) || osProcessAlive(-1) {
t.Fatal("osProcessAlive must reject non-positive pids")
}
}
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package daemon
import "golang.org/x/sys/windows"
// osProcessAlive reports whether pid is a live process on Windows. It opens the
// process for limited query access; a successful open with a non-exited status
// means the PID is live. x/sys is already a module dependency.
func osProcessAlive(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
return false
}
defer windows.CloseHandle(handle)
var code uint32
if err := windows.GetExitCodeProcess(handle, &code); err != nil {
// Could not query; treat the open as proof of life (conservative: do not
// reclaim a lock we are unsure about).
return true
}
const stillActive = 259 // STILL_ACTIVE
return code == stillActive
}
+42
View File
@@ -0,0 +1,42 @@
package daemon
import (
"os"
"path/filepath"
"strings"
)
// DefaultDir returns the per-user directory for the daemon's runtime files:
// $XDG_RUNTIME_DIR/zero when set (a tmpfs owned by the user on Linux), otherwise
// ~/.zero. Mirrors supervisor.js / config.js choosing a per-user runtime dir.
func DefaultDir() (string, error) {
if rt := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); rt != "" {
return filepath.Join(rt, "zero"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".zero"), nil
}
// Paths bundles the daemon's socket, lock, and status file paths.
type Paths struct {
Socket string
Lock string
Status string
}
// DefaultPaths returns the daemon control-socket, lock, and status file paths
// under DefaultDir.
func DefaultPaths() (Paths, error) {
dir, err := DefaultDir()
if err != nil {
return Paths{}, err
}
return Paths{
Socket: filepath.Join(dir, "daemon.sock"),
Lock: filepath.Join(dir, "daemon.lock"),
Status: filepath.Join(dir, "daemon.status"),
}, nil
}
+355
View File
@@ -0,0 +1,355 @@
package daemon
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// Exit codes a worker may use to signal restart policy. Mirrors
// reference-daemon-code-agent-js/worker-manager.js EXIT_TEMPFAIL/EXIT_PERMANENT.
const (
// ExitTempfail asks for a retry after a fixed cool-off (transient failure).
ExitTempfail = 75
// ExitPermanent asks the daemon to STOP retrying this work (fatal).
ExitPermanent = 76
)
// defaultPoolSize, defaultMaxAttempts and defaultKillTimeout are used when a
// PoolOptions field is left zero.
const (
defaultPoolSize = 4
defaultMaxAttempts = 5
defaultKillTimeout = 5 * time.Second
)
// ErrPoolDraining is returned by Run once the pool is shutting down.
var ErrPoolDraining = errors.New("daemon: pool is draining")
// ErrPermanent is returned when a worker exits ExitPermanent or the attempt cap
// is exhausted; the request must not be retried.
var ErrPermanent = errors.New("daemon: worker failed permanently")
// WorkerSpec describes the worker to launch for one session attempt.
type WorkerSpec struct {
Session string
Cwd string
// Args are the per-session exec flags (e.g. --prompt ...), parsed by the CLI
// and appended after `exec` by the production launcher.
Args []string
}
// WorkerHandle is a launched worker process the pool supervises. The production
// launcher wraps an exec.Cmd running `zero exec -i/-o stream-json`; tests inject
// a fake. Stdout yields the worker's stream-json event lines.
type WorkerHandle interface {
Stdout() Lines
// Wait blocks until the worker exits and returns its process exit code.
Wait() (int, error)
// Kill force-terminates the worker (used on drain timeout).
Kill() error
Pid() int
}
// Lines is a stream of stream-json output lines from a worker. Next returns the
// next line and io-EOF-style ok=false when the stream ends.
type Lines interface {
Next() (line string, ok bool, err error)
}
// Launcher spawns a worker for a single attempt. It must apply the sandbox/env
// policy (the production launcher scrubs the re-entrancy markers so the worker
// re-establishes its own sandbox — see newExecLauncher).
type Launcher func(ctx context.Context, spec WorkerSpec) (WorkerHandle, error)
// PoolOptions configures a Pool. Zero fields take documented defaults.
type PoolOptions struct {
// Size is the max number of concurrent workers (lease slots). Sessions beyond
// this queue until a slot frees.
Size int
// Launcher spawns a worker. Required.
Launcher Launcher
// MaxAttempts caps the total attempts per request (first + retries). When
// exhausted, Run returns ErrPermanent (mirrors capping consecutive restarts).
MaxAttempts int
// KillTimeout bounds graceful termination before SIGKILL-equivalent on drain.
KillTimeout time.Duration
// Backoff returns the delay before retry attempt n (1-based crash count).
// Defaults to BackoffWithJitter; tests inject a deterministic function.
Backoff func(attempt int) time.Duration
// TempfailDelay is the cool-off after an ExitTempfail exit. Defaults to 30s.
TempfailDelay time.Duration
// Log, when set, receives one line per lifecycle event.
Log func(string)
}
// Pool is a bounded, self-healing worker pool. A crashed worker never takes down
// the pool: Run retries on a fresh worker with backoff up to MaxAttempts, and an
// ExitPermanent worker stops retries. Drain stops new work and kills stragglers.
type Pool struct {
opts PoolOptions
slots chan struct{}
mu sync.Mutex
draining bool
active map[int]WorkerHandle // pid -> handle, for drain/kill + status
nextID int
drainOnce sync.Once
drained chan struct{}
}
// workerStat tracks one in-flight request's restart count (local to Run).
type workerStat struct {
id int
restarts int
}
// NewPool builds a pool from opts, filling defaults.
func NewPool(opts PoolOptions) (*Pool, error) {
if opts.Launcher == nil {
return nil, errors.New("daemon: pool requires a Launcher")
}
if opts.Size <= 0 {
opts.Size = defaultPoolSize
}
if opts.MaxAttempts <= 0 {
opts.MaxAttempts = defaultMaxAttempts
}
if opts.KillTimeout <= 0 {
opts.KillTimeout = defaultKillTimeout
}
if opts.TempfailDelay <= 0 {
opts.TempfailDelay = 30 * time.Second
}
if opts.Backoff == nil {
opts.Backoff = BackoffWithJitter
}
return &Pool{
opts: opts,
slots: make(chan struct{}, opts.Size),
active: map[int]WorkerHandle{},
drained: make(chan struct{}),
}, nil
}
// Size returns the configured max concurrency.
func (p *Pool) Size() int { return p.opts.Size }
func (p *Pool) logf(format string, args ...any) {
if p.opts.Log != nil {
p.opts.Log(fmt.Sprintf(format, args...))
}
}
// acquire takes a lease slot, queuing until one frees or ctx is cancelled / the
// pool drains.
func (p *Pool) acquire(ctx context.Context) error {
p.mu.Lock()
draining := p.draining
p.mu.Unlock()
if draining {
return ErrPoolDraining
}
select {
case p.slots <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
case <-p.drained:
return ErrPoolDraining
}
}
func (p *Pool) release() { <-p.slots }
// QueueDepth reports how many lease slots are currently in use.
func (p *Pool) QueueDepth() int { return len(p.slots) }
// Sink receives a worker's stream-json output lines for one request.
type Sink interface {
Line(line string)
}
// Run leases a worker slot and dispatches spec to a worker, streaming its
// stream-json lines to sink. It is at-least-once with bounded retries: a worker
// that crashes (non-zero, non-permanent) is retried on a fresh worker after a
// backoff; ExitPermanent or exhausting MaxAttempts returns ErrPermanent. Run
// queues when all slots are busy. The returned int is the final worker exit code.
func (p *Pool) Run(ctx context.Context, spec WorkerSpec, sink Sink) (int, error) {
if err := p.acquire(ctx); err != nil {
return 0, err
}
defer p.release()
// Lease hook: a sink that tracks queue->running state is notified the instant
// a worker slot is leased (mirrors lease.js acquiring a worker for a session).
if s, ok := sink.(interface{ Started() }); ok {
s.Started()
}
stat := p.newStat()
var lastErr error
for attempt := 1; attempt <= p.opts.MaxAttempts; attempt++ {
if p.isDraining() {
return 0, ErrPoolDraining
}
code, err := p.runOnce(ctx, stat.id, spec, sink)
switch {
case err != nil:
lastErr = err
if ctx.Err() != nil {
return 0, ctx.Err()
}
p.logf("worker %d launch/run error: %v", stat.id, err)
case code == 0:
return 0, nil // clean success
case code == ExitPermanent:
p.logf("worker %d exited permanently (code=%d) — not retrying", stat.id, code)
return code, ErrPermanent
case code == ExitTempfail:
lastErr = fmt.Errorf("worker %d tempfail (code=%d)", stat.id, code)
p.logf("worker %d tempfail — retry after %s", stat.id, p.opts.TempfailDelay)
if !p.sleep(ctx, p.opts.TempfailDelay) {
return 0, ctx.Err()
}
continue // tempfail retries do not count against the crash backoff
default:
lastErr = fmt.Errorf("worker %d exited code=%d", stat.id, code)
}
if attempt == p.opts.MaxAttempts {
break
}
stat.restarts++
delay := p.opts.Backoff(stat.restarts)
p.logf("worker %d restart %d after backoff %s", stat.id, stat.restarts, delay)
if !p.sleep(ctx, delay) {
return 0, ctx.Err()
}
}
if lastErr == nil {
lastErr = ErrPermanent
}
return ExitPermanent, fmt.Errorf("%w: %v", ErrPermanent, lastErr)
}
// runOnce launches a single worker, pumps its output to sink, and returns its
// exit code. The worker handle is tracked so Drain can kill it.
func (p *Pool) runOnce(ctx context.Context, id int, spec WorkerSpec, sink Sink) (int, error) {
handle, err := p.opts.Launcher(ctx, spec)
if err != nil {
return 0, err
}
p.track(handle)
defer p.untrack(handle)
// Pump stdout lines until the stream ends.
lines := handle.Stdout()
for {
line, ok, lerr := lines.Next()
if lerr != nil {
break
}
if !ok {
break
}
if sink != nil {
sink.Line(line)
}
}
return handle.Wait()
}
func (p *Pool) newStat() *workerStat {
p.mu.Lock()
defer p.mu.Unlock()
p.nextID++
return &workerStat{id: p.nextID}
}
func (p *Pool) track(h WorkerHandle) {
p.mu.Lock()
p.active[h.Pid()] = h
p.mu.Unlock()
}
func (p *Pool) untrack(h WorkerHandle) {
p.mu.Lock()
delete(p.active, h.Pid())
p.mu.Unlock()
}
func (p *Pool) isDraining() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.draining
}
// sleep waits d, returning false if ctx is cancelled first. A non-positive d
// returns immediately.
func (p *Pool) sleep(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return ctx.Err() == nil
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
}
}
// Drain stops accepting new work, gives in-flight workers a grace window
// (KillTimeout) to finish on their own, then force-kills any straggler. It
// returns as soon as the pool is idle (graceful) or the window elapses. Safe to
// call once; subsequent calls are no-ops.
func (p *Pool) Drain() {
p.drainOnce.Do(func() {
p.mu.Lock()
p.draining = true
p.mu.Unlock()
close(p.drained)
// Grace window: poll until idle or the deadline.
deadline := time.Now().Add(p.opts.KillTimeout)
for time.Now().Before(deadline) {
p.mu.Lock()
n := len(p.active)
p.mu.Unlock()
if n == 0 {
return // all workers drained gracefully
}
time.Sleep(5 * time.Millisecond)
}
// Timed out: force-kill remaining workers.
p.mu.Lock()
handles := make([]WorkerHandle, 0, len(p.active))
for _, h := range p.active {
handles = append(handles, h)
}
p.mu.Unlock()
for _, h := range handles {
p.logf("drain: killing straggler worker pid=%d", h.Pid())
_ = h.Kill()
}
})
}
// WorkerStats returns a snapshot of the currently-active (busy) workers, bounded
// by the pool size. The on-demand pool keeps no idle workers, so an empty result
// means the pool is idle.
func (p *Pool) WorkerStats() []WorkerStatus {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]WorkerStatus, 0, len(p.active))
for pid := range p.active {
out = append(out, WorkerStatus{PID: pid, State: "busy"})
}
return out
}
+248
View File
@@ -0,0 +1,248 @@
package daemon
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
)
// --- test doubles ---------------------------------------------------------
type fakeLines struct {
lines []string
i int
}
func (f *fakeLines) Next() (string, bool, error) {
if f.i >= len(f.lines) {
return "", false, nil
}
l := f.lines[f.i]
f.i++
return l, true, nil
}
type fakeWorker struct {
pid int
out []string
exitCode int
killed int32
waitCh chan struct{} // when non-nil, Wait blocks until closed (drain tests)
}
func (w *fakeWorker) Stdout() Lines { return &fakeLines{lines: w.out} }
func (w *fakeWorker) Wait() (int, error) {
if w.waitCh != nil {
<-w.waitCh
}
return w.exitCode, nil
}
func (w *fakeWorker) Kill() error {
atomic.StoreInt32(&w.killed, 1)
if w.waitCh != nil {
select {
case <-w.waitCh:
default:
close(w.waitCh)
}
}
return nil
}
func (w *fakeWorker) Pid() int { return w.pid }
// seqLauncher hands out the given workers in order, then errors.
func seqLauncher(workers ...*fakeWorker) (Launcher, *int32) {
var calls int32
l := func(_ context.Context, _ WorkerSpec) (WorkerHandle, error) {
n := atomic.AddInt32(&calls, 1)
idx := int(n) - 1
if idx >= len(workers) {
return nil, errors.New("no more fake workers")
}
return workers[idx], nil
}
return l, &calls
}
type collectSink struct {
mu sync.Mutex
lines []string
}
func (c *collectSink) Line(line string) {
c.mu.Lock()
c.lines = append(c.lines, line)
c.mu.Unlock()
}
// --- tests ----------------------------------------------------------------
func TestPoolRunStreamsAndSucceeds(t *testing.T) {
launcher, calls := seqLauncher(&fakeWorker{pid: 1, out: []string{"a", "b"}, exitCode: 0})
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher})
if err != nil {
t.Fatalf("NewPool: %v", err)
}
sink := &collectSink{}
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
if err != nil || code != 0 {
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
}
if len(sink.lines) != 2 || sink.lines[0] != "a" || sink.lines[1] != "b" {
t.Fatalf("sink lines = %v, want [a b]", sink.lines)
}
if *calls != 1 {
t.Fatalf("launcher calls = %d, want 1", *calls)
}
}
func TestPoolRunRetriesWithBackoff(t *testing.T) {
launcher, calls := seqLauncher(
&fakeWorker{pid: 1, exitCode: 1}, // crash
&fakeWorker{pid: 2, exitCode: 1}, // crash
&fakeWorker{pid: 3, exitCode: 0, out: []string{"ok"}}, // success
)
var backoffArgs []int
pool, _ := NewPool(PoolOptions{
Size: 1, Launcher: launcher, MaxAttempts: 5,
Backoff: func(attempt int) time.Duration { backoffArgs = append(backoffArgs, attempt); return 0 },
})
sink := &collectSink{}
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, sink)
if err != nil || code != 0 {
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
}
if *calls != 3 {
t.Fatalf("launcher calls = %d, want 3 (2 crashes + success)", *calls)
}
// Backoff invoked between attempts with an increasing 1-based restart count.
if len(backoffArgs) != 2 || backoffArgs[0] != 1 || backoffArgs[1] != 2 {
t.Fatalf("backoff attempts = %v, want [1 2]", backoffArgs)
}
}
func TestPoolRunPermanentStopsImmediately(t *testing.T) {
launcher, calls := seqLauncher(&fakeWorker{pid: 1, exitCode: ExitPermanent})
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, Backoff: func(int) time.Duration { return 0 }})
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
if !errors.Is(err, ErrPermanent) {
t.Fatalf("Run err = %v, want ErrPermanent", err)
}
if code != ExitPermanent {
t.Fatalf("Run code = %d, want %d", code, ExitPermanent)
}
if *calls != 1 {
t.Fatalf("permanent exit must not retry; launcher calls = %d, want 1", *calls)
}
}
func TestPoolRunCapExhausted(t *testing.T) {
launcher, calls := seqLauncher(
&fakeWorker{pid: 1, exitCode: 1},
&fakeWorker{pid: 2, exitCode: 1},
&fakeWorker{pid: 3, exitCode: 1},
)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 3, Backoff: func(int) time.Duration { return 0 }})
_, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
if !errors.Is(err, ErrPermanent) {
t.Fatalf("Run err = %v, want ErrPermanent after cap", err)
}
if *calls != 3 {
t.Fatalf("launcher calls = %d, want 3 (== MaxAttempts)", *calls)
}
}
func TestPoolRunTempfailRetries(t *testing.T) {
launcher, calls := seqLauncher(
&fakeWorker{pid: 1, exitCode: ExitTempfail},
&fakeWorker{pid: 2, exitCode: 0, out: []string{"ok"}},
)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, TempfailDelay: time.Millisecond})
code, err := pool.Run(context.Background(), WorkerSpec{Session: "s"}, &collectSink{})
if err != nil || code != 0 {
t.Fatalf("Run = (%d,%v), want (0,nil)", code, err)
}
if *calls != 2 {
t.Fatalf("launcher calls = %d, want 2 (tempfail + success)", *calls)
}
}
func TestPoolQueuesWhenFull(t *testing.T) {
block := make(chan struct{})
first := &fakeWorker{pid: 1, waitCh: block}
second := &fakeWorker{pid: 2, exitCode: 0}
launcher, _ := seqLauncher(first, second)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
started := make(chan struct{})
go func() {
close(started)
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
}()
<-started
// Wait until the first run holds the only slot.
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
secondDone := make(chan struct{})
go func() {
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{})
close(secondDone)
}()
// The second run must NOT complete while the slot is held.
select {
case <-secondDone:
t.Fatal("second run completed while the only slot was busy (no queueing)")
case <-time.After(50 * time.Millisecond):
}
// Free the slot; the queued run now proceeds.
close(block)
select {
case <-secondDone:
case <-time.After(2 * time.Second):
t.Fatal("queued run did not proceed after slot freed")
}
}
func TestPoolDrainKillsStraggler(t *testing.T) {
block := make(chan struct{})
straggler := &fakeWorker{pid: 1, waitCh: block}
launcher, _ := seqLauncher(straggler)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher, MaxAttempts: 1, KillTimeout: 10 * time.Millisecond})
runDone := make(chan struct{})
go func() {
_, _ = pool.Run(context.Background(), WorkerSpec{Session: "a"}, &collectSink{})
close(runDone)
}()
waitFor(t, func() bool { return pool.QueueDepth() == 1 })
pool.Drain() // KillTimeout elapses, straggler is force-killed
if atomic.LoadInt32(&straggler.killed) != 1 {
t.Fatal("Drain must kill a straggler still running after KillTimeout")
}
select {
case <-runDone:
case <-time.After(2 * time.Second):
t.Fatal("run did not finish after drain kill")
}
// A new Run after drain is refused.
if _, err := pool.Run(context.Background(), WorkerSpec{Session: "b"}, &collectSink{}); !errors.Is(err, ErrPoolDraining) {
t.Fatalf("Run after drain err = %v, want ErrPoolDraining", err)
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
+184
View File
@@ -0,0 +1,184 @@
// 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
}
+120
View File
@@ -0,0 +1,120 @@
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)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
// Package remote adds an OPT-IN, TLS-only network bridge on top of zero's local
// daemon (internal/daemon). It lets a remote client drive the SAME daemon
// SessionManager/Pool over the network, behind bearer-token authentication and a
// protocol-version floor. The local Unix-socket daemon is unchanged and remains
// the default; nothing here activates unless `zero daemon serve-remote` is run.
//
// Security (fail closed):
// - TLS is mandatory; the bridge refuses to serve without a cert/key.
// - A connection is authenticated BEFORE any control frame is dispatched; a
// failed/absent token closes the connection (after a small backoff) and no
// session frame is ever processed.
// - Tokens are compared in constant time and are never logged.
// - The control-frame size cap and version negotiation from internal/daemon are
// reused unchanged; oversize/old-version handshakes are rejected.
// - A remote-driven session runs through the same daemon dispatch, so it stays
// under the same sandbox + risk model — remote never bypasses local controls.
package remote
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/Gitlawb/zero/internal/daemon"
)
// Env vars the bridge reads for its bearer token.
const (
EnvToken = "ZERO_DAEMON_REMOTE_TOKEN"
EnvTokenFile = "ZERO_DAEMON_REMOTE_TOKEN_FILE"
)
// ErrUnauthorized is returned when a token does not match.
var ErrUnauthorized = errors.New("remote: unauthorized")
// Authenticator verifies a bearer token presented by a remote client.
type Authenticator interface {
Authenticate(token string) error
}
// TokenAuthenticator compares a presented token against a fixed secret in
// constant time.
type TokenAuthenticator struct {
token string
}
// NewTokenAuthenticator builds a token authenticator, refusing an empty secret
// (fail closed — a bridge must never accept everyone).
func NewTokenAuthenticator(token string) (*TokenAuthenticator, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("remote: a non-empty auth token is required")
}
return &TokenAuthenticator{token: token}, nil
}
// Authenticate reports nil when token matches the configured secret.
func (a *TokenAuthenticator) Authenticate(token string) error {
if subtle.ConstantTimeCompare([]byte(token), []byte(a.token)) == 1 {
return nil
}
return ErrUnauthorized
}
// TokenFromEnv resolves the bridge token from EnvToken, or a file named by
// EnvTokenFile. It never logs the token.
func TokenFromEnv() (string, error) {
if t := strings.TrimSpace(os.Getenv(EnvToken)); t != "" {
return t, nil
}
if file := strings.TrimSpace(os.Getenv(EnvTokenFile)); file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("remote: read token file: %w", err)
}
t := strings.TrimSpace(string(data))
if t == "" {
return "", errors.New("remote: token file is empty")
}
return t, nil
}
return "", fmt.Errorf("remote: set %s or %s", EnvToken, EnvTokenFile)
}
// Attestation is an optional post-token hook (e.g. workload attestation). The
// default is a no-op; a deployment can supply a stricter implementation.
type Attestation interface {
Verify(meta map[string]string) error
}
type noopAttestation struct{}
func (noopAttestation) Verify(map[string]string) error { return nil }
// authRequest is the first frame a remote client sends (before the daemon
// hello). Token is never logged.
type authRequest struct {
Token string `json:"token"`
Version int `json:"version"`
Meta map[string]string `json:"meta,omitempty"`
}
// authResponse is the bridge's reply to the auth handshake.
type authResponse struct {
OK bool `json:"ok"`
Version int `json:"version,omitempty"`
Message string `json:"message,omitempty"`
}
func writeAuthRequest(w io.Writer, req authRequest) error {
payload, err := json.Marshal(req)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readAuthRequest(r io.Reader) (authRequest, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return authRequest{}, err
}
if kind != daemon.KindCtrl {
return authRequest{}, errors.New("remote: expected auth frame")
}
var req authRequest
if err := json.Unmarshal(payload, &req); err != nil {
return authRequest{}, fmt.Errorf("remote: decode auth request: %w", err)
}
return req, nil
}
func writeAuthResponse(w io.Writer, resp authResponse) error {
payload, err := json.Marshal(resp)
if err != nil {
return err
}
return daemon.WriteFrame(w, daemon.KindCtrl, payload)
}
func readAuthResponse(r io.Reader) (authResponse, error) {
kind, payload, err := daemon.ReadFrame(r)
if err != nil {
return authResponse{}, err
}
if kind != daemon.KindCtrl {
return authResponse{}, errors.New("remote: expected auth response frame")
}
var resp authResponse
if err := json.Unmarshal(payload, &resp); err != nil {
return authResponse{}, fmt.Errorf("remote: decode auth response: %w", err)
}
return resp, nil
}
+67
View File
@@ -0,0 +1,67 @@
package remote
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestTokenAuthenticator(t *testing.T) {
if _, err := NewTokenAuthenticator(" "); err == nil {
t.Fatal("empty token must be rejected (fail closed)")
}
a, err := NewTokenAuthenticator("s3cret")
if err != nil {
t.Fatalf("NewTokenAuthenticator: %v", err)
}
if err := a.Authenticate("s3cret"); err != nil {
t.Fatalf("matching token should authenticate: %v", err)
}
if err := a.Authenticate("wrong"); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("mismatch err = %v, want ErrUnauthorized", err)
}
if err := a.Authenticate(""); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("empty presented token err = %v, want ErrUnauthorized", err)
}
}
func TestTokenFromEnv(t *testing.T) {
// Clear both so the no-config path errors.
t.Setenv(EnvToken, "")
t.Setenv(EnvTokenFile, "")
if _, err := TokenFromEnv(); err == nil {
t.Fatal("TokenFromEnv with neither var set must error")
}
// Direct env.
t.Setenv(EnvToken, "from-env")
if tok, err := TokenFromEnv(); err != nil || tok != "from-env" {
t.Fatalf("TokenFromEnv(env) = %q, %v", tok, err)
}
// File (env takes precedence, so clear env first).
t.Setenv(EnvToken, "")
file := filepath.Join(t.TempDir(), "tok")
if err := os.WriteFile(file, []byte(" from-file\n"), 0o600); err != nil {
t.Fatalf("write token file: %v", err)
}
t.Setenv(EnvTokenFile, file)
if tok, err := TokenFromEnv(); err != nil || tok != "from-file" {
t.Fatalf("TokenFromEnv(file) = %q, %v", tok, err)
}
// Empty file fails closed.
if err := os.WriteFile(file, []byte(" \n"), 0o600); err != nil {
t.Fatalf("rewrite token file: %v", err)
}
if _, err := TokenFromEnv(); err == nil {
t.Fatal("empty token file must error")
}
}
func TestServerTLSConfigRequiresCertKey(t *testing.T) {
if _, err := ServerTLSConfig("", ""); err == nil {
t.Fatal("ServerTLSConfig must require a cert and key (TLS mandatory)")
}
if _, err := ServerTLSConfig("/nope/cert.pem", "/nope/key.pem"); err == nil {
t.Fatal("ServerTLSConfig must error on unreadable cert/key")
}
}
+214
View File
@@ -0,0 +1,214 @@
package remote
import (
"crypto/tls"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/daemon"
)
const (
defaultMaxConnections = 32
defaultHandshakeTimeout = 10 * time.Second
defaultAuthFailDelay = 250 * time.Millisecond
)
// Bridge serves authenticated remote connections and drives the local daemon's
// control dispatch for each one.
type Bridge struct {
server *daemon.Server
auth Authenticator
attest Attestation
minVersion int
handshakeTimeout time.Duration
authFailDelay time.Duration
log func(string)
sem chan struct{}
mu sync.Mutex
listener net.Listener
}
// BridgeOptions configures a Bridge.
type BridgeOptions struct {
// Server is the local daemon to drive (required).
Server *daemon.Server
// Authenticator verifies bearer tokens (required).
Authenticator Authenticator
// Attestation is an optional post-token hook; nil => no-op.
Attestation Attestation
// MinVersion is the lowest control-protocol version accepted; 0 =>
// daemon.ProtoVersion.
MinVersion int
// MaxConnections bounds concurrent remote connections; 0 => default.
MaxConnections int
// HandshakeTimeout bounds the auth handshake; 0 => default.
HandshakeTimeout time.Duration
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
AuthFailDelay time.Duration
Log func(string)
}
// NewBridge validates options and builds a Bridge.
func NewBridge(opts BridgeOptions) (*Bridge, error) {
if opts.Server == nil {
return nil, errors.New("remote: bridge requires a daemon server")
}
if opts.Authenticator == nil {
return nil, errors.New("remote: bridge requires an authenticator")
}
minVersion := opts.MinVersion
if minVersion <= 0 {
minVersion = daemon.ProtoVersion
}
maxConns := opts.MaxConnections
if maxConns <= 0 {
maxConns = defaultMaxConnections
}
handshakeTimeout := opts.HandshakeTimeout
if handshakeTimeout <= 0 {
handshakeTimeout = defaultHandshakeTimeout
}
authFailDelay := opts.AuthFailDelay
switch {
case authFailDelay < 0:
authFailDelay = 0
case authFailDelay == 0:
authFailDelay = defaultAuthFailDelay
}
attest := opts.Attestation
if attest == nil {
attest = noopAttestation{}
}
return &Bridge{
server: opts.Server,
auth: opts.Authenticator,
attest: attest,
minVersion: minVersion,
handshakeTimeout: handshakeTimeout,
authFailDelay: authFailDelay,
log: opts.Log,
sem: make(chan struct{}, maxConns),
}, nil
}
func (b *Bridge) logf(format string, args ...any) {
if b.log != nil {
b.log(fmt.Sprintf(format, args...))
}
}
// ListenAndServeTLS binds a TLS listener on addr and serves until the listener
// is closed (via Close). It refuses to serve without a TLS config (no plaintext
// remote bridge).
func (b *Bridge) ListenAndServeTLS(addr string, tlsConfig *tls.Config) error {
if tlsConfig == nil {
return errors.New("remote: TLS config is required; refusing to serve a plaintext remote bridge")
}
listener, err := tls.Listen("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("remote: listen %s: %w", addr, err)
}
b.mu.Lock()
b.listener = listener
b.mu.Unlock()
return b.Serve(listener)
}
// Close stops the listener started by ListenAndServeTLS, causing Serve to
// return. Safe to call before serving or more than once.
func (b *Bridge) Close() error {
b.mu.Lock()
listener := b.listener
b.listener = nil // clear so a repeat Close is a no-op, not a closed-listener error
b.mu.Unlock()
if listener != nil {
return listener.Close()
}
return nil
}
// Serve accepts connections from listener until it returns an error (e.g. it is
// closed). Each connection is authenticated, then handed to the daemon's control
// dispatch. Connections beyond MaxConnections are refused immediately.
func (b *Bridge) Serve(listener net.Listener) error {
for {
conn, err := listener.Accept()
if err != nil {
return err
}
select {
case b.sem <- struct{}{}:
default:
// At capacity: refuse fast (fail closed) rather than queueing.
b.logf("remote: refused connection (at capacity)")
_ = conn.Close()
continue
}
go func() {
defer func() { <-b.sem }()
b.handle(conn)
}()
}
}
// handle authenticates conn then drives the daemon dispatch. It always closes
// conn (directly on a failed handshake, or via ServeConn on success).
func (b *Bridge) handle(conn net.Conn) {
// Bound the auth handshake so a stalled peer cannot hold a connection slot.
_ = conn.SetDeadline(time.Now().Add(b.handshakeTimeout))
req, err := readAuthRequest(conn)
if err != nil {
_ = conn.Close()
return
}
if req.Version < b.minVersion {
b.deny(conn, "unsupported protocol version")
return
}
if err := b.auth.Authenticate(req.Token); err != nil {
b.deny(conn, "unauthorized")
return
}
if err := b.attest.Verify(req.Meta); err != nil {
b.deny(conn, "attestation failed")
return
}
if err := writeAuthResponse(conn, authResponse{OK: true, Version: daemon.ProtoVersion}); err != nil {
_ = conn.Close()
return
}
// Clear the handshake deadline: a session may stream for a long time.
_ = conn.SetDeadline(time.Time{})
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
}
// deny rejects an unauthenticated connection after a small backoff (to slow
// brute force). The reason is generic and never includes any token material.
func (b *Bridge) deny(conn net.Conn, message string) {
if b.authFailDelay > 0 {
time.Sleep(b.authFailDelay)
}
_ = writeAuthResponse(conn, authResponse{OK: false, Message: message})
_ = conn.Close()
}
// ServerTLSConfig builds a server TLS config from a cert/key pair, refusing if
// either is missing (TLS is mandatory for the remote bridge).
func ServerTLSConfig(certFile, keyFile string) (*tls.Config, error) {
certFile = strings.TrimSpace(certFile)
keyFile = strings.TrimSpace(keyFile)
if certFile == "" || keyFile == "" {
return nil, errors.New("remote: a TLS cert and key are required for serve-remote")
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("remote: load TLS keypair: %w", err)
}
return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
}
+342
View File
@@ -0,0 +1,342 @@
package remote
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/daemon"
)
// ---- fake daemon worker (the daemon's test fakes are unexported) -------------
type fakeLines struct{ ch <-chan string }
func (l fakeLines) Next() (string, bool, error) {
line, ok := <-l.ch
return line, ok, nil
}
type fakeWorker struct {
ch chan string
pid int
}
func (w *fakeWorker) Stdout() daemon.Lines { return fakeLines{ch: w.ch} }
func (w *fakeWorker) Wait() (int, error) { return 0, nil }
func (w *fakeWorker) Kill() error { return nil }
func (w *fakeWorker) Pid() int { return w.pid }
// staticLauncher emits the given lines then ends the stream.
func staticLauncher(lines ...string) daemon.Launcher {
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
ch := make(chan string, len(lines))
for _, l := range lines {
ch <- l
}
close(ch)
return &fakeWorker{ch: ch, pid: 1}, nil
}
}
// blockingLauncher returns a worker whose stream stays open until release is
// closed, so a session (and its bridge connection slot) is held open.
func blockingLauncher(release <-chan struct{}) daemon.Launcher {
return func(_ context.Context, _ daemon.WorkerSpec) (daemon.WorkerHandle, error) {
ch := make(chan string)
go func() {
<-release
close(ch)
}()
return &fakeWorker{ch: ch, pid: 1}, nil
}
}
func newBridgeServer(t *testing.T, launcher daemon.Launcher) *daemon.Server {
t.Helper()
dir := t.TempDir()
pool, err := daemon.NewPool(daemon.PoolOptions{Size: 4, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
if err != nil {
t.Fatalf("NewPool: %v", err)
}
mgr, err := daemon.NewSessionManager(daemon.SessionManagerOptions{Pool: pool})
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
srv, err := daemon.NewServer(daemon.ServerOptions{
Paths: daemon.Paths{Socket: filepath.Join(dir, "s.sock"), Lock: filepath.Join(dir, "s.lock"), Status: filepath.Join(dir, "s.status")},
Manager: mgr,
Pool: pool,
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
t.Cleanup(srv.Shutdown)
return srv
}
// genTestCert writes a self-signed cert/key (valid for 127.0.0.1) to temp files.
func genTestCert(t *testing.T) (certFile, keyFile string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "zero-remote-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
dir := t.TempDir()
certFile = filepath.Join(dir, "cert.pem")
keyFile = filepath.Join(dir, "key.pem")
certOut, _ := os.Create(certFile)
_ = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: der})
_ = certOut.Close()
keyDER, _ := x509.MarshalECPrivateKey(key)
keyOut, _ := os.Create(keyFile)
_ = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
_ = keyOut.Close()
return certFile, keyFile
}
// startBridge starts a TLS bridge on 127.0.0.1:0 and returns its address + cert.
func startBridge(t *testing.T, srv *daemon.Server, opts BridgeOptions) (addr, caCert string) {
t.Helper()
certFile, keyFile := genTestCert(t)
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
if err != nil {
t.Fatalf("ServerTLSConfig: %v", err)
}
opts.Server = srv
bridge, err := NewBridge(opts)
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
tlsLn := tls.NewListener(ln, tlsConfig)
go func() { _ = bridge.Serve(tlsLn) }()
t.Cleanup(func() { _ = tlsLn.Close() })
return ln.Addr().String(), certFile
}
func TestBridgeRunStatusRoundTrip(t *testing.T) {
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
auth, _ := NewTokenAuthenticator("tok")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
client, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
if err != nil {
t.Fatalf("DialRemote: %v", err)
}
var got []string
if err := client.Run("sess-1", "", "hello", nil, func(l string) { got = append(got, l) }); err != nil {
t.Fatalf("Run: %v", err)
}
_ = client.Close()
if len(got) != 2 || got[0] != `{"type":"event","seq":1}` {
t.Fatalf("run output = %v", got)
}
// Status over a fresh remote connection (same protocol as local).
statusClient, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
if err != nil {
t.Fatalf("DialRemote(status): %v", err)
}
report, err := statusClient.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
_ = statusClient.Close()
if report == nil || report.PID == 0 {
t.Fatalf("status report = %+v", report)
}
}
func TestBridgeRejectsBadToken(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("correct")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, AuthFailDelay: -1})
_, err := DialRemote(RemoteConfig{Address: addr, Token: "wrong", CACertFile: ca})
if !errors.Is(err, ErrUnauthorized) {
t.Fatalf("DialRemote(bad token) err = %v, want ErrUnauthorized", err)
}
}
func TestBridgeRejectsLowVersion(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
// Require a version higher than the client speaks (daemon.ProtoVersion).
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MinVersion: daemon.ProtoVersion + 1, AuthFailDelay: -1})
_, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
if err == nil {
t.Fatal("DialRemote with a below-min version must be rejected")
}
}
func TestBridgeRequiresVerifiableCert(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
addr, _ := startBridge(t, srv, BridgeOptions{Authenticator: auth})
// No CA configured + a self-signed server cert => verification must fail
// (NEVER InsecureSkipVerify).
if _, err := DialRemote(RemoteConfig{Address: addr, Token: "tok"}); err == nil {
t.Fatal("DialRemote must reject an untrusted self-signed cert")
}
}
func TestListenAndServeTLSRequiresConfig(t *testing.T) {
srv := newBridgeServer(t, staticLauncher())
auth, _ := NewTokenAuthenticator("tok")
bridge, _ := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
if err := bridge.ListenAndServeTLS("127.0.0.1:0", nil); err == nil {
t.Fatal("ListenAndServeTLS(nil) must refuse to serve plaintext")
}
}
func TestListenAndServeTLSServesAndCloses(t *testing.T) {
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`))
auth, _ := NewTokenAuthenticator("tok")
certFile, keyFile := genTestCert(t)
tlsConfig, err := ServerTLSConfig(certFile, keyFile)
if err != nil {
t.Fatalf("ServerTLSConfig: %v", err)
}
bridge, err := NewBridge(BridgeOptions{Server: srv, Authenticator: auth})
if err != nil {
t.Fatalf("NewBridge: %v", err)
}
// Bind an ephemeral port, discover it, then serve via ListenAndServeTLS by
// passing the same addr (it rebinds) — instead we bind here to learn the port
// and serve through the public entry on that exact address.
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("probe listen: %v", err)
}
addr := probe.Addr().String()
_ = probe.Close()
serveErr := make(chan error, 1)
go func() { serveErr <- bridge.ListenAndServeTLS(addr, tlsConfig) }()
// Wait until it accepts a connection, then drive one run.
var client *daemon.Client
deadline := time.Now().Add(2 * time.Second)
for {
client, err = DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: certFile, Timeout: 300 * time.Millisecond})
if err == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("bridge never came up: %v", err)
}
time.Sleep(20 * time.Millisecond)
}
var got []string
if err := client.Run("s", "", "hi", nil, func(l string) { got = append(got, l) }); err != nil {
t.Fatalf("Run: %v", err)
}
_ = client.Close()
if len(got) != 1 {
t.Fatalf("got %d lines, want 1", len(got))
}
// Close stops the accept loop; a second Close is a no-op (not an error).
if err := bridge.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := bridge.Close(); err != nil {
t.Fatalf("second Close should be a no-op, got: %v", err)
}
select {
case <-serveErr: // ListenAndServeTLS returned after Close
case <-time.After(2 * time.Second):
t.Fatal("ListenAndServeTLS did not return after Close")
}
}
func TestBridgeResumeAfterReconnect(t *testing.T) {
srv := newBridgeServer(t, staticLauncher(`{"type":"event","seq":1}`, `{"type":"event","seq":2}`))
auth, _ := NewTokenAuthenticator("tok")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth})
// First connection runs the session to completion.
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
if err != nil {
t.Fatalf("DialRemote 1: %v", err)
}
if err := c1.Run("sess-resume", "", "hi", nil, func(string) {}); err != nil {
t.Fatalf("Run: %v", err)
}
_ = c1.Close()
// A new connection re-attaches and replays the buffered session output —
// the dropped first connection did not lose the session.
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
if err != nil {
t.Fatalf("DialRemote 2: %v", err)
}
defer c2.Close()
var replayed []string
if err := c2.Attach("sess-resume", func(l string) { replayed = append(replayed, l) }); err != nil {
t.Fatalf("Attach: %v", err)
}
if len(replayed) != 2 {
t.Fatalf("re-attach replayed %d lines, want 2", len(replayed))
}
}
func TestBridgeConnectionCap(t *testing.T) {
release := make(chan struct{})
defer close(release)
srv := newBridgeServer(t, blockingLauncher(release))
auth, _ := NewTokenAuthenticator("tok")
addr, ca := startBridge(t, srv, BridgeOptions{Authenticator: auth, MaxConnections: 1})
// First connection holds the only slot with a blocking run.
c1, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca})
if err != nil {
t.Fatalf("DialRemote 1: %v", err)
}
defer c1.Close()
runErr := make(chan error, 1)
go func() { runErr <- c1.Run("sess-block", "", "hi", nil, func(string) {}) }()
// Wait until the slot is in use, then a second dial must be refused.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
c2, err := DialRemote(RemoteConfig{Address: addr, Token: "tok", CACertFile: ca, Timeout: 500 * time.Millisecond})
if err != nil {
return // refused as expected
}
_ = c2.Close()
time.Sleep(20 * time.Millisecond)
}
t.Fatal("second connection was not refused at the connection cap")
}
+108
View File
@@ -0,0 +1,108 @@
package remote
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/Gitlawb/zero/internal/daemon"
)
const defaultDialTimeout = 15 * time.Second
// RemoteConfig configures a remote dial.
type RemoteConfig struct {
// Address is host:port of the remote bridge (required).
Address string
// Token is the bearer token (required).
Token string
// CACertFile, when set, is the only CA trusted for the server cert (use for a
// self-signed bridge). Empty => system roots. InsecureSkipVerify is never used.
CACertFile string
// ServerName overrides the TLS/SNI verification name; empty => host of Address.
ServerName string
// Timeout bounds the dial + auth handshake; 0 => default.
Timeout time.Duration
}
// DialRemote establishes a verified TLS connection to a remote bridge,
// authenticates with the bearer token, and returns a daemon.Client ready for
// Run/Attach/Status — the same client the local socket uses, so remote and local
// share one protocol. The server certificate is always verified (never
// InsecureSkipVerify).
func DialRemote(cfg RemoteConfig) (*daemon.Client, error) {
address := strings.TrimSpace(cfg.Address)
if address == "" {
return nil, errors.New("remote: address is required")
}
token := strings.TrimSpace(cfg.Token)
if token == "" {
return nil, errors.New("remote: a non-empty token is required")
}
tlsConfig, err := clientTLSConfig(cfg)
if err != nil {
return nil, err
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultDialTimeout
}
// Context-aware TLS dial (tls.DialWithDialer is deprecated and not
// cancellation-aware). The context bounds the dial + TLS handshake only; the
// returned conn is independent of it for the subsequent auth/stream I/O.
dialCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
dialer := &tls.Dialer{NetDialer: &net.Dialer{}, Config: tlsConfig}
conn, err := dialer.DialContext(dialCtx, "tcp", address)
if err != nil {
return nil, fmt.Errorf("remote: dial %s: %w", address, err)
}
// Bound the auth handshake; the daemon handshake + stream run without a deadline.
_ = conn.SetDeadline(time.Now().Add(timeout))
if err := writeAuthRequest(conn, authRequest{Token: token, Version: daemon.ProtoVersion}); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("remote: send auth: %w", err)
}
resp, err := readAuthResponse(conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("remote: auth handshake: %w", err)
}
if !resp.OK {
_ = conn.Close()
return nil, fmt.Errorf("%w: %s", ErrUnauthorized, resp.Message)
}
_ = conn.SetDeadline(time.Time{})
return daemon.NewClientConn(conn)
}
// clientTLSConfig builds a verifying client TLS config. It never disables
// verification; for a self-signed bridge, set CACertFile to the bridge's cert.
func clientTLSConfig(cfg RemoteConfig) (*tls.Config, error) {
tc := &tls.Config{MinVersion: tls.VersionTLS12}
serverName := strings.TrimSpace(cfg.ServerName)
if serverName == "" {
if host, _, err := net.SplitHostPort(cfg.Address); err == nil {
serverName = host
}
}
tc.ServerName = serverName
if ca := strings.TrimSpace(cfg.CACertFile); ca != "" {
pem, err := os.ReadFile(ca)
if err != nil {
return nil, fmt.Errorf("remote: read CA cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("remote: CA cert file contained no certificates")
}
tc.RootCAs = pool
}
return tc, nil
}
+301
View File
@@ -0,0 +1,301 @@
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
listener net.Listener
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{}),
}, 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.listener = listener
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.wg.Add(1)
go func() {
defer s.wg.Done()
s.handleConn(conn)
}()
}
}
// 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
if s.listener != nil {
_ = s.listener.Close()
}
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) { s.handleConn(conn) }
// handleConn performs the handshake then dispatches a single control command.
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
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
}
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 {
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})
}
+176
View File
@@ -0,0 +1,176 @@
package daemon
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func newTestServer(t *testing.T, launcher Launcher) (*Server, Paths) {
t.Helper()
dir := t.TempDir()
paths := Paths{
Socket: filepath.Join(dir, "d.sock"),
Lock: filepath.Join(dir, "d.lock"),
Status: filepath.Join(dir, "d.status"),
}
pool, err := NewPool(PoolOptions{Size: 2, Launcher: launcher, KillTimeout: 200 * time.Millisecond})
if err != nil {
t.Fatalf("NewPool: %v", err)
}
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
srv, err := NewServer(ServerOptions{Paths: paths, Manager: mgr, Pool: pool})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
return srv, paths
}
func waitForFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("file %s did not appear within timeout", path)
}
func TestServerEndToEnd(t *testing.T) {
out := []string{`{"type":"event","seq":1}`, `{"type":"event","seq":2}`}
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: out, exitCode: 0})
srv, paths := newTestServer(t, launcher)
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve() }()
waitForFile(t, paths.Status)
// --- run a session and collect its stream-json output ---
runClient, err := Dial(paths.Socket)
if err != nil {
t.Fatalf("Dial(run): %v", err)
}
var got []string
if err := runClient.Run("s1", "", "hello", nil, func(line string) { got = append(got, line) }); err != nil {
t.Fatalf("Run: %v", err)
}
runClient.Close()
if len(got) != 2 || got[0] != out[0] || got[1] != out[1] {
t.Fatalf("streamed lines = %v, want %v", got, out)
}
// --- status reflects the session ---
statusClient, err := Dial(paths.Socket)
if err != nil {
t.Fatalf("Dial(status): %v", err)
}
report, err := statusClient.Status()
statusClient.Close()
if err != nil {
t.Fatalf("Status: %v", err)
}
if report.PoolSize != 2 {
t.Fatalf("status PoolSize = %d, want 2", report.PoolSize)
}
foundSession := false
for _, s := range report.Sessions {
if s.ID == "s1" {
foundSession = true
if s.State != string(SessionDone) {
t.Fatalf("session s1 state = %s, want done", s.State)
}
}
}
if !foundSession {
t.Fatalf("status missing session s1: %+v", report.Sessions)
}
// --- attach to the finished session sees the buffered history ---
attachClient, err := Dial(paths.Socket)
if err != nil {
t.Fatalf("Dial(attach): %v", err)
}
var attached []string
if err := attachClient.Attach("s1", func(line string) { attached = append(attached, line) }); err != nil {
t.Fatalf("Attach: %v", err)
}
attachClient.Close()
if len(attached) != 2 {
t.Fatalf("attach lines = %v, want 2 buffered", attached)
}
// --- graceful stop, daemon cleans up ---
stopClient, err := Dial(paths.Socket)
if err != nil {
t.Fatalf("Dial(stop): %v", err)
}
if err := stopClient.Shutdown(); err != nil {
t.Fatalf("Shutdown: %v", err)
}
stopClient.Close()
select {
case err := <-serveErr:
if err != nil {
t.Fatalf("Serve returned error: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Serve did not return after shutdown")
}
// Socket, status, and lock files are removed on exit.
for _, p := range []string{paths.Socket, paths.Status, paths.Lock} {
if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("file %s not cleaned up after shutdown: %v", p, err)
}
}
}
func TestServerSecondInstanceFails(t *testing.T) {
block := make(chan struct{})
defer close(block)
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
srv1, paths := newTestServer(t, launcher)
serveErr := make(chan error, 1)
go func() { serveErr <- srv1.Serve() }()
waitForFile(t, paths.Status)
// A second server on the same paths must refuse to start (single instance).
pool2, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr2, _ := NewSessionManager(SessionManagerOptions{Pool: pool2})
srv2, _ := NewServer(ServerOptions{Paths: paths, Manager: mgr2, Pool: pool2})
if err := srv2.Serve(); !errors.Is(err, ErrAlreadyRunning) {
t.Fatalf("second Serve err = %v, want ErrAlreadyRunning", err)
}
srv1.Shutdown()
<-serveErr
}
func TestServerRejectsUnknownCommand(t *testing.T) {
block := make(chan struct{})
defer close(block)
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
srv, paths := newTestServer(t, launcher)
go func() { _ = srv.Serve() }()
defer srv.Shutdown()
waitForFile(t, paths.Status)
client, err := Dial(paths.Socket)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
// Run with an empty session id is rejected with an error frame.
err = client.Run("", "", "hi", nil, nil)
if err == nil {
t.Fatal("run with empty session id must return an error")
}
}
+306
View File
@@ -0,0 +1,306 @@
package daemon
import (
"context"
"errors"
"sort"
"sync"
)
// Session/lease/roster model. Mirrors reference-daemon-code-agent-js/
// session-manager.js (sessionID -> worker + metrics), lease.js (one active
// request per worker) and roster.js (the live session registry). Because zero's
// `exec` worker is one-shot, each session is served by exactly one worker via
// Pool.Run, so "one active request per worker" holds inherently and the pool's
// bounded slots provide the queue-when-full behavior.
// SessionState is a session's lifecycle phase.
type SessionState string
const (
// SessionQueued: created, waiting for a free pool slot.
SessionQueued SessionState = "queued"
// SessionRunning: a worker is leased and producing output.
SessionRunning SessionState = "running"
// SessionDone: the worker finished cleanly.
SessionDone SessionState = "done"
// SessionFailed: the worker failed permanently / the run errored.
SessionFailed SessionState = "failed"
)
// ErrSessionExists is returned when starting a session ID already in use.
var ErrSessionExists = errors.New("daemon: session already exists")
// ErrSessionNotFound is returned by Get/Attach for an unknown session ID.
var ErrSessionNotFound = errors.New("daemon: session not found")
const defaultSessionBuffer = 1024
// Session is one routed agent run. It buffers recent output (so a late `attach`
// sees history) and fans live lines out to subscribers. It implements the pool's
// Sink (Line) and the lease "started" hook (Started).
type Session struct {
id string
cwd string
mu sync.Mutex
state SessionState
buffer []string
maxBuffer int
lines int
finished bool
err error
exitCode int
subscribers map[int]chan string
nextSub int
done chan struct{}
}
func newSession(id, cwd string, maxBuffer int) *Session {
if maxBuffer <= 0 {
maxBuffer = defaultSessionBuffer
}
return &Session{
id: id,
cwd: cwd,
state: SessionQueued,
maxBuffer: maxBuffer,
subscribers: map[int]chan string{},
done: make(chan struct{}),
}
}
// ID returns the session ID.
func (s *Session) ID() string { return s.id }
// Done is closed when the session finishes (success or failure).
func (s *Session) Done() <-chan struct{} { return s.done }
// Err returns the terminal error (nil on clean completion); valid after Done.
func (s *Session) Err() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.err
}
// State returns the current lifecycle phase.
func (s *Session) State() SessionState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
// Started marks the session running once the pool leases a worker (lease hook).
func (s *Session) Started() {
s.mu.Lock()
if s.state == SessionQueued {
s.state = SessionRunning
}
s.mu.Unlock()
}
// Line records and broadcasts one stream-json output line.
func (s *Session) Line(line string) {
s.mu.Lock()
s.lines++
s.buffer = append(s.buffer, line)
if len(s.buffer) > s.maxBuffer {
// Drop the oldest line; the ring keeps only the most recent maxBuffer.
s.buffer = s.buffer[len(s.buffer)-s.maxBuffer:]
}
subs := make([]chan string, 0, len(s.subscribers))
for _, ch := range s.subscribers {
subs = append(subs, ch)
}
s.mu.Unlock()
for _, ch := range subs {
// Non-blocking: a slow/stalled subscriber must not stall the worker pump.
// Dropped live lines remain available via the buffer for a fresh attach.
select {
case ch <- line:
default:
}
}
}
func (s *Session) finish(exitCode int, err error) {
s.mu.Lock()
s.finished = true
s.exitCode = exitCode
s.err = err
if err != nil {
s.state = SessionFailed
} else {
s.state = SessionDone
}
subs := s.subscribers
s.subscribers = map[int]chan string{}
s.mu.Unlock()
for _, ch := range subs {
close(ch)
}
close(s.done)
}
// Subscribe returns the buffered history plus a channel of subsequent live lines
// and a cancel func. If the session has already finished, the live channel is
// returned already closed. Mirrors attach.js.
func (s *Session) Subscribe() (buffered []string, live <-chan string, cancel func()) {
s.mu.Lock()
defer s.mu.Unlock()
buffered = append([]string(nil), s.buffer...)
if s.finished {
ch := make(chan string)
close(ch)
return buffered, ch, func() {}
}
id := s.nextSub
s.nextSub++
ch := make(chan string, 256)
s.subscribers[id] = ch
cancel = func() {
s.mu.Lock()
if c, ok := s.subscribers[id]; ok {
delete(s.subscribers, id)
close(c)
}
s.mu.Unlock()
}
return buffered, ch, cancel
}
func (s *Session) status() SessionStatus {
s.mu.Lock()
defer s.mu.Unlock()
return SessionStatus{ID: s.id, State: string(s.state), Lines: s.lines}
}
func (s *Session) isFinished() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.finished
}
// defaultMaxSessions bounds the retained session registry so a long-running
// daemon does not accumulate finished sessions without limit.
const defaultMaxSessions = 256
// SessionManager owns the live session registry and routes each session to a
// pool worker. Mirrors session-manager.js + roster.js. Finished sessions are
// retained (so a late `attach` sees history) up to MaxSessions, past which the
// oldest FINISHED ones are evicted; running/queued sessions are never evicted.
type SessionManager struct {
pool *Pool
maxBuffer int
maxSessions int
mu sync.Mutex
sessions map[string]*Session
order []string // creation order, for FIFO eviction of finished sessions
}
// SessionManagerOptions configures a SessionManager.
type SessionManagerOptions struct {
Pool *Pool
MaxBuffer int // per-session output ring size; 0 => default
// MaxSessions caps the retained session registry; 0 => default. The oldest
// FINISHED sessions are evicted once the cap is exceeded.
MaxSessions int
}
// NewSessionManager builds a manager over pool.
func NewSessionManager(opts SessionManagerOptions) (*SessionManager, error) {
if opts.Pool == nil {
return nil, errors.New("daemon: session manager requires a Pool")
}
maxSessions := opts.MaxSessions
if maxSessions <= 0 {
maxSessions = defaultMaxSessions
}
return &SessionManager{
pool: opts.Pool,
maxBuffer: opts.MaxBuffer,
maxSessions: maxSessions,
sessions: map[string]*Session{},
}, nil
}
// Start creates a session for spec and dispatches it to the pool. It returns the
// Session immediately (non-blocking); the run proceeds in the background and the
// session transitions queued -> running -> done/failed. A duplicate ID is
// rejected with ErrSessionExists.
func (m *SessionManager) Start(ctx context.Context, spec WorkerSpec) (*Session, error) {
m.mu.Lock()
if _, exists := m.sessions[spec.Session]; exists {
m.mu.Unlock()
return nil, ErrSessionExists
}
sess := newSession(spec.Session, spec.Cwd, m.maxBuffer)
m.sessions[spec.Session] = sess
m.order = append(m.order, spec.Session)
m.pruneLocked()
m.mu.Unlock()
go func() {
code, err := m.pool.Run(ctx, spec, sess)
sess.finish(code, err)
}()
return sess, nil
}
// pruneLocked evicts the oldest FINISHED sessions while the registry exceeds
// maxSessions, so a long-running daemon does not retain finished sessions without
// bound. Running/queued sessions are never evicted (a transient burst can exceed
// the cap until those finish). Caller must hold m.mu.
func (m *SessionManager) pruneLocked() {
if len(m.sessions) <= m.maxSessions {
return
}
kept := m.order[:0]
for _, id := range m.order {
s, ok := m.sessions[id]
if !ok {
continue // already removed
}
if len(m.sessions) > m.maxSessions && s.isFinished() {
delete(m.sessions, id)
continue
}
kept = append(kept, id)
}
m.order = kept
}
// Get returns a session by ID.
func (m *SessionManager) Get(id string) (*Session, bool) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.sessions[id]
return s, ok
}
// Attach returns the buffered history + live stream for an existing session.
func (m *SessionManager) Attach(id string) (buffered []string, live <-chan string, cancel func(), err error) {
s, ok := m.Get(id)
if !ok {
return nil, nil, nil, ErrSessionNotFound
}
b, l, c := s.Subscribe()
return b, l, c, nil
}
// Statuses returns a snapshot of all sessions, sorted by ID for stable output.
func (m *SessionManager) Statuses() []SessionStatus {
m.mu.Lock()
sessions := make([]*Session, 0, len(m.sessions))
for _, s := range m.sessions {
sessions = append(sessions, s)
}
m.mu.Unlock()
out := make([]SessionStatus, 0, len(sessions))
for _, s := range sessions {
out = append(out, s.status())
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
+179
View File
@@ -0,0 +1,179 @@
package daemon
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
func drain(t *testing.T, buffered []string, live <-chan string) []string {
t.Helper()
got := append([]string(nil), buffered...)
timeout := time.After(2 * time.Second)
for {
select {
case line, ok := <-live:
if !ok {
return got
}
got = append(got, line)
case <-timeout:
t.Fatal("timed out draining session stream")
}
}
}
func TestSessionStartRoutesAndStreams(t *testing.T) {
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"e1", "e2", "e3"}, exitCode: 0})
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
mgr, err := NewSessionManager(SessionManagerOptions{Pool: pool})
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
sess, err := mgr.Start(context.Background(), WorkerSpec{Session: "s1"})
if err != nil {
t.Fatalf("Start: %v", err)
}
buffered, live, cancel := sess.Subscribe()
defer cancel()
got := drain(t, buffered, live)
if len(got) != 3 || got[0] != "e1" || got[2] != "e3" {
t.Fatalf("streamed lines = %v, want [e1 e2 e3]", got)
}
<-sess.Done()
if sess.State() != SessionDone {
t.Fatalf("state = %s, want done", sess.State())
}
}
func TestSessionDuplicateIDRejected(t *testing.T) {
block := make(chan struct{})
defer close(block)
launcher, _ := seqLauncher(&fakeWorker{pid: 1, waitCh: block})
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); err != nil {
t.Fatalf("first Start: %v", err)
}
if _, err := mgr.Start(context.Background(), WorkerSpec{Session: "dup"}); !errors.Is(err, ErrSessionExists) {
t.Fatalf("second Start err = %v, want ErrSessionExists", err)
}
}
func TestSessionLeaseQueuesWhenPoolFull(t *testing.T) {
block := make(chan struct{})
a := &fakeWorker{pid: 1, waitCh: block}
b := &fakeWorker{pid: 2, out: []string{"b1"}, exitCode: 0}
launcher, calls := seqLauncher(a, b)
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
sa, _ := mgr.Start(context.Background(), WorkerSpec{Session: "a"})
waitFor(t, func() bool { return sa.State() == SessionRunning })
sb, _ := mgr.Start(context.Background(), WorkerSpec{Session: "b"})
// b must stay queued (its worker not yet launched) while a holds the slot.
time.Sleep(50 * time.Millisecond)
if sb.State() != SessionQueued {
t.Fatalf("session b state = %s, want queued while pool full", sb.State())
}
if n := atomic.LoadInt32(calls); n != 1 {
t.Fatalf("launcher calls = %d, want 1 (b not launched yet)", n)
}
close(block) // a finishes, slot frees, b proceeds
<-sb.Done()
if sb.State() != SessionDone {
t.Fatalf("session b state = %s, want done", sb.State())
}
if n := atomic.LoadInt32(calls); n != 2 {
t.Fatalf("launcher calls = %d, want 2 after slot freed", n)
}
}
func TestSessionAttachAfterFinishSeesBuffer(t *testing.T) {
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"x", "y"}, exitCode: 0})
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "s"})
<-sess.Done()
buffered, live, cancel, err := mgr.Attach("s")
if err != nil {
t.Fatalf("Attach: %v", err)
}
defer cancel()
got := drain(t, buffered, live)
if len(got) != 2 || got[0] != "x" || got[1] != "y" {
t.Fatalf("attach buffered = %v, want [x y]", got)
}
if _, _, _, err := mgr.Attach("missing"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("Attach(missing) err = %v, want ErrSessionNotFound", err)
}
}
func TestSessionManagerEvictsFinishedOverCap(t *testing.T) {
launcher, _ := seqLauncher(
&fakeWorker{pid: 1, out: []string{"a"}, exitCode: 0},
&fakeWorker{pid: 2, out: []string{"b"}, exitCode: 0},
&fakeWorker{pid: 3, out: []string{"c"}, exitCode: 0},
)
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 2})
for _, id := range []string{"s1", "s2", "s3"} {
s, err := mgr.Start(context.Background(), WorkerSpec{Session: id})
if err != nil {
t.Fatalf("Start(%s): %v", id, err)
}
<-s.Done() // finish before starting the next so prune can evict it
}
// Past the cap of 2, the oldest FINISHED session (s1) is evicted; the two
// newest are retained.
if _, ok := mgr.Get("s1"); ok {
t.Fatal("oldest finished session must be evicted past the cap")
}
if _, ok := mgr.Get("s2"); !ok {
t.Fatal("s2 must be retained")
}
if _, ok := mgr.Get("s3"); !ok {
t.Fatal("s3 (newest) must be retained")
}
}
func TestSessionManagerKeepsRunningOverCap(t *testing.T) {
// All sessions are running (workers block), so none are evicted even past cap.
block := make(chan struct{})
defer close(block)
launcher, _ := seqLauncher(
&fakeWorker{pid: 1, waitCh: block},
&fakeWorker{pid: 2, waitCh: block},
)
pool, _ := NewPool(PoolOptions{Size: 2, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool, MaxSessions: 1})
s1, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r1"})
s2, _ := mgr.Start(context.Background(), WorkerSpec{Session: "r2"})
waitFor(t, func() bool { return s1.State() == SessionRunning && s2.State() == SessionRunning })
if _, ok := mgr.Get("r1"); !ok {
t.Fatal("a running session must never be evicted, even past the cap")
}
if _, ok := mgr.Get("r2"); !ok {
t.Fatal("a running session must never be evicted, even past the cap")
}
}
func TestSessionStatuses(t *testing.T) {
launcher, _ := seqLauncher(&fakeWorker{pid: 1, out: []string{"l"}, exitCode: 0})
pool, _ := NewPool(PoolOptions{Size: 1, Launcher: launcher})
mgr, _ := NewSessionManager(SessionManagerOptions{Pool: pool})
sess, _ := mgr.Start(context.Background(), WorkerSpec{Session: "only"})
<-sess.Done()
st := mgr.Statuses()
if len(st) != 1 || st[0].ID != "only" || st[0].State != string(SessionDone) || st[0].Lines != 1 {
t.Fatalf("statuses = %+v, want one done session with 1 line", st)
}
}
+27
View File
@@ -0,0 +1,27 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
)
// maxUnixSocketPath bounds the socket path to the smallest platform sun_path
// limit so we surface a clear error instead of a cryptic bind failure. macOS
// allows 104 bytes incl. the NUL (so 103 chars); Linux allows 108. 103 is the
// safe cross-platform ceiling.
const maxUnixSocketPath = 103
// secureSocketParent creates the socket's parent directory owner-only (0700 on
// POSIX; on Windows the per-user profile directory is already ACL-restricted).
func secureSocketParent(socketPath string) error {
return os.MkdirAll(filepath.Dir(socketPath), 0o700)
}
// checkSocketPathLength rejects an over-long unix socket path before bind.
func checkSocketPathLength(socketPath string) error {
if len(socketPath) > maxUnixSocketPath {
return fmt.Errorf("daemon: socket path too long (%d > %d): %s", len(socketPath), maxUnixSocketPath, socketPath)
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package daemon
import "os"
// hardenSocketFile restricts the control socket to its owner (0600) so no other
// local user can connect. Combined with the 0700 parent directory this makes the
// control plane owner-only, as required by the security model.
func hardenSocketFile(path string) error {
return os.Chmod(path, 0o600)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build windows
package daemon
// hardenSocketFile is a no-op on Windows: AF_UNIX socket files do not honor POSIX
// mode bits, and the daemon places the socket under the per-user profile
// directory, which is already ACL-restricted to the owner. A dedicated restricted
// ACL (or a named pipe with an explicit DACL) is a documented follow-up.
func hardenSocketFile(string) error { return nil }
+43
View File
@@ -0,0 +1,43 @@
package daemon
import "time"
// StatusReport is the daemon/worker/session snapshot returned by `zero daemon
// status` (CtrlStatusResult). Mirrors reference-daemon-code-agent-js/status.js.
type StatusReport struct {
PID int `json:"pid"`
Version int `json:"version"`
Socket string `json:"socket"`
StartedAt time.Time `json:"startedAt"`
PoolSize int `json:"poolSize"`
Workers []WorkerStatus `json:"workers"`
Sessions []SessionStatus `json:"sessions"`
QueueDepth int `json:"queueDepth"`
}
// WorkerStatus is one worker's lifecycle snapshot.
type WorkerStatus struct {
ID int `json:"id"`
PID int `json:"pid"`
State string `json:"state"`
ConsecutiveCrashes int `json:"consecutiveCrashes"`
Restarts int `json:"restarts"`
Session string `json:"session,omitempty"`
}
// SessionStatus is one session's routing/metrics snapshot.
type SessionStatus struct {
ID string `json:"id"`
State string `json:"state"`
Lines int `json:"lines"`
}
// StatusFile is the on-disk daemon status document (pid, socket, version,
// started-at), written next to the lock so `status`/`stop` work without a live
// connection. Mirrors supervisor.js's status file.
type StatusFile struct {
PID int `json:"pid"`
Socket string `json:"socket"`
Version int `json:"version"`
StartedAt time.Time `json:"startedAt"`
}
+4 -1
View File
@@ -158,6 +158,9 @@ func providerModelCheck(profile config.ProviderProfile) Check {
if emptyProviderProfile(profile) {
return check("provider.model", "Provider model", StatusWarn, "Model validity was skipped because provider config is unavailable.", nil)
}
if strings.TrimSpace(profile.Model) == "" {
return check("provider.model", "Provider model", StatusFail, "Provider model is required.", map[string]any{"provider": providerName(profile)})
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return check("provider.model", "Provider model", StatusFail, "Model registry could not be loaded: "+err.Error(), nil)
@@ -165,7 +168,7 @@ func providerModelCheck(profile config.ProviderProfile) Check {
model, err := registry.Require(profile.Model)
if err != nil {
if profile.ProviderKind == config.ProviderKindOpenAICompatible || profile.ProviderKind == config.ProviderKindAnthropicCompat {
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
return check("provider.model", "Provider model", StatusWarn, fmt.Sprintf("Custom %s model was not found in the Zero registry; runtime will pass it through to the configured provider. Run `zero doctor --connectivity` to validate the endpoint and auth.", profile.ProviderKind), map[string]any{"model": profile.Model, "provider": providerName(profile)})
}
return check("provider.model", "Provider model", StatusFail, "Provider model is invalid: "+err.Error(), map[string]any{"model": profile.Model})
}
+90
View File
@@ -86,6 +86,96 @@ func TestRunReportWarnsForUnknownOpenAICompatibleModel(t *testing.T) {
}
}
func TestProviderModelUnknownOpenAICompatibleModelWarnsPassThrough(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
Runtime: "go",
Provider: config.ProviderProfile{
Name: "openrouter",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://openrouter.ai/api/v1",
Model: "vendor/new-dynamic-model",
},
})
if !report.OK {
t.Fatalf("unknown openai-compatible model should warn, not fail: %#v", report)
}
check := report.Check("provider.model")
if check == nil {
t.Fatalf("expected provider.model check: %#v", report.Checks)
}
if check.Status != StatusWarn {
t.Fatalf("provider.model status = %s, want warn: %#v", check.Status, check)
}
if !strings.Contains(check.Message, "runtime will pass it through") || !strings.Contains(check.Message, "doctor --connectivity") {
t.Fatalf("provider.model message should explain pass-through connectivity validation, got %q", check.Message)
}
}
func TestProviderModelMissingModelFails(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
Runtime: "go",
Provider: config.ProviderProfile{
Name: "ollama-cloud",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://ollama.com/v1",
Model: " ",
},
})
if report.OK {
t.Fatalf("missing model should fail: %#v", report)
}
check := report.Check("provider.model")
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "required") {
t.Fatalf("expected missing model failure: %#v", report.Checks)
}
}
func TestProviderModelBuiltInUnknownModelFails(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
Runtime: "go",
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "vendor/new-dynamic-model",
},
})
if report.OK {
t.Fatalf("built-in provider unknown model should fail: %#v", report)
}
check := report.Check("provider.model")
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "unknown Zero model") {
t.Fatalf("expected built-in unknown model failure: %#v", report.Checks)
}
}
func TestProviderModelRegisteredProviderMismatchFails(t *testing.T) {
report := Run(Options{
Now: fixedDoctorClock("2026-06-04T15:45:00Z"),
Runtime: "go",
Provider: config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
Model: "gemini-2.5-pro",
},
})
if report.OK {
t.Fatalf("registered provider mismatch should fail: %#v", report)
}
check := report.Check("provider.model")
if check == nil || check.Status != StatusFail || !strings.Contains(check.Message, "not available for provider") {
t.Fatalf("expected registered provider mismatch failure: %#v", report.Checks)
}
}
func writeDoctorConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
+79 -187
View File
@@ -2,9 +2,6 @@ package mcp
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -14,20 +11,56 @@ import (
"net/url"
"strings"
"time"
"github.com/Gitlawb/zero/internal/oauth"
)
// ServerAuthOAuth is the value of an MCP server's auth field that selects the
// OAuth 2.0 + PKCE authorization-code flow.
const ServerAuthOAuth = "oauth"
const (
wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
defaultLoginTimeout = 3 * time.Minute
// pkceVerifierBytes yields a 43-character base64url verifier, the minimum
// length permitted by the PKCE spec while remaining high-entropy.
pkceVerifierBytes = 32
stateBytes = 32
)
const defaultLoginTimeout = 3 * time.Minute
// MCP OAuth delegates its transport/identity-agnostic engine to internal/oauth
// (PKCE, RFC 8414 discovery, authorize-URL build, token exchange/refresh) so the
// two share one implementation. MCP keeps its own LoginOptions/Login
// orchestration, OAuthConfig/StoredToken types, loopback handling, CLI, and
// on-disk token format — all behavior-preserving. The shared engine also adds an
// https-only token-endpoint guard (loopback exempt), a hardening MCP inherits.
// pkceToParams converts the shared PKCE pair to MCP's local type.
func pkceToParams(p oauth.PKCE) pkceParams {
return pkceParams{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
}
// pkceToOAuth converts MCP's local PKCE type back to the shared type.
func pkceToOAuth(p pkceParams) oauth.PKCE {
return oauth.PKCE{Verifier: p.Verifier, Challenge: p.Challenge, Method: p.Method}
}
// configFor builds the shared oauth.Config from MCP's OAuthConfig + resolved
// endpoints for a flow.
func configFor(cfg OAuthConfig, metadata authServerMetadata) oauth.Config {
return oauth.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Scopes: cfg.Scopes,
AuthorizationEndpoint: metadata.AuthorizationEndpoint,
TokenEndpoint: metadata.TokenEndpoint,
}
}
// tokenToStored converts a shared oauth.Token to MCP's StoredToken (MCP does not
// use the Account field).
func tokenToStored(t oauth.Token) StoredToken {
return StoredToken{
AccessToken: t.AccessToken,
RefreshToken: t.RefreshToken,
TokenType: t.TokenType,
Scopes: t.Scopes,
ExpiresAt: t.ExpiresAt,
}
}
// OAuthConfig describes how to authenticate to a remote MCP server using OAuth.
// Endpoints may be discovered from the server's metadata document; explicit
@@ -84,34 +117,20 @@ type authorizationFlow struct {
now func() time.Time
}
// discoverAuthorizationServer fetches the OAuth 2.0 authorization server
// metadata document published at the well-known path under the given base URL.
// discoverAuthorizationServer fetches the RFC 8414 authorization server metadata
// at the well-known path under baseURL, via the shared engine.
func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (authServerMetadata, error) {
if client == nil {
client = http.DefaultClient
}
metadataURL, err := joinWellKnown(baseURL)
meta, err := oauth.DiscoverAuthorizationServer(ctx, client, baseURL)
if err != nil {
return authServerMetadata{}, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
if err != nil {
return authServerMetadata{}, err
}
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return authServerMetadata{}, fmt.Errorf("fetch authorization server metadata: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return authServerMetadata{}, fmt.Errorf("authorization server metadata returned HTTP %d", response.StatusCode)
}
var metadata authServerMetadata
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
return authServerMetadata{}, fmt.Errorf("decode authorization server metadata: %w", err)
}
return metadata, nil
return authServerMetadata{
Issuer: meta.Issuer,
AuthorizationEndpoint: meta.AuthorizationEndpoint,
TokenEndpoint: meta.TokenEndpoint,
RegistrationEndpoint: meta.RegistrationEndpoint,
ScopesSupported: meta.ScopesSupported,
}, nil
}
// resolveAuthorizationServer discovers metadata and applies explicit config
@@ -149,44 +168,23 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
return metadata, nil
}
// newPKCE generates a high-entropy code verifier and its S256 challenge.
// newPKCE generates a high-entropy code verifier and its S256 challenge via the
// shared engine.
func newPKCE() (pkceParams, error) {
verifierRaw := make([]byte, pkceVerifierBytes)
if _, err := rand.Read(verifierRaw); err != nil {
return pkceParams{}, fmt.Errorf("generate PKCE verifier: %w", err)
p, err := oauth.NewPKCE()
if err != nil {
return pkceParams{}, err
}
verifier := base64.RawURLEncoding.EncodeToString(verifierRaw)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
return pkceParams{Verifier: verifier, Challenge: challenge, Method: "S256"}, nil
return pkceToParams(p), nil
}
func newState() (string, error) {
raw := make([]byte, stateBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
return oauth.NewState()
}
// authorizationURL builds the authorization request URL.
// authorizationURL builds the authorization request URL via the shared engine.
func (flow *authorizationFlow) authorizationURL(redirectURI string) (string, error) {
parsed, err := url.Parse(flow.metadata.AuthorizationEndpoint)
if err != nil {
return "", fmt.Errorf("parse authorization endpoint: %w", err)
}
query := parsed.Query()
query.Set("response_type", "code")
query.Set("client_id", flow.config.ClientID)
query.Set("redirect_uri", redirectURI)
query.Set("state", flow.state)
query.Set("code_challenge", flow.pkce.Challenge)
query.Set("code_challenge_method", flow.pkce.Method)
if len(flow.config.Scopes) > 0 {
query.Set("scope", strings.Join(flow.config.Scopes, " "))
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
return oauth.BuildAuthorizationURL(configFor(flow.config, flow.metadata), pkceToOAuth(flow.pkce), flow.state, redirectURI, nil)
}
// parseCallback validates the redirect query and returns the authorization
@@ -209,117 +207,30 @@ func (flow *authorizationFlow) parseCallback(values url.Values) (string, error)
return code, nil
}
// exchangeCode swaps an authorization code + PKCE verifier for tokens.
// exchangeCode swaps an authorization code + PKCE verifier for tokens via the
// shared engine.
func (flow *authorizationFlow) exchangeCode(ctx context.Context, code string, redirectURI string) (StoredToken, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", flow.config.ClientID)
form.Set("code_verifier", flow.pkce.Verifier)
if secret := strings.TrimSpace(flow.config.ClientSecret); secret != "" {
form.Set("client_secret", secret)
token, err := oauth.ExchangeCode(ctx, flow.httpClient, configFor(flow.config, flow.metadata), code, flow.pkce.Verifier, redirectURI, flow.now)
if err != nil {
return StoredToken{}, err
}
return postTokenRequest(ctx, flow.httpClient, flow.metadata.TokenEndpoint, form, StoredToken{Scopes: flow.config.Scopes}, flow.now)
return tokenToStored(token), nil
}
// refreshAccessToken exchanges a refresh token for a fresh access token. A
// response that omits a new refresh token preserves the previous one.
// refreshAccessToken exchanges a refresh token for a fresh access token via the
// shared engine. A response that omits a new refresh token preserves the
// previous one.
func refreshAccessToken(ctx context.Context, client *http.Client, cfg OAuthConfig, current StoredToken, now func() time.Time) (StoredToken, error) {
refresh := strings.TrimSpace(current.RefreshToken)
if refresh == "" {
return StoredToken{}, errors.New("no refresh token available")
}
tokenEndpoint := strings.TrimSpace(cfg.TokenEndpoint)
if tokenEndpoint == "" {
return StoredToken{}, errors.New("no token endpoint configured for refresh")
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refresh)
form.Set("client_id", cfg.ClientID)
if secret := strings.TrimSpace(cfg.ClientSecret); secret != "" {
form.Set("client_secret", secret)
}
if len(cfg.Scopes) > 0 {
form.Set("scope", strings.Join(cfg.Scopes, " "))
}
refreshed, err := postTokenRequest(ctx, client, tokenEndpoint, form, StoredToken{Scopes: current.Scopes, RefreshToken: refresh}, now)
token, err := oauth.Refresh(ctx, client, oauth.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Scopes: cfg.Scopes,
TokenEndpoint: cfg.TokenEndpoint,
}, oauth.Token{AccessToken: current.AccessToken, RefreshToken: current.RefreshToken, TokenType: current.TokenType, Scopes: current.Scopes, ExpiresAt: current.ExpiresAt}, now)
if err != nil {
return StoredToken{}, err
}
return refreshed, nil
}
// tokenResponse is the JSON returned by the token endpoint.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// postTokenRequest performs a token endpoint POST and maps the response onto a
// StoredToken. The base token supplies values to preserve (e.g. an existing
// refresh token or scope set) when the response omits them.
func postTokenRequest(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base StoredToken, now func() time.Time) (StoredToken, error) {
if client == nil {
client = http.DefaultClient
}
if now == nil {
now = time.Now
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return StoredToken{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return StoredToken{}, fmt.Errorf("token request failed: %w", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20))
var parsed tokenResponse
if len(body) > 0 {
// A malformed body on an error status is reported as a status error below.
_ = json.Unmarshal(body, &parsed)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if parsed.Error != "" {
if parsed.ErrorDescription != "" {
return StoredToken{}, fmt.Errorf("token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
}
return StoredToken{}, fmt.Errorf("token endpoint error %q", parsed.Error)
}
return StoredToken{}, fmt.Errorf("token endpoint returned HTTP %d", response.StatusCode)
}
if strings.TrimSpace(parsed.AccessToken) == "" {
return StoredToken{}, errors.New("token endpoint returned no access token")
}
token := base
token.AccessToken = parsed.AccessToken
if strings.TrimSpace(parsed.RefreshToken) != "" {
token.RefreshToken = parsed.RefreshToken
}
if strings.TrimSpace(parsed.TokenType) != "" {
token.TokenType = parsed.TokenType
}
if parsed.ExpiresIn > 0 {
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
}
if scope := strings.TrimSpace(parsed.Scope); scope != "" {
token.Scopes = strings.Fields(scope)
}
return token, nil
return tokenToStored(token), nil
}
// registerClient performs dynamic client registration against the registration
@@ -505,22 +416,3 @@ func Login(ctx context.Context, options LoginOptions) (StoredToken, error) {
return StoredToken{}, fmt.Errorf("timed out waiting for OAuth authorization callback: %w", loginCtx.Err())
}
}
func joinWellKnown(baseURL string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || parsed.Host == "" {
return "", fmt.Errorf("invalid base URL for metadata discovery: %q", baseURL)
}
// RFC 8414: the well-known segment is inserted between the host and any issuer
// path component, so a path-based issuer (https://host/tenant-a) is probed at
// https://host/.well-known/oauth-authorization-server/tenant-a — not at the
// host root (which would break tenant-scoped discovery).
issuerPath := strings.Trim(parsed.Path, "/")
parsed.Path = wellKnownAuthServerPath
if issuerPath != "" {
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
}
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
+213
View File
@@ -0,0 +1,213 @@
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
// defaultDeviceCodeLifetime bounds a device authorization that omits expires_in,
// so the expiry gate in PollDeviceToken is always active (fail closed).
const defaultDeviceCodeLifetime = 10 * time.Minute
// DeviceAuth is the result of an RFC 8628 device-authorization request.
type DeviceAuth struct {
DeviceCode string
UserCode string
VerificationURI string
VerificationURIComplete string
Interval time.Duration
ExpiresAt time.Time
}
type deviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int64 `json:"expires_in"`
Interval int64 `json:"interval"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// RequestDeviceCode performs the RFC 8628 device-authorization request. This is
// the headless/SSH login path (no browser, no loopback).
func RequestDeviceCode(ctx context.Context, client *http.Client, cfg Config, now func() time.Time) (DeviceAuth, error) {
endpoint := trimmed(cfg.DeviceAuthorizationEndpoint)
if endpoint == "" {
return DeviceAuth{}, errors.New("oauth: provider has no device authorization endpoint")
}
if err := validateTokenEndpoint(endpoint); err != nil {
return DeviceAuth{}, err
}
if client == nil {
client = http.DefaultClient
}
if now == nil {
now = time.Now
}
form := url.Values{}
form.Set("client_id", cfg.ClientID)
if secret := trimmed(cfg.ClientSecret); secret != "" {
// Confidential clients must authenticate on the device endpoint too,
// consistent with the token poll (pollDeviceOnce).
form.Set("client_secret", secret)
}
if len(cfg.Scopes) > 0 {
form.Set("scope", strings.Join(cfg.Scopes, " "))
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return DeviceAuth{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return DeviceAuth{}, fmt.Errorf("oauth: device authorization request failed: %w", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
var parsed deviceAuthResponse
if len(body) > 0 {
_ = json.Unmarshal(body, &parsed)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if parsed.Error != "" {
return DeviceAuth{}, fmt.Errorf("oauth: device authorization error %q", parsed.Error)
}
return DeviceAuth{}, fmt.Errorf("oauth: device authorization returned HTTP %d", response.StatusCode)
}
if trimmed(parsed.DeviceCode) == "" || trimmed(parsed.UserCode) == "" {
return DeviceAuth{}, errors.New("oauth: device authorization response missing device_code/user_code")
}
interval := time.Duration(parsed.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second // RFC 8628 default
}
auth := DeviceAuth{
DeviceCode: parsed.DeviceCode,
UserCode: parsed.UserCode,
VerificationURI: parsed.VerificationURI,
VerificationURIComplete: parsed.VerificationURIComplete,
Interval: interval,
}
lifetime := time.Duration(parsed.ExpiresIn) * time.Second
if lifetime <= 0 {
// Fail closed: a response without expires_in must still get a bounded
// lifetime so the poll loop's expiry gate stays active.
lifetime = defaultDeviceCodeLifetime
}
auth.ExpiresAt = now().Add(lifetime)
return auth, nil
}
// PollDeviceToken polls the token endpoint for the device grant until the user
// approves, the device code expires, ctx is done, or a terminal error occurs. It
// honors authorization_pending (keep waiting), slow_down (increase the
// interval), and expired_token.
func PollDeviceToken(ctx context.Context, client *http.Client, cfg Config, auth DeviceAuth, now func() time.Time) (Token, error) {
if now == nil {
now = time.Now
}
interval := auth.Interval
if interval <= 0 {
interval = 5 * time.Second
}
for {
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
return Token{}, errors.New("oauth: device code expired before authorization")
}
select {
case <-ctx.Done():
return Token{}, fmt.Errorf("oauth: device authorization canceled: %w", ctx.Err())
case <-time.After(interval):
}
// Re-check expiry after sleeping: slow_down (or a long interval) can push
// the next poll past ExpiresAt, and we must not poll after the deadline.
if !auth.ExpiresAt.IsZero() && !auth.ExpiresAt.After(now()) {
return Token{}, errors.New("oauth: device code expired before authorization")
}
token, err := pollDeviceOnce(ctx, client, cfg, auth.DeviceCode, now)
switch {
case err == nil:
return token, nil
case errors.Is(err, ErrAuthorizationPending):
// keep waiting at the current interval
case errors.Is(err, ErrSlowDown):
interval += 5 * time.Second // RFC 8628: add 5s on slow_down
default:
return Token{}, err
}
}
}
// pollDeviceOnce makes a single device-token poll, translating the RFC 8628
// error codes into ErrAuthorizationPending / ErrSlowDown / terminal errors.
func pollDeviceOnce(ctx context.Context, client *http.Client, cfg Config, deviceCode string, now func() time.Time) (Token, error) {
if err := validateTokenEndpoint(cfg.TokenEndpoint); err != nil {
return Token{}, err
}
if client == nil {
client = http.DefaultClient
}
form := url.Values{}
form.Set("grant_type", deviceGrantType)
form.Set("device_code", deviceCode)
form.Set("client_id", cfg.ClientID)
if secret := trimmed(cfg.ClientSecret); secret != "" {
form.Set("client_secret", secret)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(cfg.TokenEndpoint), strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return Token{}, fmt.Errorf("oauth: device token poll failed: %w", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
var parsed tokenResponse
if len(body) > 0 {
_ = json.Unmarshal(body, &parsed)
}
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices && trimmed(parsed.AccessToken) != "" {
token := Token{Scopes: cfg.Scopes}
token.AccessToken = parsed.AccessToken
token.RefreshToken = parsed.RefreshToken
token.TokenType = parsed.TokenType
if parsed.ExpiresIn > 0 {
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
}
if scope := trimmed(parsed.Scope); scope != "" {
token.Scopes = strings.Fields(scope)
}
return token, nil
}
switch parsed.Error {
case "authorization_pending":
return Token{}, ErrAuthorizationPending
case "slow_down":
return Token{}, ErrSlowDown
case "expired_token":
return Token{}, errors.New("oauth: device code expired before authorization")
case "access_denied":
return Token{}, errors.New("oauth: authorization denied by the user")
case "":
return Token{}, fmt.Errorf("oauth: device token poll returned HTTP %d", response.StatusCode)
default:
return Token{}, fmt.Errorf("oauth: device token error %q", parsed.Error)
}
}
+143
View File
@@ -0,0 +1,143 @@
package oauth
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestRequestDeviceCodeSendsClientSecret(t *testing.T) {
var gotSecret string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotSecret = r.Form.Get("client_secret")
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x","expires_in":600,"interval":5}`))
}))
defer server.Close()
// A confidential client must authenticate on the device endpoint too.
_, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", ClientSecret: "shh", DeviceAuthorizationEndpoint: server.URL}, nil)
if err != nil {
t.Fatalf("RequestDeviceCode: %v", err)
}
if gotSecret != "shh" {
t.Fatalf("client_secret = %q, want shh", gotSecret)
}
}
func TestRequestDeviceCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WXYZ-1234","verification_uri":"https://x/dev","verification_uri_complete":"https://x/dev?c=WXYZ","expires_in":600,"interval":3}`))
}))
defer server.Close()
cfg := Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL, Scopes: []string{"a"}}
auth, err := RequestDeviceCode(context.Background(), server.Client(), cfg, nil)
if err != nil {
t.Fatalf("RequestDeviceCode: %v", err)
}
if auth.DeviceCode != "dc" || auth.UserCode != "WXYZ-1234" {
t.Fatalf("device auth = %+v", auth)
}
if auth.Interval != 3*time.Second {
t.Fatalf("interval = %v, want 3s", auth.Interval)
}
if auth.VerificationURIComplete == "" {
t.Fatal("missing verification_uri_complete")
}
}
func TestRequestDeviceCodeDefaultsInterval(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"U","verification_uri":"https://x"}`))
}))
defer server.Close()
auth, err := RequestDeviceCode(context.Background(), server.Client(), Config{ClientID: "c", DeviceAuthorizationEndpoint: server.URL}, nil)
if err != nil {
t.Fatalf("RequestDeviceCode: %v", err)
}
if auth.Interval != 5*time.Second {
t.Fatalf("default interval = %v, want 5s", auth.Interval)
}
// A response without expires_in must still get a bounded expiry (fail closed),
// so the poll loop's expiry gate stays active.
if auth.ExpiresAt.IsZero() {
t.Fatal("missing expires_in must default to a bounded ExpiresAt, got zero")
}
}
func TestPollDeviceTokenHonorsPendingThenSucceeds(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n := calls.Add(1)
if n < 3 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"authorization_pending"}`))
return
}
_, _ = w.Write([]byte(`{"access_token":"at","token_type":"Bearer","expires_in":3600}`))
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
// Small interval + future expiry so the loop is fast.
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
if err != nil {
t.Fatalf("PollDeviceToken: %v", err)
}
if tok.AccessToken != "at" {
t.Fatalf("token = %+v", tok)
}
if calls.Load() != 3 {
t.Fatalf("polled %d times, want 3 (2 pending + 1 success)", calls.Load())
}
}
func TestPollDeviceTokenSlowDown(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"slow_down"}`))
return
}
_, _ = w.Write([]byte(`{"access_token":"at"}`))
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
// Expiry well beyond the slow_down back-off (+5s) so the success path never
// depends on polling after the device code has expired.
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(30 * time.Second)}
tok, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
if err != nil {
t.Fatalf("PollDeviceToken (slow_down): %v", err)
}
if tok.AccessToken != "at" {
t.Fatalf("token = %+v", tok)
}
}
func TestPollDeviceTokenExpired(t *testing.T) {
cfg := Config{ClientID: "c", TokenEndpoint: "https://unused/token"}
auth := DeviceAuth{DeviceCode: "dc", Interval: time.Millisecond, ExpiresAt: time.Now().Add(-time.Second)}
_, err := PollDeviceToken(context.Background(), http.DefaultClient, cfg, auth, nil)
if err == nil {
t.Fatal("expected expired error")
}
}
func TestPollDeviceTokenAccessDenied(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"access_denied"}`))
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
auth := DeviceAuth{DeviceCode: "dc", Interval: 5 * time.Millisecond, ExpiresAt: time.Now().Add(5 * time.Second)}
_, err := PollDeviceToken(context.Background(), server.Client(), cfg, auth, nil)
if err == nil || !strings.Contains(err.Error(), "denied") {
t.Fatalf("err = %v, want access denied", err)
}
}
+79
View File
@@ -0,0 +1,79 @@
package oauth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
const wellKnownAuthServerPath = "/.well-known/oauth-authorization-server"
// ServerMetadata is the subset of an RFC 8414 authorization-server metadata
// document the engine consumes.
type ServerMetadata struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
ScopesSupported []string `json:"scopes_supported"`
}
// DiscoverAuthorizationServer fetches the RFC 8414 metadata document at the
// well-known path under baseURL.
func DiscoverAuthorizationServer(ctx context.Context, client *http.Client, baseURL string) (ServerMetadata, error) {
metadataURL, err := joinWellKnown(baseURL)
if err != nil {
return ServerMetadata{}, err
}
return fetchMetadata(ctx, client, metadataURL)
}
// fetchMetadata GETs and decodes an authorization-server metadata document at an
// already-resolved URL (no well-known recomputation), so callers can also use it
// for the OIDC openid-configuration path.
func fetchMetadata(ctx context.Context, client *http.Client, metadataURL string) (ServerMetadata, error) {
if client == nil {
client = http.DefaultClient
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil)
if err != nil {
return ServerMetadata{}, err
}
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return ServerMetadata{}, fmt.Errorf("oauth: fetch authorization server metadata: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return ServerMetadata{}, fmt.Errorf("oauth: authorization server metadata returned HTTP %d", response.StatusCode)
}
var metadata ServerMetadata
if err := json.NewDecoder(io.LimitReader(response.Body, tokenResponseLimit)).Decode(&metadata); err != nil {
return ServerMetadata{}, fmt.Errorf("oauth: decode authorization server metadata: %w", err)
}
return metadata, nil
}
// joinWellKnown builds the RFC 8414 metadata URL, inserting the well-known
// segment between host and any issuer path component (so a path-based issuer is
// probed tenant-scoped, not at the host root).
func joinWellKnown(baseURL string) (string, error) {
parsed, err := url.Parse(trimmed(baseURL))
if err != nil || parsed.Host == "" {
return "", fmt.Errorf("oauth: invalid base URL for metadata discovery: %q", baseURL)
}
issuerPath := strings.Trim(parsed.Path, "/")
parsed.Path = wellKnownAuthServerPath
if issuerPath != "" {
parsed.Path = strings.TrimRight(wellKnownAuthServerPath, "/") + "/" + issuerPath
}
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
}
+212
View File
@@ -0,0 +1,212 @@
package oauth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const tokenResponseLimit = 1 << 20 // 1 MiB cap on token-endpoint bodies
// tokenResponse is the JSON returned by a token endpoint.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// BuildAuthorizationURL constructs the authorization request URL for an
// authorization-code + PKCE flow. PKCE S256 is always included; a non-S256
// method is refused (ErrPKCEDowngrade). extraParams (and cfg.ExtraAuthParams)
// are appended last.
func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, extraParams map[string]string) (string, error) {
if pkce.Method != MethodS256 {
return "", ErrPKCEDowngrade
}
endpoint := trimmed(cfg.AuthorizationEndpoint)
if endpoint == "" {
return "", errors.New("oauth: no authorization endpoint configured")
}
parsed, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
}
query := parsed.Query()
query.Set("response_type", "code")
query.Set("client_id", cfg.ClientID)
query.Set("redirect_uri", redirectURI)
query.Set("state", state)
query.Set("code_challenge", pkce.Challenge)
query.Set("code_challenge_method", pkce.Method)
if len(cfg.Scopes) > 0 {
query.Set("scope", strings.Join(cfg.Scopes, " "))
}
// Extra params must never override the reserved OAuth/PKCE fields above
// (e.g. forcing code_challenge_method=plain or rewriting state/redirect_uri),
// which would break the flow's security guarantees.
for k, v := range cfg.ExtraAuthParams {
if isReservedAuthParam(k) {
continue
}
query.Set(k, v)
}
for k, v := range extraParams {
if isReservedAuthParam(k) {
continue
}
query.Set(k, v)
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
// isReservedAuthParam reports whether a query key is one the engine controls and
// must not be overridable by caller-supplied extra params.
func isReservedAuthParam(key string) bool {
switch key {
case "response_type", "client_id", "redirect_uri", "state", "code_challenge", "code_challenge_method":
return true
default:
return false
}
}
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
// not https, unless it is a loopback host (mirrors oauth2-client.js
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
func validateTokenEndpoint(endpoint string) error {
parsed, err := url.Parse(trimmed(endpoint))
if err != nil || parsed.Host == "" {
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
}
if parsed.Scheme == "https" {
return nil
}
if parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) {
return nil
}
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
}
func isLoopbackHost(host string) bool {
if host == "localhost" {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
// ExchangeCode swaps an authorization code + PKCE verifier for tokens.
func ExchangeCode(ctx context.Context, client *http.Client, cfg Config, code, verifier, redirectURI string, now func() time.Time) (Token, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", cfg.ClientID)
form.Set("code_verifier", verifier)
if secret := trimmed(cfg.ClientSecret); secret != "" {
form.Set("client_secret", secret)
}
return PostToken(ctx, client, cfg.TokenEndpoint, form, Token{Scopes: cfg.Scopes}, now)
}
// Refresh exchanges a refresh token for a fresh access token. A response that
// omits a new refresh token preserves the current one.
func Refresh(ctx context.Context, client *http.Client, cfg Config, current Token, now func() time.Time) (Token, error) {
refresh := trimmed(current.RefreshToken)
if refresh == "" {
return Token{}, ErrNoRefreshToken
}
if trimmed(cfg.TokenEndpoint) == "" {
return Token{}, errors.New("oauth: no token endpoint configured for refresh")
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refresh)
form.Set("client_id", cfg.ClientID)
if secret := trimmed(cfg.ClientSecret); secret != "" {
form.Set("client_secret", secret)
}
if len(cfg.Scopes) > 0 {
form.Set("scope", strings.Join(cfg.Scopes, " "))
}
base := Token{Scopes: current.Scopes, RefreshToken: refresh, Account: current.Account}
return PostToken(ctx, client, cfg.TokenEndpoint, form, base, now)
}
// PostToken performs a token-endpoint POST and maps the response onto a Token.
// The https guard is applied first. The base token supplies values to preserve
// (e.g. an existing refresh token or scopes) when the response omits them.
// Error messages carry only the server's error/error_description — never the raw
// body — so token material in an unexpected payload is not leaked.
func PostToken(ctx context.Context, client *http.Client, tokenEndpoint string, form url.Values, base Token, now func() time.Time) (Token, error) {
if err := validateTokenEndpoint(tokenEndpoint); err != nil {
return Token{}, err
}
if client == nil {
client = http.DefaultClient
}
if now == nil {
now = time.Now
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, trimmed(tokenEndpoint), strings.NewReader(form.Encode()))
if err != nil {
return Token{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := client.Do(request)
if err != nil {
return Token{}, fmt.Errorf("oauth: token request failed: %w", err)
}
defer response.Body.Close()
body, _ := io.ReadAll(io.LimitReader(response.Body, tokenResponseLimit))
var parsed tokenResponse
if len(body) > 0 {
_ = json.Unmarshal(body, &parsed)
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
if parsed.Error != "" {
if parsed.ErrorDescription != "" {
return Token{}, fmt.Errorf("oauth: token endpoint error %q: %s", parsed.Error, parsed.ErrorDescription)
}
return Token{}, fmt.Errorf("oauth: token endpoint error %q", parsed.Error)
}
return Token{}, fmt.Errorf("oauth: token endpoint returned HTTP %d", response.StatusCode)
}
if trimmed(parsed.AccessToken) == "" {
return Token{}, errors.New("oauth: token endpoint returned no access token")
}
token := base
token.AccessToken = parsed.AccessToken
if trimmed(parsed.RefreshToken) != "" {
token.RefreshToken = parsed.RefreshToken
}
if trimmed(parsed.TokenType) != "" {
token.TokenType = parsed.TokenType
}
if parsed.ExpiresIn > 0 {
token.ExpiresAt = now().Add(time.Duration(parsed.ExpiresIn) * time.Second).UTC()
}
if scope := trimmed(parsed.Scope); scope != "" {
token.Scopes = strings.Fields(scope)
}
return token, nil
}
+212
View File
@@ -0,0 +1,212 @@
package oauth
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestNewPKCE(t *testing.T) {
p, err := NewPKCE()
if err != nil {
t.Fatalf("NewPKCE: %v", err)
}
if p.Method != MethodS256 {
t.Fatalf("method = %q, want S256", p.Method)
}
if len(p.Verifier) != 43 { // base64url(32 bytes) = 43 chars
t.Fatalf("verifier length = %d, want 43", len(p.Verifier))
}
sum := sha256.Sum256([]byte(p.Verifier))
want := base64.RawURLEncoding.EncodeToString(sum[:])
if p.Challenge != want {
t.Fatalf("challenge = %q, want base64url(sha256(verifier))", p.Challenge)
}
}
func TestNewStateUnique(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 100; i++ {
s, err := NewState()
if err != nil {
t.Fatalf("NewState: %v", err)
}
if s == "" || seen[s] {
t.Fatalf("state not unique/non-empty: %q", s)
}
seen[s] = true
}
}
func TestBuildAuthorizationURL(t *testing.T) {
cfg := Config{
ClientID: "client-123",
Scopes: []string{"read", "write"},
AuthorizationEndpoint: "https://auth.example.com/authorize",
ExtraAuthParams: map[string]string{"login_hint": "[email protected]"},
}
pkce := PKCE{Verifier: "v", Challenge: "chal", Method: MethodS256}
raw, err := BuildAuthorizationURL(cfg, pkce, "state-xyz", "http://127.0.0.1:9/callback", map[string]string{"prompt": "login"})
if err != nil {
t.Fatalf("BuildAuthorizationURL: %v", err)
}
u, _ := url.Parse(raw)
q := u.Query()
checks := map[string]string{
"response_type": "code",
"client_id": "client-123",
"redirect_uri": "http://127.0.0.1:9/callback",
"state": "state-xyz",
"code_challenge": "chal",
"code_challenge_method": "S256",
"scope": "read write",
"login_hint": "[email protected]",
"prompt": "login",
}
for k, want := range checks {
if got := q.Get(k); got != want {
t.Errorf("query %q = %q, want %q", k, got, want)
}
}
}
func TestBuildAuthorizationURLIgnoresReservedExtras(t *testing.T) {
cfg := Config{
ClientID: "c",
AuthorizationEndpoint: "https://auth.example.com/authorize",
// A hostile config tries to weaken the flow via extra params.
ExtraAuthParams: map[string]string{"state": "attacker", "code_challenge_method": "plain", "login_hint": "keep-me"},
}
pkce := PKCE{Challenge: "chal", Method: MethodS256}
raw, err := BuildAuthorizationURL(cfg, pkce, "real-state", "http://127.0.0.1:9/cb", map[string]string{"redirect_uri": "http://evil/cb"})
if err != nil {
t.Fatalf("BuildAuthorizationURL: %v", err)
}
parsed, _ := url.Parse(raw)
q := parsed.Query()
if q.Get("state") != "real-state" {
t.Fatalf("state overridden: %q", q.Get("state"))
}
if q.Get("code_challenge_method") != "S256" {
t.Fatalf("PKCE method downgraded: %q", q.Get("code_challenge_method"))
}
if q.Get("redirect_uri") != "http://127.0.0.1:9/cb" {
t.Fatalf("redirect_uri overridden: %q", q.Get("redirect_uri"))
}
if q.Get("login_hint") != "keep-me" {
t.Fatalf("non-reserved extra param should still apply: %q", q.Get("login_hint"))
}
}
func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
_, err := BuildAuthorizationURL(Config{AuthorizationEndpoint: "https://a/x"}, PKCE{Method: "plain"}, "s", "r", nil)
if !errors.Is(err, ErrPKCEDowngrade) {
t.Fatalf("err = %v, want ErrPKCEDowngrade", err)
}
}
func TestValidateTokenEndpoint(t *testing.T) {
cases := map[string]bool{
"https://auth.example.com/token": true,
"http://127.0.0.1:1455/token": true, // loopback exempt
"http://localhost:1455/token": true,
"http://[::1]:1455/token": true,
"http://auth.example.com/token": false, // non-https, non-loopback
"ftp://auth.example.com/token": false,
}
for endpoint, ok := range cases {
err := validateTokenEndpoint(endpoint)
if ok && err != nil {
t.Errorf("validateTokenEndpoint(%q) = %v, want nil", endpoint, err)
}
if !ok && err == nil {
t.Errorf("validateTokenEndpoint(%q) = nil, want error", endpoint)
}
}
}
func TestExchangeCodeSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.FormValue("grant_type") != "authorization_code" || r.FormValue("code_verifier") != "verifier" {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600,"scope":"read write"}`))
}))
defer server.Close()
now := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
tok, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "verifier", "http://127.0.0.1/cb", func() time.Time { return now })
if err != nil {
t.Fatalf("ExchangeCode: %v", err)
}
if tok.AccessToken != "at" || tok.RefreshToken != "rt" || tok.TokenType != "Bearer" {
t.Fatalf("token = %+v", tok)
}
if !tok.ExpiresAt.Equal(now.Add(time.Hour)) {
t.Fatalf("expiresAt = %v, want now+1h", tok.ExpiresAt)
}
if strings.Join(tok.Scopes, " ") != "read write" {
t.Fatalf("scopes = %v", tok.Scopes)
}
}
func TestExchangeCodeErrorRedactsBody(t *testing.T) {
// The error body contains a stray access_token-looking field; it must NOT
// surface in the returned error (only error/error_description do).
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"bad code","access_token":"SECRET-LEAK"}`))
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
_, err := ExchangeCode(context.Background(), server.Client(), cfg, "code", "v", "http://127.0.0.1/cb", nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid_grant") || !strings.Contains(err.Error(), "bad code") {
t.Fatalf("error should carry error/description, got %q", err.Error())
}
if strings.Contains(err.Error(), "SECRET-LEAK") {
t.Fatalf("error leaked raw body token material: %q", err.Error())
}
}
func TestPostTokenRefusesInsecureEndpoint(t *testing.T) {
_, err := PostToken(context.Background(), http.DefaultClient, "http://auth.example.com/token", url.Values{}, Token{}, nil)
if !errors.Is(err, ErrInsecureTokenEndpoint) {
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
}
}
func TestRefreshNoToken(t *testing.T) {
_, err := Refresh(context.Background(), http.DefaultClient, Config{TokenEndpoint: "https://a/token"}, Token{}, nil)
if !errors.Is(err, ErrNoRefreshToken) {
t.Fatalf("err = %v, want ErrNoRefreshToken", err)
}
}
func TestRefreshPreservesRefreshTokenWhenOmitted(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"access_token":"new-at","expires_in":3600}`)) // no refresh_token in response
}))
defer server.Close()
cfg := Config{ClientID: "c", TokenEndpoint: server.URL}
tok, err := Refresh(context.Background(), server.Client(), cfg, Token{RefreshToken: "keep-me"}, nil)
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if tok.AccessToken != "new-at" || tok.RefreshToken != "keep-me" {
t.Fatalf("refresh should preserve old refresh token: %+v", tok)
}
}
+74
View File
@@ -0,0 +1,74 @@
package oauth
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
)
const (
fileLockTimeout = 5 * time.Second
fileLockStaleAfter = 30 * time.Second
)
var lockSeq atomic.Uint64
// acquireFileLock takes a cross-process exclusive lock by creating lockPath with
// O_EXCL. It retries with a short backoff until a timeout, breaking a lock whose
// file is older than fileLockStaleAfter (so a crashed holder cannot deadlock the
// store). Release is ownership-aware: it removes the lock only if it still holds
// our token, so a stale-broken holder cannot delete a newer holder's lock.
func acquireFileLock(lockPath string, now func() time.Time) (func(), error) {
if now == nil {
now = time.Now
}
if err := os.MkdirAll(filepath.Dir(lockPath), 0o700); err != nil {
return nil, err
}
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), now().UnixNano(), lockSeq.Add(1))
deadline := now().Add(fileLockTimeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err == nil {
// A partial/failed write would leave a lock file without our token, so
// the releaser below could never delete it — stranding the lock for
// other processes. Fail closed: remove the file and surface the error.
if _, werr := f.WriteString(token); werr != nil {
_ = f.Close()
_ = os.Remove(lockPath)
return nil, fmt.Errorf("oauth: write token lock: %w", werr)
}
if cerr := f.Close(); cerr != nil {
_ = os.Remove(lockPath)
return nil, fmt.Errorf("oauth: close token lock: %w", cerr)
}
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
_ = os.Remove(lockPath)
}
}, nil
}
if !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("oauth: acquire token lock: %w", err)
}
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > fileLockStaleAfter {
stale, _ := os.ReadFile(lockPath)
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
_ = os.Remove(lockPath)
}
continue
}
if now().After(deadline) {
return nil, fmt.Errorf("oauth: timed out acquiring token lock %s", filepath.Base(lockPath))
}
time.Sleep(10 * time.Millisecond)
}
}
+112
View File
@@ -0,0 +1,112 @@
package oauth
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// LoopbackListener is a single-use loopback HTTP server that captures an OAuth
// redirect (?code=&state=) on 127.0.0.1 only: bind an OS-assigned loopback port,
// verify the CSRF state, hand back the code, then close.
type LoopbackListener struct {
listener net.Listener
state string
result chan callbackResult
server *http.Server
}
type callbackResult struct {
code string
err error
}
// NewLoopbackListener binds 127.0.0.1:0 (loopback only, OS-assigned port) and
// begins serving. state is the CSRF value the callback must echo back. Call
// RedirectURI to build the redirect_uri, then Wait for the code. Always Close.
func NewLoopbackListener(state string) (*LoopbackListener, error) {
// Refuse an empty state: the callback check compares the query's state to this
// value, so an empty state would match a callback carrying no state at all,
// defeating CSRF protection (fail closed).
if strings.TrimSpace(state) == "" {
return nil, errors.New("oauth: loopback listener requires a non-empty CSRF state")
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("oauth: start loopback redirect listener: %w", err)
}
l := &LoopbackListener{
listener: ln,
state: state,
result: make(chan callbackResult, 1),
}
l.server = &http.Server{Handler: http.HandlerFunc(l.handle)}
go func() { _ = l.server.Serve(ln) }()
return l, nil
}
// RedirectURI returns the http://127.0.0.1:<port>/callback redirect URI.
func (l *LoopbackListener) RedirectURI() string {
return fmt.Sprintf("http://%s/callback", l.listener.Addr().String())
}
func (l *LoopbackListener) handle(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/callback" {
http.NotFound(w, r)
return
}
code, err := parseCallback(r.URL.Query(), l.state)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "Authorization failed. You may close this window.")
} else {
_, _ = io.WriteString(w, "Authorization complete. You may close this window.")
}
select {
case l.result <- callbackResult{code: code, err: err}:
default:
}
}
// Wait blocks until the callback arrives or ctx is done, returning the
// authorization code.
func (l *LoopbackListener) Wait(ctx context.Context) (string, error) {
select {
case res := <-l.result:
return res.code, res.err
case <-ctx.Done():
return "", fmt.Errorf("oauth: timed out waiting for authorization callback: %w", ctx.Err())
}
}
// Close shuts the listener down (bounded), idempotent.
func (l *LoopbackListener) Close() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = l.server.Shutdown(shutdownCtx)
}
// parseCallback validates the redirect query and returns the authorization code,
// rejecting a mismatched state (CSRF) and surfacing provider errors.
func parseCallback(values url.Values, wantState string) (string, error) {
if got := values.Get("state"); got != wantState {
return "", ErrStateMismatch
}
if providerErr := strings.TrimSpace(values.Get("error")); providerErr != "" {
if desc := strings.TrimSpace(values.Get("error_description")); desc != "" {
return "", fmt.Errorf("oauth: authorization server returned error %q: %s", providerErr, desc)
}
return "", fmt.Errorf("oauth: authorization server returned error %q", providerErr)
}
code := strings.TrimSpace(values.Get("code"))
if code == "" {
return "", errors.New("oauth: callback missing authorization code")
}
return code, nil
}
+86
View File
@@ -0,0 +1,86 @@
package oauth
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
)
func TestLoopbackCapturesCode(t *testing.T) {
l, err := NewLoopbackListener("the-state")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
if !strings.HasPrefix(l.RedirectURI(), "http://127.0.0.1:") {
t.Fatalf("redirect URI not loopback: %q", l.RedirectURI())
}
go func() {
_, _ = http.Get(l.RedirectURI() + "?code=auth-code&state=the-state")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
code, err := l.Wait(ctx)
if err != nil {
t.Fatalf("Wait: %v", err)
}
if code != "auth-code" {
t.Fatalf("code = %q, want auth-code", code)
}
}
func TestNewLoopbackListenerRejectsEmptyState(t *testing.T) {
if _, err := NewLoopbackListener(" "); err == nil {
t.Fatal("an empty CSRF state must be rejected (fail closed)")
}
}
func TestLoopbackRejectsStateMismatch(t *testing.T) {
l, err := NewLoopbackListener("expected-state")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
go func() {
_, _ = http.Get(l.RedirectURI() + "?code=x&state=WRONG")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = l.Wait(ctx)
if !errors.Is(err, ErrStateMismatch) {
t.Fatalf("Wait err = %v, want ErrStateMismatch", err)
}
}
func TestLoopbackTimesOut(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
if _, err := l.Wait(ctx); err == nil {
t.Fatal("Wait should time out cleanly")
}
}
func TestLoopbackProviderError(t *testing.T) {
l, err := NewLoopbackListener("s")
if err != nil {
t.Fatalf("NewLoopbackListener: %v", err)
}
defer l.Close()
go func() {
_, _ = http.Get(l.RedirectURI() + "?error=access_denied&error_description=nope&state=s")
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = l.Wait(ctx)
if err == nil || !strings.Contains(err.Error(), "access_denied") {
t.Fatalf("Wait err = %v, want provider error", err)
}
}
+292
View File
@@ -0,0 +1,292 @@
package oauth
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// defaultRefreshBuffer refreshes a token this long before its hard expiry.
const defaultRefreshBuffer = 60 * time.Second
const oidcWellKnownPath = "/.well-known/openid-configuration"
// Manager ties the token store, provider registry, and HTTP client together to
// run logins and serve fresh access tokens. It is the high-level entrypoint the
// CLI and request paths use.
type Manager struct {
store *Store
registry *Registry
client *http.Client
env map[string]string
now func() time.Time
buffer time.Duration
out io.Writer
// openBrowser is invoked with the authorization URL for loopback logins.
// Tests inject a function that drives the loopback redirect.
openBrowser func(authURL string) error
}
// ManagerOptions configures a Manager.
type ManagerOptions struct {
Store *Store
Registry *Registry
HTTPClient *http.Client
Env map[string]string
Now func() time.Time
RefreshBuffer time.Duration
Out io.Writer
OpenBrowser func(authURL string) error
}
// NewManager builds a Manager, filling defaults.
func NewManager(opts ManagerOptions) (*Manager, error) {
if opts.Store == nil {
return nil, fmt.Errorf("oauth: manager requires a store")
}
registry := opts.Registry
if registry == nil {
registry = NewRegistry()
}
client := opts.HTTPClient
if client == nil {
client = http.DefaultClient
}
now := opts.Now
if now == nil {
now = time.Now
}
buffer := opts.RefreshBuffer
if buffer <= 0 {
buffer = defaultRefreshBuffer
}
out := opts.Out
if out == nil {
out = io.Discard
}
open := opts.OpenBrowser
if open == nil {
open = func(string) error { return nil }
}
return &Manager{
store: opts.Store, registry: registry, client: client,
env: opts.Env, now: now, buffer: buffer, out: out, openBrowser: open,
}, nil
}
// LoginOptions configures a single provider login.
type LoginOptions struct {
Provider string
Device bool // force device-code flow
ExtraScopes []string // appended to the provider's scopes
Timeout time.Duration // bounds the whole interactive login
}
// Login runs the provider login (loopback by default, device-code when
// requested or when the provider only supports device), stores the token under
// "provider:<name>", and returns a redaction-safe status.
func (m *Manager) Login(ctx context.Context, opts LoginOptions) (Status, error) {
cfg, flow, err := m.registry.ResolveConfig(opts.Provider, m.env)
if err != nil {
return Status{}, err
}
if len(opts.ExtraScopes) > 0 {
cfg.Scopes = append(append([]string{}, cfg.Scopes...), opts.ExtraScopes...)
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
loginCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cfg, err = m.resolveEndpoints(loginCtx, cfg)
if err != nil {
return Status{}, err
}
useDevice := opts.Device || flow == FlowDevice
var token Token
if useDevice {
token, err = m.loginDevice(loginCtx, cfg)
} else {
token, err = m.loginLoopback(loginCtx, cfg)
}
if err != nil {
return Status{}, err
}
key := ProviderKey(opts.Provider)
if err := m.store.Save(key, token); err != nil {
return Status{}, err
}
return m.statusFor(key)
}
// resolveEndpoints fills missing authorize/token/device endpoints from issuer
// discovery (RFC 8414 then OIDC), leaving any explicitly-pinned endpoint intact.
func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, error) {
if trimmed(cfg.IssuerURL) == "" {
return cfg, nil
}
if cfg.AuthorizationEndpoint != "" && cfg.TokenEndpoint != "" && cfg.DeviceAuthorizationEndpoint != "" {
return cfg, nil
}
// Discovery is best-effort: a failure is non-fatal because pinned endpoints
// may already be sufficient for the chosen flow. Only merge on success (and
// never return from the error branch, which would trip the nilerr linter).
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
if cfg.AuthorizationEndpoint == "" {
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
}
if cfg.TokenEndpoint == "" {
cfg.TokenEndpoint = meta.TokenEndpoint
}
if cfg.DeviceAuthorizationEndpoint == "" {
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
}
}
return cfg, nil
}
// discover tries the OAuth (RFC 8414) well-known path, then the OIDC
// openid-configuration path (some issuers publish only the latter).
func (m *Manager) discover(ctx context.Context, issuer string) (ServerMetadata, error) {
meta, err := DiscoverAuthorizationServer(ctx, m.client, issuer)
if err == nil && (meta.AuthorizationEndpoint != "" || meta.TokenEndpoint != "") {
return meta, nil
}
oidcURL := strings.TrimRight(strings.TrimSpace(issuer), "/") + oidcWellKnownPath
return fetchMetadata(ctx, m.client, oidcURL)
}
func (m *Manager) loginLoopback(ctx context.Context, cfg Config) (Token, error) {
if trimmed(cfg.AuthorizationEndpoint) == "" {
return Token{}, fmt.Errorf("oauth: no authorization endpoint (set the authorize URL or a discoverable issuer)")
}
state, err := NewState()
if err != nil {
return Token{}, err
}
pkce, err := NewPKCE()
if err != nil {
return Token{}, err
}
listener, err := NewLoopbackListener(state)
if err != nil {
return Token{}, err
}
defer listener.Close()
redirectURI := listener.RedirectURI()
authURL, err := BuildAuthorizationURL(cfg, pkce, state, redirectURI, nil)
if err != nil {
return Token{}, err
}
fmt.Fprintf(m.out, "Open this URL to authorize:\n %s\n", authURL)
if err := m.openBrowser(authURL); err != nil {
return Token{}, fmt.Errorf("oauth: open authorization URL: %w", err)
}
code, err := listener.Wait(ctx)
if err != nil {
return Token{}, err
}
return ExchangeCode(ctx, m.client, cfg, code, pkce.Verifier, redirectURI, m.now)
}
func (m *Manager) loginDevice(ctx context.Context, cfg Config) (Token, error) {
auth, err := RequestDeviceCode(ctx, m.client, cfg, m.now)
if err != nil {
return Token{}, err
}
target := auth.VerificationURIComplete
if target == "" {
target = auth.VerificationURI
}
// user_code is meant to be displayed to the user; it is not a secret.
fmt.Fprintf(m.out, "To authorize, visit:\n %s\nand enter code: %s\n", target, auth.UserCode)
return PollDeviceToken(ctx, m.client, cfg, auth, m.now)
}
// GetFresh returns a valid access token for key, refreshing on-demand if the
// stored token is expired or within the refresh buffer. Mirrors
// checkAndRefreshOAuthTokenIfNeeded.
func (m *Manager) GetFresh(ctx context.Context, key string) (string, error) {
token, cfg, err := m.loadForKey(key)
if err != nil {
return "", err
}
if !token.NeedsRefresh(m.now(), m.buffer) {
return token.AccessToken, nil
}
return m.refreshAndSave(ctx, key, cfg, token)
}
// Handle401 forces a refresh after an upstream 401, returning the new access
// token. Mirrors handleOAuth401Error.
func (m *Manager) Handle401(ctx context.Context, key string) (string, error) {
token, cfg, err := m.loadForKey(key)
if err != nil {
return "", err
}
return m.refreshAndSave(ctx, key, cfg, token)
}
func (m *Manager) refreshAndSave(ctx context.Context, key string, cfg Config, current Token) (string, error) {
refreshed, err := Refresh(ctx, m.client, cfg, current, m.now)
if err != nil {
return "", err
}
if err := m.store.Save(key, refreshed); err != nil {
return "", err
}
return refreshed.AccessToken, nil
}
// loadForKey loads a stored token and resolves the provider config for its key.
func (m *Manager) loadForKey(key string) (Token, Config, error) {
if err := ValidateKey(key); err != nil {
return Token{}, Config{}, err
}
token, ok, err := m.store.Load(key)
if err != nil {
return Token{}, Config{}, err
}
if !ok {
return Token{}, Config{}, fmt.Errorf("oauth: no stored token for %q", key)
}
name := strings.TrimPrefix(key, KeyPrefixProvider)
if name == key {
return Token{}, Config{}, fmt.Errorf("oauth: refresh is only supported for provider tokens (got %q)", key)
}
cfg, _, err := m.registry.ResolveConfig(name, m.env)
if err != nil {
return Token{}, Config{}, err
}
return token, cfg, nil
}
// Logout removes a provider's stored token, reporting whether one was present.
func (m *Manager) Logout(name string) (bool, error) {
return m.store.Delete(ProviderKey(name))
}
// StatusAll returns the status of every provider login.
func (m *Manager) StatusAll() ([]Status, error) {
return m.store.Status(KeyPrefixProvider)
}
func (m *Manager) statusFor(key string) (Status, error) {
statuses, err := m.store.Status(KeyPrefixProvider)
if err != nil {
return Status{}, err
}
for _, st := range statuses {
if st.Key == key {
return st, nil
}
}
return Status{Key: key}, nil
}
+182
View File
@@ -0,0 +1,182 @@
package oauth
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
// fakeProvider is an httptest server that plays a token + device endpoint.
type fakeProvider struct {
server *httptest.Server
tokenHits atomic.Int32
}
func newFakeProvider(t *testing.T, tokenJSON string) *fakeProvider {
t.Helper()
fp := &fakeProvider{}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
fp.tokenHits.Add(1)
_, _ = io.WriteString(w, tokenJSON)
})
mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) {
// Approve immediately on poll; short interval so the test is fast.
_, _ = io.WriteString(w, `{"device_code":"dc","user_code":"U-1","verification_uri":"https://example/dev","expires_in":600,"interval":1}`)
})
fp.server = httptest.NewServer(mux)
t.Cleanup(fp.server.Close)
return fp
}
func managerFor(t *testing.T, env map[string]string, openBrowser func(string) error) *Manager {
t.Helper()
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
m, err := NewManager(ManagerOptions{
Store: store,
Env: env,
OpenBrowser: openBrowser,
})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
return m
}
func TestManagerLoginLoopback(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
}
// The fake browser drives the loopback redirect with the captured state.
openBrowser := func(authURL string) error {
u, err := url.Parse(authURL)
if err != nil {
return err
}
redirect := u.Query().Get("redirect_uri")
state := u.Query().Get("state")
_, err = http.Get(redirect + "?code=the-code&state=" + url.QueryEscape(state))
return err
}
m := managerFor(t, env, openBrowser)
status, err := m.Login(context.Background(), LoginOptions{Provider: "demo"})
if err != nil {
t.Fatalf("Login: %v", err)
}
if !status.HasToken || status.Key != ProviderKey("demo") {
t.Fatalf("status = %+v", status)
}
// The token is persisted and GetFresh returns it without a needless refresh.
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
if err != nil {
t.Fatalf("GetFresh: %v", err)
}
if access != "at" {
t.Fatalf("access = %q, want at", access)
}
}
func TestManagerLoginDevice(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"dev-at","token_type":"Bearer","expires_in":3600}`)
env := map[string]string{
"ZERO_OAUTH_DEMODEV_CLIENT_ID": "client",
"ZERO_OAUTH_DEMODEV_TOKEN_URL": fp.server.URL + "/token",
"ZERO_OAUTH_DEMODEV_DEVICE_URL": fp.server.URL + "/device",
}
m := managerFor(t, env, nil)
status, err := m.Login(context.Background(), LoginOptions{Provider: "demodev", Device: true})
if err != nil {
t.Fatalf("device Login: %v", err)
}
if !status.HasToken {
t.Fatalf("device status = %+v", status)
}
}
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
}
m := managerFor(t, env, nil)
// Seed an expired token with a refresh token.
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "stale", RefreshToken: "rt", ExpiresAt: time.Now().Add(-time.Hour)}); err != nil {
t.Fatalf("seed: %v", err)
}
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
if err != nil {
t.Fatalf("GetFresh: %v", err)
}
if access != "fresh-at" {
t.Fatalf("access = %q, want fresh-at (refreshed)", access)
}
if fp.tokenHits.Load() != 1 {
t.Fatalf("token endpoint hit %d times, want 1 refresh", fp.tokenHits.Load())
}
// The refreshed token is persisted.
stored, _, _ := m.store.Load(ProviderKey("demo"))
if stored.AccessToken != "fresh-at" {
t.Fatalf("stored token not updated: %+v", stored)
}
}
func TestManagerGetFreshSkipsValidToken(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"should-not-be-used"}`)
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
}
m := managerFor(t, env, nil)
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
access, err := m.GetFresh(context.Background(), ProviderKey("demo"))
if err != nil {
t.Fatalf("GetFresh: %v", err)
}
if access != "valid" || fp.tokenHits.Load() != 0 {
t.Fatalf("a valid token must not be refreshed (access=%q hits=%d)", access, fp.tokenHits.Load())
}
}
func TestManagerHandle401ForcesRefresh(t *testing.T) {
fp := newFakeProvider(t, `{"access_token":"after-401"}`)
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "client",
"ZERO_OAUTH_DEMO_TOKEN_URL": fp.server.URL + "/token",
}
m := managerFor(t, env, nil)
// Token is still valid by clock, but Handle401 forces a refresh anyway.
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "valid", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)})
access, err := m.Handle401(context.Background(), ProviderKey("demo"))
if err != nil {
t.Fatalf("Handle401: %v", err)
}
if access != "after-401" || fp.tokenHits.Load() != 1 {
t.Fatalf("Handle401 must force a refresh (access=%q hits=%d)", access, fp.tokenHits.Load())
}
}
func TestManagerLogout(t *testing.T) {
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
_ = m.store.Save(ProviderKey("demo"), Token{AccessToken: "a"})
removed, err := m.Logout("demo")
if err != nil || !removed {
t.Fatalf("Logout = %v %v", removed, err)
}
if removed2, _ := m.Logout("demo"); removed2 {
t.Fatal("second logout should report nothing removed")
}
}
+95
View File
@@ -0,0 +1,95 @@
// Package oauth is zero's reusable OAuth 2.0 engine. It generalizes the
// authorization-code + PKCE flow that internal/mcp already uses for MCP servers
// into a transport/identity-agnostic core, and adds the pieces provider login
// needs: a device-authorization (RFC 8628) grant, a provider registry, a
// namespaced + refreshing token store, and a proactive refresh scheduler.
//
// Security invariants (enforced throughout, never relaxed):
// - PKCE S256 is mandatory on every authorization-code flow; "plain" is refused.
// - A per-flow CSRF state is generated and verified on the callback.
// - The loopback callback server binds 127.0.0.1 only, on an OS-assigned port,
// serves a single request, then closes.
// - The token endpoint must be https (loopback exempt) before any credential is
// sent — see validateTokenEndpoint.
// - Tokens, codes, and verifiers are never logged; error bodies are redacted.
// - Token files are 0600, base-confined, and file-locked.
//
// This package holds NO vendor secrets: provider client IDs and any overridable
// endpoints come from config/env, so no third-party OAuth client identity is
// baked into the binary.
package oauth
import (
"errors"
"strings"
"time"
)
// Token holds the credentials issued by an authorization server. The token
// fields are sensitive: callers must never log them, and the store persists them
// 0600. It mirrors the on-disk shape internal/mcp uses so the two stay
// format-compatible.
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
// Account is an optional non-secret identifier (email / account id) shown in
// status output; never a credential.
Account string `json:"account,omitempty"`
}
// Expired reports whether the token has an expiry that is at or before now.
// A zero ExpiresAt means "no known expiry" and is treated as not expired.
func (t Token) Expired(now time.Time) bool {
return !t.ExpiresAt.IsZero() && !t.ExpiresAt.After(now)
}
// NeedsRefresh reports whether the token is expired or falls within buffer of
// expiry (so a proactive/on-demand refresh should run). A token with no expiry
// never needs a refresh on a timer.
func (t Token) NeedsRefresh(now time.Time, buffer time.Duration) bool {
if t.ExpiresAt.IsZero() {
return false
}
return !t.ExpiresAt.After(now.Add(buffer))
}
// Config describes how to talk to one authorization server for a flow.
// Endpoints may be discovered (RFC 8414) and overridden here.
type Config struct {
ClientID string
ClientSecret string
Scopes []string
AuthorizationEndpoint string
TokenEndpoint string
DeviceAuthorizationEndpoint string
RegistrationEndpoint string
// IssuerURL is the base for metadata discovery when endpoints are not set.
IssuerURL string
// ExtraAuthParams are appended to the authorization URL (e.g. login_hint).
ExtraAuthParams map[string]string
}
// Errors returned by the engine. Callers can match these with errors.Is.
var (
// ErrPKCEDowngrade is returned if a flow is asked to use anything but S256.
ErrPKCEDowngrade = errors.New("oauth: PKCE S256 is mandatory; plain is refused")
// ErrStateMismatch is returned when the callback state does not match (CSRF).
ErrStateMismatch = errors.New("oauth: callback state mismatch; possible CSRF, login aborted")
// ErrInsecureTokenEndpoint is returned when a credential would be sent over a
// non-https, non-loopback endpoint.
ErrInsecureTokenEndpoint = errors.New("oauth: refusing to send credential to a non-https token endpoint")
// ErrNoRefreshToken is returned when a refresh is attempted without one.
ErrNoRefreshToken = errors.New("oauth: no refresh token available")
// ErrAuthorizationPending is the RFC 8628 "keep polling" signal.
ErrAuthorizationPending = errors.New("oauth: authorization pending")
// ErrSlowDown is the RFC 8628 "increase the poll interval" signal.
ErrSlowDown = errors.New("oauth: slow down")
)
// trimmed is a tiny helper used across the package.
func trimmed(s string) string { return strings.TrimSpace(s) }
+49
View File
@@ -0,0 +1,49 @@
package oauth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
const (
// pkceVerifierBytes yields a 43-character base64url verifier — the minimum
// length the PKCE spec permits while remaining high-entropy.
pkceVerifierBytes = 32
stateBytes = 32
// MethodS256 is the only code-challenge method this engine accepts.
MethodS256 = "S256"
)
// PKCE is a verifier/challenge pair for an authorization-code flow. The method
// is always S256 (the engine refuses "plain").
type PKCE struct {
Verifier string
Challenge string
Method string
}
// NewPKCE generates a high-entropy code verifier and its S256 challenge. It
// matches internal/mcp's newPKCE byte-for-byte (RawURLEncoding of 32 random
// bytes; challenge = base64url(SHA-256(verifier))) so the two engines are
// interchangeable.
func NewPKCE() (PKCE, error) {
raw := make([]byte, pkceVerifierBytes)
if _, err := rand.Read(raw); err != nil {
return PKCE{}, fmt.Errorf("oauth: generate PKCE verifier: %w", err)
}
verifier := base64.RawURLEncoding.EncodeToString(raw)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
return PKCE{Verifier: verifier, Challenge: challenge, Method: MethodS256}, nil
}
// NewState generates a high-entropy CSRF state value.
func NewState() (string, error) {
raw := make([]byte, stateBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("oauth: generate state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
+100
View File
@@ -0,0 +1,100 @@
package oauth
import (
"fmt"
"regexp"
"strings"
)
// Flow selects how a provider delivers the authorization result.
type Flow string
const (
// FlowLoopback uses a 127.0.0.1 callback server (browser required).
FlowLoopback Flow = "loopback"
// FlowDevice uses the RFC 8628 device-code flow (headless/SSH).
FlowDevice Flow = "device"
)
// providerNamePattern bounds a provider name to a safe identifier that is also a
// valid store-key segment.
var providerNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// ValidateProviderName reports whether name is a safe provider identifier.
func ValidateProviderName(name string) error {
if !providerNamePattern.MatchString(name) {
return fmt.Errorf("oauth: invalid provider name %q", name)
}
return nil
}
// Registry resolves a provider's Config from env/config. It ships NO built-in
// providers, endpoints, or client identities: every provider is defined entirely
// by the operator via ZERO_OAUTH_<NAME>_* variables, so no third-party brand or
// OAuth client identity is baked into the binary.
type Registry struct{}
// NewRegistry returns the (stateless) env-driven registry.
func NewRegistry() *Registry { return &Registry{} }
// envKey builds the ZERO_OAUTH_<NAME>_<suffix> variable name for a provider.
func envKey(name, suffix string) string {
up := strings.ToUpper(name)
up = strings.NewReplacer("-", "_", ".", "_").Replace(up)
return "ZERO_OAUTH_" + up + "_" + suffix
}
// ResolveConfig builds the oauth.Config and default Flow for a provider from its
// env/config:
//
// ZERO_OAUTH_<NAME>_CLIENT_ID (required)
// ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
// ZERO_OAUTH_<NAME>_AUTHORIZE_URL (loopback flow; or discovered via issuer)
// ZERO_OAUTH_<NAME>_TOKEN_URL (or discovered via issuer)
// ZERO_OAUTH_<NAME>_DEVICE_URL (device flow; or discovered via issuer)
// ZERO_OAUTH_<NAME>_ISSUER_URL (RFC 8414 / OIDC discovery base)
// ZERO_OAUTH_<NAME>_SCOPES (space-separated)
// ZERO_OAUTH_<NAME>_FLOW ("loopback" [default] | "device")
//
// Pinned credential-bearing endpoints must be https (loopback exempt).
func (r *Registry) ResolveConfig(name string, env map[string]string) (Config, Flow, error) {
if err := ValidateProviderName(name); err != nil {
return Config{}, "", err
}
cfg := Config{
ClientID: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_ID"))),
ClientSecret: strings.TrimSpace(envValue(env, envKey(name, "CLIENT_SECRET"))),
AuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "AUTHORIZE_URL"))),
TokenEndpoint: strings.TrimSpace(envValue(env, envKey(name, "TOKEN_URL"))),
DeviceAuthorizationEndpoint: strings.TrimSpace(envValue(env, envKey(name, "DEVICE_URL"))),
IssuerURL: strings.TrimSpace(envValue(env, envKey(name, "ISSUER_URL"))),
Scopes: strings.Fields(envValue(env, envKey(name, "SCOPES"))),
}
if cfg.ClientID == "" {
return Config{}, "", fmt.Errorf("oauth: provider %q is not configured; set %s (and its endpoints or an issuer)", name, envKey(name, "CLIENT_ID"))
}
var flow Flow
switch strings.ToLower(strings.TrimSpace(envValue(env, envKey(name, "FLOW")))) {
case "", string(FlowLoopback):
flow = FlowLoopback
case string(FlowDevice):
flow = FlowDevice
default:
return Config{}, "", fmt.Errorf("oauth: provider %q has invalid %s (want loopback or device)", name, envKey(name, "FLOW"))
}
// A token endpoint (for exchange/refresh) must be reachable directly or via
// discovery. The per-flow authorize/device endpoints are checked at login
// time (after discovery), so a refresh-only config needs only a token URL.
if cfg.IssuerURL == "" && cfg.TokenEndpoint == "" {
return Config{}, "", fmt.Errorf("oauth: provider %q needs %s or %s", name, envKey(name, "TOKEN_URL"), envKey(name, "ISSUER_URL"))
}
for _, ep := range []string{cfg.TokenEndpoint, cfg.AuthorizationEndpoint, cfg.DeviceAuthorizationEndpoint, cfg.IssuerURL} {
if ep == "" {
continue
}
if err := validateTokenEndpoint(ep); err != nil {
return Config{}, "", err
}
}
return cfg, flow, nil
}
+97
View File
@@ -0,0 +1,97 @@
package oauth
import (
"errors"
"testing"
)
func TestResolveConfigFromEnv(t *testing.T) {
r := NewRegistry()
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "my-client",
"ZERO_OAUTH_DEMO_CLIENT_SECRET": "shh",
"ZERO_OAUTH_DEMO_SCOPES": "read write",
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
}
cfg, flow, err := r.ResolveConfig("demo", env)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if cfg.ClientID != "my-client" || cfg.ClientSecret != "shh" {
t.Fatalf("client creds not applied: %+v", cfg)
}
if len(cfg.Scopes) != 2 || cfg.Scopes[0] != "read" {
t.Fatalf("scopes = %v", cfg.Scopes)
}
if cfg.TokenEndpoint != "https://auth.example.com/token" {
t.Fatalf("token endpoint = %q", cfg.TokenEndpoint)
}
if flow != FlowLoopback {
t.Fatalf("flow = %q, want loopback default", flow)
}
}
func TestResolveConfigDeviceFlow(t *testing.T) {
r := NewRegistry()
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
"ZERO_OAUTH_DEMO_TOKEN_URL": "https://auth.example.com/token",
"ZERO_OAUTH_DEMO_DEVICE_URL": "https://auth.example.com/device",
"ZERO_OAUTH_DEMO_FLOW": "device",
}
_, flow, err := r.ResolveConfig("demo", env)
if err != nil {
t.Fatalf("ResolveConfig: %v", err)
}
if flow != FlowDevice {
t.Fatalf("flow = %q, want device", flow)
}
}
func TestResolveConfigRequiresClientID(t *testing.T) {
r := NewRegistry()
if _, _, err := r.ResolveConfig("demo", map[string]string{}); err == nil {
t.Fatal("missing client id must error")
}
}
func TestResolveConfigRequiresEndpointsOrIssuer(t *testing.T) {
r := NewRegistry()
// client id but no token endpoint and no issuer => error.
_, _, err := r.ResolveConfig("demo", map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"})
if err == nil {
t.Fatal("missing endpoints/issuer must error")
}
}
func TestResolveConfigRejectsInsecureEndpoint(t *testing.T) {
r := NewRegistry()
env := map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
"ZERO_OAUTH_DEMO_AUTHORIZE_URL": "https://auth.example.com/authorize",
"ZERO_OAUTH_DEMO_TOKEN_URL": "http://insecure.example/token", // non-https, non-loopback
}
_, _, err := r.ResolveConfig("demo", env)
if !errors.Is(err, ErrInsecureTokenEndpoint) {
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
}
}
func TestResolveConfigInvalidName(t *testing.T) {
r := NewRegistry()
for _, bad := range []string{"", "has space", "../escape", "a/b"} {
if _, _, err := r.ResolveConfig(bad, nil); err == nil {
t.Errorf("ResolveConfig(%q) should reject invalid name", bad)
}
}
}
func TestEnvKey(t *testing.T) {
if got := envKey("my-svc", "CLIENT_ID"); got != "ZERO_OAUTH_MY_SVC_CLIENT_ID" {
t.Fatalf("envKey = %q", got)
}
if got := envKey("two.part", "SCOPES"); got != "ZERO_OAUTH_TWO_PART_SCOPES" {
t.Fatalf("envKey = %q", got)
}
}
+125
View File
@@ -0,0 +1,125 @@
package oauth
import (
"context"
"sync"
"time"
)
// maxSchedulerLoadErrors bounds consecutive token-load failures before the
// scheduler gives up, so a transient error retries but a permanent one (e.g. a
// deleted token) does not loop forever.
const maxSchedulerLoadErrors = 5
// RefreshScheduler proactively refreshes a stored provider token shortly before
// it expires. It is an OPTIMIZATION over the on-demand GetFresh path, never the
// source of truth: a failed scheduled refresh is non-fatal and simply retried at
// the next expiry. It is a no-op for a token with no refresh token or no expiry.
type RefreshScheduler struct {
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
started bool
}
// NewRefreshScheduler returns an idle scheduler.
func NewRefreshScheduler() *RefreshScheduler {
return &RefreshScheduler{}
}
// Start begins proactively refreshing key via the manager until the context is
// canceled or Stop is called. It returns immediately; refresh happens in a
// goroutine. Calling Start twice is a no-op after the first.
func (s *RefreshScheduler) Start(ctx context.Context, m *Manager, key string) {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
runCtx, cancel := context.WithCancel(ctx)
s.cancel = cancel
s.done = make(chan struct{})
s.started = true
done := s.done
s.mu.Unlock()
go s.loop(runCtx, m, key, done)
}
func (s *RefreshScheduler) loop(ctx context.Context, m *Manager, key string, done chan struct{}) {
defer close(done)
loadErrors := 0
for {
token, _, err := m.loadForKey(key)
if err != nil {
// A load error may be transient (a concurrent store write, a brief I/O
// hiccup): back off and retry rather than permanently stopping proactive
// refresh. Give up only after repeated consecutive failures.
loadErrors++
if loadErrors >= maxSchedulerLoadErrors {
return
}
select {
case <-ctx.Done():
return
case <-time.After(m.buffer):
}
continue
}
loadErrors = 0
// No refresh token or no expiry => nothing to schedule on a timer.
if token.RefreshToken == "" || token.ExpiresAt.IsZero() {
return
}
delay := s.delayUntilRefresh(token.ExpiresAt, m.buffer, m.now())
select {
case <-ctx.Done():
return
case <-time.After(delay):
}
// Best-effort refresh; failure is non-fatal — reload and reschedule (the
// on-demand GetFresh remains the safety net for callers).
if _, err := m.GetFresh(ctx, key); err != nil {
// Wait a bounded backoff before retrying so a persistently failing
// refresh does not hot-loop.
select {
case <-ctx.Done():
return
case <-time.After(m.buffer):
}
}
}
}
// delayUntilRefresh computes how long to wait before refreshing: aim for buffer
// before expiry, clamped to >= 0, plus a small deterministic jitter so many
// sessions do not refresh in lockstep.
func (s *RefreshScheduler) delayUntilRefresh(expiresAt time.Time, buffer time.Duration, now time.Time) time.Duration {
target := expiresAt.Add(-buffer)
delay := target.Sub(now)
if delay < 0 {
delay = 0
}
// Jitter up to ~10% of the buffer, derived from the expiry nanos (no RNG dep).
if buffer > 0 {
jitter := time.Duration(expiresAt.UnixNano()%int64(buffer/10+1)) % (buffer/10 + 1)
delay += jitter
}
return delay
}
// Stop cancels the scheduler and waits for its goroutine to exit. Safe to call
// more than once and on a never-started scheduler.
func (s *RefreshScheduler) Stop() {
s.mu.Lock()
cancel := s.cancel
done := s.done
s.cancel = nil
s.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
}
+72
View File
@@ -0,0 +1,72 @@
package oauth
import (
"context"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestRefreshSchedulerNoRefreshTokenIsNoop(t *testing.T) {
m := managerFor(t, map[string]string{"ZERO_OAUTH_DEMO_CLIENT_ID": "c"}, nil)
// Token without a refresh token => scheduler must exit promptly (no-op).
if err := m.store.Save(ProviderKey("demo"), Token{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
t.Fatalf("seed Save: %v", err)
}
s := NewRefreshScheduler()
s.Start(context.Background(), m, ProviderKey("demo"))
// Stop waits for the goroutine; if it didn't exit on its own this still returns
// because Stop cancels — but the loop should already be done.
s.Stop()
}
func TestRefreshSchedulerRefreshesBeforeExpiry(t *testing.T) {
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_, _ = io.WriteString(w, `{"access_token":"refreshed","expires_in":3600}`)
}))
defer server.Close()
store, err := NewStore(StoreOptions{FilePath: filepath.Join(t.TempDir(), "tok.json")})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
m, err := NewManager(ManagerOptions{
Store: store,
Env: map[string]string{
"ZERO_OAUTH_DEMO_CLIENT_ID": "c",
"ZERO_OAUTH_DEMO_TOKEN_URL": server.URL,
},
RefreshBuffer: 10 * time.Millisecond,
})
if err != nil {
t.Fatalf("NewManager: %v", err)
}
// Expiry just past the buffer so the scheduled delay is ~0 and a refresh fires
// almost immediately.
if err := store.Save(ProviderKey("demo"), Token{AccessToken: "old", RefreshToken: "rt", ExpiresAt: time.Now().Add(15 * time.Millisecond)}); err != nil {
t.Fatalf("seed Save: %v", err)
}
s := NewRefreshScheduler()
s.Start(context.Background(), m, ProviderKey("demo"))
defer s.Stop()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
stored, _, err := store.Load(ProviderKey("demo"))
if err != nil {
t.Fatalf("Load: %v", err)
}
if stored.AccessToken == "refreshed" {
return // success
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("scheduler did not refresh before expiry (token endpoint hits=%d)", hits.Load())
}
+324
View File
@@ -0,0 +1,324 @@
package oauth
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
const (
storeSchemaVersion = 1
// KeyPrefixProvider namespaces provider-login tokens; MCP server tokens live
// under KeyPrefixMCP in the same format (so a future MCP migration is a key
// rename, not a format change).
KeyPrefixProvider = "provider:"
KeyPrefixMCP = "mcp:"
)
// keyPattern bounds a token key to a safe, single-segment namespaced identifier
// so a key can never traverse or collide with store internals.
var keyPattern = regexp.MustCompile(`^(provider|mcp):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
// ValidateKey reports whether key is a well-formed namespaced token key.
func ValidateKey(key string) error {
if !keyPattern.MatchString(key) {
return fmt.Errorf("oauth: invalid token key %q (want \"provider:<name>\" or \"mcp:<name>\")", key)
}
return nil
}
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// Status is a redaction-safe summary of a stored token (no secret material).
type Status struct {
Key string `json:"key"`
HasToken bool `json:"hasToken"`
HasRefreshToken bool `json:"hasRefreshToken"`
TokenType string `json:"tokenType,omitempty"`
Account string `json:"account,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
Expired bool `json:"expired"`
}
// StoreOptions configures where provider OAuth tokens are persisted.
type StoreOptions struct {
FilePath string
Env map[string]string
Now func() time.Time
}
// Store persists OAuth tokens (provider + MCP namespaces) in a 0600 JSON file,
// guarded by a cross-process lock and written atomically.
type Store struct {
filePath string
now func() time.Time
mu sync.Mutex
}
type storeFile struct {
SchemaVersion int `json:"schemaVersion"`
Tokens map[string]Token `json:"tokens"`
}
// ResolveStorePath determines the on-disk location for provider OAuth tokens,
// honoring ZERO_OAUTH_TOKENS_PATH, then XDG_CONFIG_HOME, then the home dir.
func ResolveStorePath(env map[string]string) (string, error) {
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_TOKENS_PATH")); override != "" {
if filepath.IsAbs(override) {
return filepath.Clean(override), nil
}
return filepath.Abs(override)
}
configHome := strings.TrimSpace(envValue(env, "XDG_CONFIG_HOME"))
if configHome == "" {
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
if home == "" {
var err error
home, err = os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("oauth: resolve user home: %w", err)
}
}
configHome = filepath.Join(home, ".config")
} else if !filepath.IsAbs(configHome) {
resolved, err := filepath.Abs(configHome)
if err != nil {
return "", err
}
configHome = resolved
}
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
}
// NewStore builds a file-backed token store.
func NewStore(options StoreOptions) (*Store, error) {
filePath := options.FilePath
var err error
if strings.TrimSpace(filePath) == "" {
filePath, err = ResolveStorePath(options.Env)
if err != nil {
return nil, err
}
}
if !filepath.IsAbs(filePath) {
filePath, err = filepath.Abs(filePath)
if err != nil {
return nil, err
}
}
now := options.Now
if now == nil {
now = time.Now
}
return &Store{filePath: filepath.Clean(filePath), now: now}, nil
}
// Save persists a token under key, replacing any existing entry.
func (s *Store) Save(key string, token Token) error {
if err := ValidateKey(key); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
if err != nil {
return err
}
defer unlock()
state, err := s.readState()
if err != nil {
return err
}
state.Tokens[key] = token
return s.writeState(state)
}
// Load returns the token for key; the bool is false when none is stored.
func (s *Store) Load(key string) (Token, bool, error) {
if err := ValidateKey(key); err != nil {
return Token{}, false, err
}
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.readState()
if err != nil {
return Token{}, false, err
}
token, ok := state.Tokens[key]
return token, ok, nil
}
// Delete removes the token for key, reporting whether one was present.
func (s *Store) Delete(key string) (bool, error) {
if err := ValidateKey(key); err != nil {
return false, err
}
s.mu.Lock()
defer s.mu.Unlock()
unlock, err := acquireFileLock(s.filePath+".lockfile", s.now)
if err != nil {
return false, err
}
defer unlock()
state, err := s.readState()
if err != nil {
return false, err
}
if _, ok := state.Tokens[key]; !ok {
return false, nil
}
delete(state.Tokens, key)
if err := s.writeState(state); err != nil {
return false, err
}
return true, nil
}
// Status returns redaction-safe summaries of every stored token, sorted by key.
// An optional prefix filters to one namespace (e.g. KeyPrefixProvider).
func (s *Store) Status(prefix string) ([]Status, error) {
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.readState()
if err != nil {
return nil, err
}
keys := make([]string, 0, len(state.Tokens))
for k := range state.Tokens {
if prefix == "" || strings.HasPrefix(k, prefix) {
keys = append(keys, k)
}
}
sort.Strings(keys)
now := s.now()
out := make([]Status, 0, len(keys))
for _, k := range keys {
token := state.Tokens[k]
out = append(out, Status{
Key: k,
HasToken: strings.TrimSpace(token.AccessToken) != "",
HasRefreshToken: strings.TrimSpace(token.RefreshToken) != "",
TokenType: token.TokenType,
Account: token.Account,
Scopes: token.Scopes,
ExpiresAt: token.ExpiresAt,
Expired: token.Expired(now),
})
}
return out, nil
}
func (s *Store) readState() (storeFile, error) {
data, err := os.ReadFile(s.filePath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return emptyStoreFile(), nil
}
return storeFile{}, err
}
var state storeFile
if err := json.Unmarshal(data, &state); err != nil {
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
}
if state.SchemaVersion != storeSchemaVersion {
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: unsupported schemaVersion", s.filePath)
}
if state.Tokens == nil {
state.Tokens = map[string]Token{}
}
for key := range state.Tokens {
if err := ValidateKey(key); err != nil {
return storeFile{}, fmt.Errorf("oauth: invalid token file at %s: %w", s.filePath, err)
}
}
return state, nil
}
func (s *Store) writeState(state storeFile) error {
if err := os.MkdirAll(filepath.Dir(s.filePath), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
tempPath := fmt.Sprintf("%s.tmp-%d-%d", s.filePath, os.Getpid(), s.now().UnixNano())
if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil {
return err
}
if err := os.Rename(tempPath, s.filePath); err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func emptyStoreFile() storeFile {
return storeFile{SchemaVersion: storeSchemaVersion, Tokens: map[string]Token{}}
}
// FormatStatuses renders a human-readable status table without leaking token
// material.
func FormatStatuses(statuses []Status) string {
if len(statuses) == 0 {
return "No OAuth provider logins are stored."
}
var b strings.Builder
for i, st := range statuses {
if i > 0 {
b.WriteByte('\n')
}
name := strings.TrimPrefix(st.Key, KeyPrefixProvider)
b.WriteString(name)
b.WriteString(": ")
if !st.HasToken {
b.WriteString("no token")
continue
}
b.WriteString("logged in")
if st.Account != "" {
b.WriteString(" as " + st.Account)
}
if st.HasRefreshToken {
b.WriteString(" (refreshable)")
}
if !st.ExpiresAt.IsZero() {
if st.Expired {
b.WriteString(", expired at ")
} else {
b.WriteString(", expires ")
}
b.WriteString(st.ExpiresAt.UTC().Format(time.RFC3339))
}
}
return b.String()
}
// envValue reads a variable. A non-nil env map is authoritative (hermetic): a
// missing key returns "" rather than falling back to the process environment, so
// a caller/test that passes a controlled map can never pick up ambient
// ZERO_OAUTH_* / HOME / XDG_CONFIG_HOME values. Only a nil map uses os.Getenv.
func envValue(env map[string]string, key string) string {
if env != nil {
return env[key]
}
return os.Getenv(key)
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+128
View File
@@ -0,0 +1,128 @@
package oauth
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
func newTestStore(t *testing.T) (*Store, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
s, err := NewStore(StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return s, path
}
func TestStoreSaveLoadDelete(t *testing.T) {
s, _ := newTestStore(t)
tok := Token{AccessToken: "at", RefreshToken: "rt", Account: "me@x"}
if err := s.Save(ProviderKey("demo"), tok); err != nil {
t.Fatalf("Save: %v", err)
}
if err := s.Save(KeyPrefixMCP+"server1", Token{AccessToken: "mcp-at"}); err != nil {
t.Fatalf("Save mcp: %v", err)
}
got, ok, err := s.Load(ProviderKey("demo"))
if err != nil || !ok || got.AccessToken != "at" || got.Account != "me@x" {
t.Fatalf("Load = %+v ok=%v err=%v", got, ok, err)
}
removed, err := s.Delete(ProviderKey("demo"))
if err != nil || !removed {
t.Fatalf("Delete = %v %v", removed, err)
}
if _, ok, _ := s.Load(ProviderKey("demo")); ok {
t.Fatal("token should be gone after delete")
}
// The mcp-namespaced token is untouched.
if _, ok, _ := s.Load(KeyPrefixMCP + "server1"); !ok {
t.Fatal("mcp token should survive provider delete")
}
}
func TestStoreRejectsInvalidKeys(t *testing.T) {
s, _ := newTestStore(t)
for _, bad := range []string{"demo", "provider:", "provider:../escape", "mcp:bad/key", "other:x", ""} {
if err := s.Save(bad, Token{AccessToken: "x"}); err == nil {
t.Errorf("Save(%q) should be rejected", bad)
}
}
for _, ok := range []string{"provider:demo", "mcp:server-1", "provider:two-svc"} {
if err := ValidateKey(ok); err != nil {
t.Errorf("ValidateKey(%q) = %v, want nil", ok, err)
}
}
}
func TestStoreStatusFiltersByPrefix(t *testing.T) {
s, _ := newTestStore(t)
_ = s.Save(ProviderKey("demo"), Token{AccessToken: "a", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour)})
_ = s.Save(KeyPrefixMCP+"srv", Token{AccessToken: "m"})
statuses, err := s.Status(KeyPrefixProvider)
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 1 || statuses[0].Key != ProviderKey("demo") {
t.Fatalf("provider status = %+v", statuses)
}
if !statuses[0].HasRefreshToken {
t.Fatal("status should report a refresh token")
}
}
func TestStoreFileMode0600(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix file modes")
}
s, path := newTestStore(t)
if err := s.Save(ProviderKey("x"), Token{AccessToken: "a"}); err != nil {
t.Fatalf("Save: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := info.Mode().Perm(); perm != 0o600 {
t.Fatalf("token file mode = %o, want 600", perm)
}
}
func TestStoreMalformedFailsClosed(t *testing.T) {
s, path := newTestStore(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatalf("corrupt: %v", err)
}
if _, _, err := s.Load(ProviderKey("x")); err == nil {
t.Fatal("malformed store must fail closed")
}
}
func TestEnvValueHermetic(t *testing.T) {
t.Setenv("ZERO_OAUTH_DEMO_CLIENT_ID", "ambient")
// A non-nil map omitting the key must NOT leak the ambient process value.
if got := envValue(map[string]string{}, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "" {
t.Fatalf("non-nil env map must be hermetic, got %q", got)
}
// A nil map reads the process environment.
if got := envValue(nil, "ZERO_OAUTH_DEMO_CLIENT_ID"); got != "ambient" {
t.Fatalf("nil env map should read os env, got %q", got)
}
}
func TestResolveStorePathHonorsOverride(t *testing.T) {
// Use an OS-appropriate absolute path: a unix-style "/tmp/..." literal is not
// absolute on Windows (no drive), so it would be resolved against the current
// drive and a verbatim comparison would fail there.
override := filepath.Join(t.TempDir(), "custom", "tok.json")
path, err := ResolveStorePath(map[string]string{"ZERO_OAUTH_TOKENS_PATH": override})
if err != nil {
t.Fatalf("ResolveStorePath: %v", err)
}
if path != override {
t.Fatalf("path = %q, want %q", path, override)
}
}
+35
View File
@@ -40,6 +40,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
})
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return anthropic.New(anthropic.Options{
@@ -72,6 +73,40 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
}
}
func parseThinkTagsForProfile(profile config.ProviderProfile, resolved resolvedProfile) bool {
if profile.ParseThinkTags != nil {
return *profile.ParseThinkTags
}
if resolved.providerKind != config.ProviderKindOpenAICompatible {
return false
}
return modelMayEmitThinkTags(resolved.apiModel)
}
func modelMayEmitThinkTags(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
for _, marker := range []string{
"deepseek-r1",
"deepseek-reasoner",
"gpt-oss",
"glm-z1",
"kimi-k2-thinking",
"magistral",
"minimax-m3",
"nemotron",
"qwen3",
"qwq",
"reasoner",
"reasoning",
"thinking",
} {
if strings.Contains(model, marker) {
return true
}
}
return false
}
type resolvedProfile struct {
providerKind config.ProviderKind
apiModel string
+27
View File
@@ -108,6 +108,33 @@ func TestNewSupportsOpenAIProviderKind(t *testing.T) {
}
}
func TestParseThinkTagsForProfileUsesConservativeDefaultsAndOverride(t *testing.T) {
openAICompatible := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "qwen3-coder:480b"}
if !parseThinkTagsForProfile(config.ProviderProfile{}, openAICompatible) {
t.Fatal("qwen3 OpenAI-compatible model should parse inline think tags by default")
}
generic := resolvedProfile{providerKind: config.ProviderKindOpenAICompatible, apiModel: "factory-model"}
if parseThinkTagsForProfile(config.ProviderProfile{}, generic) {
t.Fatal("generic OpenAI-compatible model should preserve literal think tags by default")
}
official := resolvedProfile{providerKind: config.ProviderKindOpenAI, apiModel: "gpt-4.1"}
if parseThinkTagsForProfile(config.ProviderProfile{}, official) {
t.Fatal("official OpenAI model should preserve literal think tags by default")
}
enabled := true
if !parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &enabled}, generic) {
t.Fatal("explicit parseThinkTags=true should enable inline think parsing")
}
disabled := false
if parseThinkTagsForProfile(config.ProviderProfile{ParseThinkTags: &disabled}, openAICompatible) {
t.Fatal("explicit parseThinkTags=false should disable inline think parsing")
}
}
func TestNewResolvesKnownModelToAPIModelAndProvider(t *testing.T) {
transport := &captureTransport{
responseBody: "data: {\"type\":\"message_stop\"}\n\n",
+24 -8
View File
@@ -40,6 +40,9 @@ type Options struct {
// StreamIdleTimeout aborts the stream if no data arrives for this long.
// Zero uses defaultStreamIdleTimeout.
StreamIdleTimeout time.Duration
// ParseThinkTags converts streamed <think>...</think> content into reasoning
// events for OpenAI-compatible models known to emit that legacy format.
ParseThinkTags bool
}
// Provider streams completions from an OpenAI-compatible chat completions API.
@@ -55,6 +58,7 @@ type Provider struct {
httpClient *http.Client
userAgent string
streamIdleTimeout time.Duration
parseThinkTags bool
}
// New creates an OpenAI-compatible provider.
@@ -100,6 +104,7 @@ func New(options Options) (*Provider, error) {
httpClient: httpClient,
userAgent: options.UserAgent,
streamIdleTimeout: idleTimeout,
parseThinkTags: options.ParseThinkTags,
}, nil
}
@@ -162,7 +167,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
return
}
state := newToolState()
state := newToolState(provider.parseThinkTags)
// Use the shared SSE reader (also used by the Anthropic/Gemini providers) so
// multi-line "data:" continuation fields are joined into one payload, and the
// idle watchdog / context cancellation are handled uniformly.
@@ -170,7 +175,8 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
state.closeOpen(ctx, events)
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
@@ -178,16 +184,19 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
return
}
if err != nil {
state.closeOpen(ctx, events)
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
}
if ctxErr := ctx.Err(); ctxErr != nil {
state.closeOpen(ctx, events)
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + ctxErr.Error())})
return
}
if !state.done {
state.flushContent(ctx, events)
state.closeOpen(ctx, events)
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone, FinishReason: state.finishReason})
}
@@ -199,6 +208,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
func (provider *Provider) emitPayload(ctx context.Context, data string, state *toolState, events chan<- zeroruntime.StreamEvent) bool {
var chunk streamChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
state.flushContent(ctx, events)
state.closeOpen(ctx, events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
@@ -208,6 +218,7 @@ func (provider *Provider) emitPayload(ctx context.Context, data string, state *t
return false
}
if chunk.Error != nil {
state.flushContent(ctx, events)
state.closeOpen(ctx, events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
@@ -234,15 +245,13 @@ func (provider *Provider) emitChunk(
})
}
if choice.Delta.Content != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventText,
Content: choice.Delta.Content,
})
state.emitContent(ctx, events, choice.Delta.Content)
}
for _, toolCall := range choice.Delta.ToolCalls {
state.applyDelta(ctx, toolCall, events)
}
if choice.FinishReason == "tool_calls" {
state.flushContent(ctx, events)
state.closeOpen(ctx, events)
}
if reason := mapFinishReason(choice.FinishReason); reason != "" {
@@ -314,6 +323,13 @@ func sendEvent(ctx context.Context, events chan<- zeroruntime.StreamEvent, event
}
}
func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.StreamEvent) {
select {
case events <- event:
default:
}
}
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
messages := make([]chatMessage, 0, len(request.Messages))
for _, message := range request.Messages {
+114 -3
View File
@@ -244,6 +244,53 @@ func TestStreamCompletionEmitsReasoningBeforeRegularContent(t *testing.T) {
}
}
func TestStreamCompletionPreservesLiteralThinkTagsByDefault(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"show <think>literal</think> markup"}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
assertEvent(t, events[0], zeroruntime.StreamEventText, "show <think>literal</think> markup")
if reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning); len(reasoning) != 0 {
t.Fatalf("literal think tags must not emit reasoning by default, got %#v", reasoning)
}
}
func TestStreamCompletionSplitsInlineThinkTagsFromContent(t *testing.T) {
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"<think>private reasoning</think>public answer"}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
assertEvent(t, events[0], zeroruntime.StreamEventReasoning, "private reasoning")
assertEvent(t, events[1], zeroruntime.StreamEventText, "public answer")
if events[2].Type != zeroruntime.StreamEventDone {
t.Fatalf("third event = %#v, want done", events[2])
}
}
func TestStreamCompletionSplitsInlineThinkTagsAcrossChunks(t *testing.T) {
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"<thi"}}]}`)
writeSSE(w, `{"choices":[{"delta":{"content":"nk>reason"}}]}`)
writeSSE(w, `{"choices":[{"delta":{"content":"ing</"}}]}`)
writeSSE(w, `{"choices":[{"delta":{"content":"think> answer"}}]}`)
writeSSE(w, `[DONE]`)
})
events := collectProviderEvents(t, provider)
reasoning := eventsOfType(events, zeroruntime.StreamEventReasoning)
if len(reasoning) != 2 || reasoning[0].Content != "reason" || reasoning[1].Content != "ing" {
t.Fatalf("reasoning events = %#v, want split reasoning content", reasoning)
}
text := eventsOfType(events, zeroruntime.StreamEventText)
if len(text) != 1 || text[0].Content != " answer" {
t.Fatalf("text events = %#v, want answer-only content", text)
}
}
func TestStreamCompletionBuffersToolArgsUntilIDAndNameArrive(t *testing.T) {
provider := newTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\":"}}]}}]}`)
@@ -385,19 +432,83 @@ func TestStreamCompletionEmitsErrorWhenContextCancels(t *testing.T) {
}
}
func TestStreamCompletionFlushesBufferedContentWhenContextCancels(t *testing.T) {
release := make(chan struct{})
provider := newTestProviderWithThinkTags(t, func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"choices":[{"delta":{"content":"visible <thi"}}]}`)
select {
case <-r.Context().Done():
case <-release:
}
})
defer close(release)
ctx, cancel := context.WithCancel(context.Background())
stream, err := provider.StreamCompletion(ctx, zeroruntime.CompletionRequest{})
if err != nil {
t.Fatalf("StreamCompletion returned setup error: %v", err)
}
events := []zeroruntime.StreamEvent{}
select {
case event, ok := <-stream:
if !ok {
t.Fatal("stream closed before first text event")
}
events = append(events, event)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for first text event")
}
cancel()
events = append(events, readAll(stream)...)
if len(events) != 3 ||
events[0].Type != zeroruntime.StreamEventText || events[0].Content != "visible " ||
events[1].Type != zeroruntime.StreamEventText || events[1].Content != "<thi" ||
events[2].Type != zeroruntime.StreamEventError {
t.Fatalf("events = %#v, want text, buffered text, then context error", events)
}
if done := eventsOfType(events, zeroruntime.StreamEventDone); len(done) != 0 {
t.Fatalf("events = %#v, want no done after context cancel", events)
}
}
func newTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithKey(t, "", handler)
}
func newTestProviderWithKey(t *testing.T, apiKey string, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithOptions(t, Options{APIKey: apiKey}, handler)
}
func newTestProviderWithThinkTags(t *testing.T, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithOptions(t, Options{ParseThinkTags: true}, handler)
}
func newTestProviderWithOptions(t *testing.T, options Options, handler http.HandlerFunc) *Provider {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
options.BaseURL = server.URL
if strings.TrimSpace(options.Model) == "" {
options.Model = "gpt-test"
}
provider, err := New(Options{
APIKey: apiKey,
BaseURL: server.URL,
Model: "gpt-test",
APIKey: options.APIKey,
BaseURL: options.BaseURL,
Model: options.Model,
AuthHeader: options.AuthHeader,
AuthScheme: options.AuthScheme,
AuthHeaderValue: options.AuthHeaderValue,
CustomHeaders: options.CustomHeaders,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
MaxTokens: options.MaxTokens,
StreamIdleTimeout: options.StreamIdleTimeout,
ParseThinkTags: options.ParseThinkTags,
})
if err != nil {
t.Fatalf("New returned error: %v", err)
+147 -7
View File
@@ -3,12 +3,20 @@ package openai
import (
"context"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const (
thinkOpenTag = "<think>"
thinkCloseTag = "</think>"
)
type toolState struct {
calls map[int]*pendingToolCall
calls map[int]*pendingToolCall
think thinkTagSplitter
parseThinkTags bool
// finishReason holds the normalized terminal stop reason (zeroruntime
// FinishReason*) when the response ended abnormally (length/content_filter),
// so the provider can attach it to the done event. Empty for a normal finish.
@@ -26,8 +34,128 @@ type pendingToolCall struct {
ended bool
}
func newToolState() *toolState {
return &toolState{calls: make(map[int]*pendingToolCall)}
func newToolState(parseThinkTags bool) *toolState {
return &toolState{calls: make(map[int]*pendingToolCall), parseThinkTags: parseThinkTags}
}
type streamEventEmitter func(zeroruntime.StreamEvent)
type thinkTagSplitter struct {
pending string
inThinking bool
}
func (state *toolState) emitContent(ctx context.Context, events chan<- zeroruntime.StreamEvent, content string) {
state.emitContentWith(content, func(event zeroruntime.StreamEvent) {
sendEvent(ctx, events, event)
})
}
func (state *toolState) flushContent(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
state.flushContentWith(func(event zeroruntime.StreamEvent) {
sendEvent(ctx, events, event)
})
}
func (state *toolState) flushBufferedContent(events chan<- zeroruntime.StreamEvent) {
state.flushContentWith(func(event zeroruntime.StreamEvent) {
sendBufferedEvent(events, event)
})
}
func (state *toolState) emitContentWith(content string, emit streamEventEmitter) {
if !state.parseThinkTags {
emit(zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: content})
return
}
state.think.push(content, func(eventType zeroruntime.StreamEventType, text string) {
emit(zeroruntime.StreamEvent{Type: eventType, Content: text})
})
}
func (state *toolState) flushContentWith(emit streamEventEmitter) {
if !state.parseThinkTags {
return
}
state.think.flush(func(eventType zeroruntime.StreamEventType, text string) {
emit(zeroruntime.StreamEvent{Type: eventType, Content: text})
})
}
func (splitter *thinkTagSplitter) push(content string, emit func(zeroruntime.StreamEventType, string)) {
if content == "" {
return
}
splitter.pending += content
splitter.drain(false, emit)
}
func (splitter *thinkTagSplitter) flush(emit func(zeroruntime.StreamEventType, string)) {
splitter.drain(true, emit)
}
func (splitter *thinkTagSplitter) drain(final bool, emit func(zeroruntime.StreamEventType, string)) {
for {
tag := thinkOpenTag
if splitter.inThinking {
tag = thinkCloseTag
}
if index := indexFold(splitter.pending, tag); index >= 0 {
splitter.emitCurrent(emit, splitter.pending[:index])
splitter.pending = splitter.pending[index+len(tag):]
splitter.inThinking = !splitter.inThinking
continue
}
if final {
splitter.emitCurrent(emit, splitter.pending)
splitter.pending = ""
return
}
keep := trailingTagPrefixLen(splitter.pending, tag)
if keep == len(splitter.pending) {
return
}
emitText := splitter.pending[:len(splitter.pending)-keep]
splitter.emitCurrent(emit, emitText)
splitter.pending = splitter.pending[len(splitter.pending)-keep:]
return
}
}
func (splitter *thinkTagSplitter) emitCurrent(emit func(zeroruntime.StreamEventType, string), text string) {
if text == "" {
return
}
eventType := zeroruntime.StreamEventText
if splitter.inThinking {
eventType = zeroruntime.StreamEventReasoning
}
emit(eventType, text)
}
func trailingTagPrefixLen(text string, tag string) int {
maxLen := min(len(text), len(tag)-1)
for length := maxLen; length > 0; length-- {
if strings.EqualFold(text[len(text)-length:], tag[:length]) {
return length
}
}
return 0
}
func indexFold(text string, needle string) int {
if needle == "" {
return 0
}
if len(text) < len(needle) {
return -1
}
for index := 0; index <= len(text)-len(needle); index++ {
if strings.EqualFold(text[index:index+len(needle)], needle) {
return index
}
}
return -1
}
func (state *toolState) applyDelta(
@@ -78,6 +206,18 @@ func (state *toolState) applyDelta(
}
func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime.StreamEvent) {
state.closeOpenWith(func(event zeroruntime.StreamEvent) {
sendEvent(ctx, events, event)
})
}
func (state *toolState) closeBufferedOpen(events chan<- zeroruntime.StreamEvent) {
state.closeOpenWith(func(event zeroruntime.StreamEvent) {
sendBufferedEvent(events, event)
})
}
func (state *toolState) closeOpenWith(emit streamEventEmitter) {
indexes := make([]int, 0, len(state.calls))
for index := range state.calls {
indexes = append(indexes, index)
@@ -95,20 +235,20 @@ func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime
if call.id == "" || call.name == "" {
if call.id != "" || call.name != "" || call.arguments != "" {
call.ended = true
sendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDropped})
emit(zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDropped})
}
continue
}
if !call.started {
call.started = true
sendEvent(ctx, events, zeroruntime.StreamEvent{
emit(zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallStart,
ToolCallID: call.id,
ToolName: call.name,
})
}
if call.arguments != "" {
sendEvent(ctx, events, zeroruntime.StreamEvent{
emit(zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallDelta,
ToolCallID: call.id,
ArgumentsFragment: call.arguments,
@@ -116,7 +256,7 @@ func (state *toolState) closeOpen(ctx context.Context, events chan<- zeroruntime
call.arguments = ""
}
call.ended = true
sendEvent(ctx, events, zeroruntime.StreamEvent{
emit(zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventToolCallEnd,
ToolCallID: call.id,
})
+42 -5
View File
@@ -23,15 +23,26 @@ type Backend struct {
// under a seatbelt profile that restricts outbound to the proxy port);
// bubblewrap isolates the network namespace with no bridge to the host proxy,
// so scoped egress there collapses to deny until a real relay exists.
ScopedEgress bool `json:"scopedEgress,omitempty"`
Executable string `json:"executable,omitempty"`
Message string `json:"message,omitempty"`
ScopedEgress bool `json:"scopedEgress,omitempty"`
// ProxyEgress reports a backend that routes scoped egress through the local
// filtering proxy on a BEST-EFFORT basis (the child must honor the proxy env)
// rather than via OS-level network isolation. The WSL policy-only fallback uses
// this: there is no netns/seatbelt to enforce egress, but the proxy still
// applies the allow/deny gate to well-behaved clients.
ProxyEgress bool `json:"proxyEgress,omitempty"`
Executable string `json:"executable,omitempty"`
Message string `json:"message,omitempty"`
}
// EnforcesScopedEgress reports whether a populated NetworkScoped allowlist can be
// enforced by this backend. When false, scoped egress must fail closed (collapse
// to deny) rather than silently run with unrestricted networking.
// routed through the filtering proxy by this backend. When false, scoped egress
// must fail closed (collapse to deny) rather than silently run with unrestricted
// networking. A native backend needs an executable; the WSL fallback uses
// best-effort proxy egress (ProxyEgress) with no native isolation.
func (backend Backend) EnforcesScopedEgress() bool {
if backend.ProxyEgress {
return true
}
return backend.Available && backend.Executable != "" && backend.ScopedEgress
}
@@ -65,6 +76,12 @@ func SelectBackend(options BackendOptions) Backend {
if path, err := lookup("bwrap"); err == nil && path != "" {
return nativeBackend(goos, BackendBubblewrap, path, "bubblewrap sandbox available")
}
// Under WSL (no bubblewrap), do NOT silently degrade to a plain policy-only
// runner: select the WSL backend, which still routes egress through the
// filtering proxy and is gated on AllowPolicyOnlyRunner in the runner.
if info := detectWSL(); info.IsWSL {
return wslBackend(goos, info)
}
return policyOnlyBackend(goos, "policy-only fallback: bubblewrap is not installed")
case "darwin":
if path, err := lookup("sandbox-exec"); err == nil && path != "" {
@@ -94,6 +111,26 @@ func nativeBackend(goos string, name BackendName, executable string, message str
}
}
// wslBackend is the policy-only WSL fallback: no native isolation, but scoped
// egress is routed best-effort through the filtering proxy (ProxyEgress). The
// runner gates it on Policy.AllowPolicyOnlyRunner and records a downgrade note.
func wslBackend(goos string, info WSLInfo) Backend {
msg := "policy-only WSL fallback: bubblewrap unavailable under WSL; egress routed through the filtering proxy"
if info.IsWSL2 {
msg = "policy-only WSL2 fallback: bubblewrap unavailable/unreliable under WSL2; egress routed through the filtering proxy"
}
return Backend{
Name: BackendWSL,
Available: false,
Platform: goos,
Fallback: true,
CommandWrapping: false,
NativeIsolation: false,
ProxyEgress: true,
Message: msg,
}
}
func policyOnlyBackend(goos string, message string) Backend {
return Backend{
Name: BackendPolicyOnly,
-33
View File
@@ -1,33 +0,0 @@
package sandbox
// RequiredAutonomyForBatch returns the highest autonomy tier required to run a
// batch of operations without further prompting, given each operation's
// classified risk. RiskCritical and RiskHigh both map to AutonomyHigh because
// the autonomy ladder has three tiers and the risk ladder has four. Any risk
// level that is empty or unrecognised fails closed to AutonomyHigh so an
// unclassified operation can never silently lower the batch ceiling. An empty
// batch requires only AutonomyLow.
func RequiredAutonomyForBatch(risks []Risk) Autonomy {
required := AutonomyLow
for _, risk := range risks {
tier := riskToAutonomy(risk.Level)
if autonomyRank[tier] > autonomyRank[required] {
required = tier
}
}
return required
}
func riskToAutonomy(level RiskLevel) Autonomy {
switch level {
case RiskLow:
return AutonomyLow
case RiskMedium:
return AutonomyMedium
case RiskHigh, RiskCritical:
return AutonomyHigh
default:
// Unknown / empty risk is treated as the highest tier (fail-closed).
return AutonomyHigh
}
}
-26
View File
@@ -1,26 +0,0 @@
package sandbox
import "testing"
func TestRequiredAutonomyForBatch(t *testing.T) {
cases := []struct {
name string
risks []Risk
want Autonomy
}{
{"empty slice is low", nil, AutonomyLow},
{"single low", []Risk{{Level: RiskLow}}, AutonomyLow},
{"mixed low and medium is medium", []Risk{{Level: RiskLow}, {Level: RiskMedium}}, AutonomyMedium},
{"any high is high", []Risk{{Level: RiskLow}, {Level: RiskHigh}}, AutonomyHigh},
{"any critical is high", []Risk{{Level: RiskMedium}, {Level: RiskCritical}}, AutonomyHigh},
{"unknown risk fails closed to high", []Risk{{Level: RiskLevel("weird")}}, AutonomyHigh},
{"empty-level risk fails closed to high", []Risk{{Level: RiskLevel("")}}, AutonomyHigh},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := RequiredAutonomyForBatch(tc.risks); got != tc.want {
t.Fatalf("RequiredAutonomyForBatch(%v) = %q, want %q", tc.risks, got, tc.want)
}
})
}
}
+94 -4
View File
@@ -2,6 +2,8 @@ package sandbox
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
@@ -32,9 +34,21 @@ import (
type egressProxy struct {
listener net.Listener
server *http.Server
allowed []string
denied []string
log func(string)
// socksListener serves the SOCKS5 CONNECT front-end on a SEPARATE loopback
// port. It runs the same authorize()/domainAllowed() gate as the HTTP proxy, so
// a client configured with ALL_PROXY=socks5://<socksAddr> is filtered
// identically. Bound to 127.0.0.1:0 like the HTTP listener.
socksListener net.Listener
allowed []string
denied []string
log func(string)
// mitm, when non-nil (Policy.InspectTLS), makes handleConnect TERMINATE TLS
// using a locally generated CA so the decrypted Host can be re-checked against
// the allow/deny gate. mitmTransport dials upstreams with full validation
// (system roots unless upstreamRoots is set for tests) — never InsecureSkipVerify.
mitm *mitmCA
mitmTransport *http.Transport
// domainPrompt, when set, is asked to authorize an UNKNOWN host (one neither
// allowed nor explicitly denied) instead of failing closed. It must return
@@ -71,6 +85,14 @@ type egressOptions struct {
// fail-closed behavior.
DomainPrompt func(ctx context.Context, host string, port int) (bool, error)
PromptTimeout time.Duration
// InspectTLS enables TLS termination (MITM) so the proxy can enforce the
// allow/deny gate on the decrypted request Host. Off => pure CONNECT
// passthrough (the default, byte-for-byte unchanged).
InspectTLS bool
// upstreamRoots overrides the root CAs used to validate UPSTREAM TLS. nil =
// system roots (production). Tests set it to an httptest server's pool so
// validation is exercised WITHOUT InsecureSkipVerify.
upstreamRoots *x509.CertPool
}
// errEmptyAllowlist is returned when a scoped-egress proxy is requested with no
@@ -89,9 +111,18 @@ func newEgressProxy(options egressOptions) (*egressProxy, error) {
if err != nil {
return nil, fmt.Errorf("bind scoped-egress proxy: %w", err)
}
// Bind the SOCKS5 front-end on its own loopback port. Fail closed: if it
// cannot bind, tear down the HTTP listener and return an error so the caller
// treats scoped egress as a full deny rather than starting a partial proxy.
socksListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
_ = listener.Close()
return nil, fmt.Errorf("bind scoped-egress socks proxy: %w", err)
}
proxy := &egressProxy{
listener: listener,
socksListener: socksListener,
allowed: allowed,
denied: denied,
log: options.Log,
@@ -100,6 +131,31 @@ func newEgressProxy(options egressOptions) (*egressProxy, error) {
decisionCache: map[string]bool{},
inflight: map[string]chan struct{}{},
}
if options.InspectTLS {
ca, err := newMITMCA()
if err != nil {
_ = listener.Close()
_ = socksListener.Close()
return nil, err
}
proxy.mitm = ca
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
proxy.mitmTransport = &http.Transport{
// Bound the TCP connect phase so a blackholed upstream cannot park a
// handler+goroutine for the OS default (minutes) — mirrors the
// net.DialTimeout the passthrough CONNECT path uses.
DialContext: dialer.DialContext,
// Upstream TLS is validated normally; upstreamRoots is nil in production
// (system roots) and set only by tests. NEVER InsecureSkipVerify.
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: options.upstreamRoots},
// Keep upstream on HTTP/1.1: the decrypted client side is HTTP/1.1, so an
// HTTP/2 upstream response would serialize an invalid status line back to
// the client. (handleConnectMITM also normalizes the response proto.)
ForceAttemptHTTP2: false,
TLSHandshakeTimeout: 30 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
}
}
proxy.server = &http.Server{
Handler: http.HandlerFunc(proxy.handle),
ReadHeaderTimeout: 30 * time.Second,
@@ -108,10 +164,11 @@ func newEgressProxy(options egressOptions) (*egressProxy, error) {
// Serve returns ErrServerClosed on Close; nothing else to do.
_ = proxy.server.Serve(listener)
}()
go proxy.serveSocks(socksListener)
return proxy, nil
}
// Addr returns the loopback host:port the proxy is listening on.
// Addr returns the loopback host:port the HTTP proxy is listening on.
func (proxy *egressProxy) Addr() string {
if proxy == nil || proxy.listener == nil {
return ""
@@ -119,6 +176,25 @@ func (proxy *egressProxy) Addr() string {
return proxy.listener.Addr().String()
}
// SocksAddr returns the loopback host:port the SOCKS5 front-end is listening on,
// or "" when no SOCKS listener is active.
func (proxy *egressProxy) SocksAddr() string {
if proxy == nil || proxy.socksListener == nil {
return ""
}
return proxy.socksListener.Addr().String()
}
// CACertPEM returns the MITM CA's public certificate (PEM) when TLS inspection
// is active, or nil for a plain passthrough proxy. The runner writes this to a
// file and surfaces it via ZERO_SANDBOX_CA_CERT.
func (proxy *egressProxy) CACertPEM() []byte {
if proxy == nil || proxy.mitm == nil {
return nil
}
return proxy.mitm.caPEM()
}
// Close stops the proxy and releases its listener. It is safe to call multiple
// times.
func (proxy *egressProxy) Close() error {
@@ -130,12 +206,26 @@ func (proxy *egressProxy) Close() error {
if proxy.server != nil {
err = proxy.server.Close()
}
// Closing the SOCKS listener unblocks serveSocks's Accept loop so the
// goroutine returns. Report its error only if the HTTP close succeeded.
if proxy.socksListener != nil {
if cerr := proxy.socksListener.Close(); cerr != nil && err == nil {
err = cerr
}
}
if proxy.mitmTransport != nil {
proxy.mitmTransport.CloseIdleConnections()
}
})
return err
}
func (proxy *egressProxy) handle(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodConnect {
if proxy.mitm != nil {
proxy.handleConnectMITM(w, r)
return
}
proxy.handleConnect(w, r)
return
}
+156
View File
@@ -0,0 +1,156 @@
package sandbox
import (
"encoding/binary"
"io"
"net"
"strconv"
"time"
)
// SOCKS5 wire constants (RFC 1928). The proxy speaks only the no-auth CONNECT
// subset: enough for a sandboxed process configured with ALL_PROXY=socks5://...
// to tunnel TCP through the same allow/deny gate the HTTP proxy enforces.
const (
socksVersion5 = 0x05
socksNoAuth = 0x00
socksNoAcceptable = 0xFF
socksCmdConnect = 0x01
socksAtypIPv4 = 0x01
socksAtypDomain = 0x03
socksAtypIPv6 = 0x04
socksRepSuccess = 0x00
socksRepGeneralFailure = 0x01
socksRepNotAllowed = 0x02
socksRepCmdUnsupported = 0x07
)
// socksHandshakeTimeout bounds the greeting+request exchange so a stalled or
// malformed client cannot pin a goroutine; it is cleared before the relay so an
// established tunnel is not torn down.
const socksHandshakeTimeout = 30 * time.Second
// serveSocks accepts SOCKS5 connections until the listener is closed.
func (proxy *egressProxy) serveSocks(listener net.Listener) {
for {
conn, err := listener.Accept()
if err != nil {
return // listener closed
}
go proxy.handleSocksConn(conn)
}
}
// handleSocksConn runs one SOCKS5 CONNECT through the SAME authorize() gate as
// the HTTP proxy: an allowed (host, port) is dialed and relayed; a denied target
// gets a SOCKS "connection not allowed" reply and is never dialed. Fail-closed:
// any protocol/read error just closes the connection.
func (proxy *egressProxy) handleSocksConn(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(socksHandshakeTimeout))
host, port, ok := socksNegotiate(conn)
if !ok {
return
}
targetLabel := net.JoinHostPort(host, strconv.Itoa(port))
if !proxy.authorize(host, port) {
proxy.logDecision("deny", "SOCKS", targetLabel)
_ = socksReply(conn, socksRepNotAllowed)
return
}
upstream, err := net.DialTimeout("tcp", targetLabel, 30*time.Second)
if err != nil {
proxy.logDecision("deny", "SOCKS", targetLabel)
_ = socksReply(conn, socksRepGeneralFailure)
return
}
defer upstream.Close()
if err := socksReply(conn, socksRepSuccess); err != nil {
return
}
// Clear the handshake deadline; the tunnel is long-lived.
_ = conn.SetDeadline(time.Time{})
proxy.logDecision("allow", "SOCKS", targetLabel)
relay(conn, upstream)
}
// socksNegotiate performs the no-auth greeting and a CONNECT request, returning
// the requested host and port. ok=false means negotiation failed (an error reply
// has already been written where the protocol calls for one) and the caller
// should close the connection.
func socksNegotiate(conn net.Conn) (host string, port int, ok bool) {
greeting := make([]byte, 2) // VER, NMETHODS
if _, err := io.ReadFull(conn, greeting); err != nil || greeting[0] != socksVersion5 {
return "", 0, false
}
methods := make([]byte, int(greeting[1]))
if _, err := io.ReadFull(conn, methods); err != nil {
return "", 0, false
}
// RFC 1928: select a method the client actually offered. We only support
// no-auth; if the client did not offer it, reply "no acceptable methods"
// (0xFF) and abort rather than forcing a method it never advertised.
supportsNoAuth := false
for _, method := range methods {
if method == socksNoAuth {
supportsNoAuth = true
break
}
}
if !supportsNoAuth {
_, _ = conn.Write([]byte{socksVersion5, socksNoAcceptable})
return "", 0, false
}
if _, err := conn.Write([]byte{socksVersion5, socksNoAuth}); err != nil {
return "", 0, false
}
header := make([]byte, 4) // VER, CMD, RSV, ATYP
if _, err := io.ReadFull(conn, header); err != nil || header[0] != socksVersion5 {
return "", 0, false
}
if header[1] != socksCmdConnect {
_ = socksReply(conn, socksRepCmdUnsupported)
return "", 0, false
}
switch header[3] {
case socksAtypIPv4:
addr := make([]byte, net.IPv4len)
if _, err := io.ReadFull(conn, addr); err != nil {
return "", 0, false
}
host = net.IP(addr).String()
case socksAtypIPv6:
addr := make([]byte, net.IPv6len)
if _, err := io.ReadFull(conn, addr); err != nil {
return "", 0, false
}
host = net.IP(addr).String()
case socksAtypDomain:
length := make([]byte, 1)
if _, err := io.ReadFull(conn, length); err != nil {
return "", 0, false
}
name := make([]byte, int(length[0]))
if _, err := io.ReadFull(conn, name); err != nil {
return "", 0, false
}
host = string(name)
default:
_ = socksReply(conn, socksRepGeneralFailure)
return "", 0, false
}
portBytes := make([]byte, 2)
if _, err := io.ReadFull(conn, portBytes); err != nil {
return "", 0, false
}
return host, int(binary.BigEndian.Uint16(portBytes)), true
}
// socksReply writes a minimal SOCKS5 reply with the given status and a zero IPv4
// bound address (clients ignore BND.ADDR/BND.PORT for CONNECT).
func socksReply(conn net.Conn, status byte) error {
_, err := conn.Write([]byte{socksVersion5, status, 0x00, socksAtypIPv4, 0, 0, 0, 0, 0, 0})
return err
}
+187
View File
@@ -0,0 +1,187 @@
package sandbox
import (
"io"
"net"
"strconv"
"testing"
"time"
)
// socks5Connect performs a SOCKS5 no-auth greeting and a CONNECT request for the
// given ATYP/address/port through proxyAddr, returning the open connection and
// the server's REP byte. The caller closes the connection.
func socks5Connect(t *testing.T, proxyAddr string, atyp byte, addr []byte, port int) (net.Conn, byte) {
t.Helper()
conn, err := net.DialTimeout("tcp", proxyAddr, 2*time.Second)
if err != nil {
t.Fatalf("dial socks proxy: %v", err)
}
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
// Greeting: VER=5, NMETHODS=1, METHOD=0 (no auth).
if _, err := conn.Write([]byte{socksVersion5, 0x01, socksNoAuth}); err != nil {
t.Fatalf("write greeting: %v", err)
}
method := make([]byte, 2)
if _, err := io.ReadFull(conn, method); err != nil {
t.Fatalf("read method selection: %v", err)
}
if method[0] != socksVersion5 || method[1] != socksNoAuth {
t.Fatalf("method selection = %v, want [5 0]", method)
}
// Request: VER=5, CMD=CONNECT, RSV=0, ATYP, ADDR, PORT(2, big-endian).
request := append([]byte{socksVersion5, socksCmdConnect, 0x00, atyp}, addr...)
request = append(request, byte(port>>8), byte(port&0xff))
if _, err := conn.Write(request); err != nil {
t.Fatalf("write request: %v", err)
}
reply := make([]byte, 10) // VER, REP, RSV, ATYP(IPv4), BND.ADDR(4), BND.PORT(2)
if _, err := io.ReadFull(conn, reply); err != nil {
t.Fatalf("read reply: %v", err)
}
return conn, reply[1]
}
func TestEgressProxySocksAddrBindsLoopback(t *testing.T) {
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"github.com"}})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
if proxy.SocksAddr() == "" {
t.Fatal("SocksAddr must be non-empty while the proxy is running")
}
if proxy.SocksAddr() == proxy.Addr() {
t.Fatalf("SOCKS and HTTP listeners must be distinct ports, both %q", proxy.Addr())
}
host, _, err := net.SplitHostPort(proxy.SocksAddr())
if err != nil {
t.Fatalf("SplitHostPort(%q): %v", proxy.SocksAddr(), err)
}
if host != "127.0.0.1" {
t.Fatalf("SOCKS bound to %q, want loopback 127.0.0.1", host)
}
}
// TestEgressProxySocksRejectsUnsupportedAuth verifies the proxy honors SOCKS5
// method negotiation: a client offering only methods the proxy does not support
// (here user/pass 0x02, no no-auth) gets the "no acceptable methods" reply (0xFF)
// rather than the proxy forcing a method the client never advertised.
func TestEgressProxySocksRejectsUnsupportedAuth(t *testing.T) {
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"github.com"}})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
conn, err := net.DialTimeout("tcp", proxy.SocksAddr(), 2*time.Second)
if err != nil {
t.Fatalf("dial socks proxy: %v", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(2 * time.Second))
// Greeting: VER=5, NMETHODS=1, METHOD=0x02 (user/pass) — no-auth NOT offered.
if _, err := conn.Write([]byte{socksVersion5, 0x01, 0x02}); err != nil {
t.Fatalf("write greeting: %v", err)
}
resp := make([]byte, 2)
if _, err := io.ReadFull(conn, resp); err != nil {
t.Fatalf("read method selection: %v", err)
}
if resp[0] != socksVersion5 || resp[1] != socksNoAcceptable {
t.Fatalf("method selection = %v, want [5 0xFF] (no acceptable methods)", resp)
}
}
// TestEgressProxySocksConnectAllowed verifies an allowlisted SOCKS5 CONNECT is
// authorized through the SAME gate, dialed, and relayed to a fake upstream.
func TestEgressProxySocksConnectAllowed(t *testing.T) {
upstream, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen upstream: %v", err)
}
defer upstream.Close()
go func() {
c, aerr := upstream.Accept()
if aerr != nil {
return
}
defer c.Close()
buf := make([]byte, 4)
if _, rerr := io.ReadFull(c, buf); rerr != nil {
return
}
_, _ = c.Write([]byte("pong"))
}()
_, portStr, _ := net.SplitHostPort(upstream.Addr().String())
port, _ := strconv.Atoi(portStr)
// Allow the loopback IP so the IPv4 CONNECT is authorized.
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"127.0.0.1"}})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
conn, rep := socks5Connect(t, proxy.SocksAddr(), socksAtypIPv4, net.IPv4(127, 0, 0, 1).To4(), port)
defer conn.Close()
if rep != socksRepSuccess {
t.Fatalf("SOCKS CONNECT to allowed host REP = %#x, want success (0)", rep)
}
if _, err := conn.Write([]byte("ping")); err != nil {
t.Fatalf("write through tunnel: %v", err)
}
got := make([]byte, 4)
if _, err := io.ReadFull(conn, got); err != nil {
t.Fatalf("read through tunnel: %v", err)
}
if string(got) != "pong" {
t.Fatalf("tunneled response = %q, want pong", string(got))
}
}
// TestEgressProxySocksConnectDenied verifies a non-allowlisted SOCKS5 CONNECT
// (domain form) is refused with the "connection not allowed" reply.
func TestEgressProxySocksConnectDenied(t *testing.T) {
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"allowed.example"}})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
domain := "denied.example"
addr := append([]byte{byte(len(domain))}, []byte(domain)...)
conn, rep := socks5Connect(t, proxy.SocksAddr(), socksAtypDomain, addr, 443)
conn.Close()
if rep != socksRepNotAllowed {
t.Fatalf("SOCKS CONNECT to denied host REP = %#x, want not-allowed (2)", rep)
}
}
// TestEgressProxySocksDenyWinsOverAllow verifies the SOCKS gate honors
// deny-wins: a denied subdomain of an allowed domain is refused (REP 2), proving
// the SOCKS path runs the same authorize()/domainAllowed() logic as the HTTP
// proxy rather than a parallel, weaker check.
func TestEgressProxySocksDenyWinsOverAllow(t *testing.T) {
proxy, err := newEgressProxy(egressOptions{
Allowed: []string{"allowed.example"},
Denied: []string{"blocked.allowed.example"},
})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
denied := "blocked.allowed.example"
addr := append([]byte{byte(len(denied))}, []byte(denied)...)
conn, rep := socks5Connect(t, proxy.SocksAddr(), socksAtypDomain, addr, 443)
conn.Close()
if rep != socksRepNotAllowed {
t.Fatalf("denied subdomain REP = %#x, want not-allowed (2) (deny must win)", rep)
}
}
+65 -6
View File
@@ -65,6 +65,35 @@ func (engine *Engine) Scope() *Scope {
return engine.scope
}
// ReadExclusions returns the resolved DenyRead/AllowRead exclusion matcher for
// this engine's policy, resolving each policy entry ONCE. The search tools build
// it a single time per run and reuse it across the whole walk so the predicates
// don't re-run Abs/EvalSymlinks per visited path. Returns nil for a nil engine
// (the matcher's methods treat nil as "exclude nothing").
func (engine *Engine) ReadExclusions() *ReadExclusions {
// A disabled policy enforces nothing, so it must not filter search results
// either (Evaluate already allows every request under ModeDisabled).
if engine == nil || engine.policy.Mode == ModeDisabled {
return nil
}
return &ReadExclusions{
workspaceRoot: engine.workspaceRoot,
denyRoots: resolvePolicyPaths(engine.policy.DenyRead),
allowRoots: resolvePolicyPaths(engine.policy.AllowRead),
}
}
// ReadExclusionGlobs returns the ripgrep-style --glob exclusion args for this
// engine's policy + scope (see the package-level ReadExclusionGlobs). Empty when
// DenyRead is unset or the engine has no scope.
func (engine *Engine) ReadExclusionGlobs() []string {
// A disabled policy filters nothing (parity with ReadExclusions / Evaluate).
if engine == nil || engine.policy.Mode == ModeDisabled {
return nil
}
return ReadExclusionGlobs(engine.policy, engine.scope)
}
// effectiveNetworkMode is the single source of truth for the engine's active
// network mode: it collapses an empty-allowlist scoped policy to deny, and ALSO
// downgrades scoped to deny when the backend cannot actually route scoped egress
@@ -90,6 +119,13 @@ func (engine *Engine) effectiveNetworkMode(policy Policy) NetworkMode {
// their pre-sandbox behaviour); deny blocks everything; scoped allows only hosts
// in AllowedDomains minus DeniedDomains (an empty allowlist, or a backend that
// can't enforce scoped egress, collapses to deny).
//
// By default the first-party, in-process tools that consult this gate are NOT
// subject to the network policy (EnforceToolNetwork is off): that policy exists
// to confine the sandboxed SHELL's egress, which these tools don't use, and they
// retain their own SSRF/port/redirect safeguards. Set EnforceToolNetwork to also
// hold them to the allow/scoped/deny policy. The sandboxed-shell egress decision
// lives in Evaluate via effectiveNetworkMode and is unaffected by this flag.
func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
if engine == nil {
return true, NetworkAllow
@@ -98,6 +134,10 @@ func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
if policy.Mode == ModeDisabled {
return true, NetworkAllow
}
if !policy.EnforceToolNetwork {
// First-party in-process tools are exempt unless the operator opts in.
return true, NetworkAllow
}
switch mode := engine.effectiveNetworkMode(policy); mode {
case NetworkAllow:
return true, mode
@@ -109,6 +149,19 @@ func (engine *Engine) NetworkHostAllowed(host string) (bool, NetworkMode) {
}
}
// toolNetworkExempt reports whether a request is exempt from the engine-level
// network deny because it is a first-party, in-process network TOOL — one that
// declares SideEffectNetwork (web_search / web_fetch) — and the operator has not
// opted into EnforceToolNetwork. Such tools do not use the sandboxed shell's
// egress; they keep their own SSRF/host safeguards, and NetworkHostAllowed (also
// gated by EnforceToolNetwork) governs them at run time. A SHELL command merely
// classified as network (SideEffectShell) is NOT exempt, so shell egress stays
// blocked under deny. Mirrors the NetworkHostAllowed exemption so the Evaluate
// gate and the per-tool gate never diverge.
func (engine *Engine) toolNetworkExempt(policy Policy, request Request) bool {
return !policy.EnforceToolNetwork && request.SideEffect == SideEffectNetwork
}
// scopeFor returns the scope to validate request paths against. The engine's
// shared scope applies only when the request targets the engine's own
// workspace root; a per-request override root gets an ad-hoc single-root scope
@@ -221,11 +274,17 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
if request.Permission == PermissionDeny {
return deny(request, risk, ViolationDeniedPermission, "", permissionReason(request), false)
}
if policy.EnforceWorkspace && request.WorkspaceRoot != "" {
for _, requested := range requestPaths(request) {
if violation := scope.validate(requested); violation != nil {
return deny(request, risk, violation.Code, violation.Path, violation.Reason, false)
}
// The fine-grained path lists (DenyRead/DenyWrite/AllowRead/AllowWrite) apply
// whenever the sandbox is enforcing, independent of EnforceWorkspace and even
// when there is no workspace root (absolute paths are still resolved and
// matched), so they are honored consistently with the grep/glob exclusion path
// and can't be bypassed by an engine built without a workspace root. The
// workspace boundary itself needs a root, so it is gated on having one. Mode is
// already known to be enforcing here (ModeDisabled returned above).
enforceWorkspace := policy.EnforceWorkspace && request.WorkspaceRoot != ""
for _, requested := range requestPaths(request) {
if violation := validatePathWithPolicy(scope, policy, request.SideEffect, enforceWorkspace, request.WorkspaceRoot, requested); violation != nil {
return deny(request, risk, violation.Code, violation.Path, violation.Reason, false)
}
}
// effectiveNetworkMode collapses an empty-allowlist scoped policy to deny, and
@@ -235,7 +294,7 @@ func (engine *Engine) Evaluate(ctx context.Context, request Request) Decision {
// than running with unrestricted networking. Shared with NetworkHostAllowed so
// the per-tool gate can't diverge from this decision. Allow is unchanged.
netMode := engine.effectiveNetworkMode(policy)
if netMode == NetworkDeny && HasRiskCategory(risk, "network") {
if netMode == NetworkDeny && HasRiskCategory(risk, "network") && !engine.toolNetworkExempt(policy, request) {
return deny(request, risk, ViolationNetwork, "", "network access is blocked by sandbox policy", false)
}
if policy.DenyDestructiveShell && HasRiskCategory(risk, "destructive") {
+75 -2
View File
@@ -9,6 +9,53 @@ import (
"time"
)
// TestEvaluateExemptsNetworkToolsFromDenyByDefault reproduces the web_search
// hard-deny: under NetworkDeny, the engine-level network gate must NOT deny a
// first-party network TOOL (SideEffect=network) by default, so it follows the
// normal permission flow instead of being blocked outright. EnforceToolNetwork
// restores the strict deny, and a network SHELL command stays denied either way.
func TestEvaluateExemptsNetworkToolsFromDenyByDefault(t *testing.T) {
base := Policy{Mode: ModeEnforce, Network: NetworkDeny, MaxAutonomy: AutonomyHigh}
// Default (EnforceToolNetwork off): web_search is NOT network-denied.
engine := NewEngine(EngineOptions{Policy: base})
d := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt,
})
if d.Action == ActionDeny {
t.Fatalf("web_search must not be network-denied by default, got deny: %s", d.ErrorString())
}
// Granted permission → it actually runs (ActionAllow), not blocked.
allowed := engine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
})
if allowed.Action != ActionAllow {
t.Fatalf("granted web_search must be allowed under deny by default, got %q (%s)", allowed.Action, allowed.ErrorString())
}
// EnforceToolNetwork on → strict: the network tool is denied at the gate.
strict := base
strict.EnforceToolNetwork = true
strictEngine := NewEngine(EngineOptions{Policy: strict})
ds := strictEngine.Evaluate(context.Background(), Request{
ToolName: "web_search", SideEffect: SideEffectNetwork, Permission: PermissionPrompt, PermissionGranted: true,
})
if ds.Action != ActionDeny || ds.Violation == nil || ds.Violation.Code != ViolationNetwork {
t.Fatalf("with EnforceToolNetwork, web_search must be network-denied, got %q (%+v)", ds.Action, ds.Violation)
}
// A network SHELL command (SideEffect=shell) stays denied under deny — shell
// egress is never exempt, even with EnforceToolNetwork off.
shell := engine.Evaluate(context.Background(), Request{
ToolName: "bash", SideEffect: SideEffectShell, PermissionGranted: true,
Args: map[string]any{"command": "curl https://evil.test"},
})
if shell.Action != ActionDeny || shell.Violation == nil || shell.Violation.Code != ViolationNetwork {
t.Fatalf("a network shell command must stay denied under deny, got %q (%+v)", shell.Action, shell.Violation)
}
}
func TestEngineEvaluatesReadPromptAndPersistentDecisions(t *testing.T) {
root := t.TempDir()
store, err := NewGrantStore(StoreOptions{
@@ -295,7 +342,11 @@ func TestEngineClassifiesNetworkAndDestructiveShellCommands(t *testing.T) {
}
func TestEngineDeniesNetworkSideEffectWhenPolicyBlocksNetwork(t *testing.T) {
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: DefaultPolicy()})
// EnforceToolNetwork opts the in-process network tools into the policy; by
// default they are exempt (see TestEvaluateExemptsNetworkToolsFromDenyByDefault).
policy := DefaultPolicy()
policy.EnforceToolNetwork = true
engine := NewEngine(EngineOptions{WorkspaceRoot: t.TempDir(), Policy: policy})
decision := engine.Evaluate(context.Background(), Request{
ToolName: "web_fetch",
@@ -736,7 +787,11 @@ func TestEngineNetworkHostAllowed(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
engine := NewEngine(EngineOptions{Policy: tc.policy, Backend: tc.backend})
// This table exercises the ENFORCED tool-network gate; the default
// (exempt) posture is covered by TestEngineNetworkHostAllowedToolNetworkDefaultExempt.
policy := tc.policy
policy.EnforceToolNetwork = true
engine := NewEngine(EngineOptions{Policy: policy, Backend: tc.backend})
allowed, mode := engine.NetworkHostAllowed(tc.host)
if allowed != tc.allowed || mode != tc.mode {
t.Fatalf("NetworkHostAllowed(%q) = (%v, %q), want (%v, %q)", tc.host, allowed, mode, tc.allowed, tc.mode)
@@ -749,3 +804,21 @@ func TestEngineNetworkHostAllowed(t *testing.T) {
t.Fatalf("nil engine NetworkHostAllowed = (%v, %q), want (true, allow)", allowed, mode)
}
}
// TestEngineNetworkHostAllowedToolNetworkDefaultExempt verifies the default
// posture: with EnforceToolNetwork off, the in-process tool gate is exempt from
// the network policy, so even a deny / scoped-unlisted host is allowed (the
// sandboxed-shell egress decision is separate and unaffected).
func TestEngineNetworkHostAllowedToolNetworkDefaultExempt(t *testing.T) {
egressBackend := Backend{Name: BackendSandboxExec, Available: true, Executable: "/usr/bin/sandbox-exec", ScopedEgress: true}
policies := []Policy{
{Mode: ModeEnforce, Network: NetworkDeny},
{Mode: ModeEnforce, Network: NetworkScoped, AllowedDomains: []string{"allowed.test"}},
}
for _, policy := range policies {
engine := NewEngine(EngineOptions{Policy: policy, Backend: egressBackend})
if allowed, _ := engine.NetworkHostAllowed("evil.test"); !allowed {
t.Fatalf("default (EnforceToolNetwork off) must allow the tool host under network=%q", policy.Network)
}
}
}
+261
View File
@@ -0,0 +1,261 @@
package sandbox
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
// MITM TLS interception (opt-in via Policy.InspectTLS). Mirrors
// reference-sandbox-wsl-mitm-code-agent-js/sandbox-manager.js mitmCA/getMitmCA
// and network/proxy.js. The proxy terminates TLS toward the SANDBOXED CLIENT
// using an ephemeral, locally generated CA so it can read the decrypted request
// and apply the SAME allow/deny gate on the real Host — visibility, not new
// authority. The UPSTREAM connection is always validated against real roots
// (never InsecureSkipVerify).
// mitmCA is an ephemeral certificate authority that mints per-host leaf certs.
// It lives only for the proxy's lifetime; only its PUBLIC cert is ever exposed
// (so the sandboxed client can trust the minted leaves).
type mitmCA struct {
cert *x509.Certificate
key *ecdsa.PrivateKey
certDER []byte
mu sync.Mutex
leaves map[string]*tls.Certificate
}
// maxMITMLeafCacheEntries caps the per-host leaf cache so an attacker cannot grow
// it without bound. Combined with minting for the AUTHORIZED host (not arbitrary
// client SNI), this keeps the cache size to roughly the allowlist size.
const maxMITMLeafCacheEntries = 512
func randomSerial() (*big.Int, error) {
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("mitm: serial: %w", err)
}
return serial, nil
}
// newMITMCA generates a fresh ECDSA CA. The key never leaves the process.
func newMITMCA() (*mitmCA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("mitm: generate CA key: %w", err)
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "zero sandbox MITM CA", Organization: []string{"zero-sandbox"}},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("mitm: create CA cert: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("mitm: parse CA cert: %w", err)
}
return &mitmCA{cert: cert, key: key, certDER: der, leaves: map[string]*tls.Certificate{}}, nil
}
// caPEM returns the CA's PUBLIC certificate in PEM (safe to write/expose).
func (ca *mitmCA) caPEM() []byte {
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.certDER})
}
// leafFor mints (and caches) a leaf certificate for host, signed by the CA, with
// the CA appended so the chain verifies against the CA alone.
func (ca *mitmCA) leafFor(host string) (*tls.Certificate, error) {
host = hostnameOnly(host)
if host == "" {
return nil, fmt.Errorf("mitm: empty host for leaf cert")
}
ca.mu.Lock()
defer ca.mu.Unlock()
if leaf, ok := ca.leaves[host]; ok {
return leaf, nil
}
// Bound the cache: past the cap, mint without caching so memory stays bounded
// (a small per-request cost rather than unbounded growth).
cacheable := len(ca.leaves) < maxMITMLeafCacheEntries
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(host); ip != nil {
tmpl.IPAddresses = []net.IP{ip}
} else {
tmpl.DNSNames = []string{host}
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &leafKey.PublicKey, ca.key)
if err != nil {
return nil, fmt.Errorf("mitm: mint leaf for %s: %w", host, err)
}
leaf := &tls.Certificate{Certificate: [][]byte{der, ca.certDER}, PrivateKey: leafKey}
if cacheable {
ca.leaves[host] = leaf
}
return leaf, nil
}
// writeMITMCAFile writes the CA's PUBLIC cert PEM to an owner-only temp file and
// returns its path, so the runner can surface it via ZERO_SANDBOX_CA_CERT.
func writeMITMCAFile(pemBytes []byte) (string, error) {
f, err := os.CreateTemp("", "zero-sandbox-ca-*.pem")
if err != nil {
return "", fmt.Errorf("mitm: create CA file: %w", err)
}
if err := f.Chmod(0o600); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", err
}
if _, err := f.Write(pemBytes); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", err
}
if err := f.Close(); err != nil {
_ = os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
// handleConnectMITM terminates TLS toward the client (minting a leaf for the
// requested host), then for each decrypted request re-checks the real Host
// through the SAME authorize gate and forwards it upstream with full system-root
// validation. A denied host gets a 403 over the decrypted channel and is never
// forwarded.
func (proxy *egressProxy) handleConnectMITM(w http.ResponseWriter, r *http.Request) {
target := r.Host
if target == "" {
target = r.URL.Host
}
if !proxy.authorizeTarget(target, 443) {
proxy.logDecision("deny", "CONNECT", target)
http.Error(w, "scoped egress: host not allowed", http.StatusForbidden)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "scoped egress: hijack unsupported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
return
}
defer clientConn.Close()
if _, err := io.WriteString(clientConn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
}
host := hostnameOnly(target)
tlsConn := tls.Server(clientConn, &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
// Mint for the AUTHORIZED CONNECT host, never the arbitrary client SNI:
// honoring SNI would let an attacker churn distinct names to force
// unbounded key generation and cache growth.
return proxy.mitm.leafFor(host)
},
})
if err := tlsConn.Handshake(); err != nil {
return
}
defer tlsConn.Close()
reader := bufio.NewReader(tlsConn)
for {
req, err := http.ReadRequest(reader)
if err != nil {
return // client closed or EOF
}
reqHost := req.Host
if reqHost == "" {
reqHost = host
}
// MITM widens VISIBILITY, never authority: the decrypted Host passes the
// SAME full authorize() gate as the CONNECT check (deny-wins, empty allowlist
// => deny, and an interactive DomainPrompt is honored consistently).
if !proxy.authorizeTarget(reqHost, 443) {
proxy.logDecision("deny", req.Method, reqHost)
writeMITMResponse(tlsConn, http.StatusForbidden, "scoped egress: host not allowed")
return
}
req.URL.Scheme = "https"
req.URL.Host = normalizeConnectTarget(target)
req.RequestURI = ""
resp, err := proxy.mitmTransport.RoundTrip(req)
if err != nil {
proxy.logDecision("deny", req.Method, reqHost)
writeMITMResponse(tlsConn, http.StatusBadGateway, "scoped egress: upstream request failed")
return
}
proxy.logDecision("allow", req.Method, reqHost)
// Re-frame the response as HTTP/1.1 for the decrypted (HTTP/1.1) client
// connection, regardless of how the upstream replied.
resp.Proto, resp.ProtoMajor, resp.ProtoMinor = "HTTP/1.1", 1, 1
writeErr := resp.Write(tlsConn)
_ = resp.Body.Close()
if writeErr != nil || req.Close || resp.Close {
return
}
}
}
// writeMITMResponse writes a minimal plaintext HTTP/1.1 response over the
// decrypted client connection.
func writeMITMResponse(conn net.Conn, code int, msg string) {
body := msg + "\n"
resp := &http.Response{
StatusCode: code,
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{"Content-Type": []string{"text/plain; charset=utf-8"}},
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
}
_ = resp.Write(conn)
}
+158
View File
@@ -0,0 +1,158 @@
package sandbox
import (
"bufio"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestEgressProxyPassthroughNoCAByDefault(t *testing.T) {
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"github.com"}})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
if proxy.mitm != nil {
t.Fatal("InspectTLS off must not create a MITM CA (pure passthrough)")
}
if proxy.CACertPEM() != nil {
t.Fatal("passthrough proxy must expose no CA cert")
}
}
func TestMITMCAMintsChainingLeaf(t *testing.T) {
ca, err := newMITMCA()
if err != nil {
t.Fatalf("newMITMCA: %v", err)
}
leaf, err := ca.leafFor("api.example.com")
if err != nil {
t.Fatalf("leafFor: %v", err)
}
leafCert, err := x509.ParseCertificate(leaf.Certificate[0])
if err != nil {
t.Fatalf("parse leaf: %v", err)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(ca.caPEM()) {
t.Fatal("append CA PEM")
}
if _, err := leafCert.Verify(x509.VerifyOptions{
Roots: roots,
DNSName: "api.example.com",
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
t.Fatalf("minted leaf must chain to the CA: %v", err)
}
// Per-host caching.
if leaf2, _ := ca.leafFor("api.example.com"); leaf2 != leaf {
t.Fatal("leafFor must cache one leaf per host")
}
}
// mitmConnect performs a CONNECT through the proxy then opens a TLS client that
// trusts ONLY the MITM CA, returning the decrypted client connection.
func mitmConnect(t *testing.T, proxyAddr, target string, caPEM []byte, serverName string) *tls.Conn {
t.Helper()
conn, err := net.DialTimeout("tcp", proxyAddr, 2*time.Second)
if err != nil {
t.Fatalf("dial proxy: %v", err)
}
// Bound the whole exchange (CONNECT read + TLS handshake + request/response) so
// a regression cannot hang the test indefinitely.
if err := conn.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
t.Fatalf("set deadline: %v", err)
}
fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target)
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, &http.Request{Method: "CONNECT"})
if err != nil {
t.Fatalf("read CONNECT response: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("CONNECT status = %d, want 200", resp.StatusCode)
}
if br.Buffered() != 0 {
t.Fatalf("proxy over-sent %d bytes after CONNECT 200", br.Buffered())
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
t.Fatal("append MITM CA PEM")
}
tlsConn := tls.Client(conn, &tls.Config{RootCAs: pool, ServerName: serverName})
if err := tlsConn.Handshake(); err != nil {
t.Fatalf("client TLS handshake trusting the MITM CA: %v", err)
}
return tlsConn
}
func TestEgressProxyMITMForwardsAllowlistedHost(t *testing.T) {
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, "upstream-ok")
}))
defer upstream.Close()
host := mustHost(t, upstream.URL)
target := mustHostPort(t, upstream.URL)
// Trust the upstream's real cert explicitly (NOT InsecureSkipVerify): proves
// the MITM still validates the upstream chain.
upstreamRoots := x509.NewCertPool()
upstreamRoots.AddCert(upstream.Certificate())
proxy, err := newEgressProxy(egressOptions{Allowed: []string{host}, InspectTLS: true, upstreamRoots: upstreamRoots})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
if proxy.CACertPEM() == nil {
t.Fatal("InspectTLS must generate a CA")
}
tlsConn := mitmConnect(t, proxy.Addr(), target, proxy.CACertPEM(), host)
defer tlsConn.Close()
fmt.Fprintf(tlsConn, "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", host)
resp, err := http.ReadResponse(bufio.NewReader(tlsConn), &http.Request{Method: http.MethodGet})
if err != nil {
t.Fatalf("read decrypted response: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("allowlisted forwarded request status = %d, want 200", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read decrypted body: %v", err)
}
if !strings.Contains(string(body), "upstream-ok") {
t.Fatalf("MITM did not forward the allowlisted request, got %q", string(body))
}
}
func TestEgressProxyMITMBlocksDeniedDecryptedHost(t *testing.T) {
// CONNECT to an allowed host, but send a Host header that is NOT allowlisted —
// the MITM re-checks the DECRYPTED Host and blocks it before forwarding.
proxy, err := newEgressProxy(egressOptions{Allowed: []string{"127.0.0.1"}, InspectTLS: true})
if err != nil {
t.Fatalf("newEgressProxy: %v", err)
}
defer proxy.Close()
tlsConn := mitmConnect(t, proxy.Addr(), "127.0.0.1:443", proxy.CACertPEM(), "127.0.0.1")
defer tlsConn.Close()
fmt.Fprint(tlsConn, "GET / HTTP/1.1\r\nHost: blocked.example\r\nConnection: close\r\n\r\n")
resp, err := http.ReadResponse(bufio.NewReader(tlsConn), nil)
if err != nil {
t.Fatalf("read decrypted response: %v", err)
}
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("denied decrypted Host status = %d, want 403", resp.StatusCode)
}
}
+374
View File
@@ -0,0 +1,374 @@
package sandbox
import (
"os"
"path/filepath"
"strings"
)
// This file implements the fine-grained AllowRead/DenyRead/AllowWrite/DenyWrite
// path lists (Policy fields). They layer ON TOP of the workspace + Scope guards
// and never bypass them: AllowRead only re-includes inside a DenyRead carve-out,
// AllowWrite is consulted only after the workspace/Scope guard already denied a
// write, and every match is symlink-resolved so a symlink prefix cannot evade a
// deny or sneak past an allow. All lists default empty, so an unconfigured policy
// behaves exactly as before.
// resolvePolicyPath home-expands, makes absolute, and symlink-resolves a single
// policy path entry. ok is false for a blank entry or one that does not exist
// (EvalSymlinks requires existence) so a bogus entry is dropped — a non-existent
// deny protects nothing and a non-existent allow grants nothing.
func resolvePolicyPath(entry string) (string, bool) {
trimmed := strings.TrimSpace(entry)
if trimmed == "" {
return "", false
}
if trimmed == "~" || strings.HasPrefix(trimmed, "~/") || strings.HasPrefix(trimmed, "~"+string(filepath.Separator)) {
home, err := os.UserHomeDir()
if err != nil {
return "", false
}
trimmed = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(trimmed[1:], "/"), string(filepath.Separator)))
}
absolute, err := filepath.Abs(trimmed)
if err != nil {
return "", false
}
resolved, err := filepath.EvalSymlinks(absolute)
if err != nil {
return "", false
}
return resolved, true
}
// resolvePolicyPaths resolves and de-duplicates a list of policy path entries,
// dropping blanks and non-existent entries. Files and directories are both kept
// (a DenyRead/DenyWrite entry may target a single sensitive file).
func resolvePolicyPaths(entries []string) []string {
if len(entries) == 0 {
return nil
}
seen := make(map[string]struct{}, len(entries))
out := make([]string, 0, len(entries))
for _, entry := range entries {
resolved, ok := resolvePolicyPath(entry)
if !ok {
continue
}
if _, dup := seen[resolved]; dup {
continue
}
seen[resolved] = struct{}{}
out = append(out, resolved)
}
return out
}
// resolveWriteRootPaths is resolvePolicyPaths restricted to existing directories
// that are not the filesystem root — the only valid targets for an OS write bind
// and for an AllowWrite grant root.
func resolveWriteRootPaths(entries []string) []string {
resolved := resolvePolicyPaths(entries)
if len(resolved) == 0 {
return nil
}
out := make([]string, 0, len(resolved))
for _, path := range resolved {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
continue
}
if filepath.Dir(path) == path {
continue // refuse the filesystem root as a write root
}
out = append(out, path)
}
return out
}
// pathUnderPolicyRoot reports whether requestedPath lies within root. A relative
// requestedPath is resolved against workspaceRoot; the portion of an absolute
// path outside root is symlink-resolved (via NormalizePrefixForRoot) so a
// symlink prefix cannot evade the match. root must be an already-resolved
// absolute path.
func pathUnderPolicyRoot(requestedPath, root, workspaceRoot string) bool {
if root == "" {
return false
}
abs := requestedPath
if !filepath.IsAbs(abs) {
if workspaceRoot == "" {
return false
}
abs = filepath.Join(workspaceRoot, abs)
}
normalized := NormalizePrefixForRoot(abs, root)
return pathWithinRoot(root, normalized)
}
// readDenied reports whether path is excluded by the DenyRead list with no
// more-specific AllowRead re-inclusion. "More specific" means an AllowRead entry
// nested inside the matched DenyRead entry — that subtree is read back in while
// the rest of the denied tree stays blocked. It resolves the policy entries on
// each call, so it suits a one-shot check (the Evaluate gate). A search walk that
// checks many paths should use ReadExclusions, which resolves the entries once.
func readDenied(policy Policy, workspaceRoot, path string) bool {
return readDeniedResolved(workspaceRoot, resolvePolicyPaths(policy.DenyRead), resolvePolicyPaths(policy.AllowRead), path)
}
// readDeniedResolved is readDenied operating on ALREADY-resolved deny/allow roots,
// so a caller that resolved the policy entries once can reuse them across many
// path checks instead of re-running Abs/EvalSymlinks per path.
func readDeniedResolved(workspaceRoot string, denyRoots, allowRoots []string, path string) bool {
if len(denyRoots) == 0 {
return false
}
for _, deny := range denyRoots {
if !pathUnderPolicyRoot(path, deny, workspaceRoot) {
continue
}
reincluded := false
for _, allow := range allowRoots {
// The allow entry must sit inside the deny entry to be "more specific",
// and the path must fall under that allow entry.
if pathWithinRoot(deny, allow) && pathUnderPolicyRoot(path, allow, workspaceRoot) {
reincluded = true
break
}
}
if !reincluded {
return true
}
}
return false
}
// ReadExclusions holds the resolved DenyRead/AllowRead roots for a policy so a
// search walk resolves each policy entry ONCE (Abs/EvalSymlinks) and reuses the
// result across every visited path, rather than re-resolving per path. Build it
// with Engine.ReadExclusions and reuse it for the whole grep/glob walk.
type ReadExclusions struct {
workspaceRoot string
denyRoots []string
allowRoots []string
}
// Active reports whether any DenyRead root is configured. When false the
// exclusions are a no-op and the search behaves exactly as before.
func (rx *ReadExclusions) Active() bool {
return rx != nil && len(rx.denyRoots) > 0
}
// PathExcluded reports whether reading path is excluded by DenyRead, honoring a
// more-specific AllowRead re-inclusion. It is the per-file predicate for a walk.
func (rx *ReadExclusions) PathExcluded(path string) bool {
if !rx.Active() {
return false
}
return readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path)
}
// DirExcluded reports whether a directory subtree can be skipped wholesale during
// a walk: it is read-denied AND contains no nested AllowRead root (descending is
// required to reach a re-included subtree). When it returns false on a denied dir
// because of a nested allow, PathExcluded still filters the denied siblings.
func (rx *ReadExclusions) DirExcluded(path string) bool {
if !rx.Active() {
return false
}
if !readDeniedResolved(rx.workspaceRoot, rx.denyRoots, rx.allowRoots, path) {
return false
}
abs := path
if !filepath.IsAbs(abs) && rx.workspaceRoot != "" {
abs = filepath.Join(rx.workspaceRoot, path)
}
// allowRoots are symlink-resolved (resolvePolicyPaths), so resolve abs the
// same way before the prefix comparison — otherwise a dir reached THROUGH a
// symlink would fail to match a nested AllowRead root and be wrongly skipped,
// dropping a re-included subtree. Best-effort: keep abs if it can't resolve
// (e.g. a non-existent path), matching the deny check's tolerant behavior.
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
}
return !hasNestedAllowReadResolved(rx.allowRoots, abs)
}
// allowWriteScope builds an ad-hoc Scope from the resolved AllowWrite roots so a
// write to an AllowWrite path is validated with the SAME symlink-traversal logic
// the workspace Scope uses. Returns nil when there are no usable AllowWrite roots.
func allowWriteScope(policy Policy) *Scope {
roots := resolveWriteRootPaths(policy.AllowWrite)
if len(roots) == 0 {
return nil
}
return &Scope{workspaceRoot: roots[0], extraRoots: roots[1:]}
}
// validateWritePath enforces the write precedence: DenyWrite wins, then (when
// workspace enforcement is on) a workspace/Scope-writable path is allowed, then
// an absolute path under an AllowWrite root is allowed, otherwise the base
// workspace/Scope violation stands. The DenyWrite list applies regardless of
// enforceWorkspace; the workspace boundary itself applies only when
// enforceWorkspace. It never bypasses the symlink/out-of-workspace guards.
func validateWritePath(scope *Scope, policy Policy, enforceWorkspace bool, workspaceRoot, path string) *pathViolation {
// DenyWrite wins regardless of workspace enforcement.
for _, deny := range resolvePolicyPaths(policy.DenyWrite) {
if pathUnderPolicyRoot(path, deny, workspaceRoot) {
return &pathViolation{
Code: ViolationPolicyDenied,
Path: path,
Reason: path + " is excluded by the sandbox DenyWrite policy",
}
}
}
if !enforceWorkspace {
// Workspace confinement is off: only the explicit DenyWrite list restricts.
return nil
}
base := scope.validate(path)
if base == nil {
return nil // writable under the workspace / Scope guard
}
// AllowWrite only extends ABSOLUTE paths: a relative path is inherently
// workspace-relative and already resolved by the base guard above.
if filepath.IsAbs(path) {
if allow := allowWriteScope(policy); allow != nil && allow.validate(path) == nil {
return nil
}
}
return base
}
// validatePathWithPolicy is the single entry point the engine uses to validate a
// request path. The fine-grained read/write lists (DenyRead/DenyWrite, with
// AllowRead/AllowWrite) apply whenever the sandbox is enforcing, INDEPENDENT of
// enforceWorkspace, so they match the grep/glob path that also honors DenyRead
// directly. The workspace boundary (scope.validate) applies only when
// enforceWorkspace. Behavior is unchanged when the lists are empty.
func validatePathWithPolicy(scope *Scope, policy Policy, sideEffect SideEffect, enforceWorkspace bool, workspaceRoot, path string) *pathViolation {
// A relative path cannot be anchored without a workspace root, so it cannot be
// checked against the (absolute) path lists or workspace boundary. Fail closed
// when there is anything to enforce; otherwise it is a no-op (unchanged from the
// pre-path-list behavior, where an empty workspace root skipped validation).
if workspaceRoot == "" && !filepath.IsAbs(path) {
if enforceWorkspace || policyHasPathLists(policy) {
return &pathViolation{
Code: ViolationOutsideWorkspace,
Path: path,
Reason: path + " cannot be validated without a workspace root",
}
}
return nil
}
switch sideEffect {
case SideEffectRead:
if readDenied(policy, workspaceRoot, path) {
return &pathViolation{
Code: ViolationPolicyDenied,
Path: path,
Reason: path + " is excluded by the sandbox DenyRead policy",
}
}
if enforceWorkspace {
return scope.validate(path)
}
return nil
case SideEffectWrite, SideEffectOutOfWorkspace:
return validateWritePath(scope, policy, enforceWorkspace, workspaceRoot, path)
default:
if enforceWorkspace {
return scope.validate(path)
}
return nil
}
}
// hasNestedAllowReadResolved reports whether any already-resolved AllowRead root
// sits strictly inside dir (an already-resolved absolute path). When true, a
// read-denied dir must still be descended during a walk so the re-included
// subtree is reachable.
func hasNestedAllowReadResolved(allowRoots []string, dir string) bool {
for _, allow := range allowRoots {
if allow != dir && pathWithinRoot(dir, allow) {
return true
}
}
return false
}
// workspaceRelGlob returns target as a clean, slash-separated path relative to
// workspaceRoot, or ok=false when target is the root itself or lies outside it
// (a workspace-rooted search never reaches such a path, so no glob is needed).
func workspaceRelGlob(workspaceRoot, target string) (string, bool) {
rel, err := filepath.Rel(workspaceRoot, target)
if err != nil {
return "", false
}
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", false
}
return filepath.ToSlash(rel), true
}
// ReadExclusionGlobs returns ripgrep-style --glob exclusion args for the policy's
// DenyRead subtrees that fall inside the scope's workspace root, so a
// ripgrep-based search never descends into a read-denied subtree. For each such
// entry it emits `--glob`, `!<rel>` and `--glob`, `!<rel>/**`. Mirrors the
// read-subtree exclusion globs used by comparable executor sandboxes.
//
// The projection is exclusions-only: a positive ripgrep glob would switch the
// search into whitelist mode and restrict it to only matching files, so AllowRead
// re-inclusion is NOT expressed here. The Go-native grep/glob tools honor
// AllowRead precisely via the cached predicate (Engine.ReadExclusions); this
// function is the coarser ripgrep-format export for an external rg-based
// consumer. Empty when DenyRead is unset (the default), so search behavior is
// unchanged.
func ReadExclusionGlobs(policy Policy, scope *Scope) []string {
denyRoots := resolvePolicyPaths(policy.DenyRead)
if len(denyRoots) == 0 || scope == nil {
return nil
}
workspaceRoot := scope.WorkspaceRoot()
if workspaceRoot == "" {
return nil
}
var globs []string
for _, deny := range denyRoots {
rel, ok := workspaceRelGlob(workspaceRoot, deny)
if !ok {
continue
}
globs = append(globs, "--glob", "!"+rel, "--glob", "!"+rel+"/**")
}
return globs
}
// policyHasPathLists reports whether any fine-grained path list has an
// ENFORCEABLE entry. It resolves the lists (matching how the rest of this file
// normalizes them) rather than counting raw config, so a typo or non-existent
// entry — which resolution drops — doesn't spuriously fail-close relative
// requests when there is no workspace root.
func policyHasPathLists(policy Policy) bool {
return len(resolvePolicyPaths(policy.DenyRead)) > 0 ||
len(resolvePolicyPaths(policy.AllowRead)) > 0 ||
len(resolvePolicyPaths(policy.DenyWrite)) > 0 ||
len(resolveWriteRootPaths(policy.AllowWrite)) > 0
}
// dedupeStrings returns xs with duplicates removed, preserving first-seen order.
func dedupeStrings(xs []string) []string {
if len(xs) <= 1 {
return xs
}
seen := make(map[string]struct{}, len(xs))
out := make([]string, 0, len(xs))
for _, x := range xs {
if _, dup := seen[x]; dup {
continue
}
seen[x] = struct{}{}
out = append(out, x)
}
return out
}
+426
View File
@@ -0,0 +1,426 @@
package sandbox
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// resolvedTempDir returns a symlink-resolved temp dir so paths built under it
// compare equal to the EvalSymlinks-normalized forms the path lists produce
// (macOS /var -> /private/var would otherwise diverge).
func resolvedTempDir(t *testing.T) string {
t.Helper()
resolved, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("EvalSymlinks(tempdir): %v", err)
}
return resolved
}
func mkdir(t *testing.T, path string) string {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%q): %v", path, err)
}
return path
}
func TestReadDenyAllowPrecedence(t *testing.T) {
ws := resolvedTempDir(t)
mkdir(t, filepath.Join(ws, "src"))
secret := mkdir(t, filepath.Join(ws, "secret"))
public := mkdir(t, filepath.Join(ws, "secret", "public"))
cases := []struct {
name string
policy Policy
path string
wantDenied bool
}{
{
name: "no lists: nothing denied",
policy: Policy{},
path: filepath.Join(secret, "data"),
wantDenied: false,
},
{
name: "denyread blocks subtree",
policy: Policy{DenyRead: []string{secret}},
path: filepath.Join(secret, "data"),
wantDenied: true,
},
{
name: "denyread does not touch siblings",
policy: Policy{DenyRead: []string{secret}},
path: filepath.Join(ws, "src", "main.go"),
wantDenied: false,
},
{
name: "more-specific allowread re-includes",
policy: Policy{DenyRead: []string{secret}, AllowRead: []string{public}},
path: filepath.Join(public, "ok.txt"),
wantDenied: false,
},
{
name: "allowread only re-includes its own subtree",
policy: Policy{DenyRead: []string{secret}, AllowRead: []string{public}},
path: filepath.Join(secret, "other.txt"),
wantDenied: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := readDenied(tc.policy, ws, tc.path); got != tc.wantDenied {
t.Fatalf("readDenied(%q) = %v, want %v", tc.path, got, tc.wantDenied)
}
})
}
}
func TestWritePrecedenceMatrix(t *testing.T) {
ws := resolvedTempDir(t)
mkdir(t, filepath.Join(ws, "src"))
wsSecret := mkdir(t, filepath.Join(ws, "secret"))
ext := resolvedTempDir(t)
mkdir(t, filepath.Join(ext, "build"))
extProtected := mkdir(t, filepath.Join(ext, "build", "protected"))
outside := resolvedTempDir(t) // never allowed
scope, err := NewScope(ws, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
cases := []struct {
name string
policy Policy
path string
wantAllow bool
}{
{
name: "workspace path writable by default",
policy: Policy{},
path: filepath.Join(ws, "src", "main.go"),
wantAllow: true,
},
{
name: "outside path denied by default",
policy: Policy{},
path: filepath.Join(outside, "x"),
wantAllow: false,
},
{
name: "allowwrite extends to external root",
policy: Policy{AllowWrite: []string{filepath.Join(ext, "build")}},
path: filepath.Join(ext, "build", "out.o"),
wantAllow: true,
},
{
name: "denywrite wins over workspace",
policy: Policy{DenyWrite: []string{wsSecret}},
path: filepath.Join(wsSecret, "creds"),
wantAllow: false,
},
{
name: "denywrite wins over allowwrite",
policy: Policy{AllowWrite: []string{filepath.Join(ext, "build")}, DenyWrite: []string{extProtected}},
path: filepath.Join(extProtected, "x"),
wantAllow: false,
},
{
name: "allowwrite does not allow unlisted external path",
policy: Policy{AllowWrite: []string{filepath.Join(ext, "build")}},
path: filepath.Join(outside, "x"),
wantAllow: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// This matrix exercises the workspace-enforced write precedence.
violation := validateWritePath(scope, tc.policy, true, ws, tc.path)
gotAllow := violation == nil
if gotAllow != tc.wantAllow {
t.Fatalf("validateWritePath(%q) allow=%v (violation=%v), want allow=%v", tc.path, gotAllow, violation, tc.wantAllow)
}
})
}
}
func TestReadExclusionGlobs(t *testing.T) {
ws := resolvedTempDir(t)
secret := mkdir(t, filepath.Join(ws, "secret"))
scope, err := NewScope(ws, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
// Empty DenyRead → no globs (default behavior unchanged).
if globs := ReadExclusionGlobs(Policy{}, scope); globs != nil {
t.Fatalf("empty DenyRead must yield no globs, got %v", globs)
}
// DenyRead inside the workspace → ripgrep exclusion globs for the rel path.
got := strings.Join(ReadExclusionGlobs(Policy{DenyRead: []string{secret}}, scope), " ")
want := "--glob !secret --glob !secret/**"
if got != want {
t.Fatalf("ReadExclusionGlobs = %q, want %q", got, want)
}
// DenyRead OUTSIDE the workspace → skipped (a rooted search never reaches it).
outside := resolvedTempDir(t)
if globs := ReadExclusionGlobs(Policy{DenyRead: []string{outside}}, scope); globs != nil {
t.Fatalf("out-of-workspace DenyRead must yield no globs, got %v", globs)
}
}
// TestReadDirExcludedDescendsForNestedAllow verifies a read-denied dir is NOT
// skipped wholesale when it contains a nested AllowRead root, so the walk can
// still reach the re-included subtree.
func TestReadDirExcludedDescendsForNestedAllow(t *testing.T) {
ws := resolvedTempDir(t)
secret := mkdir(t, filepath.Join(ws, "secret"))
public := mkdir(t, filepath.Join(ws, "secret", "public"))
engine := NewEngine(EngineOptions{
WorkspaceRoot: ws,
Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, DenyRead: []string{secret}, AllowRead: []string{public}},
})
// The denied dir must be descended (nested allow), but the denied dir's own
// files are still excluded per-file.
rx := engine.ReadExclusions()
if rx.DirExcluded(secret) {
t.Fatalf("a denied dir with a nested AllowRead must not be skipped wholesale")
}
if !rx.PathExcluded(filepath.Join(secret, "creds")) {
t.Fatalf("a denied file outside the re-included subtree must be excluded")
}
if rx.PathExcluded(filepath.Join(public, "ok")) {
t.Fatalf("a re-included AllowRead file must not be excluded")
}
}
// TestDirExcludedResolvesSymlinkPrefix verifies DirExcluded resolves a symlink
// in the path prefix before checking for a nested AllowRead root, so a denied dir
// reached through a symlink is NOT skipped wholesale when it contains a
// re-included subtree (the resolved allowRoots would otherwise never match).
func TestDirExcludedResolvesSymlinkPrefix(t *testing.T) {
ws := resolvedTempDir(t)
real := mkdir(t, filepath.Join(ws, "real"))
secret := mkdir(t, filepath.Join(real, "secret"))
public := mkdir(t, filepath.Join(secret, "public"))
link := filepath.Join(ws, "link")
if err := os.Symlink(real, link); err != nil {
t.Skipf("symlinks unavailable on this platform: %v", err)
}
engine := NewEngine(EngineOptions{
WorkspaceRoot: ws,
Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, DenyRead: []string{secret}, AllowRead: []string{public}},
})
rx := engine.ReadExclusions()
// secret reached THROUGH the symlink still contains the re-included AllowRead
// subtree once the prefix is resolved, so it must not be skipped wholesale.
if rx.DirExcluded(filepath.Join(link, "secret")) {
t.Fatalf("DirExcluded must resolve the symlink prefix and detect the nested AllowRead (must not skip)")
}
}
// TestPathListsApplyWithoutWorkspaceEnforcement verifies the explicit path lists
// (DenyWrite/DenyRead) are honored even when EnforceWorkspace is off, while the
// workspace boundary itself is not enforced in that mode.
func TestPathListsApplyWithoutWorkspaceEnforcement(t *testing.T) {
ws := resolvedTempDir(t)
secret := mkdir(t, filepath.Join(ws, "secret"))
outside := resolvedTempDir(t)
scope, err := NewScope(ws, nil)
if err != nil {
t.Fatalf("NewScope: %v", err)
}
// DenyWrite still blocks even with workspace enforcement off.
denyPolicy := Policy{DenyWrite: []string{secret}}
if v := validateWritePath(scope, denyPolicy, false, ws, filepath.Join(secret, "creds")); v == nil {
t.Fatal("DenyWrite must block even when EnforceWorkspace is off")
}
// With enforcement off and no DenyWrite match, an out-of-workspace write is
// NOT blocked (the workspace boundary is intentionally not applied).
if v := validateWritePath(scope, denyPolicy, false, ws, filepath.Join(outside, "x")); v != nil {
t.Fatalf("with EnforceWorkspace off, a non-denied path must be allowed, got %v", v)
}
// DenyRead still blocks reads even with workspace enforcement off.
if v := validatePathWithPolicy(scope, Policy{DenyRead: []string{secret}}, SideEffectRead, false, ws, filepath.Join(secret, "x")); v == nil {
t.Fatal("DenyRead must block reads even when EnforceWorkspace is off")
}
}
// TestEvaluatePathListsWithoutWorkspaceRoot verifies the path lists are enforced
// even on an engine built WITHOUT a workspace root: absolute paths are matched
// against DenyWrite, and an unanchorable relative path fails closed.
func TestEvaluatePathListsWithoutWorkspaceRoot(t *testing.T) {
secret := resolvedTempDir(t)
other := resolvedTempDir(t)
engine := NewEngine(EngineOptions{
Policy: Policy{Mode: ModeEnforce, MaxAutonomy: AutonomyHigh, DenyWrite: []string{secret}},
})
denied := engine.Evaluate(context.Background(), Request{
ToolName: "write_file", SideEffect: SideEffectWrite,
Args: map[string]any{"path": filepath.Join(secret, "x")},
})
if denied.Action != ActionDeny {
t.Fatalf("absolute DenyWrite path must be denied without a workspace root, got %q", denied.Action)
}
ok := engine.Evaluate(context.Background(), Request{
ToolName: "write_file", SideEffect: SideEffectWrite,
Args: map[string]any{"path": filepath.Join(other, "x")},
})
if ok.Action == ActionDeny {
t.Fatalf("a non-denied absolute path must not be denied without a workspace root, got deny: %s", ok.ErrorString())
}
rel := engine.Evaluate(context.Background(), Request{
ToolName: "write_file", SideEffect: SideEffectWrite,
Args: map[string]any{"path": "x"},
})
if rel.Action != ActionDeny {
t.Fatalf("an unanchorable relative path must fail closed when path lists exist, got %q", rel.Action)
}
}
// TestReadExclusionsInactiveWhenDisabled verifies a disabled policy filters
// nothing — ReadExclusions/ReadExclusionGlobs are inert under ModeDisabled even
// when DenyRead is configured.
func TestReadExclusionsInactiveWhenDisabled(t *testing.T) {
ws := resolvedTempDir(t)
secret := mkdir(t, filepath.Join(ws, "secret"))
engine := NewEngine(EngineOptions{
WorkspaceRoot: ws,
Policy: Policy{Mode: ModeDisabled, DenyRead: []string{secret}},
})
if rx := engine.ReadExclusions(); rx.Active() {
t.Fatal("ReadExclusions must be inactive under a disabled policy")
}
if globs := engine.ReadExclusionGlobs(); globs != nil {
t.Fatalf("ReadExclusionGlobs must be empty under a disabled policy, got %v", globs)
}
}
// TestEvaluateAppliesReadDeny verifies the lists are wired into Evaluate: a read
// under DenyRead is denied, while a sibling read is not.
func TestEvaluateAppliesReadDeny(t *testing.T) {
ws := resolvedTempDir(t)
mkdir(t, filepath.Join(ws, "src"))
secret := mkdir(t, filepath.Join(ws, "secret"))
engine := NewEngine(EngineOptions{
WorkspaceRoot: ws,
Policy: Policy{
Mode: ModeEnforce,
EnforceWorkspace: true,
MaxAutonomy: AutonomyHigh,
DenyRead: []string{secret},
},
})
denied := engine.Evaluate(context.Background(), Request{
ToolName: "read_file",
SideEffect: SideEffectRead,
Args: map[string]any{"path": filepath.Join(secret, "creds.txt")},
})
if denied.Action != ActionDeny {
t.Fatalf("read under DenyRead = %q, want deny", denied.Action)
}
ok := engine.Evaluate(context.Background(), Request{
ToolName: "read_file",
SideEffect: SideEffectRead,
Args: map[string]any{"path": filepath.Join(ws, "src", "main.go")},
})
if ok.Action == ActionDeny {
t.Fatalf("read of a non-denied sibling must not be denied, got %q (%s)", ok.Action, ok.ErrorString())
}
}
// TestEvaluateAppliesWriteAllow verifies an external write is denied by default
// but permitted once the path is on AllowWrite.
func TestEvaluateAppliesWriteAllow(t *testing.T) {
ws := resolvedTempDir(t)
ext := resolvedTempDir(t)
build := mkdir(t, filepath.Join(ext, "build"))
base := Policy{Mode: ModeEnforce, EnforceWorkspace: true, MaxAutonomy: AutonomyHigh}
target := filepath.Join(build, "out.o")
denyEngine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: base})
denied := denyEngine.Evaluate(context.Background(), Request{
ToolName: "write_file",
SideEffect: SideEffectWrite,
Args: map[string]any{"path": target},
})
if denied.Action != ActionDeny {
t.Fatalf("external write without AllowWrite = %q, want deny", denied.Action)
}
allowPolicy := base
allowPolicy.AllowWrite = []string{build}
allowEngine := NewEngine(EngineOptions{WorkspaceRoot: ws, Policy: allowPolicy})
allowed := allowEngine.Evaluate(context.Background(), Request{
ToolName: "write_file",
SideEffect: SideEffectWrite,
Args: map[string]any{"path": target},
})
if allowed.Action == ActionDeny {
t.Fatalf("external write under AllowWrite must not be denied, got deny: %s", allowed.ErrorString())
}
}
// TestWriteRootsReflectAllowWrite verifies AllowWrite roots flow into the OS
// backend write binds (so a sandboxed shell can write there).
func TestWriteRootsReflectAllowWrite(t *testing.T) {
ws := resolvedTempDir(t)
ext := resolvedTempDir(t)
build := mkdir(t, filepath.Join(ext, "build"))
engine := NewEngine(EngineOptions{
WorkspaceRoot: ws,
Policy: Policy{Mode: ModeEnforce, EnforceWorkspace: true, AllowWrite: []string{build}},
})
roots := engine.writeRoots(ws)
found := false
for _, r := range roots {
if r == build {
found = true
}
}
if !found {
t.Fatalf("writeRoots must include AllowWrite root %q, got %v", build, roots)
}
}
// TestSandboxExecProfileEmitsDenyWriteRule verifies a DenyWrite directory becomes
// an explicit seatbelt deny clause AFTER the write-allow (last-match-wins).
func TestSandboxExecProfileEmitsDenyWriteRule(t *testing.T) {
ws := resolvedTempDir(t)
secret := mkdir(t, filepath.Join(ws, "secret"))
policy := Policy{Mode: ModeEnforce, EnforceWorkspace: true, DenyWrite: []string{secret}}
profile := sandboxExecProfile([]string{ws}, policy, "", "", "")
// The path is escaped the same way the profile escapes it (e.g. Windows
// backslashes are doubled), so the assertion holds on every platform.
wantDeny := `(deny file-write* (subpath "` + sandboxProfileString(secret) + `"))`
if !strings.Contains(profile, wantDeny) {
t.Fatalf("profile missing DenyWrite rule %q:\n%s", wantDeny, profile)
}
// The deny clause must appear AFTER the write-allow so seatbelt's
// last-match-wins lets it override the bind.
allowIdx := strings.Index(profile, "(allow file-write*")
denyIdx := strings.Index(profile, wantDeny)
if allowIdx < 0 || denyIdx < 0 || denyIdx < allowIdx {
t.Fatalf("DenyWrite rule must follow the write-allow (allow@%d deny@%d):\n%s", allowIdx, denyIdx, profile)
}
}
+90
View File
@@ -0,0 +1,90 @@
package sandbox
import "testing"
func TestIsAlreadySandboxed(t *testing.T) {
// A lone ZERO_SANDBOXED=1 (no corroborating backend marker) must NOT count as
// sandboxed — stronger provenance than a single ambient flag.
t.Setenv(EnvSandboxBackend, "")
t.Setenv(EnvSandboxed, "1")
if IsAlreadySandboxed() {
t.Fatalf("IsAlreadySandboxed must be false when only %s=1 is set without %s", EnvSandboxed, EnvSandboxBackend)
}
// The backend marker alone (no ZERO_SANDBOXED=1) must not count either.
t.Setenv(EnvSandboxBackend, string(BackendBubblewrap))
t.Setenv(EnvSandboxed, "")
if IsAlreadySandboxed() {
t.Fatalf("IsAlreadySandboxed must be false when only %s is set", EnvSandboxBackend)
}
// Both correlated markers present (as sandboxEnvironment sets them) → sandboxed.
t.Setenv(EnvSandboxed, "1")
if !IsAlreadySandboxed() {
t.Fatalf("IsAlreadySandboxed must be true when both %s=1 and %s are set", EnvSandboxed, EnvSandboxBackend)
}
t.Setenv(EnvSandboxed, "0")
if IsAlreadySandboxed() {
t.Fatalf("IsAlreadySandboxed must be false when %s=0", EnvSandboxed)
}
}
func TestSandboxEnvironmentMarksSandboxed(t *testing.T) {
env := sandboxEnvironment(DefaultPolicy(), BackendBubblewrap, "/home/agent")
// Both correlated markers must be set: IsAlreadySandboxed requires BOTH, so a
// regression that drops either one would silently break re-entrancy detection.
wantSandboxed := EnvSandboxed + "=1"
wantBackend := EnvSandboxBackend + "=" + string(BackendBubblewrap)
var gotSandboxed, gotBackend bool
for _, entry := range env {
switch entry {
case wantSandboxed:
gotSandboxed = true
case wantBackend:
gotBackend = true
}
}
if !gotSandboxed || !gotBackend {
t.Fatalf("sandboxEnvironment must set %q and %q so a wrapped command is detectable; got %#v", wantSandboxed, wantBackend, env)
}
}
func TestBuildCommandPlanWrapsWhenNotAlreadySandboxed(t *testing.T) {
// Ensure neither re-entrancy marker is set so we are NOT seen as sandboxed.
t.Setenv(EnvSandboxed, "")
t.Setenv(EnvSandboxBackend, "")
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"},
})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if !plan.Wrapped || plan.Name != "/usr/bin/bwrap" {
t.Fatalf("expected a wrapped bubblewrap plan, got wrapped=%v name=%q", plan.Wrapped, plan.Name)
}
}
func TestBuildCommandPlanReEntrancyGuardReturnsPassThrough(t *testing.T) {
// Simulate running inside an already-wrapped process: BOTH correlated markers
// that sandboxEnvironment sets together must be present for the guard to fire.
t.Setenv(EnvSandboxed, "1")
t.Setenv(EnvSandboxBackend, string(BackendBubblewrap))
root := t.TempDir()
engine := NewEngine(EngineOptions{
WorkspaceRoot: root,
Policy: DefaultPolicy(),
Backend: Backend{Name: BackendBubblewrap, Available: true, Executable: "/usr/bin/bwrap"},
})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
if plan.Wrapped {
t.Fatalf("re-entrancy guard must return an unwrapped pass-through plan, got wrapped=%v name=%q args=%v", plan.Wrapped, plan.Name, plan.Args)
}
if plan.Name != "/bin/sh" {
t.Fatalf("pass-through plan must run the command directly, got name=%q", plan.Name)
}
}
+16
View File
@@ -106,6 +106,22 @@ func classifyWithScope(request Request, scope *Scope) Risk {
if pipedInstallerPattern.MatchString(command) {
add("piped_installer", RiskCritical)
}
// AST second opinion (analyzer.go): walks the parsed shell tree, so it
// catches destructive/network programs the regexes miss — e.g. shred,
// fdisk, parted, and commands hidden behind sudo/env wrappers or a
// `sh -c <payload>` launcher — and flags an unparseable (obfuscated)
// script as elevated risk. It only ADDS categories, so a benign,
// parseable command is classified exactly as before.
analysis := AnalyzeCommand(command)
if analysis.Network {
add("network", RiskCritical)
}
if analysis.Destructive {
add("destructive", RiskCritical)
}
if analysis.TooComplex {
add("unparseable_command", RiskHigh)
}
}
for _, path := range requestPaths(request) {
+66
View File
@@ -229,3 +229,69 @@ func TestClassifyNoneSideEffectIsLowRisk(t *testing.T) {
t.Fatalf("control-only tool must not classify as out_of_workspace: %#v", risk)
}
}
// The following tests cover the AST analyzer wired into classifyWithScope as a
// second opinion to the regex detectors.
func TestClassifyASTCatchesDestructiveProgramsRegexMisses(t *testing.T) {
// shred/fdisk/parted are irrecoverably destructive but absent from the regex
// pattern; the AST analyzer flags them — including behind a sh -c launcher or
// a sudo/env wrapper (effectiveProgram resolves the real program). The escalated
// level (Critical) is part of the contract, so assert it alongside the category.
for _, command := range []string{
"shred -u secret.txt",
"fdisk /dev/sda",
"parted /dev/sda mklabel gpt",
"bash -c 'shred /etc/passwd'",
"sudo shred -u secret.txt",
"env shred -u secret.txt",
} {
risk := classifyCommand(command)
if risk.Level != RiskCritical || !HasRiskCategory(risk, "destructive") {
t.Fatalf("Classify(%q) = level %s, categories %v; want critical destructive", command, risk.Level, risk.Categories)
}
}
}
func TestClassifyASTCatchesNetworkProgramsRegexMisses(t *testing.T) {
for _, command := range []string{
"telnet example.com 23",
"ftp ftp.example.com",
"sftp user@host",
"sudo telnet example.com 23",
} {
risk := classifyCommand(command)
if risk.Level != RiskCritical || !HasRiskCategory(risk, "network") {
t.Fatalf("Classify(%q) = level %s, categories %v; want critical network", command, risk.Level, risk.Categories)
}
}
}
func TestClassifyFlagsUnparseableCommand(t *testing.T) {
// An unparseable (e.g. obfuscated) script can't be analyzed statically; the
// AST analyzer reports TooComplex so the classifier elevates it to High.
risk := classifyCommand(`echo "unterminated`)
if risk.Level != RiskHigh || !HasRiskCategory(risk, "unparseable_command") {
t.Fatalf("Classify of unparseable command = level %s, categories %v; want high unparseable_command", risk.Level, risk.Categories)
}
}
func TestClassifyASTDoesNotFlagQuotedProgramName(t *testing.T) {
// A program name inside a quoted argument is not a command, so the AST
// analyzer must not flag it (documenting a destructive command in an echo).
risk := classifyCommand(`echo "shred wipes files"`)
if HasRiskCategory(risk, "destructive") {
t.Fatalf("Classify(%q) wrongly flagged destructive: %v", `echo "shred wipes files"`, risk.Categories)
}
}
func TestClassifyBenignCommandStaysClean(t *testing.T) {
for _, command := range []string{"echo hello", "ls -la", "go build ./..."} {
risk := classifyCommand(command)
for _, category := range []string{"destructive", "network", "unparseable_command"} {
if HasRiskCategory(risk, category) {
t.Fatalf("Classify(%q) wrongly flagged %s: %v", command, category, risk.Categories)
}
}
}
}
+220 -24
View File
@@ -17,6 +17,13 @@ const bubblewrapWorkspace = "/workspace"
var errPolicyOnlyRunnerDisabled = errors.New("policy-only sandbox runner is disabled")
// errWSLPolicyOnlyDisabled is returned when running under WSL without bubblewrap
// and the policy has NOT opted into the policy-only runner — fail closed rather
// than run with no native isolation.
var errWSLPolicyOnlyDisabled = errors.New("bubblewrap is unavailable under WSL and Policy.AllowPolicyOnlyRunner is disabled: " +
"enable AllowPolicyOnlyRunner to run under the policy-only WSL fallback (engine policy + proxy egress, no native isolation), " +
"or install/enable bubblewrap")
type CommandSpec struct {
Name string
Args []string
@@ -39,6 +46,10 @@ type CommandPlan struct {
// StartDenialMonitor to capture what the sandbox blocked. Empty unless
// Policy.MonitorDenials is set on a macOS sandbox-exec plan.
MonitorTag string `json:"monitorTag,omitempty"`
// Notes records auditable least-privilege downgrade notes — e.g. the WSL
// policy-only fallback noting that native isolation was unavailable. Surfaced to
// the operator; never affects enforcement.
Notes []string `json:"notes,omitempty"`
// cleanup releases resources tied to the plan's lifetime — currently the
// scoped-egress proxy, which must outlive the command run and be shut down
// afterwards. It is never serialized; callers invoke it via Cleanup() once the
@@ -99,6 +110,29 @@ func ProxyEnv(addr string) []string {
}
}
// ProxyEnvWithSocks is the SOCKS-aware form of ProxyEnv built ON TOP of it:
// HTTP_PROXY/HTTPS_PROXY stay on the HTTP listener (httpAddr) for HTTP(S)-aware
// clients, while ALL_PROXY/all_proxy are re-pointed at the SOCKS5 front-end
// (socksAddr) so a client that honors ALL_PROXY tunnels arbitrary TCP through the
// same allow/deny gate. When socksAddr is empty it returns exactly ProxyEnv's
// output, so the SOCKS upgrade is purely additive and never weakens the default.
func ProxyEnvWithSocks(httpAddr, socksAddr string) []string {
env := ProxyEnv(httpAddr)
if socksAddr == "" {
return env
}
socksURL := "socks5://" + socksAddr
for i, kv := range env {
switch {
case strings.HasPrefix(kv, "ALL_PROXY="):
env[i] = "ALL_PROXY=" + socksURL
case strings.HasPrefix(kv, "all_proxy="):
env[i] = "all_proxy=" + socksURL
}
}
return env
}
func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*exec.Cmd, CommandPlan, error) {
if ctx == nil {
ctx = context.Background()
@@ -118,10 +152,20 @@ func (engine *Engine) CommandContext(ctx context.Context, spec CommandSpec) (*ex
// only applies to engines built without a workspace root (NewEngine always
// builds a scope otherwise); it is kept as defense in depth.
func (engine *Engine) writeRoots(workspaceRoot string) []string {
var roots []string
if engine.scope != nil {
return engine.scope.Roots()
roots = engine.scope.Roots()
} else {
roots = []string{workspaceRoot}
}
return []string{workspaceRoot}
// Reflect the policy's AllowWrite roots in the OS backend write binds so a
// sandboxed shell command may write where the policy grants writes. DenyWrite
// is enforced at the policy gate, and on sandbox-exec additionally as an
// explicit deny rule (see sandboxExecProfile).
if extra := resolveWriteRootPaths(engine.policy.AllowWrite); len(extra) > 0 {
roots = dedupeStrings(append(roots, extra...))
}
return roots
}
func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
@@ -146,6 +190,15 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
if backend.Name == "" {
backend = Backend{Name: BackendPolicyOnly, Message: "policy-only fallback: sandbox backend was not selected"}
}
// Re-entrancy guard: a command spawned by a process we already wrapped (both
// ZERO_SANDBOXED=1 and ZERO_SANDBOX_BACKEND set in its env — see
// IsAlreadySandboxed) must not be wrapped again — nested bwrap / sandbox-exec
// fails and a second egress proxy would be redundant. Return a pass-through
// plan. Mirrors the already-sandboxed guard used by comparable executor
// sandboxes.
if IsAlreadySandboxed() {
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
}
if policy.Mode == ModeDisabled {
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
}
@@ -166,6 +219,18 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
}
return sandboxExecCommandPlan(spec, workspaceRoot, engine.writeRoots(workspaceRoot), policy, backend, egress), nil
}
case BackendWSL:
// WSL fallback: no native isolation available. Fail closed unless the policy
// opted into the policy-only runner; otherwise run policy-only with the
// command's egress routed through the filtering proxy and an auditable note.
if !policy.AllowPolicyOnlyRunner {
return CommandPlan{}, errWSLPolicyOnlyDisabled
}
egress, err := startScopedEgress(policy, backend)
if err != nil {
return CommandPlan{}, err
}
return wslCommandPlan(spec, workspaceRoot, policy, backend, egress), nil
}
if !policy.AllowPolicyOnlyRunner {
return CommandPlan{}, errPolicyOnlyRunnerDisabled
@@ -173,12 +238,79 @@ func (engine *Engine) BuildCommandPlan(spec CommandSpec) (CommandPlan, error) {
return directCommandPlan(spec, backend, policy, workspaceRoot), nil
}
// wslCommandPlan builds the WSL policy-only fallback plan: the command runs
// DIRECTLY (no native wrap, since bubblewrap is unavailable under WSL) but with
// the sandbox env markers (ZERO_SANDBOXED=1, ZERO_SANDBOX_BACKEND=wsl) and, when
// a scoped-egress proxy was started, the proxy env so well-behaved clients route
// through the allow/deny gate. It carries a least-privilege downgrade note.
func wslCommandPlan(spec CommandSpec, workspaceRoot string, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
// The WSL fallback runs the command DIRECTLY (no native wrap), so it inherits
// the caller's environment like the generic policy-only runner; the sandbox
// markers are appended last so they cannot be shadowed by the caller's env.
var env []string
if spec.Env != nil {
env = cloneStrings(spec.Env)
} else {
env = append(env, os.Environ()...)
}
env = append(env,
EnvSandboxBackend+"="+string(BackendWSL),
"ZERO_SANDBOX_NETWORK="+string(policy.Network),
EnvSandboxed+"=1",
)
note := "WSL: native process isolation (bubblewrap) is unavailable; downgraded to the policy-only runner " +
"(least privilege). The engine still enforces the full policy."
if egress != nil {
env = append(env, ProxyEnvWithSocks(egress.addr, egress.socksAddr)...)
env = appendCACertEnv(env, egress)
note = "WSL: native process isolation (bubblewrap) is unavailable; downgraded to the policy-only runner " +
"with proxy egress (least privilege). The engine still enforces the full policy."
}
plan := CommandPlan{
Backend: backend,
WorkspaceRoot: workspaceRoot,
Policy: policy,
Wrapped: false,
Name: spec.Name,
Args: cloneStrings(spec.Args),
Dir: spec.Dir,
Env: env,
Notes: []string{note},
}
if egress != nil {
plan.cleanup = egress.cleanup
}
return plan
}
// scopedEgress holds the address of a started scoped-egress proxy and the
// cleanup that shuts it down. A nil *scopedEgress means scoped egress is not in
// effect for this command (the network mode is allow or deny-equivalent).
type scopedEgress struct {
addr string
cleanup func()
addr string
// socksAddr is the loopback address of the proxy's SOCKS5 front-end, used to
// set ALL_PROXY=socks5://<socksAddr>. Empty when the proxy exposes no SOCKS
// listener, in which case ALL_PROXY falls back to the HTTP proxy.
socksAddr string
// caCertPath is the path to the MITM CA's public cert (PEM), set only when
// Policy.InspectTLS started a TLS-terminating proxy. Surfaced to the sandboxed
// client via ZERO_SANDBOX_CA_CERT so it can trust the minted leaves.
caCertPath string
cleanup func()
}
// EnvCACert is the env var pointing the sandboxed client at the MITM CA's public
// cert so it can trust the proxy's minted leaf certificates (only set when
// Policy.InspectTLS is on).
const EnvCACert = "ZERO_SANDBOX_CA_CERT"
// appendCACertEnv adds ZERO_SANDBOX_CA_CERT when the egress proxy is terminating
// TLS (InspectTLS). It is a no-op for a plain passthrough proxy.
func appendCACertEnv(env []string, egress *scopedEgress) []string {
if egress == nil || egress.caCertPath == "" {
return env
}
return append(env, EnvCACert+"="+egress.caCertPath)
}
// startScopedEgress starts the local filtering proxy when the policy's effective
@@ -196,13 +328,32 @@ func startScopedEgress(policy Policy, backend Backend) (*scopedEgress, error) {
return nil, nil
}
proxy, err := startEgressProxy(egressOptions{
Allowed: policy.AllowedDomains,
Denied: policy.DeniedDomains,
Allowed: policy.AllowedDomains,
Denied: policy.DeniedDomains,
InspectTLS: policy.InspectTLS,
})
if err != nil {
return nil, fmt.Errorf("scoped egress unavailable, denying network: %w", err)
}
return &scopedEgress{addr: proxy.Addr(), cleanup: func() { _ = proxy.Close() }}, nil
se := &scopedEgress{addr: proxy.Addr(), socksAddr: proxy.SocksAddr(), cleanup: func() { _ = proxy.Close() }}
if policy.InspectTLS {
// Write the MITM CA's PUBLIC cert so the sandboxed client can trust the
// minted leaves; surface it via ZERO_SANDBOX_CA_CERT and clean it up with
// the proxy. Reuses no new bind path: sandbox-exec already allows file-read,
// and the WSL policy-only runner shares the host filesystem.
caPath, werr := writeMITMCAFile(proxy.CACertPEM())
if werr != nil {
_ = proxy.Close()
return nil, werr
}
se.caCertPath = caPath
base := se.cleanup
se.cleanup = func() {
base()
_ = os.Remove(caPath)
}
}
return se, nil
}
func directCommandPlan(spec CommandSpec, backend Backend, policy Policy, workspaceRoot string) CommandPlan {
@@ -374,21 +525,25 @@ func findSeccompHelper() string {
}
func sandboxExecCommandPlan(spec CommandSpec, workspaceRoot string, writeRoots []string, policy Policy, backend Backend, egress *scopedEgress) CommandPlan {
var proxyPort string
var proxyPort, socksPort string
if egress != nil {
if _, port, err := net.SplitHostPort(egress.addr); err == nil {
proxyPort = port
}
if _, port, err := net.SplitHostPort(egress.socksAddr); err == nil {
socksPort = port
}
}
denialTag := ""
if policy.MonitorDenials {
denialTag = nextSandboxDenialTag()
}
args := []string{"-p", sandboxExecProfile(writeRoots, policy, proxyPort, denialTag), spec.Name}
args := []string{"-p", sandboxExecProfile(writeRoots, policy, proxyPort, socksPort, denialTag), spec.Name}
args = append(args, spec.Args...)
env := sandboxEnvironment(policy, BackendSandboxExec, workspaceRoot)
if egress != nil {
env = append(env, ProxyEnv(egress.addr)...)
env = append(env, ProxyEnvWithSocks(egress.addr, egress.socksAddr)...)
env = appendCACertEnv(env, egress)
}
plan := CommandPlan{
Backend: backend,
@@ -426,8 +581,9 @@ func sandboxEnvironment(policy Policy, backend BackendName, home string) []strin
"HOME=" + home,
"PATH=" + firstEnv("PATH", defaultPath()),
"TERM=" + firstEnv("TERM", "dumb"),
"ZERO_SANDBOX_BACKEND=" + string(backend),
EnvSandboxBackend + "=" + string(backend),
"ZERO_SANDBOX_NETWORK=" + string(policy.Network),
EnvSandboxed + "=1",
}
if runtime.GOOS == "windows" {
env = append(env, "COMSPEC="+firstEnv("COMSPEC", "cmd.exe"))
@@ -540,8 +696,8 @@ func sandboxMachLookupRule() string {
return "(allow mach-lookup\n " + strings.Join(filters, "\n ") + ")"
}
func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, denialTag string) string {
networkRule := networkRuleFor(policy, proxyPort)
func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, socksPort string, denialTag string) string {
networkRule := networkRuleFor(policy, proxyPort, socksPort)
writeRule := "(allow file-write*)"
if policy.EnforceWorkspace {
// The granted write roots are the only writable *project* locations. Temp
@@ -566,7 +722,7 @@ func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, de
// filters `log stream` for this exact (per-plan) tag.
denyDefault = `(deny default (with message "` + sandboxProfileString(denialTag) + `"))`
}
return strings.Join([]string{
rules := []string{
"(version 1)",
denyDefault,
"(allow process*)",
@@ -579,27 +735,67 @@ func sandboxExecProfile(writeRoots []string, policy Policy, proxyPort string, de
sandboxMachLookupRule(),
"(allow file-read*)",
writeRule,
networkRule,
}, "\n")
}
// DenyWrite entries are emitted AFTER the write allow so seatbelt's
// last-match-wins makes them override the workspace/AllowWrite binds — so
// "DenyWrite wins" holds for shell commands too, not just the policy gate.
rules = append(rules, denyWriteRules(policy)...)
rules = append(rules, networkRule)
return strings.Join(rules, "\n")
}
// denyWriteRules returns seatbelt deny clauses for the policy's resolved
// DenyWrite paths: a (subpath ...) clause for a directory, a (literal ...) clause
// for a single file. Empty when DenyWrite is unset.
func denyWriteRules(policy Policy) []string {
resolved := resolvePolicyPaths(policy.DenyWrite)
if len(resolved) == 0 {
return nil
}
out := make([]string, 0, len(resolved))
for _, path := range resolved {
filter := `(subpath "` + sandboxProfileString(path) + `")`
if info, err := os.Stat(path); err == nil && !info.IsDir() {
filter = `(literal "` + sandboxProfileString(path) + `")`
}
out = append(out, "(deny file-write* "+filter+")")
}
return out
}
// networkRuleFor returns the seatbelt network clause for a policy. allow opens
// all network; deny (and an empty-allowlist scoped policy, which effectiveNetwork
// collapses to deny) blocks all network; scoped denies general network but
// permits only outbound to the local proxy port on localhost, so traffic must
// flow through the filtering proxy. A scoped policy with no resolvable proxy port
// falls back to a full deny (fail closed).
func networkRuleFor(policy Policy, proxyPort string) string {
// permits only outbound to the local proxy ports on localhost, so traffic must
// flow through the filtering proxy. Both the HTTP proxy port and the SOCKS5
// front-end port are allowed (a sandboxed process configured with
// ALL_PROXY=socks5://... must reach the SOCKS listener). A scoped policy with no
// resolvable proxy port falls back to a full deny (fail closed).
func networkRuleFor(policy Policy, proxyPort string, socksPort string) string {
switch effectiveNetwork(policy) {
case NetworkAllow:
return "(allow network*)"
case NetworkScoped:
if strings.TrimSpace(proxyPort) == "" {
rules := []string{"(deny network*)"}
seen := map[string]struct{}{}
// Deny by default, then allow only outbound to each distinct proxy port on
// loopback. Duplicate/empty ports are skipped so the clause stays minimal.
for _, port := range []string{proxyPort, socksPort} {
port = strings.TrimSpace(port)
if port == "" {
continue
}
if _, ok := seen[port]; ok {
continue
}
seen[port] = struct{}{}
rules = append(rules, `(allow network-outbound (remote ip "localhost:`+sandboxProfileString(port)+`"))`)
}
if len(rules) == 1 {
// No resolvable proxy port: fail closed.
return "(deny network*)"
}
// Deny by default, then allow only outbound to the proxy on loopback.
return "(deny network*)\n" +
`(allow network-outbound (remote ip "localhost:` + sandboxProfileString(proxyPort) + `"))`
return strings.Join(rules, "\n")
default:
return "(deny network*)"
}
+12 -3
View File
@@ -23,8 +23,17 @@ func TestScopedNetworkGateInEvaluate(t *testing.T) {
}
sandboxExec := Backend{Name: BackendSandboxExec, Available: true, Executable: "/usr/sbin/sandbox-exec", ScopedEgress: true}
// This test exercises the network GATE for a network tool, so it opts the
// in-process tools into the network policy (EnforceToolNetwork); by default
// those tools are exempt and would not be gated here.
enforcedScoped := func(allowed, denied []string) Policy {
p := scopedPolicy(allowed, denied)
p.EnforceToolNetwork = true
return p
}
// An enforcing backend lets a populated scoped policy permit a network tool.
enforcing := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy([]string{"github.com"}, nil), Backend: sandboxExec})
enforcing := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: enforcedScoped([]string{"github.com"}, nil), Backend: sandboxExec})
if decision := enforcing.Evaluate(context.Background(), networkRequest); decision.Action == ActionDeny {
t.Fatalf("enforcing backend + scoped policy denied a network tool: %#v", decision)
}
@@ -35,7 +44,7 @@ func TestScopedNetworkGateInEvaluate(t *testing.T) {
"policy-only": {},
}
for name, backend := range unenforceable {
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy([]string{"github.com"}, nil), Backend: backend})
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: enforcedScoped([]string{"github.com"}, nil), Backend: backend})
decision := engine.Evaluate(context.Background(), networkRequest)
if decision.Action != ActionDeny || decision.Violation == nil || decision.Violation.Code != ViolationNetwork {
t.Fatalf("%s backend must deny scoped network it cannot enforce, got %#v", name, decision)
@@ -43,7 +52,7 @@ func TestScopedNetworkGateInEvaluate(t *testing.T) {
}
// Empty allowlist still fails closed regardless of backend.
empty := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy(nil, nil), Backend: sandboxExec})
empty := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: enforcedScoped(nil, nil), Backend: sandboxExec})
decision := empty.Evaluate(context.Background(), networkRequest)
if decision.Action != ActionDeny || decision.Violation == nil || decision.Violation.Code != ViolationNetwork {
t.Fatalf("empty scoped policy must deny network like NetworkDeny, got %#v", decision)
+48 -4
View File
@@ -233,7 +233,7 @@ func resolvedTestPath(t *testing.T, path string) string {
}
func TestSandboxExecProfileIncludesExtraWriteRoots(t *testing.T) {
profile := sandboxExecProfile([]string{"/ws", "/extra root"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "")
profile := sandboxExecProfile([]string{"/ws", "/extra root"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "", "")
if !strings.Contains(profile, "(allow file-write*") {
t.Fatalf("profile missing file-write rule:\n%s", profile)
}
@@ -251,7 +251,7 @@ func TestSandboxExecProfileIncludesExtraWriteRoots(t *testing.T) {
}
func TestSandboxExecProfileTagsDenialsWhenMonitoring(t *testing.T) {
off := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "")
off := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "", "")
if strings.Contains(off, "with message") {
t.Fatalf("denials must not be tagged when monitoring is off:\n%s", off)
}
@@ -259,7 +259,7 @@ func TestSandboxExecProfileTagsDenialsWhenMonitoring(t *testing.T) {
t.Fatalf("profile missing the plain default-deny:\n%s", off)
}
on := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true, MonitorDenials: true}, "", "run-tag-123")
on := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true, MonitorDenials: true}, "", "", "run-tag-123")
if !strings.Contains(on, `(deny default (with message "run-tag-123"))`) {
t.Fatalf("denials must be tagged when monitoring is on:\n%s", on)
}
@@ -291,7 +291,7 @@ func TestSandboxExecCommandPlanUsesUniquePerPlanDenialTag(t *testing.T) {
}
func TestSandboxExecProfileGrantsSignalAndMachLookup(t *testing.T) {
profile := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "")
profile := sandboxExecProfile([]string{"/ws"}, Policy{Mode: ModeEnforce, EnforceWorkspace: true}, "", "", "")
// Signalling own process group lets a sandboxed script kill the children it
// spawns; without it seatbelt denies kill() with "Operation not permitted".
@@ -465,3 +465,47 @@ func TestProxyEnv(t *testing.T) {
t.Fatalf("ProxyEnv NO_PROXY must exclude loopback, got %q/%q", env["NO_PROXY"], env["no_proxy"])
}
}
func TestProxyEnvWithSocks(t *testing.T) {
env := map[string]string{}
for _, kv := range ProxyEnvWithSocks("127.0.0.1:8899", "127.0.0.1:1080") {
if i := strings.IndexByte(kv, '='); i >= 0 {
env[kv[:i]] = kv[i+1:]
}
}
// HTTP_PROXY/HTTPS_PROXY stay on the HTTP listener...
const httpURL = "http://127.0.0.1:8899"
for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"} {
if env[key] != httpURL {
t.Fatalf("ProxyEnvWithSocks[%s] = %q, want %q", key, env[key], httpURL)
}
}
// ...while ALL_PROXY/all_proxy point at the SOCKS5 front-end.
const socksURL = "socks5://127.0.0.1:1080"
for _, key := range []string{"ALL_PROXY", "all_proxy"} {
if env[key] != socksURL {
t.Fatalf("ProxyEnvWithSocks[%s] = %q, want %q", key, env[key], socksURL)
}
}
if !strings.Contains(env["NO_PROXY"], "127.0.0.1") {
t.Fatalf("ProxyEnvWithSocks NO_PROXY must exclude loopback, got %q", env["NO_PROXY"])
}
}
// TestProxyEnvWithSocksEmptyFallsBackToHTTP verifies the SOCKS upgrade is purely
// additive: with no SOCKS address, ALL_PROXY falls back to the HTTP proxy exactly
// as the legacy ProxyEnv produced, never weakening the default.
func TestProxyEnvWithSocksEmptyFallsBackToHTTP(t *testing.T) {
env := map[string]string{}
for _, kv := range ProxyEnvWithSocks("127.0.0.1:8899", "") {
if i := strings.IndexByte(kv, '='); i >= 0 {
env[kv[:i]] = kv[i+1:]
}
}
const httpURL = "http://127.0.0.1:8899"
for _, key := range []string{"ALL_PROXY", "all_proxy"} {
if env[key] != httpURL {
t.Fatalf("ProxyEnvWithSocks(empty socks)[%s] = %q, want HTTP fallback %q", key, env[key], httpURL)
}
}
}
+72
View File
@@ -12,6 +12,31 @@ import (
// only an explicit truthy value enables it.
const EnvAutoAllowBash = "ZERO_SANDBOX_AUTO_ALLOW_BASH"
// EnvSandboxed marks a process that zero has already wrapped in a sandbox: every
// wrapped command carries ZERO_SANDBOXED=1 in its environment. When such a
// process spawns another command through the engine, the re-entrancy guard
// returns a pass-through plan instead of double-wrapping it — nested bwrap /
// sandbox-exec fails, and a second egress proxy would be redundant. Mirrors the
// already-sandboxed guard used by comparable executor sandboxes. Unset by default.
const EnvSandboxed = "ZERO_SANDBOXED"
// EnvSandboxBackend records which backend wrapped the command. sandboxEnvironment
// always sets it alongside EnvSandboxed, so it serves as a corroborating marker:
// the re-entrancy guard requires BOTH, raising the provenance bar above a single
// ambient flag (a stray or hand-exported ZERO_SANDBOXED=1 with no backend marker
// no longer forces an unsandboxed pass-through).
const EnvSandboxBackend = "ZERO_SANDBOX_BACKEND"
// IsAlreadySandboxed reports whether the current process is already running
// inside a zero-created sandbox. It requires BOTH correlated markers that
// sandboxEnvironment sets together — EnvSandboxed == "1" AND a non-empty
// EnvSandboxBackend — so a single user-set/inherited ZERO_SANDBOXED=1 cannot by
// itself disable wrapping. zero sets both only on genuinely wrapped commands;
// pass-through (direct) plans set neither.
func IsAlreadySandboxed() bool {
return os.Getenv(EnvSandboxed) == "1" && strings.TrimSpace(os.Getenv(EnvSandboxBackend)) != ""
}
type SideEffect string
type Permission string
type PermissionMode string
@@ -106,6 +131,11 @@ const (
BackendBubblewrap BackendName = "bubblewrap"
BackendSandboxExec BackendName = "sandbox-exec"
BackendPolicyOnly BackendName = "policy-only"
// BackendWSL is the policy-only fallback used under WSL when bubblewrap is
// unavailable/unreliable: there is no native OS isolation, but network egress
// is still routed through the local filtering proxy and the command runs under
// the full policy engine. It is surfaced via ZERO_SANDBOX_BACKEND=wsl.
BackendWSL BackendName = "wsl"
)
const (
@@ -133,6 +163,14 @@ type Policy struct {
// NetworkDeny so existing policies keep their exact behaviour.
AllowedDomains []string `json:"allowedDomains,omitempty"`
DeniedDomains []string `json:"deniedDomains,omitempty"`
// EnforceToolNetwork, when true, subjects the first-party in-process network
// tools (web_search / web_fetch) to the sandbox network policy via
// NetworkHostAllowed. It is OFF by default: that policy exists to confine the
// sandboxed SHELL's egress, which these tools do not use, so by default they
// are allowed to run (keeping their own SSRF/port/redirect/redaction
// safeguards). The sandboxed-shell egress decision is independent of this flag.
// Turn it on to also hold web_search/web_fetch to the allow/scoped/deny policy.
EnforceToolNetwork bool `json:"enforceToolNetwork,omitempty"`
// AutoAllowBashWhenSandboxed, when true, auto-allows the bash tool WITHOUT a
// permission prompt — but only when the sandbox is actually active (a
// native-isolation backend wraps the command). The sandbox is then the safety
@@ -153,6 +191,40 @@ type Policy struct {
// (runs without the filter) when the helper binary is not found. Ignored on
// non-bubblewrap backends.
BlockUnixSockets bool `json:"blockUnixSockets,omitempty"`
// AllowRead/DenyRead/AllowWrite/DenyWrite are fine-grained path lists layered
// ON TOP of the workspace + Scope guards; they never bypass the symlink /
// out-of-workspace protections. Each entry is home-expanded, made absolute, and
// symlink-resolved (an entry that does not exist is dropped). All default empty,
// so an unconfigured policy behaves exactly as before. Semantics:
//
// - Read: a path readable under the base workspace/Scope guard is denied if it
// falls under a DenyRead entry, UNLESS a more-specific AllowRead entry (one
// nested inside that DenyRead entry) re-includes it. AllowRead only
// re-includes within a DenyRead carve-out; it never extends reads beyond the
// workspace.
// - Write: DenyWrite wins over everything; otherwise a path writable under the
// workspace/Scope guard is allowed; otherwise an absolute path under an
// AllowWrite root is allowed; otherwise it is denied. AllowWrite roots are
// also reflected in the OS backend write binds, and on sandbox-exec DenyWrite
// entries are emitted as explicit deny rules so the precedence holds for
// shell commands too.
AllowRead []string `json:"allowRead,omitempty"`
DenyRead []string `json:"denyRead,omitempty"`
AllowWrite []string `json:"allowWrite,omitempty"`
DenyWrite []string `json:"denyWrite,omitempty"`
// InspectTLS, when true, lets the in-process scoped-egress proxy TERMINATE TLS
// (using a locally generated, ephemeral CA) so it can enforce the per-host
// allow/deny rules on the DECRYPTED request Host — today a CONNECT tunnel only
// reveals the host once. OFF by default: the proxy stays a pure CONNECT
// passthrough, byte-for-byte unchanged. SECURITY/TRUST: enabling this means the
// sandbox can read the plaintext of the sandboxed process's TLS traffic. The
// MITM only re-signs toward the sandboxed client; the upstream connection is
// ALWAYS validated against the system roots (never InsecureSkipVerify), and the
// decrypted Host still passes the SAME authorize()/domainAllowed() gate — MITM
// widens visibility, never authority. The generated CA's public cert is written
// to the sandbox runtime dir and surfaced via ZERO_SANDBOX_CA_CERT so the
// sandboxed client can trust it.
InspectTLS bool `json:"inspectTLS,omitempty"`
}
type Request struct {
+26
View File
@@ -0,0 +1,26 @@
package sandbox
import "strings"
// WSLInfo describes the Windows Subsystem for Linux environment, if any. It is
// derived from /proc/version. Mirrors reference wsl/index.js isWSL/isWSL2 and
// sandbox-manager.js isWsl (reads /proc/version, matches "microsoft").
type WSLInfo struct {
IsWSL bool
IsWSL2 bool
Kernel string
}
// parseWSL classifies a /proc/version string. IsWSL is set when it contains
// "microsoft" or "wsl"; IsWSL2 additionally requires "microsoft" and the absence
// of the legacy "wsl1" marker. Factored out (pure) so it is table-testable
// without a real /proc on any GOOS.
func parseWSL(procVersion string) WSLInfo {
lower := strings.ToLower(procVersion)
isWSL := strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl")
info := WSLInfo{IsWSL: isWSL, Kernel: strings.TrimSpace(procVersion)}
if isWSL {
info.IsWSL2 = strings.Contains(lower, "microsoft") && !strings.Contains(lower, "wsl1")
}
return info
}
+15
View File
@@ -0,0 +1,15 @@
//go:build linux
package sandbox
import "os"
// detectWSL reads /proc/version once and classifies the WSL environment. A read
// error (no /proc) yields the zero WSLInfo (not WSL).
func detectWSL() WSLInfo {
data, err := os.ReadFile("/proc/version")
if err != nil {
return WSLInfo{}
}
return parseWSL(string(data))
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package sandbox
// detectWSL is a no-op on non-Linux platforms: WSL is a Linux-under-Windows
// environment, so the host GOOS is never WSL. Keeps the package building on all
// platforms.
func detectWSL() WSLInfo { return WSLInfo{} }
+139
View File
@@ -0,0 +1,139 @@
package sandbox
import (
"errors"
"strings"
"testing"
)
func TestParseWSL(t *testing.T) {
cases := []struct {
name string
input string
wantWSL bool
wantWSL2 bool
}{
{
name: "wsl2 microsoft kernel",
input: "Linux version 5.15.90.1-microsoft-standard-WSL2 (...)",
wantWSL: true,
wantWSL2: true,
},
{
name: "wsl1 legacy marker",
input: "Linux version 4.4.0-19041-Microsoft (wsl1 build) ...",
wantWSL: true,
wantWSL2: false,
},
{
name: "plain linux",
input: "Linux version 6.8.0-generic (gcc ...) #1 SMP",
wantWSL: false,
wantWSL2: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := parseWSL(tc.input)
if got.IsWSL != tc.wantWSL || got.IsWSL2 != tc.wantWSL2 {
t.Fatalf("parseWSL(%q) = {IsWSL:%v IsWSL2:%v}, want {%v %v}", tc.input, got.IsWSL, got.IsWSL2, tc.wantWSL, tc.wantWSL2)
}
if got.IsWSL && got.Kernel == "" {
t.Fatalf("parseWSL should record the kernel string")
}
})
}
}
func wslBackendForTest() Backend {
return Backend{Name: BackendWSL, Platform: "linux", Fallback: true, ProxyEgress: true}
}
func TestWSLPlanFailsClosedWithoutPolicyOnly(t *testing.T) {
root := t.TempDir()
policy := scopedPolicy([]string{"github.com"}, nil)
policy.AllowPolicyOnlyRunner = false // explicitly refuse the degraded runner
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: policy, Backend: wslBackendForTest()})
_, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root})
if !errors.Is(err, errWSLPolicyOnlyDisabled) {
t.Fatalf("WSL without AllowPolicyOnlyRunner err = %v, want errWSLPolicyOnlyDisabled", err)
}
}
func TestWSLPolicyOnlyPlanCarriesProxyAndMarkers(t *testing.T) {
root := t.TempDir()
// scopedPolicy uses DefaultPolicy (AllowPolicyOnlyRunner=true) + an allowlist,
// so the WSL fallback starts the filtering proxy and runs policy-only.
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: scopedPolicy([]string{"github.com"}, nil), Backend: wslBackendForTest()})
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root, Env: []string{}})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
defer plan.Cleanup()
// Policy-only: the command runs directly, not OS-wrapped.
if plan.Wrapped || plan.Name != "/bin/sh" {
t.Fatalf("WSL plan must run the command directly, got wrapped=%v name=%q", plan.Wrapped, plan.Name)
}
env := strings.Join(plan.Env, "\n")
if !strings.Contains(env, EnvSandboxed+"=1") {
t.Fatalf("WSL plan env missing %s=1:\n%s", EnvSandboxed, env)
}
if !strings.Contains(env, EnvSandboxBackend+"=wsl") {
t.Fatalf("WSL plan env missing %s=wsl:\n%s", EnvSandboxBackend, env)
}
if !strings.Contains(env, "HTTP_PROXY=") || !strings.Contains(env, "ALL_PROXY=") {
t.Fatalf("WSL plan env missing proxy env (ProxyEnvWithSocks):\n%s", env)
}
if len(plan.Notes) == 0 || !strings.Contains(strings.Join(plan.Notes, " "), "policy-only") {
t.Fatalf("WSL plan must record a least-privilege downgrade note, got %v", plan.Notes)
}
}
func TestWSLPlanPreservesCallerEnv(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: wslBackendForTest()})
plan, err := engine.BuildCommandPlan(CommandSpec{
Name: "/bin/sh", Args: []string{"-c", "true"}, Dir: root,
Env: []string{"FOO=bar", "PATH=/custom/bin"},
})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
defer plan.Cleanup()
env := strings.Join(plan.Env, "\n")
// The WSL direct-run inherits the caller's env (it is not OS-wrapped)...
if !strings.Contains(env, "FOO=bar") || !strings.Contains(env, "PATH=/custom/bin") {
t.Fatalf("WSL plan must preserve caller env, got:\n%s", env)
}
// ...with the sandbox markers appended (so they win over caller env).
if !strings.Contains(env, EnvSandboxed+"=1") || !strings.Contains(env, EnvSandboxBackend+"=wsl") {
t.Fatalf("WSL plan must append sandbox markers:\n%s", env)
}
}
func TestWSLPolicyOnlyDenyPlanHasMarkersNoProxy(t *testing.T) {
root := t.TempDir()
// Default policy (NetworkDeny) → no proxy started, but the WSL markers + note
// still apply and the plan still runs (AllowPolicyOnlyRunner default true).
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy(), Backend: wslBackendForTest()})
// Explicit empty env so the assertion below is not confused by a dev/CI
// HTTP_PROXY inherited via os.Environ().
plan, err := engine.BuildCommandPlan(CommandSpec{Name: "/bin/sh", Args: []string{"-c", "pwd"}, Dir: root, Env: []string{}})
if err != nil {
t.Fatalf("BuildCommandPlan: %v", err)
}
defer plan.Cleanup()
env := strings.Join(plan.Env, "\n")
if !strings.Contains(env, EnvSandboxBackend+"=wsl") || !strings.Contains(env, EnvSandboxed+"=1") {
t.Fatalf("WSL deny plan must still carry sandbox markers:\n%s", env)
}
if len(plan.Notes) == 0 || !strings.Contains(strings.Join(plan.Notes, " "), "policy-only") {
t.Fatalf("WSL deny plan must record a least-privilege downgrade note, got %v", plan.Notes)
}
if strings.Contains(env, "HTTP_PROXY=") || strings.Contains(env, "HTTPS_PROXY=") || strings.Contains(env, "ALL_PROXY=") {
t.Fatalf("WSL deny plan must NOT export any proxy env vars (network denied):\n%s", env)
}
}
+44 -12
View File
@@ -58,14 +58,20 @@ type BuildArgsInput struct {
CurrentDepth int
Description string
Cwd string
// PermissionMode is the parent's resolved permission mode. Empty preserves
// the historical "--auto high" (the Task tool omits it); a caller that
// passes a non-unsafe mode (e.g. the swarm propagating a non-unsafe
// orchestrator) gets a non-unsafe child so authority is never widened.
PermissionMode string
}
type BuildResumeArgsInput struct {
SessionID string
Prompt string
CurrentDepth int
Manifest Manifest
Cwd string
SessionID string
Prompt string
CurrentDepth int
Manifest Manifest
Cwd string
PermissionMode string
}
type BuildArgsResult struct {
@@ -90,8 +96,32 @@ type TaskRunOptions struct {
ParentReasoningEffort string
CurrentDepth int
Cwd string
// PermissionMode propagates the parent's resolved permission mode to the
// child. Empty keeps the historical "--auto high"; a non-unsafe value runs
// the child non-unsafe so it never gains more authority than the parent.
PermissionMode string
}
// specialistAutonomy maps a parent permission mode to the child's "--auto"
// level. A headless specialist child cannot answer interactive prompts, so it
// runs autonomously — but only at "high" (unsafe) when the parent is itself
// unsafe (or when no mode is provided, preserving the Task tool's behavior).
// Any other parent mode yields the non-unsafe "low" level, so the child's
// authority never exceeds the parent's.
func specialistAutonomy(permissionMode string) string {
switch strings.TrimSpace(permissionMode) {
case "", string(permissionModeUnsafe):
return "high"
default:
return "low"
}
}
// permissionModeUnsafe mirrors agent.PermissionModeUnsafe without importing the
// agent package (which would create an import cycle): exec resolves "--auto high"
// to this mode.
const permissionModeUnsafe = "unsafe"
type ExecResult struct {
Result tools.Result
SessionID string
@@ -147,7 +177,7 @@ func (executor Executor) BuildArgs(input BuildArgsInput) (BuildArgsResult, error
args := []string{"exec", "--init-session-id", sessionID}
args = append(args, promptArgs...)
args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort)
args = append(args, "--auto", "high", "--output-format", "stream-json")
args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json")
toolAllowlist, err := resolvedToolAllowlist(input.Manifest)
if err != nil {
return BuildArgsResult{}, err
@@ -199,7 +229,7 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR
}
args := []string{"exec", "--resume", sessionID}
args = append(args, promptArgs...)
args = append(args, "--auto", "high", "--output-format", "stream-json")
args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json")
toolAllowlist, err := resolvedToolAllowlist(input.Manifest)
if err != nil {
return BuildArgsResult{}, err
@@ -230,6 +260,7 @@ func (executor Executor) runFresh(ctx context.Context, params TaskParameters, op
CurrentDepth: options.CurrentDepth,
Description: params.Description,
Cwd: options.Cwd,
PermissionMode: options.PermissionMode,
})
if err != nil {
return ExecResult{}, err
@@ -257,11 +288,12 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o
return ExecResult{}, err
}
built, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: params.Resume,
Prompt: params.Prompt,
CurrentDepth: options.CurrentDepth,
Manifest: manifest,
Cwd: options.Cwd,
SessionID: params.Resume,
Prompt: params.Prompt,
CurrentDepth: options.CurrentDepth,
Manifest: manifest,
Cwd: options.Cwd,
PermissionMode: options.PermissionMode,
})
if err != nil {
return ExecResult{}, err
+59
View File
@@ -64,6 +64,65 @@ func TestBuildArgsCreatesFreshSpecialistExecInvocation(t *testing.T) {
}
}
func TestSpecialistAutonomyByPermissionMode(t *testing.T) {
cases := map[string]string{
"": "high", // back-compat: the Task tool omits the mode
"unsafe": "high",
"auto": "low",
"ask": "low",
"spec-draft": "low",
"whatever": "low",
}
for mode, want := range cases {
if got := specialistAutonomy(mode); got != want {
t.Errorf("specialistAutonomy(%q) = %q, want %q", mode, got, want)
}
}
}
func TestBuildArgsAutonomyHonorsPermissionMode(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, SystemPrompt: "x", ResolvedTools: []string{"read_file"}}
// A non-unsafe parent mode yields a non-unsafe child (--auto low), so a
// swarm member never gains more authority than a non-unsafe orchestrator.
res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto"})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
if !containsSequence(res.Args, []string{"--auto", "low"}) {
t.Fatalf("non-unsafe parent must yield --auto low, got %v", res.Args)
}
if containsSequence(res.Args, []string{"--auto", "high"}) {
t.Fatalf("non-unsafe parent must NOT yield --auto high: %v", res.Args)
}
// An unsafe parent (and the empty back-compat case) keep --auto high.
for _, mode := range []string{"unsafe", ""} {
out, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: mode})
if err != nil {
t.Fatalf("BuildArgs(%q): %v", mode, err)
}
if !containsSequence(out.Args, []string{"--auto", "high"}) {
t.Fatalf("mode %q must yield --auto high, got %v", mode, out.Args)
}
}
}
func TestBuildResumeArgsAutonomyHonorsPermissionMode(t *testing.T) {
executor := Executor{}
manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, ResolvedTools: []string{"read_file"}}
res, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: "child_session", Prompt: "p", Manifest: manifest, PermissionMode: "auto",
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
if !containsSequence(res.Args, []string{"--auto", "low"}) || containsSequence(res.Args, []string{"--auto", "high"}) {
t.Fatalf("resume non-unsafe parent must yield --auto low, got %v", res.Args)
}
}
func TestBuildArgsSessionTitleFallsBackToNameWithoutDescription(t *testing.T) {
executor := Executor{NewSessionID: func() (string, error) { return "child", nil }}
manifest := Manifest{
+259
View File
@@ -0,0 +1,259 @@
package swarm
import (
"errors"
"fmt"
"sort"
"sync"
"time"
)
// TaskStatus is the lifecycle state of a swarm task.
type TaskStatus string
const (
// StatusPending: registered but the member has not started.
StatusPending TaskStatus = "pending"
// StatusRunning: the member is executing.
StatusRunning TaskStatus = "running"
// StatusDone: the member finished successfully.
StatusDone TaskStatus = "done"
// StatusFailed: the member exited with an error.
StatusFailed TaskStatus = "failed"
// StatusHandedOff: ownership was transferred to another agent.
StatusHandedOff TaskStatus = "handed-off"
)
// terminal reports whether a status is final (no further transitions expected).
func (s TaskStatus) terminal() bool {
return s == StatusDone || s == StatusFailed || s == StatusHandedOff
}
// valid reports whether a status is one of the known lifecycle states. Unknown
// statuses are rejected (fail closed) so a task can never enter an undefined
// state that Summarize and the lifecycle logic cannot reason about.
func (s TaskStatus) valid() bool {
switch s {
case StatusPending, StatusRunning, StatusDone, StatusFailed, StatusHandedOff:
return true
default:
return false
}
}
// Task is one unit of work tracked by the coordinator. It is returned by value
// from snapshots so callers cannot mutate coordinator state without the lock.
type Task struct {
ID string
AgentID string
Team string
Description string
Status TaskStatus
Result string
Err string
CreatedAt time.Time
UpdatedAt time.Time
}
// agentColors palette for stable per-agent coloring in the TUI/status. Provider
// -neutral ANSI-ish names; the renderer maps them to styles.
var agentColors = []string{"cyan", "magenta", "green", "yellow", "blue", "red"}
// Coordinator is the in-memory task registry + team color assigner shared by an
// orchestrator and its members. It is safe for concurrent use.
type Coordinator struct {
mu sync.RWMutex
tasks map[string]*Task
colors map[string]string // agentID -> color
colorIndex int
now func() time.Time // injectable clock for tests
}
// NewCoordinator returns an empty coordinator using the wall clock.
func NewCoordinator() *Coordinator {
return &Coordinator{
tasks: map[string]*Task{},
colors: map[string]string{},
now: time.Now,
}
}
// ErrTaskExists is returned when registering a duplicate task ID.
var ErrTaskExists = errors.New("swarm: task already registered")
// ErrUnknownTask is returned when updating/removing a missing task ID.
var ErrUnknownTask = errors.New("swarm: unknown task")
// Register adds a new pending task. The ID must be unique and non-empty.
func (c *Coordinator) Register(id, agentID, team, description string) (Task, error) {
if id == "" {
return Task{}, errors.New("swarm: task id is required")
}
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.tasks[id]; ok {
return Task{}, fmt.Errorf("%w: %s", ErrTaskExists, id)
}
now := c.now()
t := &Task{
ID: id,
AgentID: agentID,
Team: team,
Description: description,
Status: StatusPending,
CreatedAt: now,
UpdatedAt: now,
}
c.tasks[id] = t
c.assignColorLocked(agentID)
return *t, nil
}
// SetStatus transitions a task. Transitions out of a terminal state are
// rejected (fail closed) so a late member update cannot resurrect a finished
// task.
func (c *Coordinator) SetStatus(id string, status TaskStatus) error {
if !status.valid() {
return fmt.Errorf("swarm: invalid task status %q", status)
}
c.mu.Lock()
defer c.mu.Unlock()
t, ok := c.tasks[id]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownTask, id)
}
if t.Status.terminal() && t.Status != status {
return fmt.Errorf("swarm: task %s is %s (terminal); cannot move to %s", id, t.Status, status)
}
t.Status = status
t.UpdatedAt = c.now()
return nil
}
// Complete marks a task done with its result.
func (c *Coordinator) Complete(id, result string) error {
return c.finish(id, StatusDone, result, "")
}
// Fail marks a task failed with an error message.
func (c *Coordinator) Fail(id, errMsg string) error {
return c.finish(id, StatusFailed, "", errMsg)
}
func (c *Coordinator) finish(id string, status TaskStatus, result, errMsg string) error {
c.mu.Lock()
defer c.mu.Unlock()
t, ok := c.tasks[id]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownTask, id)
}
if t.Status.terminal() {
return fmt.Errorf("swarm: task %s already %s", id, t.Status)
}
t.Status = status
t.Result = result
t.Err = errMsg
t.UpdatedAt = c.now()
return nil
}
// Reassign transfers a task to a new agent (handoff/orphan-adoption). The task
// returns to pending under the new owner unless it is already terminal.
func (c *Coordinator) Reassign(id, newAgentID string) error {
c.mu.Lock()
defer c.mu.Unlock()
t, ok := c.tasks[id]
if !ok {
return fmt.Errorf("%w: %s", ErrUnknownTask, id)
}
if t.Status.terminal() {
return fmt.Errorf("swarm: task %s already %s; cannot reassign", id, t.Status)
}
t.AgentID = newAgentID
t.Status = StatusPending
t.UpdatedAt = c.now()
c.assignColorLocked(newAgentID)
return nil
}
// Get returns a snapshot of one task.
func (c *Coordinator) Get(id string) (Task, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
t, ok := c.tasks[id]
if !ok {
return Task{}, false
}
return *t, true
}
// List returns snapshots of all tasks, ordered by creation time then ID for
// stable output.
func (c *Coordinator) List() []Task {
c.mu.RLock()
out := make([]Task, 0, len(c.tasks))
for _, t := range c.tasks {
out = append(out, *t)
}
c.mu.RUnlock()
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
return out[i].ID < out[j].ID
}
return out[i].CreatedAt.Before(out[j].CreatedAt)
})
return out
}
// Color returns the stable color assigned to an agent (assigning one on first
// use), so status output colors each member consistently.
func (c *Coordinator) Color(agentID string) string {
c.mu.Lock()
defer c.mu.Unlock()
return c.assignColorLocked(agentID)
}
func (c *Coordinator) assignColorLocked(agentID string) string {
if agentID == "" {
return ""
}
if color, ok := c.colors[agentID]; ok {
return color
}
color := agentColors[c.colorIndex%len(agentColors)]
c.colors[agentID] = color
c.colorIndex++
return color
}
// Summary is an aggregate count of tasks by status for the status tool.
type Summary struct {
Total int
Pending int
Running int
Done int
Failed int
HandedOff int
}
// Summarize aggregates current task counts.
func (c *Coordinator) Summarize() Summary {
c.mu.RLock()
defer c.mu.RUnlock()
s := Summary{Total: len(c.tasks)}
for _, t := range c.tasks {
switch t.Status {
case StatusPending:
s.Pending++
case StatusRunning:
s.Running++
case StatusDone:
s.Done++
case StatusFailed:
s.Failed++
case StatusHandedOff:
s.HandedOff++
}
}
return s
}
+151
View File
@@ -0,0 +1,151 @@
package swarm
import (
"errors"
"sync"
"testing"
)
func TestCoordinatorRegisterAndDuplicate(t *testing.T) {
c := NewCoordinator()
if _, err := c.Register("t1", "a1", "team", "desc"); err != nil {
t.Fatalf("Register: %v", err)
}
task, ok := c.Get("t1")
if !ok || task.Status != StatusPending {
t.Fatalf("Get after register: ok=%v status=%v", ok, task.Status)
}
if _, err := c.Register("t1", "a1", "team", "desc"); !errors.Is(err, ErrTaskExists) {
t.Fatalf("duplicate Register err = %v, want ErrTaskExists", err)
}
if _, err := c.Register("", "a", "team", "d"); err == nil {
t.Fatal("empty id must error")
}
}
func TestCoordinatorLifecycleTransitions(t *testing.T) {
c := NewCoordinator()
_, _ = c.Register("t1", "a1", "team", "desc")
if err := c.SetStatus("t1", StatusRunning); err != nil {
t.Fatalf("SetStatus running: %v", err)
}
if err := c.Complete("t1", "result-data"); err != nil {
t.Fatalf("Complete: %v", err)
}
task, _ := c.Get("t1")
if task.Status != StatusDone || task.Result != "result-data" {
t.Fatalf("after Complete: status=%v result=%q", task.Status, task.Result)
}
// Terminal guard: cannot move done -> running, nor complete twice.
if err := c.SetStatus("t1", StatusRunning); err == nil {
t.Fatal("moving a done task to running must fail")
}
if err := c.Complete("t1", "x"); err == nil {
t.Fatal("completing a done task must fail")
}
if err := c.Fail("t1", "boom"); err == nil {
t.Fatal("failing a done task must fail")
}
}
func TestCoordinatorRejectsInvalidStatus(t *testing.T) {
c := NewCoordinator()
_, _ = c.Register("t1", "a1", "team", "desc")
if err := c.SetStatus("t1", TaskStatus("bogus")); err == nil {
t.Fatal("SetStatus must reject an unknown status")
}
// The task stays in its prior (valid) state.
if task, _ := c.Get("t1"); task.Status != StatusPending {
t.Fatalf("rejected status must not mutate the task, got %v", task.Status)
}
}
func TestCoordinatorUnknownTask(t *testing.T) {
c := NewCoordinator()
if err := c.SetStatus("missing", StatusRunning); !errors.Is(err, ErrUnknownTask) {
t.Fatalf("SetStatus(missing) = %v, want ErrUnknownTask", err)
}
if err := c.Complete("missing", "x"); !errors.Is(err, ErrUnknownTask) {
t.Fatalf("Complete(missing) = %v, want ErrUnknownTask", err)
}
}
func TestCoordinatorReassign(t *testing.T) {
c := NewCoordinator()
_, _ = c.Register("t1", "a1", "team", "desc")
_ = c.SetStatus("t1", StatusRunning)
if err := c.Reassign("t1", "a2"); err != nil {
t.Fatalf("Reassign: %v", err)
}
task, _ := c.Get("t1")
if task.AgentID != "a2" || task.Status != StatusPending {
t.Fatalf("after Reassign: agent=%q status=%v", task.AgentID, task.Status)
}
// Cannot reassign a terminal task.
_ = c.Complete("t1", "done")
if err := c.Reassign("t1", "a3"); err == nil {
t.Fatal("reassigning a terminal task must fail")
}
}
func TestCoordinatorColorStability(t *testing.T) {
c := NewCoordinator()
first := c.Color("a1")
if first == "" {
t.Fatal("Color should assign a non-empty color")
}
if again := c.Color("a1"); again != first {
t.Fatalf("Color not stable: %q vs %q", first, again)
}
if other := c.Color("a2"); other == "" {
t.Fatal("second agent should also get a color")
}
if c.Color("") != "" {
t.Fatal("empty agent id should get no color")
}
}
func TestCoordinatorSummarize(t *testing.T) {
c := NewCoordinator()
_, _ = c.Register("p", "a", "team", "")
_, _ = c.Register("r", "b", "team", "")
_ = c.SetStatus("r", StatusRunning)
_, _ = c.Register("d", "c", "team", "")
_ = c.Complete("d", "ok")
s := c.Summarize()
if s.Total != 3 || s.Pending != 1 || s.Running != 1 || s.Done != 1 {
t.Fatalf("Summarize = %+v", s)
}
}
func TestCoordinatorConcurrentRegister(t *testing.T) {
c := NewCoordinator()
var wg sync.WaitGroup
for i := 0; i < 50; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
id := "task-" + string(rune('a'+n%26)) + "-" + itoa(n)
_, _ = c.Register(id, id, "team", "desc")
_ = c.SetStatus(id, StatusRunning)
_ = c.Complete(id, "done")
}(i)
}
wg.Wait()
if got := len(c.List()); got != 50 {
t.Fatalf("List len = %d, want 50", got)
}
}
// itoa avoids importing strconv just for the concurrency test id.
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
+133
View File
@@ -0,0 +1,133 @@
// Package swarm adds a multi-agent SWARM on top of zero's single sub-agent
// mechanism (internal/specialist): an orchestrator can spawn and coordinate
// MULTIPLE specialist members that run concurrently, communicate via per-agent
// mailboxes, and hand work off. It composes internal/specialist (to launch each
// member), internal/daemon (pooled workers when a daemon is running) and
// internal/background. Everything is additive and opt-in — the existing single
// "task" tool is unchanged; swarm tools are only active when invoked.
//
// Every member runs under the SAME sandbox + risk/autonomy policy as the
// orchestrator: the swarm never grants a member more authority than its parent.
package swarm
import (
"errors"
"sort"
"strings"
"sync"
)
// ErrUnknownAgentType is returned when a definition lookup misses.
var ErrUnknownAgentType = errors.New("swarm: unknown agent type")
// modelInherit is the sentinel meaning "use the orchestrator's model" — members
// never silently pick a different (e.g. more capable) model.
const modelInherit = "inherit"
// Definition is one entry in the agent roster: how a member of a given type
// behaves (agent type, when to use it, model — "inherit" by default —,
// permission mode, and a system-prompt builder).
type Definition struct {
AgentType string
WhenToUse string
Model string // "inherit" => use the orchestrator's model
PermissionMode string
// SystemPrompt returns the member's system prompt for the given task context.
// It is a func so a definition can fold the task briefing in.
SystemPrompt func(ctx PromptContext) string
}
// PromptContext is the minimal context handed to a definition's SystemPrompt.
type PromptContext struct {
Team string
Task string
}
// Registry is the agent roster: definitions looked up by agent type. Built-ins
// are seeded at construction; user-defined definitions extend or override them.
type Registry struct {
mu sync.RWMutex
defs map[string]Definition
}
// NewRegistry builds a registry seeded with the built-in roster.
func NewRegistry() *Registry {
r := &Registry{defs: map[string]Definition{}}
for _, def := range builtinDefinitions() {
r.defs[def.AgentType] = def
}
return r
}
// Register adds or overrides a definition (user-defined agents extend the
// built-ins). An empty AgentType is rejected.
func (r *Registry) Register(def Definition) error {
agentType := strings.TrimSpace(def.AgentType)
if agentType == "" {
return errors.New("swarm: definition requires an agentType")
}
if def.Model == "" {
def.Model = modelInherit
}
def.AgentType = agentType
r.mu.Lock()
r.defs[agentType] = def
r.mu.Unlock()
return nil
}
// Lookup returns the definition for agentType, or ErrUnknownAgentType.
func (r *Registry) Lookup(agentType string) (Definition, error) {
r.mu.RLock()
def, ok := r.defs[strings.TrimSpace(agentType)]
r.mu.RUnlock()
if !ok {
return Definition{}, ErrUnknownAgentType
}
return def, nil
}
// AgentTypes returns the registered agent types, sorted, for status/help.
func (r *Registry) AgentTypes() []string {
r.mu.RLock()
types := make([]string, 0, len(r.defs))
for t := range r.defs {
types = append(types, t)
}
r.mu.RUnlock()
sort.Strings(types)
return types
}
// builtinDefinitions returns the seeded roster (teammate, subagent) — kept
// minimal; user-defined agents extend it via Register.
func builtinDefinitions() []Definition {
return []Definition{
{
AgentType: "teammate",
WhenToUse: "In-process teammate for parallel task execution; delegate work to run alongside the orchestrator.",
Model: modelInherit,
// Empty PermissionMode => inherit the orchestrator's mode (never widened).
SystemPrompt: func(ctx PromptContext) string {
return "You are a teammate agent collaborating with an orchestrator on team " +
displayTeam(ctx.Team) + ". Complete your assigned task and report results.\n\nTask: " + ctx.Task
},
},
{
AgentType: "subagent",
WhenToUse: "General-purpose subagent for an isolated, delegated task; starts with zero prior context.",
Model: modelInherit,
SystemPrompt: func(ctx PromptContext) string {
return "You are a subagent spawned to complete a specific task. You start with zero context — " +
"the briefing below is all you know.\n\nTask: " + ctx.Task
},
},
}
}
func displayTeam(team string) string {
if strings.TrimSpace(team) == "" {
return "default"
}
return team
}
+74
View File
@@ -0,0 +1,74 @@
package swarm
import (
"errors"
"testing"
)
func TestRegistryBuiltins(t *testing.T) {
r := NewRegistry()
for _, want := range []string{"teammate", "subagent"} {
def, err := r.Lookup(want)
if err != nil {
t.Fatalf("builtin %q missing: %v", want, err)
}
if def.Model != modelInherit {
t.Fatalf("builtin %q model = %q, want inherit", want, def.Model)
}
if def.SystemPrompt == nil {
t.Fatalf("builtin %q must have a SystemPrompt", want)
}
if got := def.SystemPrompt(PromptContext{Team: "t", Task: "do x"}); got == "" {
t.Fatalf("builtin %q SystemPrompt returned empty", want)
}
}
}
func TestRegistryLookupUnknown(t *testing.T) {
r := NewRegistry()
if _, err := r.Lookup("nope"); !errors.Is(err, ErrUnknownAgentType) {
t.Fatalf("Lookup(nope) err = %v, want ErrUnknownAgentType", err)
}
}
func TestRegistryRegisterOverrideAndDefaultModel(t *testing.T) {
r := NewRegistry()
if err := r.Register(Definition{AgentType: " custom "}); err != nil {
t.Fatalf("Register: %v", err)
}
def, err := r.Lookup("custom")
if err != nil {
t.Fatalf("Lookup(custom): %v", err)
}
if def.AgentType != "custom" {
t.Fatalf("agentType not trimmed: %q", def.AgentType)
}
if def.Model != modelInherit {
t.Fatalf("default model = %q, want inherit", def.Model)
}
// Override a builtin.
if err := r.Register(Definition{AgentType: "teammate", Model: "fixed-model"}); err != nil {
t.Fatalf("override Register: %v", err)
}
if got, _ := r.Lookup("teammate"); got.Model != "fixed-model" {
t.Fatalf("override model = %q, want fixed-model", got.Model)
}
}
func TestRegistryRegisterRejectsEmpty(t *testing.T) {
r := NewRegistry()
if err := r.Register(Definition{AgentType: " "}); err == nil {
t.Fatal("Register with blank agentType must error")
}
}
func TestRegistryAgentTypesSorted(t *testing.T) {
r := NewRegistry()
_ = r.Register(Definition{AgentType: "alpha"})
types := r.AgentTypes()
for i := 1; i < len(types); i++ {
if types[i-1] > types[i] {
t.Fatalf("AgentTypes not sorted: %v", types)
}
}
}
+47
View File
@@ -0,0 +1,47 @@
package swarm
import (
"context"
"github.com/Gitlawb/zero/internal/specialist"
)
// NewSpecialistLauncher adapts internal/specialist.Executor into a
// MemberLauncher: each swarm member runs as a specialist sub-agent. This is the
// production "direct" launch path (used when no daemon pool is wired). The
// member inherits the orchestrator's model via spec.Model and runs in spec.Cwd,
// so it stays under the same sandbox + policy as its parent — the swarm never
// grants a member more authority.
//
// agentType maps to the specialist agent name; the specialist's own definition
// for that name governs the member's system prompt and tools. RunInBackground is
// deliberately left false: the swarm provides concurrency itself (one goroutine
// per member), so each Run executes to completion inside its goroutine.
//
// spec.PermissionMode (resolved + clamped by buildSpec, never above the
// orchestrator's) is propagated so the child only runs unsafe/high autonomy when
// the orchestrator is itself unsafe; otherwise it runs non-unsafe. An empty mode
// is substituted with the non-unsafe "auto" default so a swarm member can never
// fall into the historical empty-means-high path — that is the Task tool's
// behavior, not the swarm's.
func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher {
return FuncLauncher{Run: func(ctx context.Context, spec MemberSpec) (MemberResult, error) {
permissionMode := spec.PermissionMode
if permissionMode == "" {
permissionMode = permissionModeAuto
}
res, err := executor.Run(ctx, specialist.TaskParameters{
Name: spec.AgentType,
Prompt: spec.Task,
Description: spec.AgentType + " · team " + spec.Team,
}, specialist.TaskRunOptions{
ParentModel: spec.Model,
Cwd: spec.Cwd,
PermissionMode: permissionMode,
})
if err != nil {
return MemberResult{}, err
}
return MemberResult{Result: res.Result.Output, SessionID: res.SessionID}, nil
}}
}
+176
View File
@@ -0,0 +1,176 @@
package swarm
import (
"fmt"
)
// Spawn registers a task and launches a member of agentType to run it under the
// given team, inheriting the orchestrator's policy. It returns the task id
// immediately; the member runs concurrently (under the Swarm's base context, so
// it outlives this call) and reports its result through the coordinator. If the
// team is at its slot cap the member is queued and launches when a slot frees
// (it stays pending in the coordinator until then).
func (s *Swarm) Spawn(pol Policy, teamName, agentType, task, cwd string) (string, error) {
def, err := s.registry.Lookup(agentType)
if err != nil {
return "", err
}
team := sanitizeName(teamName)
id := s.nextID(agentType)
if _, err := s.coord.Register(id, id, team, task); err != nil {
return "", err
}
s.rememberCwd(id, cwd)
spec := s.buildSpec(pol, id, id, team, def, task, cwd)
s.dispatch(spec)
return id, nil
}
// dispatch admits a spec to its team (launching now or queuing for a slot).
func (s *Swarm) dispatch(spec MemberSpec) {
t := s.team(spec.Team)
if t.admit(spec) {
s.launch(t, spec)
}
// Otherwise the spec is queued; the coordinator task stays pending until a
// slot frees and afterExit launches it.
}
// launch starts a member for spec and supervises it. A synchronous launch
// failure fails the task and frees the slot.
func (s *Swarm) launch(t *Team, spec MemberSpec) {
handle, err := s.launcher.Launch(s.baseCtx, spec)
if err != nil {
_ = s.coord.Fail(spec.TaskID, "launch: "+err.Error())
s.afterExit(t)
return
}
m := &Member{ID: spec.ID, AgentType: spec.AgentType, TaskID: spec.TaskID, handle: handle}
t.addMember(m)
_ = s.coord.SetStatus(spec.TaskID, StatusRunning)
go s.watch(t, m, spec)
}
// watch awaits a member, applies bounded relaunch on temporary failures, records
// the terminal outcome, then frees the slot and drains the queue.
//
// The relaunch counter is per-member (m.restarts) and the same spec is reused on
// retry. This is sound because a Member is bound 1:1 to its spec for its whole
// life (Member.ID == MemberSpec.ID); a retry never reuses the struct for a
// different spec.
func (s *Swarm) watch(t *Team, m *Member, spec MemberSpec) {
res, err := m.handle.Wait()
if err != nil {
if isRetryable(err) && m.restarts < maxMemberRestarts {
if nh, relErr := s.launcher.Launch(s.baseCtx, spec); relErr == nil {
m.restarts++
m.handle = nh
go s.watch(t, m, spec)
return
}
// fall through to record the original error if relaunch fails
}
_ = s.coord.Fail(m.TaskID, memberError(err))
} else {
_ = s.coord.Complete(m.TaskID, res.Result)
}
t.removeMember(m.ID)
s.afterExit(t)
}
// afterExit releases the just-vacated slot and launches the next queued spec, if
// any. Each exit drains at most one queued member; that member's own exit drains
// the next, so the queue empties one-per-slot without unbounded recursion.
func (s *Swarm) afterExit(t *Team) {
next, ok := t.onExit()
if !ok {
return
}
s.launch(t, next)
}
// Handoff transfers a task to a fresh member of toAgentType, delivering a note to
// the new member's inbox and marking the original task handed-off. It returns the
// new task id. A handoff of an already-terminal task is rejected (fail closed).
func (s *Swarm) Handoff(pol Policy, teamName, taskID, toAgentType, note string) (string, error) {
task, ok := s.coord.Get(taskID)
if !ok {
return "", fmt.Errorf("%w: %s", ErrUnknownTask, taskID)
}
if task.Status.terminal() {
return "", fmt.Errorf("swarm: task %s already %s; cannot hand off", taskID, task.Status)
}
def, err := s.registry.Lookup(toAgentType)
if err != nil {
return "", err
}
team := sanitizeName(teamName)
newID := s.nextID(toAgentType)
handoffTask := task.Description
if note != "" {
handoffTask += "\n\nHandoff note: " + note
}
if _, err := s.coord.Register(newID, newID, team, handoffTask); err != nil {
return "", err
}
cwd := s.cwdFor(taskID)
s.rememberCwd(newID, cwd)
// Deliver the handoff note to the new member's inbox. A mailbox failure must
// not silently strand the handoff, so it is surfaced to the caller.
if note != "" {
if mbErr := s.mailbox.Send(team, newID, Message{
From: task.AgentID, Subject: "handoff", Body: note, Type: "handoff", Time: nowRFC3339(),
}); mbErr != nil {
return "", fmt.Errorf("swarm: deliver handoff note: %w", mbErr)
}
}
// Retire the original task (it has been re-delegated).
_ = s.coord.SetStatus(taskID, StatusHandedOff)
spec := s.buildSpec(pol, newID, newID, team, def, handoffTask, cwd)
s.dispatch(spec)
return newID, nil
}
// AdoptOrphans re-parents tasks in a team whose owning member is no longer live
// (e.g. a crashed worker) onto fresh members of toAgentType, returning the
// adopted task ids. Terminal tasks and tasks with a live owner are left alone.
func (s *Swarm) AdoptOrphans(pol Policy, teamName, toAgentType string) ([]string, error) {
def, err := s.registry.Lookup(toAgentType)
if err != nil {
return nil, err
}
team := sanitizeName(teamName)
t := s.team(team)
live := t.liveAgents()
var adopted []string
for _, task := range s.coord.List() {
if task.Team != team || task.Status.terminal() {
continue
}
if _, ok := live[task.AgentID]; ok {
continue // still has a live member
}
newAgent := s.nextID(toAgentType)
if err := s.coord.Reassign(task.ID, newAgent); err != nil {
continue // raced to terminal; skip
}
cwd := s.cwdFor(task.ID)
s.rememberCwd(task.ID, cwd)
spec := s.buildSpec(pol, newAgent, task.ID, team, def, task.Description, cwd)
s.dispatch(spec)
adopted = append(adopted, task.ID)
}
return adopted, nil
}
// Collect returns snapshots of every task in a team, for swarm_collect/status.
func (s *Swarm) Collect(teamName string) []Task {
team := sanitizeName(teamName)
var out []Task
for _, task := range s.coord.List() {
if task.Team == team {
out = append(out, task)
}
}
return out
}
+345
View File
@@ -0,0 +1,345 @@
package swarm
import (
"context"
"strings"
"sync"
"testing"
"time"
)
// controllableLauncher records every launched spec and lets a test control each
// member's outcome and (optionally) gate completion until a channel is closed.
type controllableLauncher struct {
mu sync.Mutex
specs []MemberSpec
attempts map[string]int
gate chan struct{} // nil => members return immediately
result func(spec MemberSpec, attempt int) (MemberResult, error)
launchErr error
}
func newLauncher(result func(MemberSpec, int) (MemberResult, error)) *controllableLauncher {
return &controllableLauncher{attempts: map[string]int{}, result: result}
}
func (l *controllableLauncher) Launch(ctx context.Context, spec MemberSpec) (MemberHandle, error) {
l.mu.Lock()
if l.launchErr != nil {
err := l.launchErr
l.mu.Unlock()
return nil, err
}
l.specs = append(l.specs, spec)
l.attempts[spec.ID]++
attempt := l.attempts[spec.ID]
gate := l.gate
result := l.result
l.mu.Unlock()
h := &funcHandle{id: spec.ID, done: make(chan struct{})}
go func() {
defer close(h.done)
if gate != nil {
select {
case <-gate:
case <-ctx.Done():
h.err = ctx.Err()
return
}
}
if result != nil {
h.res, h.err = result(spec, attempt)
}
}()
return h, nil
}
func (l *controllableLauncher) recorded() []MemberSpec {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]MemberSpec, len(l.specs))
copy(out, l.specs)
return out
}
func (l *controllableLauncher) attemptCount(id string) int {
l.mu.Lock()
defer l.mu.Unlock()
return l.attempts[id]
}
func newSwarmFor(t *testing.T, l MemberLauncher) *Swarm {
t.Helper()
sw, err := New(Options{BaseDir: t.TempDir(), Launcher: l, MaxTeamSize: 2})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(sw.Close)
return sw
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func okFor(spec MemberSpec, _ int) (MemberResult, error) {
return MemberResult{Result: "ok:" + spec.Task, SessionID: "sess-" + spec.ID}, nil
}
func TestSpawnCompletes(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
id, err := sw.Spawn(Policy{Model: "m"}, "team", "teammate", "build widget", "/work")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, "task done", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
task, _ := sw.Coordinator().Get(id)
if task.Result != "ok:build widget" {
t.Fatalf("result = %q", task.Result)
}
}
func TestSpawnInheritsPolicy(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
_, err := sw.Spawn(Policy{Model: "orch-model", PermissionMode: permissionModeAuto}, "team", "teammate", "task", "/cwd")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
spec := l.recorded()[0]
if spec.Model != "orch-model" {
t.Fatalf("member model = %q, want inherited orch-model", spec.Model)
}
if spec.PermissionMode != permissionModeAuto {
t.Fatalf("member permission mode = %q, want inherited auto", spec.PermissionMode)
}
if spec.Cwd != "/cwd" {
t.Fatalf("member cwd = %q, want /cwd", spec.Cwd)
}
if spec.SystemPrompt == "" {
t.Fatal("member should carry a resolved system prompt")
}
}
func TestConcurrencyCapAndQueueDrains(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate
sw := newSwarmFor(t, l) // MaxTeamSize 2
pol := Policy{Model: "m"}
for i := 0; i < 5; i++ {
if _, err := sw.Spawn(pol, "team", "teammate", "task", ""); err != nil {
t.Fatalf("Spawn %d: %v", i, err)
}
}
team := sw.team("team")
if team.Running() != 2 || team.QueueDepth() != 3 {
t.Fatalf("cap not enforced: running=%d queue=%d, want 2/3", team.Running(), team.QueueDepth())
}
// Release everyone; the queue should drain one-per-slot until all are done.
close(gate)
waitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
if team.Running() != 0 || team.QueueDepth() != 0 {
t.Fatalf("after drain running=%d queue=%d, want 0/0", team.Running(), team.QueueDepth())
}
if got := len(l.recorded()); got != 5 {
t.Fatalf("launched %d members, want 5", got)
}
}
func TestRetryOnTemporaryError(t *testing.T) {
l := newLauncher(func(spec MemberSpec, attempt int) (MemberResult, error) {
if attempt < 3 {
return MemberResult{}, ErrMemberTemporary
}
return MemberResult{Result: "recovered"}, nil
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
waitFor(t, "task recovered", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
if got := l.attemptCount(id); got != 3 {
t.Fatalf("attempts = %d, want 3 (initial + 2 retries)", got)
}
task, _ := sw.Coordinator().Get(id)
if task.Result != "recovered" {
t.Fatalf("result = %q, want recovered", task.Result)
}
}
func TestRetryExhaustionFails(t *testing.T) {
l := newLauncher(func(MemberSpec, int) (MemberResult, error) {
return MemberResult{}, ErrMemberTemporary
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
if got := l.attemptCount(id); got != maxMemberRestarts+1 {
t.Fatalf("attempts = %d, want %d", got, maxMemberRestarts+1)
}
}
func TestPermanentErrorNoRetry(t *testing.T) {
l := newLauncher(func(MemberSpec, int) (MemberResult, error) {
return MemberResult{}, errPlain("hard failure")
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
if got := l.attemptCount(id); got != 1 {
t.Fatalf("attempts = %d, want 1 (no retry on permanent error)", got)
}
task, _ := sw.Coordinator().Get(id)
if task.Err == "" {
t.Fatal("failed task should record an error message")
}
}
func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate // keep the original member running so it is non-terminal
sw := newSwarmFor(t, l)
pol := Policy{Model: "m"}
origID, _ := sw.Spawn(pol, "team", "teammate", "original task", "/w")
waitFor(t, "original running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
newID, err := sw.Handoff(pol, "team", origID, "subagent", "please continue")
if err != nil {
t.Fatalf("Handoff: %v", err)
}
// Original retired.
orig, _ := sw.Coordinator().Get(origID)
if orig.Status != StatusHandedOff {
t.Fatalf("original status = %v, want handed-off", orig.Status)
}
// Note delivered to the new member's inbox.
msgs, err := sw.Mailbox().ReadAndConsume("team", newID)
if err != nil {
t.Fatalf("read new inbox: %v", err)
}
if len(msgs) != 1 || msgs[0].Body != "please continue" || msgs[0].Type != "handoff" {
t.Fatalf("handoff note not delivered: %+v", msgs)
}
// The new member carries the handoff note in its task and preserves cwd.
close(gate)
waitFor(t, "spec for new member", func() bool {
for _, s := range l.recorded() {
if s.ID == newID {
return true
}
}
return false
})
for _, s := range l.recorded() {
if s.ID == newID {
if s.Cwd != "/w" {
t.Fatalf("handoff lost cwd: %q", s.Cwd)
}
}
}
// A handoff of an already-terminal task is rejected.
waitFor(t, "new task done", func() bool {
task, ok := sw.Coordinator().Get(newID)
return ok && task.Status == StatusDone
})
if _, err := sw.Handoff(pol, "team", newID, "teammate", "again"); err == nil {
t.Fatal("handoff of a terminal task must fail")
}
}
func TestAdoptOrphans(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
// Simulate a crashed member: a running task in the coordinator whose owning
// agent has no live member in the team.
if _, err := sw.Coordinator().Register("orphan-1", "ghost", "team", "stranded work"); err != nil {
t.Fatalf("Register: %v", err)
}
_ = sw.Coordinator().SetStatus("orphan-1", StatusRunning)
adopted, err := sw.AdoptOrphans(Policy{Model: "m"}, "team", "subagent")
if err != nil {
t.Fatalf("AdoptOrphans: %v", err)
}
if len(adopted) != 1 || adopted[0] != "orphan-1" {
t.Fatalf("adopted = %v, want [orphan-1]", adopted)
}
// The orphan is relaunched under a fresh agent and completes.
waitFor(t, "orphan completed", func() bool {
task, ok := sw.Coordinator().Get("orphan-1")
return ok && task.Status == StatusDone
})
task, _ := sw.Coordinator().Get("orphan-1")
if task.AgentID == "ghost" {
t.Fatal("orphan should be reassigned to a new agent")
}
// A second adoption finds nothing (the task now has a live/owned outcome).
again, _ := sw.AdoptOrphans(Policy{}, "team", "subagent")
if len(again) != 0 {
t.Fatalf("second adoption = %v, want none", again)
}
}
func TestCollectScopesToTeam(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
a, _ := sw.Spawn(Policy{}, "alpha", "teammate", "ta", "")
_, _ = sw.Spawn(Policy{}, "beta", "teammate", "tb", "")
waitFor(t, "alpha done", func() bool {
task, ok := sw.Coordinator().Get(a)
return ok && task.Status == StatusDone
})
collected := sw.Collect("alpha")
if len(collected) != 1 || collected[0].Team != "alpha" {
t.Fatalf("Collect(alpha) = %+v, want one alpha task", collected)
}
}
func TestFuncLauncherRecoversPanic(t *testing.T) {
// A panic inside a member's Run must surface as that member's error, never
// escape the goroutine and crash the orchestrator.
l := FuncLauncher{Run: func(context.Context, MemberSpec) (MemberResult, error) {
panic("boom in member")
}}
h, err := l.Launch(context.Background(), MemberSpec{ID: "m1"})
if err != nil {
t.Fatalf("Launch: %v", err)
}
_, err = h.Wait()
if err == nil || !strings.Contains(err.Error(), "panicked") {
t.Fatalf("panic must surface as a member error, got %v", err)
}
}
func TestSpawnUnknownAgentType(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
if _, err := sw.Spawn(Policy{}, "team", "does-not-exist", "task", ""); err == nil {
t.Fatal("Spawn with unknown agent type must error")
}
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
package swarm
import (
"errors"
"os"
)
// isLockContended reports whether an O_EXCL lock-create error means the lock is
// currently held (so the caller should retry/wait) rather than a hard failure.
// On POSIX an existing lock surfaces only as os.ErrExist.
func isLockContended(err error) bool {
return errors.Is(err, os.ErrExist)
}
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package swarm
import (
"errors"
"os"
"syscall"
)
// Windows error codes that an O_EXCL create (or a racing delete) can surface
// when another writer holds the lock file open, instead of ERROR_FILE_EXISTS.
// Treating them as "contended" makes the lock retry/wait rather than fail the
// whole send/read spuriously under contention.
const (
errorAccessDenied = syscall.Errno(5) // ERROR_ACCESS_DENIED
errorSharingViolation = syscall.Errno(32) // ERROR_SHARING_VIOLATION
)
// isLockContended reports whether an O_EXCL lock-create error means the lock is
// currently held (so the caller should retry/wait) rather than a hard failure.
// On Windows a held lock can appear as ERROR_FILE_EXISTS (mapped to os.ErrExist)
// but also as ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION when the file is
// concurrently open or pending deletion.
func isLockContended(err error) bool {
if errors.Is(err, os.ErrExist) {
return true
}
return errors.Is(err, errorAccessDenied) || errors.Is(err, errorSharingViolation)
}
+385
View File
@@ -0,0 +1,385 @@
package swarm
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"time"
)
// Mailbox is a per-agent, per-team message inbox persisted as a JSON array on
// disk at <baseDir>/<team>/inboxes/<agent>.json, written atomically under a
// lock file. It is hardened for untrusted input:
//
// - inbox files and their parent dirs are owner-only (0600/0700);
// - every write takes an exclusive lock file (bounded retry, stale-break);
// - message bodies and whole inboxes are size-capped (fail closed on oversize);
// - team/agent names are sanitized so a name can never escape the base dir;
// - malformed inbox JSON is rejected rather than silently reset (fail closed).
type Mailbox struct {
// BaseDir is the root under which <team>/inboxes/<agent>.json live. It must
// be set (NewMailbox enforces this).
BaseDir string
// MaxMessageBytes caps a single serialized message. 0 => defaultMaxMessageBytes.
MaxMessageBytes int
// MaxMessages caps the number of messages retained in one inbox. 0 =>
// defaultMaxMessages. Sends past the cap fail closed.
MaxMessages int
// LockTimeout bounds how long Send/MarkRead wait for the inbox lock.
LockTimeout time.Duration
}
const (
defaultMaxMessageBytes = 64 * 1024 // 64 KiB per message
defaultMaxMessages = 1000 // per inbox
defaultLockTimeout = 5 * time.Second
lockStaleAfter = 30 * time.Second
inboxFileMode = 0o600
inboxDirMode = 0o700
)
// ErrMailboxFull is returned when an inbox is at MaxMessages.
var ErrMailboxFull = errors.New("swarm: mailbox is full")
// ErrMessageTooLarge is returned when a single message exceeds MaxMessageBytes.
var ErrMessageTooLarge = errors.New("swarm: message exceeds size cap")
// Message is one inbox entry. Time is RFC3339 and set on Send if empty.
type Message struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
Type string `json:"type"`
Read bool `json:"read"`
Time string `json:"time,omitempty"`
}
// NewMailbox validates baseDir and returns a Mailbox with defaults applied.
func NewMailbox(baseDir string) (*Mailbox, error) {
if strings.TrimSpace(baseDir) == "" {
return nil, errors.New("swarm: mailbox base dir is required")
}
abs, err := filepath.Abs(baseDir)
if err != nil {
return nil, fmt.Errorf("swarm: resolve mailbox base dir: %w", err)
}
return &Mailbox{
BaseDir: abs,
MaxMessageBytes: defaultMaxMessageBytes,
MaxMessages: defaultMaxMessages,
LockTimeout: defaultLockTimeout,
}, nil
}
var nameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9\-_]`)
// sanitizeName replaces any char outside [A-Za-z0-9-_] with '-', defaulting
// empty input to "default" and an all-unsafe result to "unknown". This
// guarantees the name is a single safe path segment (no '/', '\\', '.', '..').
func sanitizeName(name string) string {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
trimmed = "default"
}
cleaned := nameUnsafe.ReplaceAllString(trimmed, "-")
if cleaned == "" {
return "unknown"
}
return cleaned
}
func (m *Mailbox) maxMessageBytes() int {
if m.MaxMessageBytes > 0 {
return m.MaxMessageBytes
}
return defaultMaxMessageBytes
}
func (m *Mailbox) maxMessages() int {
if m.MaxMessages > 0 {
return m.MaxMessages
}
return defaultMaxMessages
}
func (m *Mailbox) lockTimeout() time.Duration {
if m.LockTimeout > 0 {
return m.LockTimeout
}
return defaultLockTimeout
}
// inboxPath returns the sanitized, base-confined inbox path for (team, agent)
// and verifies it cannot escape BaseDir lexically (defense in depth on top of
// sanitize). Symlink-aware confinement happens in ensureInboxDir.
func (m *Mailbox) inboxPath(team, agent string) (string, error) {
dir := filepath.Join(m.BaseDir, sanitizeName(team), "inboxes")
path := filepath.Join(dir, sanitizeName(agent)+".json")
if within, err := pathWithin(m.BaseDir, path); err != nil || !within {
return "", fmt.Errorf("swarm: inbox path escapes base dir (team=%q agent=%q)", team, agent)
}
return path, nil
}
// pathWithin reports whether target is base or lives under it (lexically).
func pathWithin(base, target string) (bool, error) {
rel, err := filepath.Rel(base, target)
if err != nil {
return false, err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return false, nil
}
return true, nil
}
// ensureInboxDir creates the inbox's parent directories owner-only, then
// verifies — after resolving symlinks — that the real directory stays within the
// real base dir. This defends against a symlink planted under BaseDir (e.g. a
// pre-existing "inboxes" -> /etc) that the lexical inboxPath check cannot catch.
// Only after confinement is confirmed are the directories tightened to 0700, so
// a chmod can never land outside the base via a symlink.
func (m *Mailbox) ensureInboxDir(inboxPath string) error {
dir := filepath.Dir(inboxPath) // <base>/<team>/inboxes
if err := os.MkdirAll(dir, inboxDirMode); err != nil {
return fmt.Errorf("swarm: create inbox dir: %w", err)
}
realBase, err := filepath.EvalSymlinks(m.BaseDir)
if err != nil {
return fmt.Errorf("swarm: resolve base dir: %w", err)
}
realDir, err := filepath.EvalSymlinks(dir)
if err != nil {
return fmt.Errorf("swarm: resolve inbox dir: %w", err)
}
if within, err := pathWithin(realBase, realDir); err != nil || !within {
return fmt.Errorf("swarm: inbox dir escapes base dir via symlink")
}
// MkdirAll leaves an existing dir's permissions untouched; tighten the team
// and inboxes dirs to owner-only so mailbox contents are never group/world
// readable even if a dir pre-existed with broad perms.
_ = os.Chmod(dir, inboxDirMode)
_ = os.Chmod(filepath.Dir(dir), inboxDirMode) // <base>/<team>
return nil
}
// Send appends msg to the recipient's inbox under the team, taking the inbox
// lock. It fails closed on oversize messages or a full inbox.
//
// Each send rewrites the whole inbox (read all → append one → write all), so it
// is O(N) in the inbox size. That is intentional given the small bounds
// (MaxMessages × MaxMessageBytes); if those caps are raised substantially,
// switch to an append-only log with periodic compaction.
func (m *Mailbox) Send(team, recipient string, msg Message) error {
path, err := m.inboxPath(team, recipient)
if err != nil {
return err
}
if msg.Type == "" {
msg.Type = "message"
}
if msg.To == "" {
msg.To = sanitizeName(recipient)
}
if msg.Time == "" {
msg.Time = time.Now().UTC().Format(time.RFC3339)
}
encoded, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("swarm: encode message: %w", err)
}
if len(encoded) > m.maxMessageBytes() {
return fmt.Errorf("%w: %d > %d bytes", ErrMessageTooLarge, len(encoded), m.maxMessageBytes())
}
if err := m.ensureInboxDir(path); err != nil {
return err
}
release, err := acquireLock(path+".lock", m.lockTimeout())
if err != nil {
return err
}
defer release()
messages, err := m.readLocked(path)
if err != nil {
return err
}
if len(messages) >= m.maxMessages() {
return fmt.Errorf("%w: %d messages", ErrMailboxFull, len(messages))
}
messages = append(messages, msg)
return atomicWriteJSON(path, messages)
}
// ReadAndConsume reads the recipient's inbox and marks every previously-unread
// message read under one lock, returning the messages with their read flags as
// they were BEFORE this read (so the caller can tell which were new). The inbox
// is empty (no error) if it does not yet exist; malformed inbox JSON is reported
// as an error (fail closed) and nothing is written.
func (m *Mailbox) ReadAndConsume(team, recipient string) ([]Message, error) {
path, err := m.inboxPath(team, recipient)
if err != nil {
return nil, err
}
// The lock file lives beside the inbox; create the dir so it can be taken
// even on first read of a not-yet-existing inbox.
if err := m.ensureInboxDir(path); err != nil {
return nil, err
}
release, err := acquireLock(path+".lock", m.lockTimeout())
if err != nil {
return nil, err
}
defer release()
messages, err := m.readLocked(path)
if err != nil {
return nil, err
}
if len(messages) == 0 {
return messages, nil
}
// Snapshot pre-consume state to return, then flip unread -> read on disk.
snapshot := make([]Message, len(messages))
copy(snapshot, messages)
changed := false
for i := range messages {
if !messages[i].Read {
messages[i].Read = true
changed = true
}
}
if changed {
if err := atomicWriteJSON(path, messages); err != nil {
return nil, err
}
}
return snapshot, nil
}
// readLocked reads and decodes an inbox file. Callers that mutate must hold the
// lock; pure readers may call it directly (a torn read surfaces as a decode
// error and is retried by the caller, never silently treated as empty).
func (m *Mailbox) readLocked(path string) ([]Message, error) {
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("swarm: read inbox: %w", err)
}
if len(raw) == 0 {
return nil, nil
}
// Reject an oversized inbox file outright rather than decoding untrusted,
// unbounded JSON into memory. The bound is the cap on total message bytes
// (maxMessages × maxMessageBytes) doubled to allow for pretty-printed JSON
// (indentation, field names, brackets) overhead versus the compact bytes the
// per-message cap measures.
if maxFile := m.maxMessages() * m.maxMessageBytes() * 2; len(raw) > maxFile {
return nil, fmt.Errorf("swarm: inbox file too large (%d bytes)", len(raw))
}
var messages []Message
if err := json.Unmarshal(raw, &messages); err != nil {
return nil, fmt.Errorf("swarm: malformed inbox %s: %w", filepath.Base(path), err)
}
for i := range messages {
if messages[i].Type == "" {
messages[i].Type = "message"
}
}
return messages, nil
}
// atomicWriteJSON writes data as pretty JSON to a sibling temp file (0600) then
// renames it over path, so a reader never observes a partial write.
func atomicWriteJSON(path string, data any) error {
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("swarm: encode inbox: %w", err)
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".inbox-*.tmp")
if err != nil {
return fmt.Errorf("swarm: create temp inbox: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // no-op after a successful rename
if err := tmp.Chmod(inboxFileMode); err != nil {
tmp.Close()
return fmt.Errorf("swarm: chmod temp inbox: %w", err)
}
if _, err := tmp.Write(encoded); err != nil {
tmp.Close()
return fmt.Errorf("swarm: write temp inbox: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("swarm: close temp inbox: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("swarm: commit inbox: %w", err)
}
return nil
}
// lockSeq makes each lock token unique within the process even when two
// goroutines acquire in the same nanosecond.
var lockSeq atomic.Uint64
// acquireLock takes an exclusive lock by creating lockPath with O_EXCL. It
// retries with a short backoff until timeout, breaking a lock whose file is
// older than lockStaleAfter (so a crashed holder cannot deadlock the swarm).
//
// Each holder writes a unique token into the lock file and release is
// OWNERSHIP-AWARE: it removes the lock only if it still holds our token. After a
// stale-break another writer may legitimately own the lock; unconditionally
// removing it would let a third writer in and create split-brain writers that
// can corrupt or drop mailbox updates.
func acquireLock(lockPath string, timeout time.Duration) (func(), error) {
token := fmt.Sprintf("%d-%d-%d", os.Getpid(), time.Now().UnixNano(), lockSeq.Add(1))
deadline := time.Now().Add(timeout)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, inboxFileMode)
if err == nil {
// Record our ownership token; the exclusive create already holds the
// lock, so a write error only weakens diagnostics, not safety.
_, _ = f.WriteString(token)
f.Close()
var released bool
return func() {
if released {
return
}
released = true
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == token {
os.Remove(lockPath)
}
}, nil
}
if !isLockContended(err) {
return nil, fmt.Errorf("swarm: acquire lock: %w", err)
}
// Lock held: break it if stale, otherwise wait. The break is conditional
// on the file's content being unchanged since we observed it, so we do not
// delete a lock another writer rotated in between.
if info, statErr := os.Stat(lockPath); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
stale, _ := os.ReadFile(lockPath)
if data, rerr := os.ReadFile(lockPath); rerr == nil && string(data) == string(stale) {
os.Remove(lockPath)
}
continue
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("swarm: timed out acquiring lock %s", filepath.Base(lockPath))
}
// Short retry so a freed lock is re-acquired promptly under heavy
// contention (Windows file ops are slow; a coarse sleep starves waiters).
time.Sleep(2 * time.Millisecond)
}
}
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more