Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions internal/agenteval/suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,6 @@ func validateFileList(taskPath string, field string, files []string, required bo
return problems
}

func validateExpectedChangedFiles(taskPath string, files []string) []string {
return validateFileList(taskPath, "expectedChangedFiles", files, true)
}

func validateStringList(taskPath string, field string, values []string) []string {
problems := []string{}
seen := map[string]int{}
Expand Down
27 changes: 14 additions & 13 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,18 @@ func validateAuthFlags(sub string, a authArgs) error {
// ZERO_OAUTH_STORAGE=encrypted-file selects the AES-256-GCM encrypted-at-rest
// backend (a per-user secret is created beside the token file).
func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// Validate ZERO_OAUTH_STORAGE up front: a mistyped non-empty value must fail
// fast rather than silently fall back to plaintext while the user believes
// encryption is on. Empty = default (plaintext 0600); "encrypted-file" = AES.
encrypted := false
if mode := strings.TrimSpace(os.Getenv("ZERO_OAUTH_STORAGE")); mode != "" {
if !strings.EqualFold(mode, "encrypted-file") {
return nil, fmt.Errorf("invalid ZERO_OAUTH_STORAGE %q (supported: encrypted-file)", mode)
}
encrypted = true
// Validate ZERO_OAUTH_STORAGE up front: a mistyped value must fail fast rather
// than silently change the backend. Empty = default (plaintext 0600 file);
// "encrypted-file" = AES-256-GCM; "keyring" = the OS keyring.
storage := strings.ToLower(strings.TrimSpace(os.Getenv("ZERO_OAUTH_STORAGE")))
switch storage {
case "", "file", "encrypted-file", "keyring":
default:
return nil, fmt.Errorf("invalid ZERO_OAUTH_STORAGE %q (supported: file, encrypted-file, keyring)", storage)
}
store, err := oauth.NewStore(oauth.StoreOptions{
Now: deps.now,
Encrypted: encrypted,
Now: deps.now,
Storage: storage,
})
if err != nil {
return nil, err
Expand Down Expand Up @@ -350,8 +349,10 @@ built in). For a provider named <name>, set:
Endpoint URLs must be https (loopback exempt).

Storage: tokens are written 0600 under $XDG_CONFIG_HOME/zero (override with
ZERO_OAUTH_TOKENS_PATH). Set ZERO_OAUTH_STORAGE=encrypted-file to encrypt them
at rest with AES-256-GCM (a per-user secret is created beside the token file).
ZERO_OAUTH_TOKENS_PATH). Set ZERO_OAUTH_STORAGE=encrypted-file to encrypt them at
rest with AES-256-GCM (a per-user secret beside the file), or
ZERO_OAUTH_STORAGE=keyring to use the OS keyring (macOS Keychain / Linux
secret-tool). MCP server tokens share the same store.

Flags:
--device Use the device-code flow (headless/SSH; no browser)
Expand Down
95 changes: 90 additions & 5 deletions internal/cli/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ func runDaemon(args []string, stdout io.Writer, stderr io.Writer, _ appDeps) int
return runDaemonAttach(rest, stdout, stderr)
case "serve-remote":
return runDaemonServeRemote(rest, stdout, stderr)
case "link":
return runDaemonLink(rest, stdout, stderr)
case "-h", "--help", "help":
return writeDaemonUsage(stdout, exitSuccess)
default:
Expand All @@ -56,14 +58,20 @@ Commands:
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-remote --addr <host:port> --tls-cert <f> --tls-key <f> [--bundle-dir <d>]
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).
(or $ZERO_DAEMON_REMOTE_TOKEN_FILE). --bundle-dir enables
git-bundle uploads, extracted into per-link work trees.
link --remote <host:port> --repo <dir> --id <name> [--out <file>]
Upload repo's git history to the remote as a bundle and
print the extracted remote path. --out saves a session
link file (0600). Accepts --token/--ca-cert/--server-name.
link --show <file> Print a saved session link.

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.
local socket. Use the link's remote path as --cwd to run against a linked repo.
`)
return code
}
Expand Down Expand Up @@ -399,7 +407,7 @@ func daemonDialError(flags remoteDialFlags, err error) string {
// 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 := "", "", ""
addr, certFile, keyFile, bundleDir := "", "", "", ""
minVersion, maxConns := 0, 0
for i := 0; i < len(args); i++ {
a := args[i]
Expand Down Expand Up @@ -449,6 +457,14 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
return writeExecUsageError(stderr, "--max-conns requires a value")
}
maxConns = atoiOrZero(v)
case a == "--bundle-dir":
v, ok := value()
if !ok {
return writeExecUsageError(stderr, "--bundle-dir requires a value")
}
bundleDir = v
case strings.HasPrefix(a, "--bundle-dir="):
bundleDir = strings.TrimPrefix(a, "--bundle-dir=")
default:
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon serve-remote", a))
}
Expand Down Expand Up @@ -491,7 +507,7 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
return writeAppError(stderr, err.Error(), exitCrash)
}
bridge, err := remote.NewBridge(remote.BridgeOptions{
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, Log: logf,
Server: srv, Authenticator: auth, MinVersion: minVersion, MaxConnections: maxConns, BundleDir: bundleDir, Log: logf,
})
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
Expand Down Expand Up @@ -524,6 +540,75 @@ func runDaemonServeRemote(args []string, stdout io.Writer, stderr io.Writer) int
}
}

// runDaemonLink uploads a repo's git history to a remote bridge as a bundle
// (link without --show), or prints a saved session link (--show <file>).
func runDaemonLink(args []string, stdout io.Writer, stderr io.Writer) int {
var addr, repo, id, token, caCert, serverName, out, show string
// flags maps each "--name" to the string it sets; both "--name v" and
// "--name=v" forms are accepted.
flags := map[string]*string{
"--remote": &addr, "--repo": &repo, "--id": &id, "--token": &token,
"--ca-cert": &caCert, "--server-name": &serverName, "--out": &out, "--show": &show,
}
for i := 0; i < len(args); i++ {
a := args[i]
if a == "-h" || a == "--help" {
return writeDaemonUsage(stdout, exitSuccess)
}
name, inlineVal, hasInline := a, "", false
if eq := strings.IndexByte(a, '='); eq >= 0 {
name, inlineVal, hasInline = a[:eq], a[eq+1:], true
}
dst, ok := flags[name]
if !ok {
return writeExecUsageError(stderr, fmt.Sprintf("unknown flag %q for daemon link", a))
}
if hasInline {
*dst = inlineVal
continue
}
if i+1 >= len(args) {
return writeExecUsageError(stderr, fmt.Sprintf("%s requires a value", name))
}
i++
*dst = args[i]
}

if strings.TrimSpace(show) != "" {
link, err := remote.LoadSessionLink(show)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "link %s -> %s on %s\n", link.LinkID, link.RemotePath, link.Address)
return exitSuccess
}

if strings.TrimSpace(addr) == "" || strings.TrimSpace(repo) == "" || strings.TrimSpace(id) == "" {
return writeExecUsageError(stderr, "daemon link requires --remote, --repo, and --id (or --show <file>)")
}
if strings.TrimSpace(token) == "" {
token, _ = remote.TokenFromEnv() // best effort; UploadRepoBundle rejects an empty token
}
link, err := remote.UploadRepoBundle(remote.RemoteConfig{
Address: addr,
Token: token,
CACertFile: caCert,
ServerName: serverName,
}, repo, id)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "uploaded %s; remote repo at %s\n", link.LinkID, link.RemotePath)
fmt.Fprintf(stdout, "run it with: zero daemon run --remote %s --cwd %s ...\n", link.Address, link.RemotePath)
if strings.TrimSpace(out) != "" {
if err := link.Save(out); err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
fmt.Fprintf(stdout, "saved session link to %s\n", out)
}
return exitSuccess
}

// 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 {
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
Mode: notify.Mode(strings.TrimSpace(execNotifyMode(options, resolved))),
FocusMode: notify.FocusAlways,
})
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Headless runs can safely
// log a failed delivery to stderr (never stdout). The sink redacts before
// logging, so a token in the URL or message is masked.
notify.MaybeAddWebhookSink(notifier, os.Getenv, func(format string, args ...any) {
fmt.Fprintf(stderr, "[notify] "+format+"\n", args...)
})
if options.useSpec {
return runExecSpecDraft(execSpecDraftRun{
options: options,
Expand Down
11 changes: 11 additions & 0 deletions internal/daemon/remote/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,23 @@ type noopAttestation struct{}

func (noopAttestation) Verify(map[string]string) error { return nil }

// Connection modes negotiated in the auth handshake. The default (empty) is a
// daemon session, preserving the original behavior; "bundle" requests a one-shot
// git-bundle upload instead of a session.
const (
ModeSession = "session"
ModeBundle = "bundle"
)

// 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"`
// Mode selects the connection's purpose: "" / "session" => a daemon session
// (default), "bundle" => a git-bundle upload. Unknown modes are rejected.
Mode string `json:"mode,omitempty"`
}

// authResponse is the bridge's reply to the auth handshake.
Expand Down
47 changes: 44 additions & 3 deletions internal/daemon/remote/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const (
defaultMaxConnections = 32
defaultHandshakeTimeout = 10 * time.Second
defaultAuthFailDelay = 250 * time.Millisecond
// defaultMaxBundleBytes caps a single uploaded git bundle (256 MiB) so a
// remote peer cannot exhaust the host's disk.
defaultMaxBundleBytes = 256 << 20
)

// Bridge serves authenticated remote connections and drives the local daemon's
Expand All @@ -27,6 +30,8 @@ type Bridge struct {
minVersion int
handshakeTimeout time.Duration
authFailDelay time.Duration
bundleDir string
maxBundleBytes int64
log func(string)
sem chan struct{}

Expand All @@ -51,7 +56,13 @@ type BridgeOptions struct {
HandshakeTimeout time.Duration
// AuthFailDelay slows brute-force attempts; <0 => none, 0 => default.
AuthFailDelay time.Duration
Log func(string)
// BundleDir is the directory under which uploaded git bundles are extracted
// into per-link working trees. Empty disables bundle uploads entirely (a
// bundle-mode connection is then refused — opt-in, fail closed).
BundleDir string
// MaxBundleBytes caps a single uploaded bundle; 0 => default.
MaxBundleBytes int64
Log func(string)
}

// NewBridge validates options and builds a Bridge.
Expand Down Expand Up @@ -85,13 +96,19 @@ func NewBridge(opts BridgeOptions) (*Bridge, error) {
if attest == nil {
attest = noopAttestation{}
}
maxBundleBytes := opts.MaxBundleBytes
if maxBundleBytes <= 0 {
maxBundleBytes = defaultMaxBundleBytes
}
return &Bridge{
server: opts.Server,
auth: opts.Authenticator,
attest: attest,
minVersion: minVersion,
handshakeTimeout: handshakeTimeout,
authFailDelay: authFailDelay,
bundleDir: strings.TrimSpace(opts.BundleDir),
maxBundleBytes: maxBundleBytes,
log: opts.Log,
sem: make(chan struct{}, maxConns),
}, nil
Expand Down Expand Up @@ -179,13 +196,37 @@ func (b *Bridge) handle(conn net.Conn) {
b.deny(conn, "attestation failed")
return
}
// Resolve the connection mode before accepting it. Validating here (before the
// success response) lets the bridge fail closed: an unknown mode, or a bundle
// upload when bundle transfer is disabled, is denied without side effects.
mode := req.Mode
if mode == "" {
mode = ModeSession
}
switch mode {
case ModeSession:
case ModeBundle:
if b.bundleDir == "" {
b.deny(conn, "bundle transfer not enabled")
return
}
default:
b.deny(conn, "unsupported mode")
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.
// Clear the handshake deadline: a session may stream, and a bundle upload may
// transfer many megabytes, for a long time.
_ = conn.SetDeadline(time.Time{})
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
switch mode {
case ModeBundle:
b.handleBundle(conn) // receives + extracts the bundle, then closes conn
default:
b.server.ServeConn(conn) // performs the daemon handshake + one command, then closes conn
}
}

// deny rejects an unauthenticated connection after a small backoff (to slow
Expand Down
Loading
Loading