mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
* 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.
* notify: wire the webhook sink behind an opt-in env var
The WebhookSink (Slack / generic JSON POST on turn completion) was fully
implemented, redaction-safe, and tested — but never attached to a Notifier, so
deadcode flagged its entire machinery (NewWebhookSink, Emit, text, redactLinks,
log, eventType, readSnippet) as unreachable.
Wire it via a small, testable helper, MaybeAddWebhookSink(n, env, logf):
- reads ZERO_NOTIFY_WEBHOOK_URL (+ optional ZERO_NOTIFY_WEBHOOK_SUMMARY)
- no-op when the URL is unset/blank, so it is safe to call unconditionally
- sourcing the URL from the environment keeps the secret token out of any
on-disk config file
Attached at both notifier construction sites:
- headless exec: failed deliveries log to stderr (never stdout); the sink
redacts before logging
- TUI: logf=nil so a delivery failure can't corrupt the alt-screen
The sink stays gated by the existing Mode/focus policy (verified by
TestNotifierOffSuppressesSinks), so a webhook only fires when notifications are
enabled (e.g. --notify both) — no change to default behavior. Opt-in and
fail-closed: with the env var unset nothing is attached.
deadcode: 100 -> 93 unreachable funcs (the 7 WebhookSink entries become
reachable); no new dead code. gofmt/vet/build(host+linux+windows)/test -race/
staticcheck(no new)/govulncheck(0) all pass.
* swarm: add opt-in recurring-spawn scheduler (wakeup + daily)
Adds a dependency-free Scheduler to internal/swarm so a member can be spawned on
a recurring schedule, plus a swarm_schedule tool to drive it.
- Schedule is interval-based ("wakeup", every >= 1s) with an optional first
delay and a MaxRuns cap; a daily "HH:MM" ("cron") time is expressed as a 24h
interval whose first delay lands on the next occurrence.
- Each fire spawns a fresh member through the existing Swarm.Spawn path, so
members inherit the recorded policy and are bounded by the team slot cap +
queue. Non-overlapping by default: a fire is skipped while the job's previous
spawn is still running, preventing queue pile-up.
- Scheduler is lazily created (Swarm.Scheduler()), opt-in (nothing runs until a
job is added), and tied to the Swarm's base context — Swarm.Close() stops the
scheduler first so no spawn fires during shutdown.
- swarm_schedule tool: action add|list|cancel; prompt-gated like swarm_spawn;
add validates schedule + agent type up front (fail fast).
The timing loop takes an injectable ticker, so the tests drive fires
deterministically with no real time (fire/skip/cap/cancel/close, plus pure
nextDailyDelay/parseClock/swarmInt). All swarm tests pass 3x under -race.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(clean)/govulncheck(0)/
deadcode(no new) all pass.
* daemon/remote: SessionLink + git-bundle upload over the bridge
Adds an opt-in way to ship a local repo's git history to a remote daemon and
link a remote working tree to it — without a shared filesystem or a network git
remote. The daemon core (protocol/server) is untouched; all changes live in the
remote package plus a CLI hook.
Wire:
- The auth handshake gains an optional Mode field ("" / "session" => a daemon
session as before; "bundle" => a one-shot git-bundle upload). Unknown modes,
or bundle mode when uploads are disabled, are denied (fail closed).
- Bundle transfer reuses the daemon frame codec: a header frame (link id +
exact size), then the bundle bytes as KindData frames, then a result frame.
Server (Bridge):
- BridgeOptions.BundleDir enables uploads (empty => refused). A received bundle
is size-capped (default 256 MiB), staged to a temp file, `git bundle verify`-d,
then `git clone`-d into a staging dir and atomically renamed over
<BundleDir>/<link-id>. Link ids are sanitized to a single path component and
containment is re-checked, so an upload can never escape the bundle dir.
Client:
- UploadRepoBundle creates `git bundle create --all` of a work tree, hashes it
(sha256), streams it over an authenticated bundle-mode TLS connection, and
returns a SessionLink {address, link id, remote path, bundle sha}. SessionLink
persists as a 0600 atomic JSON file (token never stored) with Save/Load/Validate.
CLI:
- `daemon serve-remote --bundle-dir <d>` enables uploads.
- `daemon link --remote <addr> --repo <dir> --id <name> [--out <file>]` uploads
and prints the remote path (+ a ready run command); `daemon link --show <file>`
prints a saved link.
Tests: git bundle create/verify/clone roundtrip; link-id sanitization + path
containment; SessionLink save/load/validate (0600); and a full bridge upload E2E
over real TLS (extract + content check + re-upload-replaces + disabled + bad
token). All remote tests pass 2x under -race; CLI smoke verified via the binary.
Gates: gofmt/vet/build(host+linux+windows)/test/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* mcp: unify OAuth token storage onto the shared oauth store
MCP server tokens lived in their own mcp-oauth-tokens.json with a bespoke
codec/lock, separate from the provider-login store (internal/oauth). The oauth
store already reserved the "mcp:" key namespace for exactly this. Unify them.
- mcp.TokenStore now delegates to internal/oauth.Store, keying each server's
token as "mcp:<server>". Its public API (StoredToken/TokenStatus/Save/Load/
Delete/Status/FilePath) is unchanged, so callers (network client, CLI) are
untouched. The bespoke read/write/lock code is removed in favor of the shared,
cross-process-locked, atomic store.
- Transparent, non-destructive migration on first construction: a legacy
mcp-oauth-tokens.json is imported into the unified store (only entries not
already present — a newer unified token is never overwritten), then renamed to
a ".migrated" backup so it is imported at most once and stays recoverable.
A missing / unreadable / unknown-schema legacy file is left untouched and
never blocks startup. An explicit FilePath suppresses the default-path
migration so tests/overrides can't touch the real user config.
- Server names that can't form a valid namespaced key (oauth.ValidateKey) are
rejected on save and skipped during migration. Added oauth.Store.FilePath().
The token format was already identical (access/refresh/type/scopes/expires), so
this is a key rename, not a format change. Existing TokenStore tests pass
unchanged (they exercise the API); added migration + namespacing + newer-wins
tests. Local run check: `zero mcp oauth status` migrates a seeded legacy file
into oauth-tokens.json under mcp:demo, renames the legacy to .migrated, and leaks
no token material.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(clean)/
govulncheck(0)/deadcode(no new) all pass.
* oauth: add an opt-in OS keyring storage backend
Introduces a pluggable storage backend for the unified oauth token store and a
dependency-free OS keyring implementation, selected with
ZERO_OAUTH_STORAGE=keyring (default stays the 0600 file).
- internal/keyring: a small, third-party-dep-free secret store backed by the OS
tooling — `security` on macOS, `secret-tool` (libsecret) on Linux; other
platforms report unsupported. On Linux the secret is passed via stdin (never
the argv); on macOS `security` takes it as an argument (briefly visible in the
process list) — documented, and a reason to prefer the file backend there.
A runner seam makes the per-OS command logic fully testable without a real
keychain.
- internal/oauth: Store now persists its JSON blob through a blobStore backend.
fileBlob preserves the exact prior behavior (0600, atomic write, cross-process
lock file). keyringBlob stores the blob as one base64 entry (keeps multi-line
JSON a single control-char-free value) via a KeyringClient. NewStore resolves
the backend from StoreOptions.Storage or ZERO_OAUTH_STORAGE; unknown storage
and an unavailable keyring fail closed with a clear error.
Because both `zero auth` and the (now unified) MCP token store construct their
store through oauth.NewStore, the keyring backend is honored by both with no
call-site changes. Added oauth.Store.FilePath() returns a "keyring:" identifier
for that backend.
No new module dependency (go.mod unchanged). Tests: keyring round-trip/not-found/
unsupported/missing-binary/stdin-not-argv via an injected runner; store keyring
round-trip + base64-at-rest + storage selection; all existing file-backend tests
pass unchanged. Local run check (macOS): validated the exact `security`
add/find/delete shapes and the not-found exit code (44) against the real binary,
and `mcp oauth status` with ZERO_OAUTH_STORAGE=keyring reads the real keychain
cleanly.
Gates: gofmt/vet/build(host+linux+windows)/test -race/staticcheck(no new)/
govulncheck(0)/deadcode(no new) all pass.
* all: prune unused unexported functions (dead-code sweep)
Removes 15 unexported functions/types/consts that are unreachable even from
tests (every one flagged by staticcheck U1000), clearing the U1000 noise the v16
audit surfaced. Conservative by design: only unexported, test-unused symbols are
removed — exported internal API (which may be an intentional surface or used only
by tests) is deliberately left in place.
Removed:
- agenteval: validateExpectedChangedFiles
- providers/gemini: const providerName
- tui: stripMarkdownInline, removeTrailingAtToken,
noopModelSwitchCompactionPolicy (+ BeforeModelSwitch), deleteComposerLineBefore,
deleteComposerLineAfter, deleteCompletedFileMentionBefore, mcpViewStatus,
mcpServerLines, fitCommandOutput, (*commandPicker).clearQuery,
tuiTheme.onPanel2, indentText
No behavior change (the symbols had no callers). staticcheck U1000 is now empty;
deadcode drops accordingly with no genuinely-new unreachable functions (verified
by a name-based diff vs origin/main). gofmt/vet/build(host+linux+windows)/full
test all pass.
* Address review feedback on the audit backlog
Resolve CodeRabbit review comments on this PR:
- keyring isNotFound: match the specific not-found exit codes (macOS `security`
44 = errSecItemNotFound; `secret-tool` 1) instead of treating any non-zero
exit as "no entry", so a real failure (locked keychain, permissions) surfaces
instead of being masked as absent.
- swarm scheduler daily_at: recompute each fire as the next local HH:MM rather
than adding a fixed 24h, so a wall-clock daily schedule does not drift across
DST. Adds Daily/Hour/Minute to Schedule and a clock to the Scheduler.
- swarm scheduler: reject Add when the scheduler context is already canceled
(not just when closed); re-check cancellation after a tick so a tick+cancel
race can't spawn one extra member after Cancel/Close.
- swarm schedule_tool swarmInt: reject non-integer / non-finite JSON numbers
(max_runs=1.9 errors instead of silently truncating to 1).
- mcp migrateLegacy: resolve a relative LegacyPath to absolute before the
same-file guard so it can't be bypassed by a relative spelling.
- oauth keyring store: serialize the keyring read-modify-write with a
cross-process lock file (best-effort, beside the config dir) so concurrent
processes don't clobber each other's tokens; previously withLock was a no-op.
Adds tests for the non-not-found keyring error, the daily delay recompute, the
Add-after-cancel guard, and non-integer swarmInt.
* swarm: make nextDailyDelay roll the day, not add 24h (DST)
CodeRabbit follow-up: the run loop already recomputes a daily job's delay each
cycle, but nextDailyDelay itself rolled "passed today" to the next occurrence
with Add(24*time.Hour). On a DST changeover day (23h/25h) that fires an hour
early/late. Use time.Date(..., day+1, hour, minute, ...) so Go normalizes the
date and applies the correct local offset, holding the wall-clock HH:MM across
DST. Adds an America/New_York spring-forward test (skips if tzdata is absent).
673 lines
21 KiB
Go
673 lines
21 KiB
Go
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 "link":
|
|
return runDaemonLink(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> [--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). --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. Use the link's remote path as --cwd to run against a linked repo.
|
|
`)
|
|
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, bundleDir := "", "", "", ""
|
|
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)
|
|
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))
|
|
}
|
|
}
|
|
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, BundleDir: bundleDir, 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)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|