Files
zero/internal/keyring/keyring_test.go
KRATOS c38444157c Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune (#216)
* 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).
2026-06-16 14:48:12 +05:30

217 lines
6.3 KiB
Go

package keyring
import (
"context"
"os/exec"
"strings"
"testing"
)
// fakeExit is a not-found exit error: it satisfies the ExitCode() seam used by
// isNotFound without spawning a real process.
type fakeExit struct{ code int }
func (e fakeExit) Error() string { return "exit status" }
func (e fakeExit) ExitCode() int { return e.code }
// fakeKeyring is an in-memory simulation of the OS tools driven through the
// runner seam. It records the last stdin so tests can assert the secret never
// travels via argv on Linux.
type fakeKeyring struct {
goos string
data map[string]string
lastStdin string
lastArgs []string
}
func newFake(goos string) *fakeKeyring {
return &fakeKeyring{goos: goos, data: map[string]string{}}
}
func (f *fakeKeyring) keyring() *Keyring { return &Keyring{run: f.run, goos: f.goos} }
func flagValue(args []string, flag string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func attrValue(args []string, attr string) string {
for i := 0; i < len(args)-1; i++ {
if args[i] == attr {
return args[i+1]
}
}
return ""
}
func key(service, account string) string { return service + "\x00" + account }
func (f *fakeKeyring) run(_ context.Context, name string, stdin []byte, args ...string) ([]byte, error) {
f.lastStdin = string(stdin)
f.lastArgs = append([]string{name}, args...)
if len(args) == 0 {
return nil, fakeExit{1}
}
switch f.goos {
case "darwin":
svc, acct := flagValue(args, "-s"), flagValue(args, "-a")
switch args[0] {
case "add-generic-password":
f.data[key(svc, acct)] = flagValue(args, "-w")
return nil, nil
case "find-generic-password":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v + "\n"), nil // security prints a trailing newline
}
return nil, fakeExit{44}
case "delete-generic-password":
if _, ok := f.data[key(svc, acct)]; ok {
delete(f.data, key(svc, acct))
return nil, nil
}
return nil, fakeExit{44}
}
case "linux":
svc, acct := attrValue(args, "service"), attrValue(args, "account")
switch args[0] {
case "store":
f.data[key(svc, acct)] = string(stdin)
return nil, nil
case "lookup":
if v, ok := f.data[key(svc, acct)]; ok {
return []byte(v), nil // secret-tool prints no trailing newline
}
return nil, fakeExit{1}
case "clear":
delete(f.data, key(svc, acct))
return nil, nil
}
}
return nil, fakeExit{1}
}
func TestKeyringGetSurfacesNonNotFoundError(t *testing.T) {
// On macOS only exit 44 (errSecItemNotFound) means "no entry"; any other
// non-zero exit is a real failure that must surface, not be masked as absent.
k := &Keyring{
goos: "darwin",
run: func(_ context.Context, _ string, _ []byte, _ ...string) ([]byte, error) {
return nil, fakeExit{1}
},
}
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("a non-44 exit must surface as an error, got ok=%v err=%v", ok, err)
}
}
func TestKeyringRoundTripDarwin(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("zero", "tokens", "blob-AAA"); err != nil {
t.Fatalf("Set: %v", err)
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
if got != "blob-AAA" {
t.Fatalf("Get = %q, want blob-AAA", got)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
if _, ok, _ := k.Get("zero", "tokens"); ok {
t.Fatal("token should be gone after delete")
}
}
func TestKeyringRoundTripLinuxUsesStdin(t *testing.T) {
f := newFake("linux")
k := f.keyring()
if err := k.Set("zero", "tokens", "blob-BBB"); err != nil {
t.Fatalf("Set: %v", err)
}
// The secret must travel via stdin, never the argument vector.
if f.lastStdin != "blob-BBB" {
t.Fatalf("secret not sent via stdin: stdin=%q", f.lastStdin)
}
for _, a := range f.lastArgs {
if strings.Contains(a, "blob-BBB") {
t.Fatalf("secret leaked into argv: %v", f.lastArgs)
}
}
got, ok, err := k.Get("zero", "tokens")
if err != nil || !ok || got != "blob-BBB" {
t.Fatalf("Get = %q ok=%v err=%v", got, ok, err)
}
existed, err := k.Delete("zero", "tokens")
if err != nil || !existed {
t.Fatalf("Delete: existed=%v err=%v", existed, err)
}
}
func TestKeyringGetMissingIsNotError(t *testing.T) {
for _, goos := range []string{"darwin", "linux"} {
k := newFake(goos).keyring()
if _, ok, err := k.Get("zero", "absent"); err != nil || ok {
t.Fatalf("[%s] Get(absent) = ok=%v err=%v, want false/nil", goos, ok, err)
}
if existed, err := k.Delete("zero", "absent"); err != nil || existed {
t.Fatalf("[%s] Delete(absent) = existed=%v err=%v, want false/nil", goos, existed, err)
}
}
}
func TestKeyringUnsupportedPlatform(t *testing.T) {
k := &Keyring{run: newFake("windows").run, goos: "windows"}
if k.Available() {
t.Fatal("windows should report unavailable")
}
if err := k.Set("zero", "tokens", "x"); err == nil {
t.Fatal("Set on unsupported platform should error")
}
if _, _, err := k.Get("zero", "tokens"); err == nil {
t.Fatal("Get on unsupported platform should error")
}
if _, err := k.Delete("zero", "tokens"); err == nil {
t.Fatal("Delete on unsupported platform should error")
}
}
func TestKeyringValidation(t *testing.T) {
k := newFake("darwin").keyring()
if err := k.Set("", "a", "s"); err == nil {
t.Fatal("empty service should error")
}
if err := k.Set("svc", "", "s"); err == nil {
t.Fatal("empty account should error")
}
}
func TestKeyringMissingBinaryError(t *testing.T) {
// A missing tool surfaces as a wrapped, descriptive error (not not-found).
k := &Keyring{goos: "linux", run: func(context.Context, string, []byte, ...string) ([]byte, error) {
return nil, &exec.Error{Name: "secret-tool", Err: exec.ErrNotFound}
}}
if err := k.Set("zero", "tokens", "x"); err == nil || !strings.Contains(err.Error(), "secret-tool") {
t.Fatalf("missing-binary Set error = %v, want mention of secret-tool", err)
}
// A missing binary on Get must not be misread as not-found.
if _, ok, err := k.Get("zero", "tokens"); err == nil || ok {
t.Fatalf("missing-binary Get = ok=%v err=%v, want error", ok, err)
}
}
func TestAvailable(t *testing.T) {
if !(newFake("darwin").keyring().Available()) {
t.Fatal("darwin should be available")
}
if !(newFake("linux").keyring().Available()) {
t.Fatal("linux should be available")
}
}