Skip to content

feat: fan out cookie and secret sync to multiple sinks - #122

Merged
mvanhorn merged 7 commits into
mainfrom
feat/multi-sink-cookie-fanout
Sep 10, 2026
Merged

mvanhorn merged 7 commits into
mainfrom
feat/multi-sink-cookie-fanout

Conversation

@mvanhorn

Copy link
Copy Markdown
Owner

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-sink source.yaml keeps working unchanged.

Implements docs/plans/2026-09-09-1436-feat-multi-sink-cookie-fanout-plan.md.

Key decisions

  • Additive config, legacy retained. source.yaml gains a sinks: list; the existing scalar sink:/peer: still load and synthesize a one-element list via ResolvedSinks(). No migration.
  • Read once, seal-and-POST per sink. Cookies and secrets are built once; only sealing and transport repeat per sink.
  • No silent downgrade. A sink with a peer requires that peer's key; a missing key isolates the sink instead of falling back to the fleet-wide shared secret.
  • Failure isolation + scaled --once deadline. Per-sink failures don't abort the others (errors.Join preserves ErrAmbiguousPeer); a partial/total failure is a non-zero exit; the --once outer timeout scales with sink count so a slow first sink can't starve later ones.
  • Per-sink state, keyed by peer hostname, with the cross-sink aggregate retained for old state files.
  • --add-sink pairs and appends a sink, filing the key under the operator --peer name (with the announced hostname recorded separately).

Testing

  • New unit tests: config resolution and legacy synthesis, two-sink fan-out delivery, per-sink failure isolation, missing-key isolation (no silent downgrade), per-sink state and legacy state decode, doctor per-sink reporting, the wizard append, and a decode guard over the shipped example.
  • go build ./..., go vet ./..., and gofmt are clean.
  • Full internal/config, internal/state, internal/cli suites pass except one pre-existing environment failure, TestCheckDaemonBinaryPath, which fails on main too (it trips on two agentcookie binaries installed on the dev machine).

Code review

ce-code-review and 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)

  • Sink trust tiering — every sink receives the same full cookie/secret set, so the weakest sink's compromise exposes everything. Decide whether a lower-trust sink (e.g. grok-bot) should get a scoped payload before this is relied on. Default: all sinks equally trusted.
  • Revocation — removing a sink is deferred; the interim revoke is deleting its sinks: entry and keys/<peer>.json. Decide whether remove-sink belongs in this PR.
  • Chronically-dead sink under --watch — an offline-but-reachable sink costs its full timeout serially each push. Default: fast-fail connect timeout, concurrency deferred.

Post-Deploy Monitoring & Validation

  • Validate: agentcookie source --once against a two-sink config delivers to both reachable sinks; killing one leaves the other delivered with a partial-success (non-zero) exit.
  • Watch: agentcookie status and agentcookie doctor per-sink last-push/last-error; the source LaunchAgent stderr [<url>] posted N cookies lines.
  • Healthy signal: each configured sink shows a recent last push and 0 failures.
  • Failure signal / rollback: if a legacy single-sink config regresses, revert the branch; the change is additive and legacy configs are covered by tests.
  • Window/owner: first day of use across the three sinks; repo owner.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PFRsAqcEwq1jZnRk4zFQyy

mvanhorn and others added 6 commits September 9, 2026 15:15
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
@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown

Greptile Summary

Adds multi-sink fan-out so one source snapshot can be sealed and delivered independently to multiple paired sinks while preserving legacy single-sink configuration.

  • Introduces an additive sinks: configuration and per-sink key resolution.
  • Isolates sink failures and scales the one-shot timeout for sequential delivery.
  • Adds per-sink state and operator reporting through status and doctor.
  • Extends the installation wizard to pair and append sinks.
  • Documents the configuration, trust model, and operational workflow.

Confidence Score: 3/5

The PR is not yet safe to merge because two previously reported wizard defects remain unresolved.

Adding a sink still rewrites source.yaml without reloading or restarting an already-running source --watch process, so the new sink remains inactive until a manual restart. The same rewrite still omits cmux.domain_filter, which can broaden local cmux cookie delivery after adding a sink. The legacy aggregate TotalPushes counter also remains incremented once per successful sink rather than once per source push cycle.

Files Needing Attention: internal/cli/wizard.go, internal/cli/source.go

Important Files Changed

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
Loading

Reviews (2): Last reviewed commit: "style: satisfy golangci-lint (max builti..." | Re-trigger Greptile

Comment thread internal/cli/wizard.go
Comment on lines +574 to +577
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 New sink stays inactive

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

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread internal/cli/wizard.go
Comment on lines +789 to +795
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))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Domain filter is dropped

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

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Comment thread internal/cli/source.go
Comment on lines +299 to +306
} else {
ps.TotalPushes++
ps.LastPushCount = r.Count
ps.LastPush = now
ps.LastError = ""
srcState.TotalPushes++
srcState.LastPushCount = r.Count
srcState.LastPush = now

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Push count scales per sink

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

Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

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
@mvanhorn
mvanhorn merged commit 994abf2 into main Sep 10, 2026
5 of 6 checks passed
shawnhamby added a commit to shawnhamby/agentcookie that referenced this pull request Sep 12, 2026
….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.
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.

1 participant