mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -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).
170 lines
5.4 KiB
Go
170 lines
5.4 KiB
Go
// 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 }
|
|
|
|
// 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.
|
|
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
|
|
}
|