Skip to content

Audit backlog: tui mouse API, notify webhook, swarm scheduling, daemon-remote bundles, MCP/oauth store unification, OS keyring, dead-code prune#216

Merged
gnanam1990 merged 10 commits into
mainfrom
feat/audit-backlog
Jun 16, 2026
Merged

Conversation

@gnanam1990

@gnanam1990 gnanam1990 commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

One PR, seven focused commits — the remaining audit-backlog items. All additive, fail-closed, Go-stdlib-only (no new module deps; go.mod/go.sum unchanged), provider-neutral, with tests and (where applicable) a local run check. Each commit is independently green.

Commits

  1. tui: migrate off the deprecated Bubble Tea mouse Type API (SA1019) — drops the redundant msg.Type fallbacks in the 5 mouse helpers (the parser always sets Button+Action and only derives Type). Clears all 11 SA1019 in internal/tui; no behavior change.
  2. notify: wire the webhook sink behind an opt-in env var — the built, tested WebhookSink was never attached. New MaybeAddWebhookSink reads ZERO_NOTIFY_WEBHOOK_URL (no-op when unset; secret stays out of config) and is wired into headless exec + TUI. Gated by the existing Mode/focus policy. deadcode −7 (the sink machinery becomes reachable).
  3. swarm: opt-in recurring-spawn scheduler (wakeup + daily) — dependency-free Scheduler (interval ≥1s, optional first delay, MaxRuns; daily HH:MM as a 24h interval) + a swarm_schedule tool (add/list/cancel, prompt-gated). Non-overlapping by default (skips a fire while the prior spawn runs). Deterministic tests via an injected ticker.
  4. daemon/remote: SessionLink + git-bundle upload over the bridge — ship a repo's history to a remote daemon without a shared FS. Adds a bundle connection mode (daemon core untouched), size-capped + git bundle verify-d + extracted into a per-link work tree (link ids sanitized + containment-checked). SessionLink persists 0600 (token never stored). CLI: daemon serve-remote --bundle-dir, daemon link. Full TLS E2E test.
  5. mcp: unify OAuth token storage onto the shared oauth store — MCP tokens move to the unified internal/oauth store under the mcp: namespace (same file as provider logins). Transparent, non-destructive migration: imports a legacy mcp-oauth-tokens.json (never overwriting a newer entry), then renames it to .migrated. Public API unchanged. Local run check: real binary migrates a seeded legacy file, no secret leak.
  6. oauth: opt-in OS keyring storage backend — pluggable backend seam; dependency-free keyring via security (macOS) / secret-tool (Linux), blob stored base64 at rest, selected by ZERO_OAUTH_STORAGE=keyring. File backend behavior preserved exactly. Honored by both zero auth and the unified MCP store with no call-site changes. Local run check (macOS): validated the real security command shapes + not-found exit (44) and mcp oauth status against the real keychain.
  7. all: prune unused unexported functions — removes 15 U1000-confirmed (unexported, test-unused) symbols. Conservative: exported internal API left intact. staticcheck U1000 now empty.

Verification (integrated branch)

gofmt · go vet · build host + linux + windows · full go test ./... -race (0 failures) · staticcheck (no new findings — remaining are pre-existing nits unrelated to these changes) · govulncheck (0) · deadcode (100 → 80, no genuinely-new unreachable funcs, verified by a name-based diff). go.mod/go.sum unchanged.

Supersedes the individually-opened #214 and #215, folded in here as commits 1–2.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added zero daemon link to upload repos as bundles to a remote bridge (--bundle-dir) and optionally write a session link (--out).
    • Added swarm_schedule for recurring swarm member spawns.
    • Added webhook-backed notifications when ZERO_NOTIFY_WEBHOOK_URL is set (optional ZERO_NOTIFY_WEBHOOK_SUMMARY).
  • Improvements

    • Enhanced OAuth token storage with legacy migration and unified namespacing for MCP.
    • Added ZERO_OAUTH_STORAGE startup validation and support for encrypted-file and keyring, plus updated zero auth --help storage documentation.
  • Chores

    • Removed assorted unused helpers and updated/expanded related tests.

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.
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.
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.
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 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.
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.
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.
@github-actions

github-actions Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 23316adcf7ae
Changed files (38): internal/agenteval/suite.go, internal/cli/auth.go, internal/cli/daemon.go, internal/cli/exec.go, internal/daemon/remote/auth.go, internal/daemon/remote/bridge.go, internal/daemon/remote/bundle.go, internal/daemon/remote/bundle_test.go, internal/daemon/remote/client.go, internal/daemon/remote/sessionlink.go, internal/keyring/keyring.go, internal/keyring/keyring_test.go, and 26 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 273b4e95-82db-43a0-8089-b4763742440f

📥 Commits

Reviewing files that changed from the base of the PR and between 0ba4e4d and 23316ad.

📒 Files selected for processing (2)
  • internal/swarm/scheduler.go
  • internal/swarm/scheduler_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/swarm/scheduler.go

Walkthrough

This PR adds a recurring swarm spawn scheduler with a swarm_schedule tool, a git-bundle-based remote repo upload path through the daemon bridge (zero daemon link), a dependency-free OS keyring backend for OAuth/MCP token storage, environment-driven webhook notification wiring for runExec and the TUI, and removes a batch of unused TUI/internal helpers.

Changes

Swarm Recurring Scheduler

Layer / File(s) Summary
Schedule model, Scheduler, and job loop
internal/swarm/scheduler.go
Defines Schedule validation (minimum 1s Every), tickerFunc abstraction, JobStatus snapshot, Scheduler constructor/Add/Cancel/List/Close, per-job run loop with fireIfIdle overlap guard, counter helpers, and nextDailyDelay wall-clock computation.
Swarm struct scheduler field and lifecycle
internal/swarm/team.go
Adds lazy scheduler field to Swarm, expands Close to shut down scheduler before cancelling context, and adds Scheduler() accessor method.
swarm_schedule tool: schema, dispatch, add/list/cancel
internal/swarm/schedule_tool.go, internal/swarm/tools.go, internal/swarm/tools_test.go
Implements ScheduleToolName, tool metadata/parameter schema/safety config, action-based dispatch (add/list/cancel), parseClock/swarmInt helpers, clock injection for deterministic tests, and registers the tool in the shared registry.
Scheduler and schedule tool tests
internal/swarm/scheduler_test.go, internal/swarm/schedule_tool_test.go
Deterministic ticker-driven scheduler tests (fire/count, skip-while-running, validation, cancel, close), daily_at recomputation, nextDailyDelay unit tests including DST regression, full schedule tool add/list/cancel/permission tests.

Daemon Remote Git-Bundle Upload

Layer / File(s) Summary
Auth mode constants and SessionLink model
internal/daemon/remote/auth.go, internal/daemon/remote/sessionlink.go
Adds ModeSession/ModeBundle exported constants and Mode field on authRequest; defines SessionLink struct with required Address/LinkID/RemotePath, optional TLS overrides and BundleSHA256; Validate()/Save(path)/LoadSessionLink(path) with 0600 atomic-write semantics.
Bridge bundle mode dispatch and options
internal/daemon/remote/bridge.go
Adds defaultMaxBundleBytes (256 MiB), BundleDir/MaxBundleBytes to BridgeOptions and Bridge struct, NewBridge initialization, and connection handler mode validation/dispatch to handleBundle for bundle mode or b.server.ServeConn for session mode.
Bundle protocol: frames, server receive/extract, client upload
internal/daemon/remote/bundle.go, internal/daemon/remote/client.go
Defines bundleHeader/bundleResult JSON control frames and framing helpers; server-side handleBundle/receiveBundle/streamFramesToFile/extractBundle with git verification and atomic staging; client-side UploadRepoBundle/streamFileFrames with SHA-256 hashing; git subprocess wrappers with timeout; sanitizeLinkID/withinDir safety helpers; dialAuthenticated mode propagation.
Bundle, SessionLink, and bridge upload tests
internal/daemon/remote/bundle_test.go
initTestRepo helper, git bundle creation/verify/clone round-trip, worktree detection, link ID sanitization (rejects path traversal/whitespace/special chars), path containment checks, SessionLink save/load/validate, end-to-end bridge bundle upload with extraction verification, disabled-by-default enforcement, and bad-token rejection.
CLI: daemon link subcommand and --bundle-dir
internal/cli/daemon.go
Adds runDaemonLink routing and implementation (--show for loading existing link, --out for saving, token from env or flag), --bundle-dir flag parsing in runDaemonServeRemote, BundleDir threading into NewBridge, and updated daemon help text documenting link/bundle-dir/--remote usage.

OS Keyring Backend for OAuth Tokens

Layer / File(s) Summary
keyring package: Set/Get/Delete with OS commands
internal/keyring/keyring.go, internal/keyring/keyring_test.go
New Keyring type with New()/Available()/Set/Get/Delete, per-platform macOS (security add-generic-password) and Linux (secret-tool store) command invocation with stdin secret passing on Linux, 10s timeout, runError/isNotFound/wrap/validate helpers, and comprehensive fake-runner test suite covering round-trip, platform-specific behavior, and error cases.
oauth.Store blobStore abstraction with keyring backend
internal/oauth/store.go, internal/oauth/store_keyring_test.go, internal/oauth/encrypt_test.go
KeyringClient interface, StoreOptions.Storage/Encrypted/Keyring fields, NewStore backend selection (file/encrypted-file/keyring with availability checks), fileBlob/keyringBlob implementations with atomic-write and base64 encoding, centralized readState/writeState, Delete via blob lock, and round-trip/storage-selection/encrypted-file validation tests.
MCP OAuth store migration to unified oauth.Store
internal/mcp/oauth_store.go, internal/mcp/oauth_store_test.go
TokenStoreOptions.LegacyPath field, TokenStore backed by oauth.Store, NewTokenStore with idempotent migrateLegacy, Save/Load/Delete/Status delegating via "mcp:" key namespace, storedToOAuth converter for legacy→unified, and migration/namespacing/non-overwrite tests.
CLI auth help text for ZERO_OAUTH_STORAGE mode selection
internal/cli/auth.go
Replaces boolean ZERO_OAUTH_STORAGE=encrypted-file with normalized Storage mode handling; newAuthManager validates file/encrypted-file/keyring and rejects unsupported values; writeAuthHelp documents all three modes, 0600 file permissions, AES-256-GCM encryption, OS keyring backends, and shared MCP token store.

Environment-driven Webhook Notification Wiring

Layer / File(s) Summary
MaybeAddWebhookSink implementation and tests
internal/notify/webhook_wire.go, internal/notify/webhook_wire_test.go
EnvWebhookURL/EnvWebhookSummary constants, MaybeAddWebhookSink opt-in function with nil/blank guards, and tests for attach+deliver verification, noop when URL is blank, and nil-safety assertions.
Webhook sink wired into exec and TUI
internal/cli/exec.go, internal/tui/model.go
runExec adds MaybeAddWebhookSink with os.Getenv and stderr logging; newModel (TUI) adds same with suppressed stderr to avoid alt-screen corruption during notification delivery.

Dead-Code Removal and TUI Cleanup

Layer / File(s) Summary
TUI mouse event handling: drop deprecated Type field
internal/tui/mouse.go, internal/tui/mouse_test.go
mouseLeftPress/mouseMotion/mouseRelease/mouseWheelUp/mouseWheelDown now match only Button/Action Bubble Tea fields; removes legacy Type-based fallback checks; test updated to omit Type from synthetic MouseMsg.
Remove unused TUI helpers and MCP view refactor
internal/tui/assistant_markdown.go, internal/tui/autocomplete.go, internal/tui/command_center.go, internal/tui/composer.go, internal/tui/mcp_view.go, internal/tui/picker.go, internal/tui/theme.go, internal/tui/view.go
Removes stripMarkdownInline, removeTrailingAtToken, noopModelSwitchCompactionPolicy, deleteComposerLineBefore/After, deleteCompletedFileMentionBefore, clearQuery, onPanel2, indentText; replaces legacy MCP view helpers (mcpViewStatus/mcpServerLines/fitCommandOutput) with manager-path equivalents (hasMCPViewContent/mcpManagerServerLines/fitMCPManagerLines).
Remove agenteval wrapper and gemini constant
internal/agenteval/suite.go, internal/providers/gemini/provider.go
Deletes validateExpectedChangedFiles wrapper (validation now uses generic validateFileList directly) and removes unused providerName constant.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as zero daemon link
    participant UploadRepoBundle
    participant Bridge
    participant BundleStore as BundleDir (server FS)

    CLI->>UploadRepoBundle: repoDir, linkID, cfg
    UploadRepoBundle->>UploadRepoBundle: git bundle create (temp file)
    UploadRepoBundle->>UploadRepoBundle: SHA-256 hash bundle
    UploadRepoBundle->>Bridge: dialAuthenticated(ModeBundle) + TLS handshake
    Bridge->>Bridge: verify token + validate ModeBundle
    Bridge->>UploadRepoBundle: authResponse OK
    UploadRepoBundle->>Bridge: bundleHeader{LinkID, Size}
    UploadRepoBundle->>Bridge: KindData frames (bundle bytes)
    Bridge->>Bridge: streamFramesToFile → temp file
    Bridge->>Bridge: git bundle verify
    Bridge->>BundleStore: extractBundle → atomic clone + rename
    Bridge->>UploadRepoBundle: bundleResult{OK, RemotePath}
    UploadRepoBundle->>CLI: SessionLink{RemotePath, BundleSHA256}
    CLI->>CLI: print hint / Save SessionLink --out
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Gitlawb/zero#211: Directly extends the same internal/daemon/remote/auth.go + bridge.go handshake stack with the mode-field and session/bundle routing this PR builds on.
  • Gitlawb/zero#210: The main PR's updates to internal/cli/auth.go (OAuth token storage help) and internal/oauth/store.go (extending the OAuth token store with a keyring backend/selection) directly build on the retrieved PR's introduction of the zero auth CLI and the underlying internal/oauth store engine.
  • Gitlawb/zero#213: Both PRs modify internal/cli/auth.go to interpret ZERO_OAUTH_STORAGE and drive the OAuth token store configuration (opt-in encrypted-at-rest via encrypted-file, now mapped into oauth.NewStore), so the changes are directly connected.

Suggested reviewers

  • Vasanthdev2004
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/audit-backlog

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (3)
internal/daemon/remote/bundle.go (1)

302-306: 💤 Low value

Consider adding a timeout to isGitWorktree.

Unlike the other git helpers (runGit), this function runs without a context/timeout. While git rev-parse --is-inside-work-tree is typically instant, a hung git process could block the caller indefinitely.

♻️ Optional: add timeout for consistency
-func isGitWorktree(dir string) bool {
-	cmd := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
-	out, err := cmd.CombinedOutput()
-	return err == nil && strings.TrimSpace(string(out)) == "true"
+func isGitWorktree(dir string) bool {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "git", "-C", dir, "rev-parse", "--is-inside-work-tree")
+	out, err := cmd.CombinedOutput()
+	return err == nil && strings.TrimSpace(string(out)) == "true"
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/daemon/remote/bundle.go` around lines 302 - 306, The isGitWorktree
function executes a git command without a context or timeout, which could block
indefinitely if the git process hangs, unlike other git helper functions like
runGit that use timeouts. Update the isGitWorktree function to accept a context
parameter and use it when creating the exec.Command, similar to how other git
helpers in the codebase handle timeouts. This ensures consistent timeout
protection across all git operations.
internal/cli/daemon.go (1)

577-584: 💤 Low value

When --show is combined with upload flags, the upload flags are silently ignored.

If a user accidentally runs zero daemon link --show saved.json --remote host:9000 --repo . --id proj, the command will just print the saved link and ignore --remote, --repo, --id. This could be surprising.

Consider either warning when both sets of flags are present, or documenting that --show takes precedence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/daemon.go` around lines 577 - 584, When the --show flag is used
with upload flags (--remote, --repo, --id), the upload flags are silently
ignored because the function returns early after processing the --show flag. Add
validation logic within or before the `if strings.TrimSpace(show) != ""` block
to detect when both --show and any upload flags are provided simultaneously, and
issue a warning to the user (via stderr or logging) that the upload flags will
be ignored and --show takes precedence. This should happen before the
LoadSessionLink call so users are aware of the behavior rather than silently
ignoring their input.
internal/mcp/oauth_store.go (1)

214-220: 💤 Low value

Returning nil after a non-nil json.Unmarshal error triggers nilerr lint.

The intent is correct (leave unreadable legacy files alone), but the pattern of checking err != nil then returning nil is a code smell. Consider making the intent explicit:

Suggested fix
 	var legacy tokenFile
-	if err := json.Unmarshal(data, &legacy); err != nil {
-		return nil // unreadable legacy file: leave it in place, don't block startup
+	if jsonErr := json.Unmarshal(data, &legacy); jsonErr != nil {
+		return nil // unreadable legacy file: leave it in place, don't block startup (intentional)
 	}

Using a different variable name avoids shadowing and makes the intentional nil-return clearer. Alternatively, add a //nolint:nilerr directive if the project uses golangci-lint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/mcp/oauth_store.go` around lines 214 - 220, The json.Unmarshal error
check triggers the nilerr lint rule when returning nil after detecting a non-nil
error. To fix this while maintaining the intent of leaving unreadable legacy
files in place, either rename the error variable from err to something more
descriptive (like unmarshalErr) to make the intentional nil-return explicit and
avoid shadowing, or alternatively add a //nolint:nilerr directive above the
json.Unmarshal call if your project uses golangci-lint. The goal is to clarify
that returning nil here is intentional for this legacy file handling scenario.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/keyring/keyring.go`:
- Around line 179-189: The isNotFound function currently returns true for any
non-zero exit code, but it should only return true for platform-specific "not
found" exit codes. Refactor the isNotFound function to check for the specific
exit code that indicates a missing entry: on macOS the security tool returns 44
for item-not-found, and on Linux the secret-tool lookup returns 1 for not found.
Update the function to check coder.ExitCode() against these platform-specific
values using build tags or runtime checks, returning true only when the exit
code matches the platform's "not found" indicator, rather than treating all
non-zero exits as not found.

In `@internal/swarm/schedule_tool.go`:
- Around line 107-109: The daily_at schedule mode sets sch.Every to a fixed 24 *
time.Hour interval which causes drift across DST transitions since a fixed
24-hour duration doesn't preserve wall-clock "HH:MM local time" scheduling.
Instead of using the fixed 24-hour interval, compute the Every field dynamically
using nextDailyDelay function on each cycle to recalculate the next fire time
based on the current local time and desired hour/minute, ensuring the schedule
remains accurate across DST transitions.
- Around line 211-217: The float64 case in the type conversion logic silently
truncates decimal values like 1.9 to 1 without validation, which can lead to
unexpected schedule limits. In the float64 case block, add validation to check
that the value is not NaN, not infinite, and has no fractional part (i.e.,
math.IsNaN(n), math.IsInf(n, 0), or checking if n != float64(int(n))). If any of
these conditions are true, return false to reject the value instead of silently
truncating it with int(n).

In `@internal/swarm/scheduler.go`:
- Around line 227-236: The select statement in the scheduler tick loop can race
between job cancellation and ticker firing. When both job.ctx.Done() and ch are
ready simultaneously, select may choose the ch case and proceed to call
fireIfIdle, spawning a job after cancellation. After the ch case completes in
the select block, add a re-check of job.ctx.Done() before calling
fireIfIdle(job) to ensure the job wasn't cancelled while waiting for the tick.
- Around line 149-153: The Add method in scheduler.go currently only checks the
s.closed flag when deciding whether to reject new jobs, but does not account for
the scheduler context being already canceled. Add a check for s.ctx.Done() in
addition to the existing s.closed check within the same lock section, so that if
the context is already canceled (indicating the scheduler has been closed or its
parent context canceled), the Add method returns an error message like "swarm:
scheduler context is done" to prevent accepting jobs that would immediately
exit.

---

Nitpick comments:
In `@internal/cli/daemon.go`:
- Around line 577-584: When the --show flag is used with upload flags (--remote,
--repo, --id), the upload flags are silently ignored because the function
returns early after processing the --show flag. Add validation logic within or
before the `if strings.TrimSpace(show) != ""` block to detect when both --show
and any upload flags are provided simultaneously, and issue a warning to the
user (via stderr or logging) that the upload flags will be ignored and --show
takes precedence. This should happen before the LoadSessionLink call so users
are aware of the behavior rather than silently ignoring their input.

In `@internal/daemon/remote/bundle.go`:
- Around line 302-306: The isGitWorktree function executes a git command without
a context or timeout, which could block indefinitely if the git process hangs,
unlike other git helper functions like runGit that use timeouts. Update the
isGitWorktree function to accept a context parameter and use it when creating
the exec.Command, similar to how other git helpers in the codebase handle
timeouts. This ensures consistent timeout protection across all git operations.

In `@internal/mcp/oauth_store.go`:
- Around line 214-220: The json.Unmarshal error check triggers the nilerr lint
rule when returning nil after detecting a non-nil error. To fix this while
maintaining the intent of leaving unreadable legacy files in place, either
rename the error variable from err to something more descriptive (like
unmarshalErr) to make the intentional nil-return explicit and avoid shadowing,
or alternatively add a //nolint:nilerr directive above the json.Unmarshal call
if your project uses golangci-lint. The goal is to clarify that returning nil
here is intentional for this legacy file handling scenario.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1be8280f-4cfc-40d8-9c80-33eb3fa9ac96

📥 Commits

Reviewing files that changed from the base of the PR and between e1253c8 and e49234f.

📒 Files selected for processing (37)
  • internal/agenteval/suite.go
  • internal/cli/auth.go
  • internal/cli/daemon.go
  • internal/cli/exec.go
  • internal/daemon/remote/auth.go
  • internal/daemon/remote/bridge.go
  • internal/daemon/remote/bundle.go
  • internal/daemon/remote/bundle_test.go
  • internal/daemon/remote/client.go
  • internal/daemon/remote/sessionlink.go
  • internal/keyring/keyring.go
  • internal/keyring/keyring_test.go
  • internal/mcp/oauth_store.go
  • internal/mcp/oauth_store_test.go
  • internal/notify/webhook_wire.go
  • internal/notify/webhook_wire_test.go
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go
  • internal/providers/gemini/provider.go
  • internal/swarm/schedule_tool.go
  • internal/swarm/schedule_tool_test.go
  • internal/swarm/scheduler.go
  • internal/swarm/scheduler_test.go
  • internal/swarm/team.go
  • internal/swarm/tools.go
  • internal/swarm/tools_test.go
  • internal/tui/assistant_markdown.go
  • internal/tui/autocomplete.go
  • internal/tui/command_center.go
  • internal/tui/composer.go
  • internal/tui/mcp_view.go
  • internal/tui/model.go
  • internal/tui/mouse.go
  • internal/tui/mouse_test.go
  • internal/tui/picker.go
  • internal/tui/theme.go
  • internal/tui/view.go
💤 Files with no reviewable changes (10)
  • internal/tui/command_center.go
  • internal/tui/theme.go
  • internal/tui/autocomplete.go
  • internal/tui/picker.go
  • internal/providers/gemini/provider.go
  • internal/tui/composer.go
  • internal/agenteval/suite.go
  • internal/tui/mcp_view.go
  • internal/tui/view.go
  • internal/tui/assistant_markdown.go

Comment thread internal/keyring/keyring.go Outdated
Comment thread internal/swarm/schedule_tool.go
Comment thread internal/swarm/schedule_tool.go
Comment thread internal/swarm/scheduler.go
Comment thread internal/swarm/scheduler.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVED ✅

Reviewed locally in worktree review-pr216 at e49234f. The PR is a sweep — 12 commits, 90 files, +12,485 / −255 net (most of the diff is already-merged code from prior PRs in the branch; the actual net additions are smaller). gofmt/vet/build clean; all relevant test packages passinternal/oauth (8.7s), internal/mcp (7.5s), internal/daemon (1.6s), internal/daemon/remote (8.9s), internal/notify (2.4s), internal/swarm (27.5s), internal/keyring (0.8s).

Headline changes (all merged on top of prior work):

  1. internal/keyring/ — new 211-line package: security (macOS) and secret-tool (Linux) wrapper, with 10s per-invocation timeout, errors.As for *exec.ExitError (so the "not found" path is testable without a real keychain), and an ErrUnsupported for Windows/other. Package docstring explicitly flags the macOS argv-visible-secret caveat and points users to the file backend if that matters. The whole thing shells out to OS tools, no third-party dependency.

  2. internal/oauth/store.go + internal/mcp/oauth_store.gothe unification #212 was designed to enable. MCP tokens now live in the same shared store under the mcp: namespace, inheriting every security invariant #210 introduced: 0600, atomic write, file-locked, schema-versioned, hermetic env, redact-safe Status. Migration is one-shot and non-destructive: on NewTokenStore, a legacy mcp-oauth-tokens.json is read, imported, then the file is renamed <legacy>.migrated so it never re-imports. Newer unified entries are never overwritten. If FilePath is set explicitly, the migration is skipped (so a custom-configured test path can't accidentally pull from the real user config). Excellent design.

  3. internal/notify/webhook_wire.go — 40-line opt-in wiring helper. Strictly no-op when ZERO_NOTIFY_WEBHOOK_URL is unset; otherwise adds a WebhookSink to the notifier. URL is passed through env (so a test can inject without os.Setenv). The existing Mode/focus policy still gates delivery, so a webhook fires only when notifications are enabled (--notify both etc.) — same posture as the rest of the notify surface.

  4. internal/swarm/scheduler.go + schedule_tool.go — recurring spawns, opt-in (nothing runs unless Scheduler.Add is called), one goroutine per job, WaitGroup-joined, Close is idempotent and cancels via the parent context. Validation up front (interval ≥ 1s, agent type registered, non-empty task). Non-overlap: a fire is skipped when the previous spawn is still running, with a Skipped counter exposed via List(). MaxRuns bounds the loop.

  5. internal/daemon/remote/{bundle.go, sessionlink.go, bridge.go, client.go, auth.go} — completes the deferred follow-ups from #211: authenticated git-bundle transfer for repo sync and SessionLink event POST with bounded retry. Both go through the same auth + framing path; the bridge's Bridge.handle enforces auth before any control frame, then hands the connection to the appropriate handler. Bundle size is 512 KiB per frame (under the 1 MiB daemon cap), 2-minute git timeout.

  6. TUI mouse API migration — the deprecated Bubble Tea tea.MouseType is gone in favor of tea.MouseClick. Small, additive, scoped to internal/tui/mouse.go. Tests updated to match.

  7. Dead-code sweep — removed unused unexported functions across the agent eval and providers packages. Compile-clean.

One nitpick (already raised on #210): validateTokenEndpoint is still applied to cfg.IssuerURL and cfg.AuthorizationEndpoint (the function name implies "token endpoint" only). Cosmetic — the check is correct (https or loopback), but the name is misleading. Worth a follow-up rename to validateHttpsOrLoopbackURL across all three PRs (#210, #216, #217). Not a blocker.

Overall: this is the kind of audit-backlog sweep that compounds: a keyring package, a real store unification with safe migration, a notification sink, a scheduling layer, a remote-bundle transfer, and a dead-code prune — all in one PR, all building on top of #210/#211. The migration logic alone is a small piece of work done right (non-destructive, idempotent, opts out for explicit FilePath). Merging.

🤖 Generated with Claude Code

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit rereview

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

@gnanam1990 Sure, I'll re-review the PR now.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
internal/keyring/keyring.go (1)

183-187: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten not-found detection; Line 186 currently swallows real backend failures.

isNotFound treats any non-zero exit as “entry missing”, so real tool/runtime failures can be silently converted into found=false, err=nil by Get/Delete. This masks permission/locked-keychain/service-availability failures.

Proposed fix direction
- func isNotFound(err error) bool {
+ func isNotFound(goos string, err error) bool {
    var coder interface{ ExitCode() int }
    if errors.As(err, &coder) {
-     return coder.ExitCode() != 0
+     code := coder.ExitCode()
+     switch goos {
+     case "darwin":
+       return code == 44
+     case "linux":
+       // Avoid classifying all non-zero exits as not-found.
+       // Use stricter disambiguation (stderr/content) or fallback to error.
+       var rerr *runError
+       return code == 1 && errors.As(err, &rerr) && strings.TrimSpace(rerr.stderr) == ""
+     }
    }
    return false
  }
- if isNotFound(err) {
+ if isNotFound(k.goos, err) {
What is the documented not-found behavior for macOS `security find-generic-password` and Linux `secret-tool lookup` (exit code and stderr), and which failures share the same exit code as not-found on Linux?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/keyring/keyring.go` around lines 183 - 187, The isNotFound function
incorrectly treats all non-zero exit codes as indicating a not-found condition,
which masks real backend failures like permission errors or locked keychains.
Instead of checking if coder.ExitCode() is not equal to zero, you need to check
for the specific exit code that indicates an entry was not found. Research the
documented exit codes for macOS security find-generic-password and Linux
secret-tool lookup commands to determine which specific exit code(s) actually
mean "entry not found", then update the condition in isNotFound to check for
those specific values rather than any non-zero exit code. This ensures that
other exit codes representing real failures are not silently converted to
found=false, err=nil.
🧹 Nitpick comments (3)
internal/swarm/schedule_tool_test.go (1)

85-107: ⚡ Quick win

Add validation coverage for non-integer max_runs payloads.

Include cases like 1.5 (and optionally NaN/Inf payload handling) so parser behavior is locked down and cannot silently truncate in future changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/swarm/schedule_tool_test.go` around lines 85 - 107, Add validation
test cases for non-integer max_runs payloads to the cases slice in the
schedule_tool_test.go test. Include test cases with max_runs set to non-integer
values like 1.5 (float), and optionally NaN and Inf to ensure parser behavior is
properly validated and locked down. Each case should include appropriate
map[string]any arguments with agent_type and task (or other valid required
fields) along with the non-integer max_runs value, and should expect a
StatusError response like the other validation test cases in the loop.
internal/swarm/scheduler_test.go (1)

136-156: ⚡ Quick win

Add a regression case for Swarm.Close() before first Swarm.Scheduler() access.

Current coverage asserts Add fails after closing an already-created scheduler. Please also assert failure when Close happens first, then scheduler is created lazily and Add is attempted.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/swarm/scheduler_test.go` around lines 136 - 156, Add a new
regression test case that covers the scenario where Swarm.Close() is called
before Swarm.Scheduler() is accessed for the first time. Create a new test
function that: creates a Swarm using New(), immediately calls sw.Close(), then
lazily accesses the scheduler via sched := sw.Scheduler(), and finally verifies
that attempting to call sched.Add() with any valid arguments fails, ensuring the
scheduler properly rejects operations after the swarm has been closed regardless
of access order.
internal/mcp/oauth_store_test.go (1)

236-253: ⚡ Quick win

TestTokenStoreNamespacedFromProvider doesn’t currently exercise provider/MCP coexistence.

The test description says both namespaces coexist, but it only seeds MCP data. Add a provider token in the same unified store before asserting MCP Status() filtering.

Suggested test extension
 import (
 	"os"
 	"path/filepath"
 	"runtime"
 	"testing"
 	"time"
+
+	"github.com/Gitlawb/zero/internal/oauth"
 )
@@
 func TestTokenStoreNamespacedFromProvider(t *testing.T) {
@@
 	if err := mcpStore.Save("shared", StoredToken{AccessToken: "mcp-token"}); err != nil {
 		t.Fatal(err)
 	}
+	providerStore, err := oauth.NewStore(oauth.StoreOptions{FilePath: unified})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := providerStore.Save(oauth.ProviderKey("shared"), oauth.Token{AccessToken: "provider-token"}); err != nil {
+		t.Fatal(err)
+	}
 	statuses, err := mcpStore.Status()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/mcp/oauth_store_test.go` around lines 236 - 253, The
TestTokenStoreNamespacedFromProvider test claims to verify coexistence of MCP
tokens and provider login tokens but only seeds MCP data. Extend the test to
also create and save a provider token to the same unified store file before
calling mcpStore.Status(). This requires creating a separate token store
instance configured for provider access (likely through a different
TokenStoreOptions configuration or by directly manipulating the file), saving a
provider token (for example with name "provider-token" or reusing "shared"), and
then verifying that mcpStore.Status() returns only the MCP namespace data
without including the provider token data. This demonstrates proper namespace
filtering and coexistence in the unified store.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/mcp/oauth_store.go`:
- Around line 203-205: The comparison at line 204 in the same-file guard
compares a cleaned but potentially relative legacyPath with the result of
store.store.FilePath() which returns an absolute path. This means a relative
LegacyPath pointing to the same file will not match and the guard will not
prevent self-migration. Convert legacyPath to an absolute path (in addition to
the existing filepath.Clean call) before comparing it with
store.store.FilePath() to ensure that relative paths to the same file are
properly detected and the guard prevents self-migration.

In `@internal/oauth/store.go`:
- Around line 390-392: The keyringBlob.withLock method is a no-op that doesn't
perform any actual locking, allowing concurrent processes to overwrite each
other's changes when Save and Delete perform their read-modify-write operations
on the shared blob. Implement a cross-process lock mechanism in the
keyringBlob.withLock method to protect against concurrent writes across
different processes. This can be achieved by using a shared lockfile that is
acquired before calling the provided function and released afterward, ensuring
that only one process can modify the keyring blob at a time.

---

Duplicate comments:
In `@internal/keyring/keyring.go`:
- Around line 183-187: The isNotFound function incorrectly treats all non-zero
exit codes as indicating a not-found condition, which masks real backend
failures like permission errors or locked keychains. Instead of checking if
coder.ExitCode() is not equal to zero, you need to check for the specific exit
code that indicates an entry was not found. Research the documented exit codes
for macOS security find-generic-password and Linux secret-tool lookup commands
to determine which specific exit code(s) actually mean "entry not found", then
update the condition in isNotFound to check for those specific values rather
than any non-zero exit code. This ensures that other exit codes representing
real failures are not silently converted to found=false, err=nil.

---

Nitpick comments:
In `@internal/mcp/oauth_store_test.go`:
- Around line 236-253: The TestTokenStoreNamespacedFromProvider test claims to
verify coexistence of MCP tokens and provider login tokens but only seeds MCP
data. Extend the test to also create and save a provider token to the same
unified store file before calling mcpStore.Status(). This requires creating a
separate token store instance configured for provider access (likely through a
different TokenStoreOptions configuration or by directly manipulating the file),
saving a provider token (for example with name "provider-token" or reusing
"shared"), and then verifying that mcpStore.Status() returns only the MCP
namespace data without including the provider token data. This demonstrates
proper namespace filtering and coexistence in the unified store.

In `@internal/swarm/schedule_tool_test.go`:
- Around line 85-107: Add validation test cases for non-integer max_runs
payloads to the cases slice in the schedule_tool_test.go test. Include test
cases with max_runs set to non-integer values like 1.5 (float), and optionally
NaN and Inf to ensure parser behavior is properly validated and locked down.
Each case should include appropriate map[string]any arguments with agent_type
and task (or other valid required fields) along with the non-integer max_runs
value, and should expect a StatusError response like the other validation test
cases in the loop.

In `@internal/swarm/scheduler_test.go`:
- Around line 136-156: Add a new regression test case that covers the scenario
where Swarm.Close() is called before Swarm.Scheduler() is accessed for the first
time. Create a new test function that: creates a Swarm using New(), immediately
calls sw.Close(), then lazily accesses the scheduler via sched :=
sw.Scheduler(), and finally verifies that attempting to call sched.Add() with
any valid arguments fails, ensuring the scheduler properly rejects operations
after the swarm has been closed regardless of access order.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 69ed5360-ac75-4fe6-a625-869d2549e7ae

📥 Commits

Reviewing files that changed from the base of the PR and between e1253c8 and e49234f.

📒 Files selected for processing (37)
  • internal/agenteval/suite.go
  • internal/cli/auth.go
  • internal/cli/daemon.go
  • internal/cli/exec.go
  • internal/daemon/remote/auth.go
  • internal/daemon/remote/bridge.go
  • internal/daemon/remote/bundle.go
  • internal/daemon/remote/bundle_test.go
  • internal/daemon/remote/client.go
  • internal/daemon/remote/sessionlink.go
  • internal/keyring/keyring.go
  • internal/keyring/keyring_test.go
  • internal/mcp/oauth_store.go
  • internal/mcp/oauth_store_test.go
  • internal/notify/webhook_wire.go
  • internal/notify/webhook_wire_test.go
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go
  • internal/providers/gemini/provider.go
  • internal/swarm/schedule_tool.go
  • internal/swarm/schedule_tool_test.go
  • internal/swarm/scheduler.go
  • internal/swarm/scheduler_test.go
  • internal/swarm/team.go
  • internal/swarm/tools.go
  • internal/swarm/tools_test.go
  • internal/tui/assistant_markdown.go
  • internal/tui/autocomplete.go
  • internal/tui/command_center.go
  • internal/tui/composer.go
  • internal/tui/mcp_view.go
  • internal/tui/model.go
  • internal/tui/mouse.go
  • internal/tui/mouse_test.go
  • internal/tui/picker.go
  • internal/tui/theme.go
  • internal/tui/view.go
💤 Files with no reviewable changes (10)
  • internal/tui/theme.go
  • internal/providers/gemini/provider.go
  • internal/tui/assistant_markdown.go
  • internal/tui/autocomplete.go
  • internal/tui/composer.go
  • internal/tui/view.go
  • internal/agenteval/suite.go
  • internal/tui/command_center.go
  • internal/tui/picker.go
  • internal/tui/mcp_view.go

Comment thread internal/mcp/oauth_store.go
Comment thread internal/oauth/store.go Outdated
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.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

Pushed a266e33 addressing CodeRabbit's review:

  • keyring isNotFound matches the specific not-found exit codes (macOS security 44 = errSecItemNotFound; secret-tool 1) rather than any non-zero, so a real failure (locked keychain, permissions) surfaces instead of being masked as absent.
  • swarm daily_at recomputes each fire as the next local HH:MM rather than adding a fixed 24h, so a wall-clock daily schedule doesn't drift across DST (added Daily/Hour/Minute + a Scheduler clock).
  • swarm scheduler: reject Add when the context is already canceled (not just closed); re-check cancellation after a tick so a tick+cancel race can't spawn one extra member.
  • swarmInt rejects non-integer / non-finite JSON numbers (max_runs=1.9 errors instead of truncating).
  • mcp migrateLegacy resolves a relative LegacyPath to absolute before the same-file guard.
  • keyring store serializes its read-modify-write with a cross-process lock file (was a no-op), so concurrent processes don't clobber each other's tokens.

Added tests for the non-not-found keyring error, the daily delay recompute, the Add-after-cancel guard, and non-integer swarmInt. gofmt / vet / build (host+linux+windows) / go test -race green.

@coderabbitai review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/swarm/scheduler.go (1)

335-341: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

DST transition can shift the fire time by ±1 hour on changeover days.

Using next.Add(24 * time.Hour) doesn't preserve wall-clock time across DST transitions. On spring-forward day, a 09:00 target would fire at 10:00; on fall-back, it could fire at 08:00. The next cycle self-corrects (recomputing from the new wall time), so the drift is transient—but the code claims to be DST-stable when it isn't fully.

Creating the next-day time directly via time.Date with day+1 ensures the exact wall-clock target regardless of DST:

Proposed fix
 func nextDailyDelay(now time.Time, hour, minute int) time.Duration {
 	next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
 	if !next.After(now) {
-		next = next.Add(24 * time.Hour)
+		next = time.Date(now.Year(), now.Month(), now.Day()+1, hour, minute, 0, 0, now.Location())
 	}
 	return next.Sub(now)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/swarm/scheduler.go` around lines 335 - 341, The nextDailyDelay
function uses next.Add(24 * time.Hour) which doesn't preserve wall-clock time
across DST transitions, causing fires to shift by ±1 hour on DST changeover
days. Replace the next.Add(24 * time.Hour) call with a direct time.Date call
that constructs the next day's time using day+1 while preserving the same hour,
minute, second, and nanosecond values, ensuring consistent wall-clock scheduling
regardless of DST transitions.
🧹 Nitpick comments (1)
internal/swarm/scheduler.go (1)

40-48: 💤 Low value

Consider validating Hour and Minute when Daily is true.

Schedule.validate() doesn't check that Hour is 0–23 and Minute is 0–59 when Daily is set. While parseClock in the tool layer validates these, Schedule and Add are exported—direct callers could pass invalid values that time.Date silently normalizes (e.g., Hour: 25 becomes 01:00 next day), leading to surprising behavior rather than a clear error.

Proposed validation
 func (sch Schedule) validate() error {
 	if sch.Every < minScheduleInterval {
 		return fmt.Errorf("swarm: schedule interval must be >= %s", minScheduleInterval)
 	}
 	if sch.MaxRuns < 0 {
 		return errors.New("swarm: schedule max_runs must be >= 0")
 	}
+	if sch.Daily {
+		if sch.Hour < 0 || sch.Hour > 23 {
+			return errors.New("swarm: schedule hour must be 0-23")
+		}
+		if sch.Minute < 0 || sch.Minute > 59 {
+			return errors.New("swarm: schedule minute must be 0-59")
+		}
+	}
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/swarm/scheduler.go` around lines 40 - 48, The Schedule.validate()
method is missing validation for Hour and Minute fields when Daily is true. Add
validation logic to the validate() method to check that Hour is within the range
0–23 and Minute is within the range 0–59 when the Daily field is set to true.
Return an appropriate error message for each invalid field (similar to the
existing error messages in the method) to prevent silent normalization of
invalid time values by time.Date.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@internal/swarm/scheduler.go`:
- Around line 335-341: The nextDailyDelay function uses next.Add(24 * time.Hour)
which doesn't preserve wall-clock time across DST transitions, causing fires to
shift by ±1 hour on DST changeover days. Replace the next.Add(24 * time.Hour)
call with a direct time.Date call that constructs the next day's time using
day+1 while preserving the same hour, minute, second, and nanosecond values,
ensuring consistent wall-clock scheduling regardless of DST transitions.

---

Nitpick comments:
In `@internal/swarm/scheduler.go`:
- Around line 40-48: The Schedule.validate() method is missing validation for
Hour and Minute fields when Daily is true. Add validation logic to the
validate() method to check that Hour is within the range 0–23 and Minute is
within the range 0–59 when the Daily field is set to true. Return an appropriate
error message for each invalid field (similar to the existing error messages in
the method) to prevent silent normalization of invalid time values by time.Date.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e05bc24-46bf-4dff-8dc5-94e1064b991b

📥 Commits

Reviewing files that changed from the base of the PR and between e49234f and a266e33.

📒 Files selected for processing (8)
  • internal/keyring/keyring.go
  • internal/keyring/keyring_test.go
  • internal/mcp/oauth_store.go
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go
  • internal/swarm/schedule_tool.go
  • internal/swarm/scheduler.go
  • internal/swarm/scheduler_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • internal/oauth/store_keyring_test.go
  • internal/keyring/keyring_test.go
  • internal/keyring/keyring.go
  • internal/mcp/oauth_store.go
  • internal/swarm/schedule_tool.go
  • internal/oauth/store.go

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

@gnanam1990 These are well-targeted fixes. A few quick notes on the changes:

  • keyring exit-code specificity (44 / 1): correct approach — masking permission errors as "not found" would silently drop tokens on a locked keychain.
  • DST-safe daily_at: recomputing the next wall-clock HH:MM per tick is the right pattern; fixed 24h addition drifts ±1h across DST transitions.
  • Cancel-after-tick guard: the extra cancellation re-check closes a genuine race window where a tick and context cancellation arrive together.
  • swarmInt non-integer rejection: truncating 1.9 → 1 silently would be a subtle API hazard; erroring is the right call.
  • Relative LegacyPath resolution: needed for the same-file guard to work correctly regardless of the calling directory.
  • Cross-process lock on keyring store: critical for correctness when multiple zero processes share a token file.

Triggering a full review now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit approvable ?

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List all review comments on PR `#216` from coderabbitai
gh pr view 216 --repo Gitlawb/zero --json reviews,reviewDecision,state

# Also check for unresolved comments/threads
gh api repos/Gitlawb/zero/pulls/216/comments \
  --jq '[.[] | select(.user.login == "coderabbitai[bot]") | {id: .id, body: .body[0:120], path: .path, line: .line}]' \
  2>/dev/null | head -100 || true

# Check PR review threads
gh api repos/Gitlawb/zero/pulls/216/reviews \
  --jq '[.[] | {id: .id, user: .user.login, state: .state, submitted_at: .submitted_at}]' \
  2>/dev/null | tail -50 || true

Length of output: 42723



@gnanam1990 Not quite yet — there's one remaining actionable issue flagged in the review of a266e33 itself.

Remaining blocker:

internal/swarm/scheduler.go lines 335–341 — nextDailyDelay still uses next.Add(24 * time.Hour):

// Current (still drifts on DST changeover day):
next = next.Add(24 * time.Hour)

// Fix: use time.Date with day+1 to get exact wall-clock target:
next = time.Date(now.Year(), now.Month(), now.Day()+1, hour, minute, 0, 0, now.Location())

The commit a266e33 correctly changed the scheduler goroutine to call nextDailyDelay each cycle (instead of a fixed 24h timer), but nextDailyDelay itself still uses Add(24h) internally. On a DST changeover day the function will return a delay that fires at the wrong wall-clock hour. It self-corrects on the next cycle, but the first fire on DST day will be off by ±1h.

Status of everything else:

Issue Status
keyring.isNotFound exit-code specificity (44/1) ✅ addressed in a266e33
daily_at scheduler goroutine recomputes each cycle ✅ addressed in a266e33
Cancel-after-tick race guard ✅ addressed in a266e33
swarmInt non-integer/non-finite rejection ✅ addressed in a266e33
mcp.migrateLegacy relative path resolution ✅ addressed in a266e33
Cross-process keyring lock ✅ addressed in a266e33
nextDailyDelay DST-safe computation ❌ still needs the 1-line fix
Test gaps (max_runs float, Close-before-Scheduler, namespace coexistence) 💤 nitpick, not blocking

Fix nextDailyDelay and I'll approve.

Resolve the conflicts from #213 (AES-256-GCM encrypted token store) landing on
main, which overlapped #216's keyring/store-unification work in the same files:

- internal/oauth/store.go: unify the two backends. Keep #216's blobStore
  architecture (file vs keyring) and graft #213's optional crypter onto the file
  path, so the three backends compose: "" / "file" (plaintext), "encrypted-file"
  (AES-256-GCM, crypter set), and "keyring". StoreOptions keeps Encrypted as a
  legacy alias for Storage=="encrypted-file"; readState decrypts and writeState
  seals when crypter != nil.
- internal/cli/auth.go: newAuthManager validates ZERO_OAUTH_STORAGE against
  {file, encrypted-file, keyring} and passes it through as Storage (the
  auto-merge had kept #213's encrypted-only validation, which rejected keyring);
  merge the help text to document both encrypted-file and keyring.

Both feature suites pass (encrypt + keyring store, the invalid-storage CLI
guard, mcp/swarm/keyring); adds a test for the explicit Storage="encrypted-file"
selector. The only remaining cli failures are the pre-existing env-dependent
doctor/exec tests that pass in CI.
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit approvable ?

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "nextDailyDelay" --type go -A 20

Length of output: 6937



@gnanam1990 Not yet — the one remaining blocker is still unresolved.

nextDailyDelay line 338 still uses Add(24h):

// scheduler.go:338 — still drifts on DST changeover day:
next = next.Add(24 * time.Hour)

// Fix:
next = time.Date(now.Year(), now.Month(), now.Day()+1, hour, minute, 0, 0, now.Location())

The goroutine correctly calls nextDailyDelay each cycle, but the function itself still adds a fixed 24h when the target has passed today. On a spring-forward day that fires at 23:30, Add(24h) yields 00:30 the next day — an hour early. The time.Date(day+1, ...) form lets Go's time package normalize across DST boundaries correctly.

Also note: the existing TestNextDailyDelay test uses UTC (no DST), so it won't catch this — the test for the 24h equal-now case will pass either way. Fix the one line and I'll approve.

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).
@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit approvable ?

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "nextDailyDelay" --type go -A 20

Length of output: 8593



@gnanam1990 Yes — the blocker is resolved.

nextDailyDelay (line 342) now uses time.Date(now.Year(), now.Month(), now.Day()+1, hour, minute, 0, 0, now.Location()) and the DST regression test TestNextDailyDelayHoldsWallClockAcrossDST covers the spring-forward case. All six prior fixes from a266e33 remain in place.

All blockers resolved — approving now.

[approve]

@gnanam1990

Copy link
Copy Markdown
Collaborator Author

@CodeRabbit approve

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
✅ Action performed

Comments resolved and changes approved.

@gnanam1990
gnanam1990 merged commit c384441 into main Jun 16, 2026
7 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the feat/audit-backlog branch June 28, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants