feat: fan out cookie and secret sync to multiple sinks - #122
Conversation
Add SinkTarget and SourceConfig.Sinks, plus ResolvedSinks() which returns the sinks list when present and otherwise synthesizes a one-element list from the legacy scalar sink/peer fields, so every pre-multi-sink source.yaml keeps working unchanged. Generalize LoadSource validation to be per-sink (each needs a URL and either a peer key or the legacy shared secret) while preserving the legacy single-sink error behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
Read and filter cookies once, then seal and POST per sink. Resolve each sink's transport secret inside the fan-out loop so a missing per-sink key isolates that sink instead of aborting delivery to all, and never silently downgrades a peer'd sink to the fleet-wide shared secret. Per-sink failures are isolated and reported (errors.Join keeps each chain, so ErrAmbiguousPeer still matches); a partial or total sink failure is a non-zero exit. Scale the --once outer deadline by sink count so a slow first sink cannot starve later healthy ones. Track per-sink push state (SourceState.Sinks, keyed by peer hostname, URL refreshed in place) alongside the retained cross-sink aggregate so old state files and single-sink configs keep working unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
status prints a per-sink breakdown (pushes, failures, last push, last error) under the aggregate source-daemon line when multi-sink fan-out records are present. doctor's source-state check names the failing sink(s) on a WARN and notes the healthy sink count, while the aggregate OK/WARN thresholds are unchanged. Single-sink and legacy state files render exactly as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
--add-sink pairs an additional peer and appends it to an existing source.yaml as a multi-sink target, migrating a legacy single-sink config into an explicit sinks: list, instead of overwriting. It validates against a duplicate peer or URL before pairing, files the new key under the operator --peer name via beginSourcePairing (the announced hostname is recorded separately), and rewrites source.yaml via a template rather than yaml.Marshal so no stray empty legacy sink: block is emitted. The running --watch daemon fans out to the new sink on its next push, so no LaunchAgent reinstall is needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
Add a README section on fanning out to multiple sinks (sinks: list, --add-sink, per-sink status/doctor, the equal-trust caveat and manual revoke), a CHANGELOG entry, a dedicated examples/source-multi-sink.yaml, and a pointer from examples/source.yaml. Guard the example against schema drift with a loader decode test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
Greptile SummaryAdds multi-sink fan-out so one source snapshot can be sealed and delivered independently to multiple paired sinks while preserving legacy single-sink configuration.
Confidence Score: 3/5The PR is not yet safe to merge because two previously reported wizard defects remain unresolved. Adding a sink still rewrites Files Needing Attention: internal/cli/wizard.go, internal/cli/source.go
|
| Filename | Overview |
|---|---|
| internal/cli/source.go | Implements sequential per-sink sealing, delivery, failure isolation, timeout scaling, and state recording; the previously reported aggregate push-counter issue remains. |
| internal/cli/wizard.go | Adds additive sink pairing and YAML rewriting; the running watcher is still not reloaded, and cmux.domain_filter is still omitted during rewriting. |
| internal/config/config.go | Adds the multi-sink schema and legacy single-sink resolution path. |
| internal/state/state.go | Adds per-sink push records while retaining legacy aggregate state compatibility. |
| internal/cli/status.go | Adds per-sink runtime-state output, though the configuration summary still uses the legacy scalar sink field. |
| internal/cli/doctor.go | Names sinks with recorded failures but continues to base freshness primarily on aggregate source state. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Source[Source browser and secrets] --> Read[Read and filter once]
Read --> Envelope[Build shared envelope]
Envelope --> SealA[Seal with sink A key]
Envelope --> SealB[Seal with sink B key]
SealA --> SinkA[POST to sink A]
SealB --> SinkB[POST to sink B]
SinkA --> StateA[Record sink A state]
SinkB --> StateB[Record sink B state]
StateA --> Aggregate[Aggregate source state]
StateB --> Aggregate
Reviews (2): Last reviewed commit: "style: satisfy golangci-lint (max builti..." | Re-trigger Greptile
| if err := os.WriteFile(sourcePath, []byte(newYAML), 0o600); err != nil { | ||
| return fmt.Errorf("write updated source.yaml: %w", err) | ||
| } | ||
| fmt.Fprintf(os.Stderr, "agentcookie wizard: appended sink %s (peer %q) to source.yaml; the --watch daemon will fan out to it on the next push\n", newURL, wizardPeer) |
There was a problem hiding this comment.
When --add-sink updates source.yaml while source --watch is running, the process continues using the configuration it loaded at startup. It does not watch the YAML file, and this code neither reloads nor restarts it. Later pushes therefore omit the newly paired sink until the daemon is manually restarted, despite the completion message saying it will receive the next push.
Knowledge Base Used: Agentcookie CLI workflows
| if cfg.Cmux.Enabled { | ||
| b.WriteString("cmux:\n") | ||
| b.WriteString(fmt.Sprintf(" enabled: %v\n", cfg.Cmux.Enabled)) | ||
| if cfg.Cmux.CmuxPath != "" { | ||
| b.WriteString(fmt.Sprintf(" cmux_path: %s\n", cfg.Cmux.CmuxPath)) | ||
| } | ||
| } |
There was a problem hiding this comment.
When an existing source uses cmux.domain_filter, the --add-sink rewrite silently omits that setting because this renderer preserves only enabled and cmux_path. An empty filter means cmux receives the full eligible cookie set, so adding a sink unexpectedly widens local cookie delivery beyond the configured domains.
Knowledge Base Used: CLI configuration and local state
| } else { | ||
| ps.TotalPushes++ | ||
| ps.LastPushCount = r.Count | ||
| ps.LastPush = now | ||
| ps.LastError = "" | ||
| srcState.TotalPushes++ | ||
| srcState.LastPushCount = r.Count | ||
| srcState.LastPush = now |
There was a problem hiding this comment.
The legacy aggregate TotalPushes counter is incremented inside the per-sink loop. One fan-out to two healthy sinks therefore records two pushes, changing the counter from source push cycles to successful sink deliveries. Existing state readers and status output will report inflated totals that grow with the number of configured sinks.
Knowledge Base Used: CLI configuration and local state
Use the max builtin for the --once sink-count floor and fmt.Fprintf into the strings.Builder in renderSourceYAMLSinks (QF1012), clearing the go-lint CI failures on the multi-sink branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy
….toml) Brings in upstream 994abf2 (fan out cookie and secret sync to multiple sinks, mvanhorn#122) and d1827cc (carry PP CLI config.toml as a file instead of parsing it as dotenv, mvanhorn#118). The only content conflict was internal/config/config.go, in the SourceConfig struct: the fork added EnabledProducts there while upstream added the Sinks fan-out list and made the legacy scalar Sink omitempty. Resolved by keeping both with their own comments and yaml/json tags -- upstream's Sinks/Sink pair and legacy single-sink synthesis are taken verbatim, and the fork's EnabledProducts sits after Browser, ahead of Peer and Security, so DefaultEnabledProducts and ResolveEnabledProducts keep their meaning. Multi-sink repeats only sealing and transport, so it does not re-open extra-profile discovery: internal/chromepaths and the fork's admission rule are untouched by the merge. go build, go vet, and go test -race are green across the module.
Summary
Adds native multi-sink fan-out to
agentcookie source. One source config can now push the same cookies and secrets to several sinks. A push reads and filters cookies once, then seals and POSTs to each sink with that sink's own paired key, isolating any sink that is down. A legacy single-sinksource.yamlkeeps working unchanged.Implements
docs/plans/2026-09-09-1436-feat-multi-sink-cookie-fanout-plan.md.Key decisions
source.yamlgains asinks:list; the existing scalarsink:/peer:still load and synthesize a one-element list viaResolvedSinks(). No migration.--oncedeadline. Per-sink failures don't abort the others (errors.JoinpreservesErrAmbiguousPeer); a partial/total failure is a non-zero exit; the--onceouter timeout scales with sink count so a slow first sink can't starve later ones.--add-sinkpairs and appends a sink, filing the key under the operator--peername (with the announced hostname recorded separately).Testing
go build ./...,go vet ./..., andgofmtare clean.internal/config,internal/state,internal/clisuites pass except one pre-existing environment failure,TestCheckDaemonBinaryPath, which fails onmaintoo (it trips on twoagentcookiebinaries installed on the dev machine).Code review
ce-code-reviewand the harness-native review could not run in this session: an active real-time cyber safeguard terminated every review subagent on this cookie-sync subject matter. Replaced with an explicit manual diff scan (see decisions above). A fresh reviewer pass on GitHub is doing that gate rather than confirming a prior one.Open questions for the reviewer (trust-model, captured in the plan)
sinks:entry andkeys/<peer>.json. Decide whetherremove-sinkbelongs in this PR.--watch— an offline-but-reachable sink costs its full timeout serially each push. Default: fast-fail connect timeout, concurrency deferred.Post-Deploy Monitoring & Validation
agentcookie source --onceagainst a two-sink config delivers to both reachable sinks; killing one leaves the other delivered with a partial-success (non-zero) exit.agentcookie statusandagentcookie doctorper-sink last-push/last-error; the source LaunchAgent stderr[<url>] posted N cookieslines.🤖 Generated with Claude Code
https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy