# Configure source and sink

> Editing `source.yaml` and `sink.yaml`: sink URL, listen address validation (tailnet-only), peer hostname, Chrome DB path, CDP managed-Chrome toggle, and `skip_chrome_sqlite` for headless boxes.

- 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

- `examples/source.yaml`
- `examples/sink.yaml`
- `internal/config/config.go`
- `internal/cli/sink.go`
- `internal/cli/source.go`

---

---
title: "Configure source and sink"
description: "Editing `source.yaml` and `sink.yaml`: sink URL, listen address validation (tailnet-only), peer hostname, Chrome DB path, CDP managed-Chrome toggle, and `skip_chrome_sqlite` for headless boxes."
---

`agentcookie source` and `agentcookie sink` read their settings from two YAML files in `~/.config/agentcookie/`: `source.yaml` on the laptop and `sink.yaml` on the second Mac. Both are loaded by `internal/config.LoadSource` / `LoadSink` with strict decoding (`yaml.Decoder.KnownFields(true)`), so unknown keys are rejected at boot. `chrome.db_path` is tilde-expanded against `$HOME`. The wizard install path writes both files for you; you only edit them by hand to override a default, point at a non-Default Chrome profile, or flip the delivery mode after install.

## File locations and load rules

| File | Machine | Loaded by | Required? |
|------|---------|-----------|-----------|
| `~/.config/agentcookie/source.yaml` | source (laptop) | `config.LoadSource` | yes for `agentcookie source` |
| `~/.config/agentcookie/sink.yaml` | sink (second Mac) | `config.LoadSink` | yes for `agentcookie sink` |
| `~/.config/agentcookie/keys/<peer>.json` | both | `keystore` | yes when `peer.hostname` is set |
| `~/.config/agentcookie/blocklist.yaml` | both | `config.LoadBlocklist` | optional |

<Note>
Strict YAML decoding means a typo (`hostnme:` instead of `hostname:`) fails the load with a `decode … line N: field X not found` error rather than silently defaulting. The unified `agentcookie doctor` surfaces this as a `config-load` check.
</Note>

## source.yaml

The laptop's push side. Defines where to ship, which Chrome cookies file to read, and how the transport is authenticated.

<CodeGroup>
```yaml title="examples/source.yaml — minimal post-pairing"
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
```
</CodeGroup>

<ParamField body="sink.url" type="string" required>
URL of the sink's `/sync` endpoint. Required. The wizard defaults this to `http://<peer>:9999/sync`; MagicDNS resolves the hostname on the tailnet. Empty `sink.url` fails the load with `sink.url is required`.
</ParamField>

<ParamField body="chrome.db_path" type="string" default="~/Library/Application Support/Google/Chrome/Default/Cookies">
Path to the Chrome cookies SQLite to read. Leading `~/` is expanded. Omit to use the macOS Default-profile path returned by `config.DefaultChromeCookiesPath`. Set explicitly for a non-default profile or a custom user-data-dir.
</ParamField>

<ParamField body="peer.hostname" type="string">
Filename under `~/.config/agentcookie/keys/<peer>.json`, the per-peer 32-byte key produced by `agentcookie pair`. The source loads the key by this name at startup and uses it for AES-256-GCM transport sealing. The operator-supplied name wins over any name the sink announced during pairing.
</ParamField>

<ParamField body="security.shared_secret" type="string" deprecated>
Legacy pre-pairing transport credential. Optional, but if set must be ≥ 32 bytes — `validateSharedSecret` rejects shorter values because the AEAD key was derived by SHA-256 over the secret. Either `peer.hostname` or `security.shared_secret` must be present; the load errors with `either peer.hostname (paired key) or security.shared_secret (legacy) is required` if both are empty.
</ParamField>

## sink.yaml

The second Mac's listener side. Defines the bind address, the peer key it expects, and which delivery surfaces it writes.

<CodeGroup>
```yaml title="sink.yaml — v0.13 universal delivery (wizard default)"
listen:
  addr: 100.80.229.80:9999
peer:
  hostname: my-laptop.tailnet.ts.net
delivery: universal
```

```yaml title="sink.yaml — degraded / headless (skip_chrome_sqlite + CDP)"
listen:
  addr: 100.80.229.80:9999
peer:
  hostname: my-laptop.tailnet.ts.net
skip_chrome_sqlite: true
cdp:
  enabled: true
  profile_dir: ~/.agentcookie/chrome-profile
delivery: degraded
```
</CodeGroup>

### Field reference

<ParamField body="listen.addr" type="string" required>
`host:port` the sink's HTTP listener binds to. Required — empty fails the load with `listen.addr is required (run \`agentcookie wizard install --as sink\` …)`. Subject to the v0.12 tailnet-only policy below.
</ParamField>

<ParamField body="peer.hostname" type="string">
Filename under `~/.config/agentcookie/keys/<peer>.json`. As on the source, either this or `security.shared_secret` must be present.
</ParamField>

<ParamField body="skip_chrome_sqlite" type="bool" default="false">
v0.12.0-beta.3 headless-sink flag. When `true`, the sink daemon never reads Chrome Safe Storage and never writes Chrome's SQLite / leveldb / IndexedDB. Cookies are delivered only via the plaintext sidecar (`~/.agentcookie/cookies-plain.db`) and the per-CLI adapter session files. Used on sinks where no GUI session can answer the Chrome Safe Storage Keychain prompt.
</ParamField>

<ParamField body="cdp.enabled" type="bool" default="false">
Enable the v0.12.0-beta.3 CDP-injection write path. When `true`, after each `/sync` the sink launches its own Chrome subprocess via `chromedp` with `--remote-debugging-port=AUTO` and an isolated `--user-data-dir`, then pushes cookies through `Storage.setCookies`. That Chrome encrypts its own SQLite with its own Safe Storage key, so the sink never reads the system Chrome's Keychain item on this path.
</ParamField>

<ParamField body="cdp.profile_dir" type="string" default="~/.agentcookie/chrome-profile">
User-data-dir for the sink-launched Chrome. Tilde-expanded by the wizard (`expandHome`) before being passed to chromedp. The wizard `mkdir -p`s this path at install time with mode `0700`.
</ParamField>

<ParamField body="chrome.db_path" type="string" default="~/Library/Application Support/Google/Chrome/Default/Cookies">
Only consulted in legacy SQLite mode (`skip_chrome_sqlite: false` and `cdp.enabled: false`), where `applyEnvelopeToSink` writes the real Default profile's Cookies file. The managed-CDP path does not touch SQLite. Tilde-expanded.
</ParamField>

<ParamField body="delivery" type="string" default="">
v0.13 universal-cookie-delivery marker recording the INTENT the install resolved to. Values: `universal` (real Default Chrome profile + any-app keychain open) or `degraded` (`skip_chrome_sqlite` opt-out). `omitempty`: absent fields keep behavior unchanged for upgrade-in-place. Read by `agentcookie doctor` so it can report "any cookie CLI works here" without re-inferring it from `skip_chrome_sqlite` + a keychain probe.
</ParamField>

<ParamField body="security.shared_secret" type="string" deprecated>
Same legacy v0 stopgap as on the source: ≥ 32 bytes, replaced by pairing.
</ParamField>

## Listen-address validation (tailnet-only)

`validateListenAddr` runs at sink startup and again at `wizard install --listen` time. The rule is conservative: bind only on the tailnet 100.x range, or on explicit loopback for local dev.

| Address shape | Decision | Why |
|---------------|----------|-----|
| `100.64.0.0 – 100.127.255.255` | accepted | `tsclient.IsTailnetIP` — Tailscale's CGNAT range |
| `127.0.0.1`, `::1`, `localhost` | accepted | explicit loopback for local-dev / tests |
| `0.0.0.0`, `::`, `""` | refused | "every interface" — exposes the sink off-tailnet |
| anything else (e.g. `192.168.1.5`) | refused | "not a Tailscale 100.x address" |
| unparseable | refused | wrapping `net.SplitHostPort` error |

```text
validateListenAddr(addr):
  host, _, _ = net.SplitHostPort(addr)
  if host in {"0.0.0.0", "::", ""}        -> error "refuses to bind on … (every interface)"
  if host in {"127.0.0.1", "::1", "host"} -> ok (explicit loopback)
  if tsclient.IsTailnetIP(host)            -> ok
  else                                     -> error "not a Tailscale 100.x address"
```

<Warning>
v0.12 removed the permissive `127.0.0.1:9999` fallback for missing `listen.addr`. Pre-v0.12 sink.yaml files that omit the field now fail the load. Run `agentcookie wizard install --as sink` to have the wizard detect your Tailscale 100.x address via `tsclient.RequireTailnetIP` and write it, or set it explicitly.
</Warning>

## CDP managed-Chrome toggle

The "managed Chrome" wording in the install hints refers to the sink launching its own Chrome subprocess and managing its lifecycle, gated by a single boolean: **`cdp.enabled`**. There is no separate `cdp.managed` key — the `CDPRef` Go struct only has `Enabled` and `ProfileDir`, and strict YAML decoding rejects anything else.

```text
cdp.enabled: true        → sink runs chromedp against cdp.profile_dir after each /sync
cdp.enabled: false       → sink does not spawn Chrome; cookies land via SQLite and/or sidecar only
```

The wizard turns CDP on automatically when it resolves the install to degraded mode (so the sink's own Chrome app still sees synced cookies through CDP), and leaves it off when universal delivery is chosen (the real Default profile is already being written). Pass `--no-cdp` to the wizard for sidecar+adapter-only behavior in degraded mode.

<Frame caption="Effective delivery surfaces by sink.yaml shape">
```text
                            skip_chrome_sqlite=false                  skip_chrome_sqlite=true
                            ──────────────────────────                ──────────────────────────
cdp.enabled=false           Default Chrome SQLite                     sidecar only
                            + sidecar + adapter push                  + adapter push
                            (universal delivery)                      (sidecar+adapter)

cdp.enabled=true            (not produced by wizard)                  managed Chrome at
                            Default profile write +                   cdp.profile_dir
                            additional CDP push                       + sidecar + adapter push
                                                                      (degraded)
```
</Frame>

## skip_chrome_sqlite for headless boxes

Set `skip_chrome_sqlite: true` on a sink that has no GUI session to answer Chrome's Keychain prompt. The sink daemon then takes a fast path inside the `/sync` handler:

<Steps>
<Step title="No Safe Storage read at startup">
`runSink` skips `chrome.SafeStoragePassword()` and `chrome.DeriveAESKey`, so the LaunchAgent never blocks on a Keychain prompt that nobody can approve. The stderr line reads `skip_chrome_sqlite set; sidecar + adapter push only (no Chrome Safe Storage read, no Chrome SQLite write)`.
</Step>
<Step title="Sidecar-only write path">
Each accepted envelope routes through `applySidecarOnlyToSink`, which calls `writeCookiesSidecar` to land plaintext cookies at `~/.agentcookie/cookies-plain.db` (mode `0600`, sealed under the v0.12 master key if present). Chrome's SQLite, Local Storage, and IndexedDB on this machine are untouched.
</Step>
<Step title="Adapter push still runs">
`sinkpush.RunAll(cookies)` fires per `/sync`, writing each registered PP-CLI's session cache. Those adapter results are recorded in `~/.agentcookie/sink-state.json` for `agentcookie status` and `doctor`.
</Step>
<Step title="Optional managed-Chrome CDP delivery">
If `cdp.enabled: true` is also set, `cdpInject` runs after the sidecar write and pushes cookies into `cdp.profile_dir`. Failures are logged but do not fail `/sync` — sidecar + adapter delivery already succeeded.
</Step>
</Steps>

<Info>
The `sinkState.LastWriteMode` field reflects the active path: `sidecar+adapter` for `skip_chrome_sqlite: true`, `sqlite+leveldb` for the universal path, and either with `+cdp` appended when CDP injection fires. Read it with `agentcookie status`.
</Info>

## peer.hostname and the keystore

`peer.hostname` is intentionally just a filename. After `agentcookie pair --as source` (or `--as sink`) succeeds, `keystore.Save` writes the derived 32-byte key to `~/.config/agentcookie/keys/<peer.hostname>.json` with mode `0600`. At sync time `resolveTransportSecret` looks up the file by `peer.hostname` and uses the key for AES-GCM seal/open.

A subtle install gotcha: the sink may announce itself with a different hostname than the operator-supplied `--peer`. The wizard files the key under the operator-supplied name (so the running daemon's `peer.hostname` lookup matches) and prints `sink announced itself as %q; storing key under operator-supplied --peer %q`. If you edit `peer.hostname` later, also rename the matching file under `keys/`.

## Wizard-rendered defaults

`internal/cli/wizard.renderSinkYAML` is the canonical writer. The minimum it emits is `listen` + `peer`; `skip_chrome_sqlite`, `cdp`, and `delivery` are appended only when non-empty so an upgraded v0.12.0-beta.2 sink.yaml stays byte-for-byte stable.

```go title="renderSinkYAML — append-only blocks"
out := fmt.Sprintf("listen:\n  addr: %s\npeer:\n  hostname: %s\n", listenAddr, peer)
if skipChromeSQLite { out += "skip_chrome_sqlite: true\n" }
if cdpEnabled       { out += "cdp:\n  enabled: true\n" + ("  profile_dir: …\n" if dir) }
if delivery != ""   { out += fmt.Sprintf("delivery: %s\n", delivery) }
```

The wizard resolves the (`skip_chrome_sqlite`, `cdp.enabled`, `cdp.profile_dir`, `delivery`) tuple in two steps: `resolveSinkHeadlessMode` reads the operator's `--skip-chrome-sqlite` / `--write-chrome-sqlite` / `--no-cdp` flags, then `resolveSinkDeliveryWithKeychain` attempts the one-password Chrome Safe Storage partition open and downgrades the default-intent case to degraded if the open fails. Both run before `sink.yaml` is rendered.

## Validation errors at a glance

| Symptom on `agentcookie source` or `agentcookie sink` | Likely cause |
|------|--------------|
| `source.yaml: sink.url is required` | empty or missing `sink.url` |
| `sink.yaml: listen.addr is required (run …)` | empty or missing `listen.addr` |
| `either peer.hostname (paired key) or security.shared_secret (legacy) is required` | both empty; run `agentcookie pair` |
| `security.shared_secret must be at least 32 bytes (got N); prefer pairing` | legacy secret too short |
| `sink listen "…": refuses to bind on "0.0.0.0" (every interface)` | startup guard rejected an `any-interface` bind |
| `sink listen "…": refuses to bind on "…": not a Tailscale 100.x address` | LAN/public IP outside the 100.64.0.0/10 CGNAT range |
| `decode …: field X not found in type config.SinkConfig` | typo or stale key under `KnownFields(true)` decoding |
| `read Chrome Safe Storage from Keychain: …` on a headless sink | universal delivery on a box with no Keychain trust — set `skip_chrome_sqlite: true` or run `agentcookie wizard set-keychain-access` |

## Related pages

<CardGroup>
<Card title="Configuration files reference" href="/config-files-reference">
Full schemas and validation rules for `source.yaml`, `sink.yaml`, and the blocklist.
</Card>
<Card title="Source and sink topology" href="/source-sink-topology">
The one-way push model and what each side reads and writes.
</Card>
<Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
Default profile, sidecar, and per-CLI adapter session files — the three paths these toggles route across.
</Card>
<Card title="Headless second-Mac install" href="/headless-install">
End-to-end SSH-only install that drives `skip_chrome_sqlite` and the one-password Safe Storage open.
</Card>
<Card title="Pairing and per-peer keys" href="/pairing-and-keys">
What `peer.hostname` points at under `keys/` and how the AES-GCM key is derived.
</Card>
<Card title="Enable universal cookie delivery" href="/universal-delivery">
How the wizard resolves `delivery: universal` and the keychain steps it takes.
</Card>
</CardGroup>
