# Source and sink topology

> The one-way laptop-to-second-Mac model: the source watcher, the sink listener, the role split, and what each side reads and writes.

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `docs/architecture.md`
- `internal/cli/source.go`
- `internal/cli/sink.go`
- `internal/watcher/watcher.go`
- `internal/cli/httpserver/httpserver.go`

---

---
title: "Source and sink topology"
description: "The one-way laptop-to-second-Mac model: the source watcher, the sink listener, the role split, and what each side reads and writes."
---

agentcookie runs as two long-lived processes on two different Macs joined by a Tailscale tailnet. The **source** binary (`agentcookie source --watch`) runs on the interactive laptop, watches Chrome's `Cookies` SQLite, Local Storage, IndexedDB, and the `~/.agentcookie/secrets/` tree, and POSTs sealed envelopes one-way to the **sink** binary (`agentcookie sink`) on a second Mac. The sink listens on `:9999/sync`, decrypts each envelope with the per-peer key derived during pairing, and writes cookies and secrets to disk so any locally-run agent or CLI can read them. There is no inbound channel from sink to source — every transfer is initiated by the laptop watcher or its baseline tick.

## Role split at a glance

| Concern | Source (laptop) | Sink (second Mac / VM) |
|---------|-----------------|------------------------|
| Subcommand | `agentcookie source --watch` (or `--once`) | `agentcookie sink` |
| Triggers a push | fsnotify on Chrome dirs + secrets dir + 30 s baseline tick | n/a (passive listener) |
| Reads Chrome Safe Storage | Yes, on every push, to decrypt source values | Only when `skip_chrome_sqlite=false`, to re-encrypt sink values |
| Writes Chrome SQLite | Never | Yes, unless `skip_chrome_sqlite=true` |
| Writes cookies sidecar | Never | Yes (`~/.agentcookie/cookies-plain.db`) |
| Writes per-CLI session files | Never | Yes, via `sinkpush.RunAll` adapters |
| HTTP role on `/sync` | Client (POST `application/octet-stream`) | Server (one `http.ServeMux` handler) |
| Listen address | None for `/sync`; `:9998/pair` only during pairing | `cfg.Listen.Addr`, must be tailnet `100.x` or `127.0.0.1` |
| State file | `state.SourcePath(home)` | `state.SinkPath(home)` |
| Direction of data | Out only | In only |

The split is hard. `internal/cli/source.go` never imports `internal/cli/sink.go`; the source build never opens a listening socket for `/sync`, and the sink build never reads the source's Chrome profile or initiates a POST.

## What the source reads

`runSource` in `internal/cli/source.go` loads `source.yaml` and a per-host paired key, then on each push cycle:

- Reads Chrome's Safe Storage password via `chrome.SafeStoragePassword()` (Keychain `security find-generic-password`) and derives the per-machine AES key with `chrome.DeriveAESKey`.
- Opens the configured `chrome.db_path` (default `~/Library/Application Support/Google/Chrome/Default/Cookies`) read-only with `immutable=1`, selects every row via `chrome.ReadCookiesForHost(..., "%", key)`, and decrypts each `encrypted_value`.
- Applies the optional `blocklist.yaml` (SQLite-LIKE patterns); the v0.3 default is sync-everything.
- Runs `chrome.ClassifyCookies` to flag DBSC-suspect cookies. `--skip-dbsc-suspect` or `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1` drops them; the default ships with a warning.
- Packs `Local Storage/leveldb` via `chromedirsync.Pack`. IndexedDB is opt-in behind `AGENTCOOKIE_SYNC_INDEXEDDB=1` because typical profiles are hundreds of MB.
- Calls `secretsbus.LoadPayloadWithDiscovery(home)` to merge the v1 bus (`~/.agentcookie/secrets/<cli>/secrets.env`) with v2 discovery (`~/.agentcookie/manifests/` and the PP library).

The source never writes Chrome state on its own machine and never writes to the sink's filesystem directly.

## What the source sends

Each push wraps the readout in a `protocol.SyncEnvelope` (`internal/protocol/envelope.go`), marshals it as JSON, AES-GCM-seals it with the paired transport secret via `transport.SealWithSecret`, then POSTs to `cfg.Sink.URL` with `Content-Type: application/octet-stream`.

```go
type SyncEnvelope struct {
    ProtocolVersion     int             // current: 2; sinks accept 1..Version
    SourceHostname      string          // pairing.LocalHostname()
    Sequence            int64           // time.Now().UnixNano(), replay defense
    Cookies             []chrome.Cookie
    LocalStorageTarball []byte          // omitempty
    IndexedDBTarball    []byte          // omitempty
    IndexedDBSkipped    []string        // omitempty
    Secrets             map[string]map[string]string // v0.13 secrets bus
}
```

The outer `context.WithTimeout` is sized to `httpserver.Defaults(SyncClient).ClientTimeout` (5 minutes) plus 30 s slack so large LocalStorage/IndexedDB payloads do not time out on a slow tailnet hop.

## The source watcher loop

`internal/watcher/watcher.go` is the long-running brain of `--watch`. It watches the parent directory of `cfg.Chrome.DBPath` (Chrome renames-on-write, so watching the file directly misses events), plus the optional Local Storage and IndexedDB roots.

<ParamField body="CookiesPath" type="string" required>
  Absolute path to Chrome's Cookies SQLite. The watcher subscribes to the parent directory and filters events to `Cookies`, `Cookies-wal`, `Cookies-journal`, and `Cookies-shm`.
</ParamField>
<ParamField body="LocalStorageDir" type="string">
  Absolute path to `Local Storage/leveldb`. Missing dirs are tolerated; the baseline tick will pick them up later if they appear.
</ParamField>
<ParamField body="IndexedDBDir" type="string">
  Absolute path to `IndexedDB/`. Top-level subdir events fire pushes; in-origin writes are picked up by the baseline tick.
</ParamField>
<ParamField body="Push" type="func(ctx) (int, error)" required>
  Callback invoked after debounce expires and the rate cap allows.
</ParamField>
<ParamField body="Debounce" type="time.Duration" default="500ms">
  Time after the last fsnotify event before a push is scheduled.
</ParamField>
<ParamField body="MinInterval" type="time.Duration" default="30s">
  Minimum gap between successive pushes. v0.7 bumped this from 2 s because every sync now quits/relaunches Chrome on the sink (cookie SQLite + leveldb file locks).
</ParamField>
<ParamField body="BaselineTick" type="time.Duration" default="30s">
  Even with no fs events, run a push every `BaselineTick`. Defends against fsnotify event loss on macOS.
</ParamField>
<ParamField body="MaxBackoff" type="time.Duration" default="60s">
  Cap on exponential backoff after push failures.
</ParamField>

A startup push fires before the loop blocks so the sink is current from `t=0`. Push failures are logged and counted via `Stats` but never stop the loop.

Two extra background goroutines feed the same `push` callback:

- `secretsbus.NewWatcher(home, 0, push)` watches `~/.agentcookie/secrets/` so any rewrite of `secrets.env` triggers a payload that includes whichever surface changed.
- `secretsbus.NewDiscoveryWatcher` watches `~/.agentcookie/manifests/` and the PP library so dropping a new `agentcookie.toml` or regenerating a PP CLI triggers a push without a restart.

## What the sink listens on

`runSink` in `internal/cli/sink.go` mounts a single `http.ServeMux` with two handlers and applies the `httpserver.SinkSync` profile (256 MB body cap, 60 s read/write, 16 KB header cap).

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/healthz` | `GET` | Liveness probe, returns `ok\n`. |
| `/sync` | `POST` | Accepts a sealed envelope, applies it to local Chrome and the secrets bus. |

The address comes from `cfg.Listen.Addr`. `validateListenAddr` in `internal/cli/wizard.go` runs at startup and refuses to bind on `0.0.0.0`, `::`, empty host, or any non-tailnet routable address; `127.0.0.1` is allowed for local-dev only, otherwise the host must satisfy `tsclient.IsTailnetIP` (a 100.x address from `tailscale status`).

## The `/sync` handler, end to end

```text
POST /sync
  ├─ httpserver.LimitedReader  (SinkSync.MaxBodyBytes = 256 MiB)
  ├─ io.ReadAll                → sealed bytes
  ├─ transport.OpenWithSecret  → 401 on wrong key
  ├─ json.Unmarshal envelope   → 400 on malformed body
  ├─ ProtocolVersion in [Min,Version]  → 400 on mismatch
  ├─ seqTracker.Accept(host, seq)      → 409 on replay
  ├─ blockMatcher.Filter(envelope.Cookies)
  ├─ if --dry-run: dump JSON to stderr, return 200
  ├─ if cfg.SkipChromeSQLite:
  │      applySidecarOnlyToSink(cookies)   → only ~/.agentcookie/cookies-plain.db
  │  else:
  │      applyEnvelopeToSink(...)          → SQLite + leveldb under WithChromeDown
  ├─ if cfg.CDP.Enabled and len(cookies) > 0:
  │      cdpInject(profileDir, cookies)    → headless Chrome via Storage.setCookies
  ├─ sinkpush.RunAll(cookies)              → per-CLI adapter session files
  ├─ if len(envelope.Secrets) > 0:
  │      secretsbus.WritePayload(...)      → ~/.agentcookie/secrets/<cli>/secrets.env
  └─ stateWriter.Save(sinkState)           → ~/.agentcookie/sink-state.json
```

`seqTracker` is loaded from `protocol.DefaultSequencePath(home)` (`~/.agentcookie/sequence.json`) at startup so a captured `/sync` envelope cannot be replayed across reboots; a corrupt file fails the sink boot rather than silently resetting the high-water marks. Operator recovery is to delete the file.

## What the sink writes

The sink's full write fan-out is the contract a paired source can rely on. Each step has its own failure mode but no step can resurrect a previously-aborted step's data.

| Path | Mode | When written | Touched by |
|------|------|--------------|------------|
| `~/Library/Application Support/Google/Chrome/Default/Cookies` | rw, re-encrypted with the sink's Safe Storage key | `skip_chrome_sqlite=false` and cookies present | `applyEnvelopeToSink` → `chrome.WriteCookies` |
| `~/Library/Application Support/Google/Chrome/Default/Local Storage/leveldb` | dir replace via staging+rename | `LocalStorageTarball` present | `chromedirsync.AtomicReplaceDir` |
| `~/Library/Application Support/Google/Chrome/Default/IndexedDB` | dir replace via staging+rename | `IndexedDBTarball` present | `chromedirsync.AtomicReplaceDir` |
| `~/.agentcookie/cookies-plain.db` | 0600, optionally sealed under master key | every successful sync with cookies | `writeCookiesSidecar` |
| `~/.agentcookie/chrome-profile/...` | CDP-managed Chrome profile | `cfg.CDP.Enabled` and cookies present | `cdp.InjectCookies` |
| per-CLI session files (kooky-style, pycookiecheat-style) | adapter-specific | every successful sync with cookies | `sinkpush.RunAll` |
| `~/.agentcookie/secrets/<cli>/secrets.env` (+ optional sealed twin) | 0600 | `envelope.Secrets` non-empty | `secretsbus.WritePayload` |
| `~/.agentcookie/sink-state.json` | 0600 | end of every request, including errors | `state.NewWriter` |

The SQLite + leveldb writes happen inside `chromectl.WithChromeDown`, which holds Chrome down on the sink for the duration. v0.9 specifically uses `WithChromeDown` (not `WithChromeQuit`) so the sink never launches Chrome itself — launching would trigger Chrome's `meta.version` migration from 18 to 24 and rewrite cookies into App-Bound v20, breaking kooky-style readers. A post-write `chrome.ProbeCookiesFile` probe decrypts a few rows the way kooky v0.2.2 would and prints a one-line verify in sink stderr.

## Headless second-Mac variant

The `skip_chrome_sqlite: true` switch in `sink.yaml` makes the sink never touch Chrome Safe Storage and never write Chrome's SQLite/leveldb/IndexedDB. `applySidecarOnlyToSink` writes only `~/.agentcookie/cookies-plain.db`; PP CLIs continue to receive the synced cookies through the sidecar and through `sinkpush.RunAll`'s per-CLI adapter session files. This is the SSH-only install path on a Mac mini with no GUI session to answer the Keychain prompt that `security find-generic-password` raises.

The `Delivery` field in `SinkConfig` (`"universal"` or `"degraded"`) records the intent the wizard resolved to, so `agentcookie doctor` can report the chosen mode without re-inferring it.

## Configuration anchors per side

<CodeGroup>
```yaml source.yaml
sink:
  url: http://my-mac-mini.tailnet.ts.net:9999/sync
chrome:
  db_path: ~/Library/Application Support/Google/Chrome/Default/Cookies
peer:
  hostname: my-mac-mini.tailnet.ts.net
```

```yaml sink.yaml
listen:
  addr: 100.x.y.z:9999          # tailnet 100.x or 127.0.0.1; 0.0.0.0 is refused
cdp:
  enabled: true
  managed: true
peer:
  hostname: my-laptop.tailnet.ts.net
# skip_chrome_sqlite: true       # headless install path
```
</CodeGroup>

`SourceConfig.Sink.URL` is the only place that names the sink. `SinkConfig.Listen.Addr` is the only place that opens a listening port for sync traffic. `Peer.Hostname` on each side is the lookup key under `~/.config/agentcookie/keys/` for the X25519+HKDF-derived per-peer transport key.

## The pairing seam

Pairing temporarily inverts the listener role: the source spins up a one-shot `:9998/pair` listener and the sink POSTs into it once. After the handshake completes, both sides write the same 32-byte key to `~/.config/agentcookie/keys/<peer>.json` (mode 0600), the pairing listener shuts down, and the only socket open between the two machines is the sink's `/sync`. The same `validateListenAddr` rule applies to the pairing listener as to `/sync`.

## Operational signals

<Info>
Each side maintains its own state JSON for `agentcookie status` and `agentcookie doctor`:
- Source: push count, last push count, last push timestamp, last error, last DBSC warned/skipped counts and sample reasons.
- Sink: listen addr, last write timestamp, last write count, last write mode (`sqlite+leveldb`, `sidecar+adapter`, `dry-run`, optional `+cdp`), per-adapter results, total dropped by blocklist, last error.
</Info>

<Warning>
The sink replies with a non-200 only on hard failures: `401` (envelope failed AES-GCM open), `400` (malformed JSON or `ProtocolVersion` out of `[MinVersion..Version]`), `409` (replay — sequence not strictly greater than the last accepted for this `SourceHostname`), or `500` (write failed). CDP injection failures, adapter failures, and secrets-bus per-CLI errors are logged but never block the `200 OK` once the cookie write has committed — that is the contract that keeps PP CLIs served even when one delivery surface is degraded.
</Warning>

## Related pages

<CardGroup>
  <Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">The three sink-side delivery paths the `/sync` handler fans out to.</Card>
  <Card title="Wire protocol v1" href="/wire-protocol">POST `/sync`, AES-256-GCM seal, `SyncEnvelope` fields, and the 401/400/409 response semantics.</Card>
  <Card title="Configure source and sink" href="/configure-source-sink">Schema and validation for `source.yaml` and `sink.yaml`, including the tailnet-only listen rule.</Card>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">X25519 + HKDF-SHA256 handshake and the per-peer key file under `~/.config/agentcookie/keys/`.</Card>
  <Card title="Headless second-Mac install" href="/headless-install">The `skip_chrome_sqlite` install path for a sink with no GUI session.</Card>
  <Card title="Secrets bus" href="/secrets-bus">How the `envelope.Secrets` payload lands at `~/.agentcookie/secrets/<cli>/secrets.env`.</Card>
</CardGroup>
