Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## [Unreleased]

### Multi-sink fan-out

One source can now push the same cookies and secrets to several sinks.

- `source.yaml` accepts a `sinks:` list, each entry with its own `url` and `peer`. A legacy single-sink config (`sink:` + `peer:`) keeps working unchanged and loads as a one-element list.
- A push reads and filters cookies once, then seals and POSTs to each sink with its own paired key. A missing per-sink key isolates that sink instead of aborting the whole push, and never silently downgrades a paired sink to the legacy shared secret.
- Per-sink failures are isolated; a partial or total sink failure is a non-zero `--once` exit. The `--once` deadline scales with sink count so a slow first sink cannot starve later healthy ones.
- `agentcookie wizard install --as source --add-sink --peer <host> --sink-url <url>` pairs and appends an additional sink to an existing config.
- `agentcookie status` and `agentcookie doctor` report per-sink push state.
- Example: `examples/source-multi-sink.yaml`.

## [1.0.0] - 2026-08-13

### Featured: Mac to Linux continuous sync
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,34 @@ agentcookie wizard install --as sink \

The macOS sink writes to Chrome's encrypted SQLite, the plaintext sidecar, and per-CLI adapter session files. It can also run CDP injection into a managed Chrome subprocess. See [docs/quickstart.md](docs/quickstart.md) for the full macOS-to-macOS walkthrough.

## Fan out to multiple sinks

One source can push the same cookies and secrets to several sinks. A source push reads and filters your cookies once, then seals and POSTs to each sink independently with that sink's own paired key. A sink that is down or unreachable is isolated: it fails on its own while the other sinks still receive the payload.

List sinks under `sinks:` in `source.yaml`, each with its own `url` and `peer`:

```yaml
sinks:
- url: http://mac-mini.tailnet.ts.net:9999/sync
peer: mac-mini
- url: http://grok-bot.tailnet.ts.net:9999/sync
peer: grok-bot
chrome:
db_path: ~/Library/Application Support/Google/Chrome/Default/Cookies
```

A legacy single-sink `source.yaml` (top-level `sink:` + `peer:`) keeps working unchanged and behaves as a one-element list. To pair and append another sink without hand-editing:

```bash
agentcookie wizard install --as source --add-sink \
--peer <new-sink-hostname> \
--sink-url http://<new-sink>.tailnet.ts.net:9999/sync
```

`agentcookie status` and `agentcookie doctor` report each sink's last push and failures. See [examples/source-multi-sink.yaml](examples/source-multi-sink.yaml).

**Trust note:** every sink receives the same full cookie and secret set, so a compromise of the least-trusted sink exposes everything. Only list sinks you trust with the whole payload. To stop feeding a sink, remove its entry from `sinks:` and delete its `keys/<peer>.json`. The device-bound (DBSC) caveat below is per-sink and unchanged: each sink still needs its own Chrome signed into the same Google account.

## What about Chrome's device-bound cookies (DBSC)?

Chrome's Device Bound Session Credentials (DBSC) tie a session to one machine's secure hardware so a stolen cookie cannot be replayed elsewhere. For a site that has adopted DBSC, a copied cookie works on the sink only until its short-lived window (minutes) lapses.
Expand Down
293 changes: 293 additions & 0 deletions docs/plans/2026-09-09-1436-feat-multi-sink-cookie-fanout-plan.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions examples/source-multi-sink.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Example multi-sink source.yaml. Copy to ~/.config/agentcookie/source.yaml
# on the machine where you log in interactively (your laptop) to fan out the
# same cookies and secrets to more than one sink.
#
# One source push reads and filters your cookies once, then seals and POSTs
# to each sink independently. A sink that is down or unreachable is isolated:
# it fails on its own while the other sinks still receive the payload.
#
# Trust note: every sink receives the SAME full cookie and secret set, so a
# compromise of the least-trusted sink exposes everything. Only list sinks you
# trust with the whole payload.

sinks:
# Each entry has its own URL and peer (the keys/<peer>.json filename from
# `agentcookie pair`). Prefer MagicDNS hostnames over 100.x IPs so the
# source follows each sink across Tailscale re-auth.
- url: http://mac-mini.tailnet.ts.net:9999/sync
peer: mac-mini
- url: http://grok-bot.tailnet.ts.net:9999/sync
peer: grok-bot

chrome:
# Defaults to ~/Library/Application Support/Google/Chrome/Default/Cookies
# if omitted. Set explicitly to read from a non-default profile.
db_path: ~/Library/Application Support/Google/Chrome/Default/Cookies

# A legacy single-sink source.yaml (top-level `sink:` + `peer:`) still works
# unchanged and behaves as a one-element sinks list. Pair and append another
# sink without hand-editing with:
#
# agentcookie wizard install --as source --add-sink --peer <new-sink> \
# --sink-url http://<new-sink>.tailnet.ts.net:9999/sync
4 changes: 4 additions & 0 deletions examples/source.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@ peer:
# who haven't paired yet. After pairing, delete the field below entirely.
# security:
# shared_secret: only-set-this-before-you-pair

# Multi-sink: to push the same cookies to more than one sink, use a
# top-level `sinks:` list instead of the single `sink:`/`peer:` above.
# See examples/source-multi-sink.yaml.
28 changes: 26 additions & 2 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -985,18 +985,42 @@ func checkSourceStateFrom(st *state.SourceState, err error) Check {
}
}
if st.TotalFailures > 0 {
detail := fmt.Sprintf("last push %s ago, %d total failures", age, st.TotalFailures)
if failing := failingSinkLabels(st); failing != "" {
detail += fmt.Sprintf(" (failing sink(s): %s)", failing)
}
return Check{
Name: "Source state",
Severity: SeverityWarn,
Detail: fmt.Sprintf("last push %s ago, %d total failures", age, st.TotalFailures),
Detail: detail,
Remediation: "inspect `agentcookie status` for the most recent error",
}
}
detail := fmt.Sprintf("last push %s ago, 0 failures", age)
if n := len(st.Sinks); n > 1 {
detail += fmt.Sprintf(", %d sinks all healthy", n)
}
return Check{
Name: "Source state",
Severity: SeverityOK,
Detail: fmt.Sprintf("last push %s ago, 0 failures", age),
Detail: detail,
}
}

// failingSinkLabels lists the peer/URL of each fan-out sink whose most
// recent push failed, for the per-sink detail on the source-state WARN.
func failingSinkLabels(st *state.SourceState) string {
var labels []string
for _, s := range st.Sinks {
if s.TotalFailures > 0 && s.LastError != "" {
label := s.URL
if s.Peer != "" {
label = s.Peer
}
labels = append(labels, label)
}
}
return strings.Join(labels, ", ")
}

// checkDBSCFrom is informational: it surfaces how many cookies the last push
Expand Down
33 changes: 33 additions & 0 deletions internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,39 @@ func TestCheckSourceState(t *testing.T) {
t.Fatalf("got %q", c.Severity)
}
})
t.Run("per-sink failure names the failing sink", func(t *testing.T) {
st := &state.SourceState{
LastPush: time.Now(),
TotalFailures: 1,
Sinks: []state.SinkPushState{
{Peer: "alpha", URL: "http://a.test/sync", TotalPushes: 4},
{Peer: "bravo", URL: "http://b.test/sync", TotalFailures: 1, LastError: "connection refused"},
},
}
c := checkSourceStateFrom(st, nil)
if c.Severity != SeverityWarn {
t.Fatalf("got %q", c.Severity)
}
if !strings.Contains(c.Detail, "bravo") {
t.Errorf("detail should name the failing sink, got %q", c.Detail)
}
})
t.Run("healthy multi-sink notes sink count", func(t *testing.T) {
st := &state.SourceState{
LastPush: time.Now(),
Sinks: []state.SinkPushState{
{Peer: "alpha", URL: "http://a.test/sync", TotalPushes: 4},
{Peer: "bravo", URL: "http://b.test/sync", TotalPushes: 4},
},
}
c := checkSourceStateFrom(st, nil)
if c.Severity != SeverityOK {
t.Fatalf("got %q (%q)", c.Severity, c.Detail)
}
if !strings.Contains(c.Detail, "2 sinks") {
t.Errorf("detail should note the sink count, got %q", c.Detail)
}
})
}

func TestCheckDBSC(t *testing.T) {
Expand Down
Loading
Loading