# agentcookie Documentation

> Reference for the peer-to-peer macOS daemon that replicates Chrome cookies and per-CLI secrets from a logged-in laptop to an agent's second Mac over a Tailscale tailnet.

## Context Links

- [Agent index](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms.txt)
- [Human interactive docs](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae)
- [GitHub repository](https://github.com/mvanhorn/agentcookie)

## Repository Metadata

- Repository: mvanhorn/agentcookie

- Generated: 2026-06-01T03:16:41.694Z
- Updated: 2026-06-01T03:20:26.881Z
- Runtime: Claude Code
- Format: Documentation
- Pages: 24

## Page Index

- 01. [Overview](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/01-overview.md) - What agentcookie replicates between two Macs, the three cookie delivery surfaces, the parallel secrets bus, and the shortest source-backed path through these docs.
- 02. [Installation](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/02-installation.md) - Prerequisites (Tailscale, Chrome, Go 1.22+), `go install` of the unified CLI, and the `agentcookie wizard install` flow that drops configs, pairs, and installs the LaunchAgent.
- 03. [Quickstart](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/03-quickstart.md) - Five-minute laptop + second-Mac pairing: drop configs, run `agentcookie pair`, start the sink LaunchAgent, push from source, and verify with `agentcookie status`.
- 04. [Headless second-Mac install](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/04-headless-second-mac-install.md) - SSH-only install on a sink with no GUI session: degraded-mode fallback, the one-password Safe Storage open, and the `wizard set-keychain-access` upgrade path.
- 05. [Source and sink topology](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/05-source-and-sink-topology.md) - The one-way laptop-to-second-Mac model: the source watcher, the sink listener, the role split, and what each side reads and writes.
- 06. [Cookie delivery surfaces](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/06-cookie-delivery-surfaces.md) - The three sink-side delivery paths: real Chrome Default profile via Safe Storage, the plaintext sidecar at `~/.agentcookie/cookies-plain.db`, and per-CLI adapter session files.
- 07. [Secrets bus](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/07-secrets-bus.md) - How bearer tokens, API keys, and KEY=VALUE auth blobs ride the same encrypted push, land at `~/.agentcookie/secrets/<cli>/secrets.env`, and the v2 adoption tiers (explicit manifest, PP-CLI auto, legacy v1).
- 08. [Pairing and per-peer keys](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/08-pairing-and-per-peer-keys.md) - X25519 + HKDF-SHA256 handshake, the base32 pairing code, the per-peer key file at `~/.config/agentcookie/keys/<peer>.json`, rate-limiting on `/pair`, and the sealed master key.
- 09. [Device-bound cookies (DBSC)](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/09-device-bound-cookies-dbsc.md) - How agentcookie detects DBSC-suspect cookies, the default warn-and-ship behavior, and the `--skip-dbsc-suspect` / `AGENTCOOKIE_SKIP_DBSC_SUSPECT` drop path.
- 10. [Configure source and sink](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/10-configure-source-and-sink.md) - 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.
- 11. [Enable universal cookie delivery](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/11-enable-universal-cookie-delivery.md) - The one-login-password Safe Storage open, the `teamid:` partition list, duplicate-Keychain-item convergence, and unsigned-CGO boundary so any unmodified cookie tool reads the synced Default profile.
- 12. [Adopt a CLI with agentcookie.toml](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/12-adopt-a-cli-with-agentcookie.toml.md) - Authoring a v2 adoption manifest, declaring `[secrets]`, aliases, and `[[files]]`, running `agentcookie discover`, and migrating from imperative `secret import-from` to manifest-driven sync.
- 13. [Write a cookie adapter](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/13-write-a-cookie-adapter.md) - The ~50-line pattern for a new sink adapter: `Register()` into the adapter registry, the validate hook, the seal helper, and the five built-in PP-CLI adapters as templates.
- 14. [Drive install from an agent](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/14-drive-install-from-an-agent.md) - Using the bundled Claude Code skill to install agentcookie on source + sink unattended: required inputs, the SSH-driven flow, success signals, and error recovery.
- 15. [CLI reference](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/15-cli-reference.md) - Every `agentcookie` subcommand and its flags: `source`, `sink`, `pair`, `wizard`, `doctor`, `status`, `secret`, `discover`, `cookies`, `version`.
- 16. [Configuration files reference](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/16-configuration-files-reference.md) - Schemas and validation rules for `source.yaml`, `sink.yaml`, `allowlist.yaml`, and `blocklist.yaml` (SQLite LIKE patterns, tilde expansion, listen-address checks).
- 17. [agentcookie.toml manifest reference](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/17-agentcookie.toml-manifest-reference.md) - The v2 adoption manifest schema: `schema_version`, `[secrets]`, `[aliases]`, `[[files]]`, project kinds, discovery roots, and the three integration tiers.
- 18. [Wire protocol v1](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/18-wire-protocol-v1.md) - HTTP-over-Tailscale POST `/sync`, AES-256-GCM seal, `SyncEnvelope` JSON fields, sink validation order, and the response semantics (401/400/409).
- 19. [Secrets bus on-disk layout](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/19-secrets-bus-on-disk-layout.md) - The v1 standard layout under `~/.agentcookie/secrets/<cli>/`, file modes (0600), `secrets.env` format, optional sealed twin, and the `[[files]]` materialization path.
- 20. [pkg/agentcookiesecret reader library](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/20-pkg-agentcookiesecret-reader-library.md) - In-process Go API for consuming the secrets bus from a CLI: `Load`, key resolution rules, refresh semantics, and how a CLI uses it instead of shelling out.
- 21. [doctor and adapter verification](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/21-doctor-and-adapter-verification.md) - The fifteen `agentcookie doctor` check categories, the `DoctorReport` JSON envelope, `wizard verify-adapters` output, and exit-code semantics for agent consumption.
- 22. [LaunchAgent management](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/22-launchagent-management.md) - How `wizard install` writes `dev.agentcookie.source` / `dev.agentcookie.sink` plists, bootstrap with `launchctl`, log paths, and the v0.9 soup-to-nuts lifecycle.
- 23. [Release, signing, and notarization](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/23-release-signing-and-notarization.md) - The goreleaser pipeline, the `sign.sh` / `notarize.sh` / `release-tarball.sh` scripts, Developer ID signing for `teamid:` partition trust, and CI secret renewal.
- 24. [Troubleshooting](https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/24-troubleshooting.md) - Common failures and recovery: pairing `connection refused`, sink Keychain prompts, missing `~/go/bin` on the sink, stale Chrome `SingletonLock`, DBSC-suspect drops, and the FAQ.

## Source File Index

- `.goreleaser.yaml`
- `CHANGELOG.md`
- `cmd/agentcookie/main.go`
- `docs/architecture.md`
- `docs/consumption.md`
- `docs/faq.md`
- `docs/protocol.md`
- `docs/quickstart-beta.md`
- `docs/quickstart.md`
- `docs/runbook-adoption-manifest-author.md`
- `docs/runbook-secrets-bus-adoption.md`
- `docs/runbook-secrets-bus-gh-example.md`
- `docs/runbook-v0.10-keychain-access.md`
- `docs/runbook-v0.11-adapter-cookie-push.md`
- `docs/runbook-v0.12-codesign.md`
- `docs/runbook-v0.12-security-hardening.md`
- `docs/runbook-v0.13-one-password-keychain.md`
- `docs/runbook-v0.9-soup-to-nuts.md`
- `docs/spec-agentcookie-secrets-bus-v1.md`
- `docs/spec-agentcookie-secrets-bus-v2-adoption.md`
- `docs/threat-model.md`
- `examples/adoption-last30days/agentcookie.toml`
- `examples/adoption-third-party-cli/agentcookie.toml`
- `examples/allowlist.yaml`
- `examples/blocklist.yaml`
- `examples/gh-shim/gh`
- `examples/launchd-sink.plist`
- `examples/sink.yaml`
- `examples/source.yaml`
- `internal/chrome/dbsc_test.go`
- `internal/chrome/dbsc.go`
- `internal/chrome/keychain_keybase.go`
- `internal/chrome/keychain.go`
- `internal/chrome/launchagent_helper.go`
- `internal/chrome/sidecar.go`
- `internal/chrome/write.go`
- `internal/cli/cookies.go`
- `internal/cli/discover.go`
- `internal/cli/doctor.go`
- `internal/cli/httpserver/httpserver.go`
- `internal/cli/login_password.go`
- `internal/cli/pair.go`
- `internal/cli/root.go`
- `internal/cli/secret_coverage.go`
- `internal/cli/secret.go`
- `internal/cli/sink.go`
- `internal/cli/source.go`
- `internal/cli/status.go`
- `internal/cli/wizard_keychain.go`
- `internal/cli/wizard_verify_adapters.go`
- `internal/cli/wizard.go`
- `internal/config/allowlist.go`
- `internal/config/config.go`
- `internal/keystore/keystore.go`
- `internal/keystore/master.go`
- `internal/keystore/seal.go`
- `internal/launchd/plist.go`
- `internal/pairing/pairing.go`
- `internal/pairing/ratelimit.go`
- `internal/protocol/allowlist.go`
- `internal/protocol/envelope.go`
- `internal/protocol/sequence.go`
- `internal/secretsbus/discovery.go`
- `internal/secretsbus/filecarriage.go`
- `internal/secretsbus/manifest_v2.go`
- `internal/secretsbus/pp_cli_adapter.go`
- `internal/secretsbus/secretsbus.go`
- `internal/secretsbus/writer.go`
- `internal/sinkpush/adapter_airbnb.go`
- `internal/sinkpush/adapter_instacart.go`
- `internal/sinkpush/adapter_tablereservation.go`
- `internal/sinkpush/adapter.go`
- `internal/sinkpush/init.go`
- `internal/sinkpush/registry.go`
- `internal/sinkpush/seal.go`
- `internal/state/state.go`
- `internal/transport/crypto.go`
- `internal/watcher/watcher.go`
- `Makefile`
- `pkg/agentcookieadoption/adoption.go`
- `pkg/agentcookiesecret/doc.go`
- `pkg/agentcookiesecret/load_test.go`
- `pkg/agentcookiesecret/load.go`
- `pkg/sidecar/reader.go`
- `README.md`
- `scripts/install-beta.sh`
- `scripts/notarize.sh`
- `scripts/release-tarball.sh`
- `scripts/sign.sh`
- `skill/prompts/install-on-both-machines.md`
- `skill/SKILL.md`

---

## 01. Overview

> What agentcookie replicates between two Macs, the three cookie delivery surfaces, the parallel secrets bus, and the shortest source-backed path through these docs.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/01-overview.md
- Generated: 2026-06-01T03:00:51.240Z

### Source Files

- `README.md`
- `docs/architecture.md`
- `cmd/agentcookie/main.go`
- `internal/cli/root.go`
- `CHANGELOG.md`

---
title: "Overview"
description: "What agentcookie replicates between two Macs, the three cookie delivery surfaces, the parallel secrets bus, and the shortest source-backed path through these docs."
---

agentcookie is a macOS-only daemon and CLI that one-way-replicates Chrome session state and per-CLI auth secrets from a "source" Mac (the laptop you log in on) to a "sink" Mac (where agents act on your behalf). The source watches Chrome's `Cookies` SQLite with `fsnotify`, decrypts each row with the local Keychain-backed Safe Storage key, wraps the result in a versioned `SyncEnvelope`, AES-256-GCM-seals it under a per-peer pairing key, and POSTs it over the Tailscale tailnet to the sink's `/sync` endpoint. The sink validates the envelope, then fans the payload out across three cookie delivery surfaces and a parallel secrets bus so any unmodified or agentcookie-aware tool can read the synced session.

The unified entry point is `cmd/agentcookie/main.go`, which dispatches to the cobra tree wired in `internal/cli/root.go`: `source`, `sink`, `pair`, `status`, `version`, `wizard`, `doctor`, `secret`, `discover`, `cookies`. Configuration lives under `~/.config/agentcookie/` (`source.yaml`, `sink.yaml`, `allowlist.yaml`); paired per-peer keys live at `~/.config/agentcookie/keys/<peer>.json` mode `0600`; sink-side runtime state lives under `~/.agentcookie/`.

## What agentcookie replicates

| Surface on source | Carried in envelope | Surface on sink |
|---|---|---|
| Chrome `Cookies` SQLite (Default profile, decrypted via macOS Keychain Safe Storage) | Filtered + sealed cookie set | Re-encrypted into sink Chrome `Cookies` + plaintext sidecar + per-CLI adapter files |
| `~/.agentcookie/secrets/<cli>/secrets.env` (mode `0600`, watched by `fsnotify`) | Per-CLI `KEY=VALUE` secrets payload | Mirrored to identical path on sink; optional sealed twin under v0.12 master key |
| `agentcookie.toml` manifests discovered under user dirs and project roots | Materialized `[[files]]` blobs | Files materialized at declared `env`/path on sink |

The push is one-way: source → sink. There is no merge, no destination prompt, no GUI click after install. Filtering happens on both sides via `allowlist.yaml`, and a `SequenceTracker` in sink memory rejects equal-or-lower envelope sequences.

## Sync lifecycle

```mermaid
sequenceDiagram
    autonumber
    participant Chrome as Chrome (source Mac)
    participant Bus as secrets bus<br/>(~/.agentcookie/secrets)
    participant Src as agentcookie source<br/>(LaunchAgent watcher)
    participant Net as Tailscale tailnet<br/>(AES-256-GCM over HTTP)
    participant Sink as agentcookie sink<br/>(LaunchAgent on :9999)
    participant Out as Sink delivery surfaces

    Chrome-->>Src: fsnotify(Cookies SQLite) + debounce
    Bus-->>Src: fsnotify(secrets.env) + manifest scan
    Src->>Src: read SQLite RO, decrypt Safe Storage,<br/>filter by allowlist, build SyncEnvelope (v1)
    Src->>Net: POST /sync (sealed with per-peer key)
    Net->>Sink: deliver bytes
    Sink->>Sink: open seal (401 on key mismatch)<br/>check ProtocolVersion==1 (400)<br/>check Sequence (409 on replay)<br/>filter against sink allowlist
    Sink->>Out: 1) Chrome SQLite (re-encrypt with sink key)
    Sink->>Out: 2) ~/.agentcookie/cookies-plain.db sidecar
    Sink->>Out: 3) per-CLI adapter session files
    Sink->>Out: 4) ~/.agentcookie/secrets/<cli>/secrets.env
```

## The three cookie delivery surfaces

The sink runs all three after every successful sync; consumers pick the surface that matches how they read cookies.

<CardGroup cols={3}>
  <Card title="1. Real Chrome Default profile" href="/universal-delivery">
    Re-encrypts each cookie under the sink's Chrome Safe Storage key and upserts into the sink's `Cookies` SQLite (schema-aware INSERT … ON CONFLICT). At install, one login-password entry opens Safe Storage for the `apple-tool:`, `apple:`, and `teamid:<your-team>` partitions so any unmodified cookie reader (yt-dlp, gallery-dl, browser-driving agents) sees a logged-in Default profile. This is the universal default.
  </Card>
  <Card title="2. Plaintext sidecar" href="/cookie-delivery-surfaces">
    Decrypted cookies written to `~/.agentcookie/cookies-plain.db` for agentcookie-aware consumers that prefer not to touch Chrome. Toggleable via `AGENTCOOKIE_PLAIN_COOKIES`. Works in degraded mode when no Keychain password is available.
  </Card>
  <Card title="3. Per-CLI adapter fan-out" href="/cookie-delivery-surfaces">
    Built-in adapters write the shape each PP CLI expects: `instacart` and `table-reservation-goat` → `session.json`; `airbnb`, `ebay`, `pagliacci` → `config.toml` + `cookies.json`. New adapters are ~50 lines plus a `Register()` call.
  </Card>
</CardGroup>

## The parallel secrets bus

Bearer tokens, API keys, and other `KEY=VALUE` auth blobs ride the same encrypted push the cookies do. On the sink they land at:

```text
~/.agentcookie/secrets/
  <cli>/
    secrets.env        # mode 0600, KEY=VALUE per line
    secrets.env.sealed # optional twin under the v0.12 master key
    manifest.toml      # optional per-key sync overrides
```

Three adoption tiers coexist for opting a CLI into bus-driven sync:

| Tier | How it works | When to use |
|---|---|---|
| explicit-manifest | Drop `agentcookie.toml` with `[secrets]`, `[aliases]`, `[[files]]`; `agentcookie discover` picks it up | First-class adoption for new or external CLIs |
| pp-cli-derived | Auto-synthesized in memory from a CLI's existing `.printing-press.json` (`auth_env_var_specs`) | Printing Press CLIs that ship today with no manifest |
| legacy-v1 | Existing `~/.agentcookie/secrets/<cli>/` directories continue to work; `agentcookie secret import-from` still imports JSON/TOML/env into the standard layout | Pre-v2 setups; ad-hoc imports |

Consumers read the bus three ways: shell env (`eval "$(agentcookie secret env <cli>)"`), the in-process Go library `pkg/agentcookiesecret`, or a CLI's own `agentcookie.toml` `[[files]]` materialization.

## Security boundaries at a glance

| Boundary | Enforced by |
|---|---|
| Cookie value at rest | Chrome Safe Storage per-machine AES key + Keychain ACL |
| Cookie value in transit | AES-256-GCM with per-peer pairing key + Tailscale WireGuard |
| Pairing authenticity | X25519 + HKDF-SHA256 with the base32 pairing code mixed into the salt |
| Listener exposure | Tailnet-only bind validation in `sink.yaml`; pair endpoint rate-limited with a 64-bit code |
| Replay defense | Monotonic `Sequence` checked by sink `SequenceTracker` (HTTP 409 on regress) |
| Protocol stability | `ProtocolVersion` field on every envelope (HTTP 400 on mismatch) |
| File mode discipline | `0600` on `keys/*.json`, `secrets.env`, sealed twins |

For device-bound cookies (DBSC), the source flags suspect rows and by default ships them with a warning; pass `--skip-dbsc-suspect` or set `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1` to drop them. The secrets bus is untouched by DBSC.

## What's in the box

<Tabs>
  <Tab title="Commands">
    | Command | Role |
    |---|---|
    | `agentcookie source` | Long-lived watcher or `--once` push from the laptop |
    | `agentcookie sink` | Long-lived `/sync` listener on the second Mac |
    | `agentcookie pair` | X25519 + HKDF pairing handshake |
    | `agentcookie wizard install` | Drops configs, pairs, installs LaunchAgents, opens Safe Storage |
    | `agentcookie doctor` | Fifteen health categories; `--json` for agents |
    | `agentcookie status` | Last sync, sequence, peer state |
    | `agentcookie secret` | `list`/`get`/`set`/`rm`/`revoke`/`import-from`/`env` |
    | `agentcookie discover` | Walk for `agentcookie.toml` manifests |
    | `agentcookie cookies` | Inspect the synced cookie store |
    | `agentcookie version` | Linked-in `Version` string |
  </Tab>
  <Tab title="Module layout">
    ```text
    cmd/agentcookie/         CLI entry (cobra)
    internal/cli/            Subcommand implementations
    internal/chrome/         Read + decrypt + schema-aware write of Chrome Cookies
    internal/cdp/            Chrome DevTools Protocol client (Storage.setCookies)
    internal/transport/      AES-GCM seal/open
    internal/pairing/        X25519 + HKDF pairing handshake
    internal/keystore/       Per-peer key file management
    internal/protocol/       SyncEnvelope, SequenceTracker, AllowlistMatcher
    internal/config/         YAML loaders (source.yaml, sink.yaml, allow/blocklist)
    internal/secretsbus/     fsnotify watcher + on-disk layout for secrets
    internal/sinkpush/       Adapter registry + delivery fan-out
    internal/launchd/        LaunchAgent plist generation + bootstrap
    internal/tsclient/       Tailscale tailnet checks
    internal/watcher/        Debounced fsnotify for cookies + secrets
    pkg/agentcookiesecret/   In-process Go reader for the bus
    ```
  </Tab>
  <Tab title="On-disk layout">
    ```text
    ~/.config/agentcookie/
      source.yaml            sink URL, peer hostname, watch options
      sink.yaml              listen address, Chrome paths, sealing posture
      allowlist.yaml         SQLite-LIKE host patterns
      blocklist.yaml         negative patterns
      keys/<peer>.json       per-peer pairing key (mode 0600)

    ~/.agentcookie/
      cookies-plain.db       plaintext cookie sidecar
      secrets/<cli>/
        secrets.env          KEY=VALUE per line (mode 0600)
        secrets.env.sealed   optional sealed twin
        manifest.toml        optional per-key overrides
    ```
  </Tab>
</Tabs>

## Source-backed paths through these docs

<CardGroup cols={2}>
  <Card title="Install on two Macs" href="/installation">
    Prereqs, `go install`, and the `agentcookie wizard install` flow that pairs both sides and installs LaunchAgents.
  </Card>
  <Card title="Five-minute quickstart" href="/quickstart">
    Configs + pair + push + verify with `agentcookie doctor`.
  </Card>
  <Card title="Headless second Mac" href="/headless-install">
    SSH-only install, degraded-mode fallback, and the `wizard set-keychain-access` upgrade path.
  </Card>
  <Card title="Source and sink topology" href="/source-sink-topology">
    The one-way model, role split, and what each side reads and writes.
  </Card>
  <Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
    The three sink-side delivery paths in depth.
  </Card>
  <Card title="Secrets bus" href="/secrets-bus">
    How bearer tokens, API keys, and KEY=VALUE blobs ride the push.
  </Card>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">
    X25519 + HKDF-SHA256 handshake and the per-peer key files.
  </Card>
  <Card title="DBSC handling" href="/dbsc-handling">
    Detection, default warn-and-ship, and `--skip-dbsc-suspect`.
  </Card>
  <Card title="Wire protocol v1" href="/wire-protocol">
    `SyncEnvelope`, sink validation order, and 401/400/409 semantics.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Every `agentcookie` subcommand and its flags.
  </Card>
  <Card title="Configuration files" href="/config-files-reference">
    Schemas for `source.yaml`, `sink.yaml`, `allowlist.yaml`, `blocklist.yaml`.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Pairing `connection refused`, sink Keychain prompts, stale `SingletonLock`, DBSC-suspect drops.
  </Card>
</CardGroup>

## Status snapshot

Working today on macOS for both ends: continuous laptop-to-second-Mac sync with debounced `fsnotify`; the three cookie delivery surfaces; the secrets bus with sealed twin; v2 adoption manifests and `agentcookie discover`; tailnet-only listeners and rate-limited pairing; persistent replay defense; Developer ID signed binaries with `teamid:` partition trust; headless SSH install with degraded-mode fallback; fifteen-category `doctor`; 520+ unit tests across 26 packages. Not yet shipping: a Python reader at `clients/python/agentcookie_secret`, manifest signature verification (`signed_by` reserved for v2.1), `[secrets.command]` / `[secrets.keychain]` source kinds, `agentcookie pair --rotate` (re-run `wizard install` instead), and many-sink fan-out.

## Next

<CardGroup cols={2}>
  <Card title="Installation" href="/installation">Prereqs and `go install` flow.</Card>
  <Card title="Quickstart" href="/quickstart">Five-minute laptop + second-Mac pairing.</Card>
  <Card title="Source and sink topology" href="/source-sink-topology">Mental model for the one-way replication.</Card>
  <Card title="CLI reference" href="/cli-reference">Every subcommand and flag.</Card>
</CardGroup>

---

## 02. Installation

> Prerequisites (Tailscale, Chrome, Go 1.22+), `go install` of the unified CLI, and the `agentcookie wizard install` flow that drops configs, pairs, and installs the LaunchAgent.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/02-installation.md
- Generated: 2026-06-01T03:01:57.632Z

### Source Files

- `README.md`
- `internal/cli/wizard.go`
- `internal/cli/wizard_keychain.go`
- `scripts/install-beta.sh`
- `Makefile`

---
title: "Installation"
description: "Prerequisites (Tailscale, Chrome, Go 1.22+), `go install` of the unified CLI, and the `agentcookie wizard install` flow that drops configs, pairs, and installs the LaunchAgent."
---

`agentcookie` ships as a single Go binary (`github.com/mvanhorn/agentcookie/cmd/agentcookie`) installed identically on the source Mac and the sink Mac. Bringing a pair online is two commands: `go install ...@latest` to place the binary, then `agentcookie wizard install` on each side. The wizard drops `source.yaml` / `sink.yaml`, runs the pairing handshake (port `9998`), attempts the one-password Chrome Safe Storage open, and bootstraps the per-role LaunchAgent (`dev.agentcookie.source` / `dev.agentcookie.sink`) so all subsequent sync work runs unattended.

## Prerequisites

The wizard refuses to write a permissive listener default — Tailscale must be running so it can pin a tailnet 100.x address before rendering YAML. Chrome is only strictly required on the source for cookie reads, but the install scripts also probe for it on the sink.

<ParamField body="Tailscale" type="daemon" required>
Running and signed in on both Macs. The wizard calls `tsclient.RequireTailnetIP` to detect a 100.x address for the pairing listener (source) and the `/sync` listener (sink). A missing or unreachable Tailscale daemon causes `wizard install` to abort with `detect Tailscale 100.x address for ... listener`.
</ParamField>

<ParamField body="Google Chrome (stable)" type="application" required>
Installed on the source — the source watcher reads `~/Library/Application Support/Google/Chrome/Default/Cookies` via the Chrome Safe Storage key. On the sink, Chrome is required only when universal delivery is in use (the default).
</ParamField>

<ParamField body="Go toolchain" type="1.22+">
Needed only when installing from source. Pre-built release tarballs (`agentcookie-<version>-darwin-arm64.tar.gz`) can be used instead via `scripts/install-beta.sh --tarball <path>`.
</ParamField>

<ParamField body="macOS" type="14+ (Apple silicon)">
Source and sink both run macOS. The sink relies on macOS LaunchAgent + Keychain conventions; both sides use the macOS-only Chrome Safe Storage decrypt path.
</ParamField>

<Note>
The README pins Go 1.22+ as the public floor; `go.mod` currently targets `go 1.26.2`. Use a recent toolchain (`go install` will fetch a matching one when required) or install from a release tarball to skip the toolchain entirely.
</Note>

## Install the binary

Run on both Macs. `go install` deposits `agentcookie` in `$(go env GOBIN)` (default `$HOME/go/bin`).

```bash
go install github.com/mvanhorn/agentcookie/cmd/agentcookie@latest
```

<Tip>
If `~/go/bin` is not on the sink's `PATH`, the LaunchAgent still works (it uses absolute paths) but `ssh sink 'agentcookie ...'` will report `command not found`. Add `export PATH="$HOME/go/bin:$PATH"` to the sink shell profile.
</Tip>

### Build from source

The repo's `Makefile` provides a signing-aware local build for contributors and release builds:

| Target | Result |
|---|---|
| `make build` | `go build -o bin/agentcookie ./cmd/agentcookie` |
| `make install` | `go install ./cmd/agentcookie`, then `scripts/sign.sh "$(go env GOBIN)/agentcookie"` |
| `make` / `make all` | Build + sign in `bin/agentcookie` |
| `make release` | Build + sign + notarize (a portable binary that launches without prompts on any Mac) |
| `make verify` | `codesign -d -r-` of the local build |
| `make test` | `go test -race ./...` |

`make sign` / `make notarize` require an Apple Developer ID. Override the identity via `AGENTCOOKIE_SIGN_IDENTITY`. Plain contributors can stop at `make build` + `make test` without a cert.

### Release tarball (beta installer)

For closed-beta or air-gapped installs, `scripts/install-beta.sh` automates fetch + Gatekeeper / Developer ID verification + binary placement + wizard launch:

```bash
./install-beta.sh --as source                    # uses gh release download
./install-beta.sh --as sink   --tarball ./bundle.tar.gz \
                              --code <pairing-code> \
                              --pair-url http://<source>:9998/pair
```

The script:

- Checks Tailscale is up and the Chrome `.app` is installed.
- Downloads `*darwin-arm64.tar.gz` via `gh release download` (or accepts `--tarball`).
- Runs `codesign --verify --strict` and confirms the Developer ID OU is `NM8VT393AR`.
- Places the binary in `/usr/local/bin/agentcookie` if writable, otherwise `$HOME/bin/agentcookie`.
- Forwards `--peer`, `--code`, `--pair-url`, `--extra-binary`, `--skip-chrome-sqlite`, `--write-chrome-sqlite`, `--no-cdp`, and `--skip-keychain-prompt` to `agentcookie wizard install`.
- Auto-adds `--skip-keychain-prompt` on a sink install when stdin is not a TTY.
- Ends with `agentcookie doctor` so the operator sees the post-install health roll-up.

## Pair and install with the wizard

`agentcookie wizard install` is the single command per machine. The source side runs first because it generates the pairing code the sink consumes.

<Steps>
<Step title="Source: drop configs and start pairing listener">
On the laptop you browse from:

```bash
agentcookie wizard install --as source --peer <sink-hostname>
```

The source wizard, in order:

1. Validates `--as` (`source` or `sink`) and `--peer` (the OTHER machine's tailnet hostname).
2. Creates `~/.config/agentcookie/` (mode `0755`) and writes `source.yaml` + `blocklist.yaml` with `0600` perms if missing (use `--force` to overwrite an existing pair). `source.yaml` points at `http://<peer>:9999/sync` by default.
3. Refuses to proceed when an existing `source.yaml` has a `peer.hostname` that disagrees with `--peer` (the `guardConfigPeerMismatch` check; pass `--force` to overwrite).
4. Resolves a Tailscale 100.x address via `tsclient.RequireTailnetIP` and binds the pairing HTTP listener at `100.x.y.z:9998`. Plain `0.0.0.0`, `::`, and other any-interface binds are refused by `validateListenAddr`.
5. Prints the pairing code to stderr **and** writes a JSON sibling file `~/.agentcookie/pairing.json` so an SSH-driven agent can read the exact sink-side invocation:
   ```json
   {
     "code": "YILU-OIVK",
     "peer": "my-laptop",
     "pair_url": "http://100.x.y.z:9998/pair",
     "sink_run": "agentcookie wizard install --as sink --peer my-laptop --code YILU-OIVK --pair-url http://100.x.y.z:9998/pair"
   }
   ```
6. Blocks until the sink connects, then saves the per-peer key at `~/.config/agentcookie/keys/<peer>.json` (filed under the operator-supplied `--peer`, not the sink's announced hostname).
7. Installs the `dev.agentcookie.source` LaunchAgent invoking the resolved binary with `--watch`. Logs land in `~/.agentcookie/logs/`.

If a key for `--peer` already exists, the pairing step is skipped. Pass `--repair` to force a fresh handshake.
</Step>

<Step title="Sink: paste the code and complete the handshake">
On the second Mac:

```bash
agentcookie wizard install --as sink \
  --peer <source-hostname> \
  --code <pairing-code> \
  --pair-url http://<source-host>:9998/pair
```

`--code` and `--pair-url` are required when `--as sink`. The sink wizard then:

1. Resolves the universal-vs-degraded delivery mode (see below) and runs the Chrome Safe Storage open BEFORE rendering `sink.yaml`, so a failure cleanly downgrades the rendered config.
2. Resolves the sink's tailnet 100.x address and writes `sink.yaml` with `listen.addr: 100.x.y.z:9999`, `peer.hostname: <source>`, and the resolved `delivery:` marker (`universal` / `degraded`).
3. Runs `pairing.RunSink` against the source's `--pair-url`, derives the AES-256-GCM key, and saves `~/.config/agentcookie/keys/<peer>.json`.
4. Installs the `dev.agentcookie.sink` LaunchAgent. The daemon starts immediately under launchd.
5. Prints the optional Tailscale exit-node hint (`sudo tailscale set --exit-node=<source> --exit-node-allow-lan-access=true`) and the cookie-bridge env-var hint.

The wizard rejects `--code`/`--pair-url` mismatches at the `/pair` endpoint (HTTP 401), and the source's pair endpoint is rate-limited with a 64-bit code.
</Step>

<Step title="Verify">
Run on both Macs:

```bash
agentcookie doctor
agentcookie wizard verify-adapters
```

`doctor` walks fifteen health categories (binary signature, Tailscale, config, keystore, listener bind, sink/source state, sealing posture, adapter coverage, CDP injector, secrets-bus coverage, DBSC-suspect cookies). `verify-adapters` shows the per-adapter result of the last sync.
</Step>
</Steps>

## Cookie delivery mode resolved at install

On the sink, `resolveSinkHeadlessMode` + `resolveSinkDeliveryWithKeychain` decide what `sink.yaml` is rendered with and what runs against the Keychain. The v0.13 default is universal; degraded is the explicit opt-out path and is also the automatic fallback when the one-password open cannot complete.

| Sink flag | `skip_chrome_sqlite` | CDP | `delivery` marker | Keychain open attempted |
|---|---|---|---|---|
| (none) | `false` | `false` | `universal` | yes; on failure downgrades to degraded non-fatally |
| `--write-chrome-sqlite` | `false` | `false` | `universal` | yes; on failure WARNS and honors universal intent (no downgrade) |
| `--skip-chrome-sqlite` | `true` | `true` (unless `--no-cdp`) | `degraded` | no |
| `--skip-keychain-prompt` / `--skip-keychain-access` | `false` | `false` | `universal` | no |

In degraded mode the sink daemon never reads Chrome Safe Storage; cookie delivery uses the plaintext sidecar (`~/.agentcookie/cookies-plain.db`) + per-CLI adapter session files + CDP injection into a dedicated `~/.agentcookie/chrome-profile`. The upgrade-to-universal path is one command: `agentcookie wizard set-keychain-access`.

### Supplying the macOS login password non-interactively

The universal open prompts for the macOS login password once and never stores it. For headless / CI installs without a TTY, set the environment variable:

```bash
AGENTCOOKIE_LOGIN_PASSWORD='…' agentcookie wizard install --as sink \
  --peer <source> --code <code> --pair-url http://<source>:9998/pair
```

When neither the env var nor an interactive terminal is available, `acquireLoginPassword` returns `errNoInteractivePassword` and the wizard downgrades to degraded mode non-fatally, prints the upgrade command, and continues to install the LaunchAgent.

## `agentcookie wizard install` flag reference

<ParamField body="--as" type="source | sink" required>
Required. Selects the role. Source generates the code; sink consumes it.
</ParamField>

<ParamField body="--peer" type="hostname" required>
Required. The OTHER machine's tailnet hostname. Stored as `peer.hostname` in `source.yaml` / `sink.yaml` and as the filename for `~/.config/agentcookie/keys/<peer>.json`.
</ParamField>

<ParamField body="--listen" type="host:port">
Source pairing listener bind address. Defaults to `<tailnet 100.x>:9998`. `0.0.0.0` and other any-interface binds are refused; explicit `127.0.0.1`/`localhost` is allowed for local dev only.
</ParamField>

<ParamField body="--local-name" type="hostname">
Hostname this side announces during pairing. Defaults to `os.Hostname()`.
</ParamField>

<ParamField body="--sink-url" type="url">
Source-side override for the sink sync URL. Default: `http://<peer>:9999/sync`.
</ParamField>

<ParamField body="--code" type="string" required>
Sink-side: pairing code from the source's wizard output.
</ParamField>

<ParamField body="--pair-url" type="url" required>
Sink-side: source's pairing URL, e.g. `http://<source>:9998/pair`.
</ParamField>

<ParamField body="--repair" type="bool">
Force a fresh pairing handshake even if a key already exists for `--peer`.
</ParamField>

<ParamField body="--force" type="bool">
Overwrite existing `source.yaml` / `sink.yaml` / `blocklist.yaml`. Required to change `peer.hostname` in place.
</ParamField>

<ParamField body="--skip-daemon" type="bool">
Skip installing the LaunchAgent. Configs and pairing only.
</ParamField>

<ParamField body="--skip-exit-node-hint" type="bool">
Do not print the optional `sudo tailscale set --advertise-exit-node` / `--exit-node` hint.
</ParamField>

<ParamField body="--skip-chrome-sqlite" type="bool">
Sink: opt OUT of universal delivery. Renders `skip_chrome_sqlite: true`; the sink daemon never reads or writes Chrome's SQLite. Overrides `--write-chrome-sqlite` when both are passed.
</ParamField>

<ParamField body="--write-chrome-sqlite" type="bool">
Sink: force universal delivery and honor it even if the one-password keychain open cannot complete (does not silently downgrade).
</ParamField>

<ParamField body="--no-cdp" type="bool">
Sink: in degraded mode, do not enable CDP injection. Sidecar + adapter remain the cookie-delivery paths.
</ParamField>

<ParamField body="--skip-keychain-prompt" type="bool">
Sink: do not trigger the Chrome Safe Storage Keychain step during install. The sink daemon prompts on first sync instead.
</ParamField>

<ParamField body="--skip-partition-list" type="bool">
Sink: do not expand the Safe Storage partition list. Apple-tool callers may then prompt on first read.
</ParamField>

<ParamField body="--skip-keychain-access" type="bool">
Sink: do not run the legacy v0.10 set-keychain-access strategies (the kooky-CGO probe + partition/trust-list loop).
</ParamField>

<ParamField body="--skip-bridge-hint" type="bool">
Sink: suppress the post-install cookie-bridge env-var hint.
</ParamField>

`agentcookie wizard uninstall --as <role>` removes the LaunchAgent. Add `--purge` to also delete configs and paired keys.

## What `wizard install` writes

```text
~/.config/agentcookie/
  source.yaml          (source side; sink URL + Chrome DB path + peer hostname)
  sink.yaml            (sink side; listen addr + peer hostname + delivery + cdp)
  blocklist.yaml       (both sides; SQLite LIKE patterns, all commented by default)
  keys/<peer>.json     (per-peer X25519/HKDF-derived AES-256-GCM key)

~/.agentcookie/
  pairing.json         (source side, removed once pairing completes)
  logs/                (LaunchAgent stdout/stderr)
  cookies-plain.db     (sink side, plaintext sidecar; mode 0600)
  secrets/<cli>/       (sink side, secrets bus per CLI; mode 0600)
  chrome-profile/      (sink side, CDP-managed Chrome profile in degraded mode)

~/Library/LaunchAgents/
  dev.agentcookie.source.plist   (source; `agentcookie source --watch`)
  dev.agentcookie.sink.plist     (sink; `agentcookie sink`)
```

The installer uses `launchctl bootstrap gui/<uid> <plist>`; on an already-loaded label it falls back to `launchctl kickstart -k gui/<uid>/<label>` so re-running the wizard is idempotent.

## Troubleshooting first install

<AccordionGroup>
<Accordion title="detect Tailscale 100.x address for pair listener">
The wizard refuses to bind a pairing listener on `0.0.0.0`. Confirm `tailscale status` lists this machine and the peer, then re-run. Optional `--listen 127.0.0.1:9998` is accepted for local dev.
</Accordion>

<Accordion title="existing source.yaml/sink.yaml has peer.hostname X but --peer is Y">
`guardConfigPeerMismatch` refuses to leave a stale peer name in place because the running daemon would look up a key file that the next handshake saves under a different name. Re-run with `--force` (rewrites the config), or pass the original peer name.
</Accordion>

<Accordion title="existing paired key for <peer> found; skipping pairing">
Expected on a re-run. Pass `--repair` to force a fresh handshake (rotates the per-peer key).
</Accordion>

<Accordion title="universal keychain open did not complete">
Most often: the install has no TTY and no `AGENTCOOKIE_LOGIN_PASSWORD`. The wizard automatically downgrades to degraded (`skip_chrome_sqlite: true` + CDP) and continues. Upgrade later over SSH:

```bash
AGENTCOOKIE_LOGIN_PASSWORD='…' agentcookie wizard set-keychain-access
```
</Accordion>

<Accordion title="ssh sink 'agentcookie …' reports command not found">
`go install` placed the binary in `$HOME/go/bin`, which an SSH non-login shell may not have on `PATH`. The LaunchAgent uses absolute paths and is unaffected; for interactive use, add `~/go/bin` to the sink's shell profile or invoke `~/go/bin/agentcookie`.
</Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
<Card title="Quickstart" href="/quickstart">
Five-minute laptop + second-Mac pairing with verify-by-doctor steps.
</Card>
<Card title="Headless second-Mac install" href="/headless-install">
SSH-only install with the one-password Safe Storage open and the `wizard set-keychain-access` upgrade path.
</Card>
<Card title="Configure source and sink" href="/configure-source-sink">
Editing the written `source.yaml` / `sink.yaml`, listen-address validation, and CDP toggles.
</Card>
<Card title="LaunchAgent management" href="/launchagent-management">
The `dev.agentcookie.source` / `dev.agentcookie.sink` plists, log paths, and lifecycle.
</Card>
<Card title="Drive install from an agent" href="/agent-driven-install">
Using the bundled Claude Code skill to install on source + sink unattended.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Pairing `connection refused`, sink Keychain prompts, stale Chrome `SingletonLock`, and DBSC-suspect drops.
</Card>
</CardGroup>

---

## 03. Quickstart

> Five-minute laptop + second-Mac pairing: drop configs, run `agentcookie pair`, start the sink LaunchAgent, push from source, and verify with `agentcookie status`.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/03-quickstart.md
- Generated: 2026-06-01T03:02:35.640Z

### Source Files

- `docs/quickstart.md`
- `examples/source.yaml`
- `examples/sink.yaml`
- `examples/allowlist.yaml`
- `examples/launchd-sink.plist`

---
title: "Quickstart"
description: "Five-minute laptop + second-Mac pairing: drop configs, run `agentcookie pair`, start the sink LaunchAgent, push from source, and verify with `agentcookie status`."
---

`agentcookie` ships one binary that runs in two roles. On the laptop (the **source**), `agentcookie source` reads Chrome's `Cookies` SQLite, filters against `blocklist.yaml`, and AES-256-GCM-seals each batch under a per-peer key. On the second Mac (the **sink**), `agentcookie sink` listens on a tailnet `100.x` address, validates the envelope, and fans cookies out to the three delivery surfaces. This page walks the seven steps from "two Macs and a tailnet" to a verified push, end-to-end in roughly five minutes.

## Prerequisites

<ParamField body="Tailscale" type="both Macs" required>
Installed and signed in. `tailscale status` on each side should list the other. The sink's listen address must be a tailnet `100.x.y.z`; `agentcookie sink` refuses `0.0.0.0`, `::`, and any non-tailnet routable host at startup.
</ParamField>

<ParamField body="Chrome (stable)" type="both Macs" required>
The source reads `~/Library/Application Support/Google/Chrome/Default/Cookies`; the sink writes back through Chrome DevTools Protocol when `cdp.enabled: true`.
</ParamField>

<ParamField body="Go toolchain" type="both Macs">
The published `go.mod` declares `go 1.26.2`; pre-built release tarballs are the alternative when a recent Go is not available. The wider installation page references the lower published floor for downstream callers.
</ParamField>

<ParamField body="Auto-login on the sink" type="recommended">
A LaunchAgent runs inside the user's GUI session. The Mac mini should auto-log-in to a user session at boot so the sink is reachable after a power cycle.
</ParamField>

## Step 1 — install the binary on both machines

<CodeGroup>
```bash both Macs
go install github.com/mvanhorn/agentcookie/cmd/agentcookie@latest
mkdir -p ~/.config/agentcookie
```
</CodeGroup>

The unified `agentcookie` binary exposes `source`, `sink`, `pair`, `wizard`, `doctor`, `status`, `secret`, `discover`, `cookies`, and `version` subcommands; all are wired from `internal/cli`.

## Step 2 — drop the example configs

The repo's `examples/` directory carries ready-to-edit starters. Copy them into `~/.config/agentcookie/` and then edit the small handful of host-specific fields.

<Tabs>
<Tab title="Source (laptop)">

```bash
cd $(mktemp -d) && git clone https://github.com/mvanhorn/agentcookie.git
cp agentcookie/examples/source.yaml    ~/.config/agentcookie/source.yaml
cp agentcookie/examples/blocklist.yaml ~/.config/agentcookie/blocklist.yaml
```

Edit `~/.config/agentcookie/source.yaml`:

- `sink.url` — the sink's tailnet `/sync` URL, e.g. `http://my-mac-mini.tailnet.ts.net:9999/sync`.
- `peer.hostname` — the sink's tailnet hostname. After pairing, this is the filename under `~/.config/agentcookie/keys/<peer>.json`.
- `chrome.db_path` — only set if you read from a non-default Chrome profile.

```yaml ~/.config/agentcookie/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
```

</Tab>
<Tab title="Sink (second Mac)">

```bash
cp agentcookie/examples/sink.yaml      ~/.config/agentcookie/sink.yaml
cp agentcookie/examples/blocklist.yaml ~/.config/agentcookie/blocklist.yaml
```

Edit `~/.config/agentcookie/sink.yaml`:

- `listen.addr` — the sink's tailnet `100.x.y.z:9999`. `agentcookie sink` calls `validateListenAddr`, which rejects `0.0.0.0`, `::`, and non-tailnet hosts; loopback is permitted only for local-dev.
- `peer.hostname` — the source's tailnet hostname.
- `cdp.enabled: true` and `cdp.managed: true` (default) — the sink launches its own Chrome subprocess against `~/.agentcookie/chrome-profile` and writes cookies through CDP `Storage.setCookies`; no macOS Keychain prompt fires for this path.

```yaml ~/.config/agentcookie/sink.yaml
listen:
  addr: 100.x.y.z:9999
cdp:
  enabled: true
  managed: true
peer:
  hostname: my-laptop.tailnet.ts.net
```

</Tab>
</Tabs>

<Info>
`blocklist.yaml` is the v0.3 opt-out replacement for the legacy `allowlist.yaml`. The loader still recognises an existing `allowlist.yaml` and renames it to `allowlist.yaml.v2.bak` on first run; sync-all is now the default.
</Info>

## Step 3 — pair source and sink

`agentcookie pair` performs an X25519 + HKDF-SHA256 handshake salted with a one-time base32 code. Both sides save the derived 32-byte key to `~/.config/agentcookie/keys/<peer>.json` at mode 0600.

<Steps>
<Step title="Start the source listener">

```bash my-laptop
agentcookie pair --as source
```

The source resolves its own tailnet `100.x` address and binds `:9998`. Expected output:

```
agentcookie pair (source side)
  pairing code: YILU-OIVK
  source hostname: my-laptop.tailnet.ts.net
  listening on: 100.x.y.z:9998

  Run this on the sink machine within 10m0s
    agentcookie pair --as sink --peer my-laptop.tailnet.ts.net \
      --pair-url http://my-laptop.tailnet.ts.net:9998/pair --code YILU-OIVK

  Waiting for sink...
```

</Step>
<Step title="Complete the handshake from the sink">

```bash my-mac-mini
agentcookie pair --as sink --peer my-laptop.tailnet.ts.net \
  --pair-url http://my-laptop.tailnet.ts.net:9998/pair \
  --code YILU-OIVK
```

Both sides print a confirmation with a matching fingerprint and `key saved to ~/.config/agentcookie/keys/<peer>.json (mode 0600)`. Re-running `pair` clobbers the file; the legacy `security.shared_secret` fallback in `source.yaml`/`sink.yaml` is only consulted when no peer key exists.

</Step>
</Steps>

## Step 4 — install the sink LaunchAgent

Run the sink unattended as a user-level LaunchAgent so it survives logouts and crashes (`KeepAlive` + 10-second `ThrottleInterval`).

<Steps>
<Step title="Copy and edit the plist">

```bash my-mac-mini
cp agentcookie/examples/launchd-sink.plist ~/Library/LaunchAgents/dev.agentcookie.sink.plist
```

Edit the new plist:

- Replace `REPLACE_WITH_FULL_PATH_TO_AGENTCOOKIE` with the absolute path printed by `which agentcookie` (commonly `/Users/<you>/go/bin/agentcookie`).
- Replace `REPLACE_WITH_USERNAME` in the `HOME` environment variable.

</Step>
<Step title="Bootstrap into launchd">

```bash my-mac-mini
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.agentcookie.sink.plist
```

Logs land at `/tmp/agentcookie-sink.out.log` and `/tmp/agentcookie-sink.err.log`.

</Step>
<Step title="Optional: bring Chrome up against the managed profile">

```bash my-mac-mini
open -na "Google Chrome" --args \
  --user-data-dir=$HOME/.agentcookie/chrome-profile \
  --remote-debugging-port=9222
```

With `cdp.managed: true` (the default), the sink launches its own Chrome against this isolated profile and there is nothing to do here. Run the command above only when you want a visible Chrome window pointed at the synced profile.

</Step>
</Steps>

<Tip>
For a smoke test, skip the plist and run `agentcookie sink` in a terminal. The startup line reports the resolved listen address; `^C` to stop.
</Tip>

## Step 5 — push from the source

Trigger a one-shot push and watch it land:

```bash my-laptop
agentcookie source --once --verbose
```

Expected output (one line per matched domain plus the post summary):

```
agentcookie source: %instacart.com -> 12 cookies
agentcookie source: %granola.so   -> 3 cookies
agentcookie source: posted 15 cookies, sink replied: ok: wrote 15 cookies via cdp; dropped 0 non-allowlisted
```

`--once` runs one read+push cycle and exits — the right choice for cron, CI, and this verification step. `--watch` is the long-running fsnotify watcher that LaunchAgents call in steady state.

## Step 6 — verify with `agentcookie status`

`agentcookie status` reads `source.yaml`, `sink.yaml`, the blocklist, and the persisted source/sink state files, and prints a single roll-up.

```bash either Mac
agentcookie status
```

Expected human output (fields it surfaces on each side):

```
agentcookie 0.x.y
config dir: /Users/you/.config/agentcookie
  source -> http://my-mac-mini.tailnet.ts.net:9999/sync
    chrome db: ~/Library/Application Support/Google/Chrome/Default/Cookies
  sink listening on 100.x.y.z:9999
  allowlist v1: N domains
  source daemon: 1 pushes, 0 failures, last push 4s ago
  sink daemon:   1 writes via cdp, 0 rejected, last write 4s ago
    adapters (last run): 5 ok, 0 skipped, 0 failed (of 5)
```

Pass `--json` for an agent-friendly envelope (`source_config`, `sink_config`, `allowlist`, `source_state`, `sink_state`, `errors`). Any load error — for example a missing `source.yaml` or an unparseable `blocklist.yaml` — appears in the `errors` array and is logged to stderr as `warning:` in non-JSON mode.

<Check>
A healthy first verify shows `source daemon: 1 pushes, 0 failures` and `sink daemon: 1 writes via cdp, 0 rejected`, with the most recent push timestamp within a few seconds of when you ran `source --once`.
</Check>

## Step 7 — make it continuous

`agentcookie source --once` is a single shot. Wire it to your trigger of choice while `--watch` mode is tuned. A reasonable cron entry:

```cron crontab -e
*/5 * * * * /Users/you/go/bin/agentcookie source --once >> ~/.agentcookie/source-cron.log 2>&1
```

Five-minute resolution is fine for most sites; session tokens generally rotate on the order of hours.

## Device-bound sessions (DBSC)

A small set of sites — today, mostly Google accounts — bind a login to the source Mac's secure hardware through Chrome's Device Bound Session Credentials. A copied cookie works on the sink for a few minutes and then Chrome there cannot refresh it. `agentcookie` flags these cookies in `agentcookie doctor` and, by default, ships them with a warning.

<ParamField body="--skip-dbsc-suspect" type="flag on `agentcookie source`">
Drop DBSC-suspect cookies instead of shipping them.
</ParamField>

<ParamField body="AGENTCOOKIE_SKIP_DBSC_SUSPECT" type="env var, `1` enables">
Same effect, useful inside LaunchAgents and cron lines.
</ParamField>

For Google specifically, sign the sink's Chrome into the same account once; it establishes its own device-bound session locally. Non-DBSC sites and the secrets bus are unaffected.

## Troubleshooting

<AccordionGroup>
<Accordion title="`refuses to bind on \"0.0.0.0\"` on sink startup">
The v0.12 hardening rejects any-interface and non-tailnet binds. Re-run `tailscale status`, pin a `100.x.y.z` IP into `sink.yaml`'s `listen.addr`, and restart the LaunchAgent. Loopback (`127.0.0.1`) is permitted only when an operator types it explicitly.
</Accordion>

<Accordion title="`connection refused` while pairing">
The sink-side `pair --as sink` is reaching a source that is no longer listening (the source exits 10 minutes after printing its code). Re-run `agentcookie pair --as source` on the laptop and try again with the freshly printed code.
</Accordion>

<Accordion title="Sink replied `ok: dropped N non-allowlisted`">
The dropped cookies are matching a pattern in the sink's `blocklist.yaml`. The sink owner has final say on what lands; review and trim the sink-side blocklist.
</Accordion>

<Accordion title="`agentcookie status` shows `source: not configured`">
`source.yaml` is missing or unparseable at `~/.config/agentcookie/source.yaml`. The exact parse error is on stderr as `warning: source.yaml: ...` (or in the `errors` field of `--json` output).
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Installation" href="/installation">
Prerequisites, `go install`, and the one-command `agentcookie wizard install` alternative that bundles pairing and LaunchAgent setup.
</Card>
<Card title="Headless second-Mac install" href="/headless-install">
SSH-only sink install: degraded mode, the one-password Safe Storage open, and the `wizard set-keychain-access` upgrade.
</Card>
<Card title="Configure source and sink" href="/configure-source-sink">
Every field in `source.yaml` and `sink.yaml`, plus listen-address validation and `skip_chrome_sqlite` for headless sinks.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
The fifteen `agentcookie doctor` checks, the `DoctorReport` JSON envelope, and `wizard verify-adapters`.
</Card>
<Card title="LaunchAgent management" href="/launchagent-management">
How `wizard install` writes the source and sink plists, bootstrap with `launchctl`, and log paths.
</Card>
<Card title="Device-bound cookies (DBSC)" href="/dbsc-handling">
Detection heuristic, the default warn-and-ship behavior, and the `--skip-dbsc-suspect` drop path.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
The full failure catalogue: pairing errors, Keychain prompts, stale `SingletonLock`, missing `~/go/bin`, and DBSC drops.
</Card>
</CardGroup>

---

## 04. Headless second-Mac install

> SSH-only install on a sink with no GUI session: degraded-mode fallback, the one-password Safe Storage open, and the `wizard set-keychain-access` upgrade path.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/04-headless-second-mac-install.md
- Generated: 2026-06-01T03:02:02.782Z

### Source Files

- `docs/quickstart-beta.md`
- `docs/runbook-v0.13-one-password-keychain.md`
- `internal/cli/login_password.go`
- `internal/cli/wizard_keychain.go`
- `scripts/install-beta.sh`

---
title: "Headless second-Mac install"
description: "SSH-only install on a sink with no GUI session: degraded-mode fallback, the one-password Safe Storage open, and the `wizard set-keychain-access` upgrade path."
---

`agentcookie wizard install --as sink` runs end-to-end over SSH with no GUI session attached. On macOS 14+ it resolves one of three terminal states — a TTY plus a login password, a fully non-interactive shell with `AGENTCOOKIE_LOGIN_PASSWORD` exported, or neither — and lands either **universal** Chrome cookie delivery (any unmodified cookie CLI on the sink reads the synced Default profile) or a working **degraded** sink (sidecar + adapter delivery only, no Chrome Safe Storage access). Either way the install completes, the LaunchAgent is bootstrapped, and the upgrade from degraded to universal is the single command `agentcookie wizard set-keychain-access`.

## Decision matrix

| Install context | What runs at the keychain step | Result in `sink.yaml` | `agentcookie doctor` → Cookie delivery |
|---|---|---|---|
| SSH TTY, operator types password | Inline partition-list set with the entered password | `skip_chrome_sqlite` absent, `delivery: universal` | `universal` |
| No TTY, `AGENTCOOKIE_LOGIN_PASSWORD=…` exported | Inline partition-list set with the env password | Same as above | `universal` |
| No TTY, no env password | Open is skipped, install **downgrades non-fatally** | `skip_chrome_sqlite: true`, `cdp.enabled: true`, `delivery: degraded` | `degraded` (INFO) |
| `--skip-chrome-sqlite` explicit | Keychain step is skipped | `skip_chrome_sqlite: true`, `delivery: degraded` | `degraded` (INFO) |
| `--write-chrome-sqlite` explicit, open fails | WARNING printed, intent honored | Universal config rendered anyway | `partial` (WARN) until `set-keychain-access` runs |

The downgrade path is the default safety net: rendering a universal `sink.yaml` on a box where agentcookie cannot read the Chrome Safe Storage key would leave a daemon that fails to start, so the wizard collapses to degraded and prints the upgrade command instead.

## The one-password Safe Storage open

The keychain open the wizard performs is a single `security` invocation:

```sh
security set-generic-password-partition-list \
  -S "apple-tool:,apple:,teamid:<YOUR_TEAM>" \
  -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

The team ID is resolved from the running `agentcookie` binary's own code signature (`chrome.BinaryTeamID`). The partition list is composed by `chrome.TeamPartitionList(team)` and is exactly `apple-tool:,apple:,teamid:<TEAM>`. No `delete-generic-password` and no `add-generic-password` runs — the Safe Storage item's encryption key value is left untouched, so existing Chrome cookies stay decryptable.

<Info>
macOS requires the login keychain password to modify an existing item's access (`SecKeychainItemSetAccessWithPassword`). There is no headless bypass. One terminal password entry (no GUI dialog) is the floor; zero password entries is not achievable on this path.
</Info>

### Password sources

<ParamField body="AGENTCOOKIE_LOGIN_PASSWORD" type="env var">
When set and non-empty, supplies the login password without a prompt. Read by `internal/cli/login_password.go::acquireLoginPassword`. Passed straight to `security -k`, never logged, never persisted to disk. Briefly visible in `ps` for the lifetime of the `security` call; that is a `security -k` limitation, not an agentcookie choice.
</ParamField>

<ParamField body="TTY prompt" type="interactive">
When the env var is absent and stdin is a character device (PTY-allocated SSH session), the wizard prompts once with echo disabled: `macOS login password (grants Chrome Safe Storage access; entered once, never stored):`. Terminal echo is toggled via `/bin/stty -echo`.
</ParamField>

<ParamField body="neither available" type="error">
Stdin is not a TTY and the env var is unset. `acquireLoginPassword` returns `errNoInteractivePassword`. In default install mode this triggers the degraded downgrade described below.
</ParamField>

### What the partition list covers

- `apple-tool:` — `/usr/bin/security`. Read path for `yt-dlp`, `pycookiecheat` via the `security` CLI, `browser_cookie3`, `gallery-dl`.
- `teamid:<YOUR_TEAM>` — Developer-ID-signed binaries from the same signing team, reading via `SecItemCopyMatching`. Covers the agentcookie daemon's CGO read path and any tool you sign with the same team.
- `apple:` — Apple-signed system binaries.

A truly arbitrary **unsigned** CGO tool (a locally compiled `kooky`/`rookie` binary calling `SecItemCopyMatching` directly, not signed with your team) is not covered by `teamid:` and does not go through the `security` CLI. Sign it with your team, add it to a trust list with `--extra-binary`, or fall back to `--any-app`.

## Three install paths

<Tabs>
<Tab title="SSH with TTY (the normal path)">

```sh
ssh sink 'agentcookie wizard install --as sink \
    --peer <source-host> \
    --code <pair-code> \
    --pair-url http://<source-host>:9998/pair'
```

The wizard prompts for the macOS login password once on the SSH TTY and lands universal delivery. `agentcookie doctor` reports `Cookie delivery: universal: real Default profile written and Chrome Safe Storage readable; any unmodified cookie CLI works here`.

</Tab>
<Tab title="Fully non-interactive (CI / automation)">

```sh
ssh sink "AGENTCOOKIE_LOGIN_PASSWORD='…' agentcookie wizard install --as sink \
    --peer <source-host> \
    --code <pair-code> \
    --pair-url http://<source-host>:9998/pair"
```

The env var supplies the password with no prompt. Same universal result as the TTY path. Use a shell that does not log command arguments; do not commit the password to a shared CI log.

</Tab>
<Tab title="No password available (degraded fallback)">

```sh
ssh sink 'agentcookie wizard install --as sink ...'   # no TTY, no env var
```

The install **does not fail**. The wizard:

1. Prints `WARNING universal keychain open did not complete: no macOS login password available…`.
2. Sets `skip_chrome_sqlite: true`, `cdp.enabled: true`, `cdp.profile_dir: ~/.agentcookie/chrome-profile`, and `delivery: degraded` in `sink.yaml`.
3. Installs the sink LaunchAgent so syncs still land.
4. Prints the upgrade one-liner: `agentcookie wizard set-keychain-access`.

</Tab>
</Tabs>

## Degraded mode behavior

When `skip_chrome_sqlite: true` is rendered, the sink daemon **never** reads Chrome Safe Storage. Synced cookies land in three places that do not need the key:

```text
~/.agentcookie/
├── cookies-plain.db           # v0.8 plaintext sidecar; AGENTCOOKIE_PLAIN_COOKIES path
├── secrets/<cli>/secrets.env  # secrets-bus drop, mode 0600
└── chrome-profile/            # CDP-managed Chrome profile owned by agentcookie
    └── Default/Cookies        # Chrome encrypts this with its OWN Safe Storage key on
                               # first launch; agentcookie never touches that key
```

Per-CLI adapter session files (`v0.11`) are also rewritten after each sync, so the five built-in-adapter PP CLIs (`instacart-pp-cli`, `airbnb-vrbo-pp-cli`, eBay, Pagliacci, table-reservation-goat) keep working without env vars or Keychain prompts.

To launch the agentcookie-owned Chrome profile on the sink and see synced sites logged in:

```sh
open -a "Google Chrome" --args --user-data-dir="$HOME/.agentcookie/chrome-profile"
```

The default Chrome profile at `~/Library/Application Support/Google/Chrome/Default` is left untouched in degraded mode.

<Warning>
Pass `--no-cdp` to the wizard if you do not want any Chrome touched on the sink. Sidecar plus adapter delivery remain the cookie paths. The setting is forwarded by `install-beta.sh --no-cdp` and recognized by `wizard install` as `wizardNoCDP`.
</Warning>

## Upgrade path: `wizard set-keychain-access`

`agentcookie wizard set-keychain-access` performs the same inline one-password partition-list open used by `wizard install`, with no LaunchAgent dispatch and no GUI prompt. Run it once a login password becomes available:

<Steps>
<Step title="Open Chrome Safe Storage to apple-tool + teamid">

```sh
ssh sink 'agentcookie wizard set-keychain-access'
# or non-interactively:
ssh sink "AGENTCOOKIE_LOGIN_PASSWORD='…' agentcookie wizard set-keychain-access"
```

Expected output: `Chrome Safe Storage partition set and verified readable (apple-tool:,apple:,teamid:<TEAM>); universal delivery enabled with no GUI prompt`.

</Step>
<Step title="Flip sink.yaml from degraded to universal">

If the previous install downgraded to degraded, `sink.yaml` still carries `skip_chrome_sqlite: true`. Re-run the wizard with `--write-chrome-sqlite --force` to rewrite it:

```sh
ssh sink 'agentcookie wizard install --as sink \
    --peer <source-host> --write-chrome-sqlite --force'
```

`writeYAMLIfMissing` honors `--force` to overwrite the existing `sink.yaml`; without it the degraded values stay. The keychain step is now a no-op (the partition is already set) and resolves to universal.

</Step>
<Step title="Verify">

```sh
agentcookie doctor
security find-generic-password -s "Chrome Safe Storage" -a Chrome -w
```

Expect `Cookie delivery: universal …`. The `security` read can still report `User interaction is not allowed` (`-25308`) from a fresh bare-SSH session if the login keychain has re-locked; that is a session-lock artifact, not a partition failure. The sink daemon reads it in the GUI session.

</Step>
</Steps>

## Wizard flags that change headless behavior

<ParamField body="--skip-chrome-sqlite" type="bool" default="false">
Force degraded mode even on a box with a TTY. `sink.yaml` is written with `skip_chrome_sqlite: true`, `cdp.enabled: true` (unless `--no-cdp`), `delivery: degraded`. The keychain step is skipped entirely; there is no 60-second strategy-loop timeout in this mode.
</ParamField>

<ParamField body="--write-chrome-sqlite" type="bool" default="false">
Force universal mode and **honor it even if the keychain open fails**. Unlike the default path, this does NOT silently downgrade. The wizard renders universal config, prints a clear WARNING if the open did not complete, and points at `agentcookie wizard set-keychain-access` for later.
</ParamField>

<ParamField body="--no-cdp" type="bool" default="false">
Sidecar + adapter-only mode. Disables the CDP injection into `~/.agentcookie/chrome-profile`. Useful when the sink must not run Chrome at all.
</ParamField>

<ParamField body="--skip-keychain-access" type="bool" default="false">
Render universal `sink.yaml` but never attempt the keychain open. Operator explicitly defers the grant. Cookie CLIs prompt on first read until `wizard set-keychain-access` runs.
</ParamField>

<ParamField body="--skip-keychain-prompt" type="bool" default="false">
Same effect as `--skip-keychain-access` for the keychain step. Auto-set by `install-beta.sh` when `! [[ -t 0 ]]` on a sink install.
</ParamField>

<ParamField body="--any-app" type="bool" default="false">
Re-run with `wizard set-keychain-access --any-app` to recreate the Chrome Safe Storage item with `-A` (any application). Preserves the existing key value (`safeStoragePasswordFunc` is read first; the strategy refuses to delete if the read fails). Covers arbitrary unsigned CGO tools but is GUI-prompt-prone and security-broad — only on a dedicated sink.
</ParamField>

<ParamField body="--recreate" type="bool" default="false">
Opt back into the legacy v0.12 LaunchAgent-driven `delete-and-recreate-with-T` trust-list strategy chain. Retained for the unsigned-CGO long tail.
</ParamField>

## install-beta.sh auto-detection

`scripts/install-beta.sh --as sink` wraps the wizard for end users. On a no-TTY shell it auto-flips into headless-safe mode:

```sh
if [[ -z "$SKIP_KEYCHAIN_PROMPT" ]] && [[ "$ROLE" == "sink" ]] && ! [[ -t 0 ]]; then
  SKIP_KEYCHAIN_PROMPT="1"
fi
```

Forwarded to the wizard as `--skip-keychain-prompt`, so the install never blocks on a GUI prompt that no one is there to dismiss. The script also forwards `--skip-chrome-sqlite`, `--write-chrome-sqlite`, `--no-cdp`, and repeatable `--extra-binary <path>` from its own flags to the wizard.

## Verification

```sh
agentcookie doctor
```

The fourteenth check, `Cookie delivery`, reports one of these states (see `checkCookieDeliveryWith` in `internal/cli/doctor.go`):

| Severity | Detail | When |
|---|---|---|
| `OK` | `universal: real Default profile written and Chrome Safe Storage readable; any unmodified cookie CLI works here` | `skip_chrome_sqlite=false`, single Safe Storage item, key probe returns >0 bytes |
| `INFO` | `degraded: writes a separate profile; only agentcookie-aware tools work` | `skip_chrome_sqlite=true` (intentional headless config) |
| `INFO` | `universal config; the Chrome Safe Storage key can't be verified from this session because the login keychain is locked (expected over SSH)` | Universal config, probe returns `errSecInteractionNotAllowed` (locked-keychain false negative) |
| `WARN` | `race: N Chrome Safe Storage keychain items exist …` | Duplicate-item race; remediation is `wizard set-keychain-access` |
| `WARN` | `partial: real Default profile written but the Chrome Safe Storage key has not been granted to cookie readers` | Universal config, key not readable, not locked |

`agentcookie doctor --json` emits the same `DoctorReport` envelope for agent consumers; exit code is `1` when any check is `FAIL`.

## Duplicate-item race

If a sink previously ran in degraded mode with CDP injection, the injector relaunched Chrome and Chrome recreated its **own** Chrome Safe Storage keychain item. The keychain then holds more than one item, and the partition set on one while a reader hits another. `agentcookie doctor` flags the race and `wizard set-keychain-access` now **collapses duplicates to one before granting access**.

The converge step (`convergeSafeStorageToOneItem` in `internal/cli/wizard_keychain.go`):

1. Counts Safe Storage items via `chrome.CountSafeStorageItems`.
2. If `n > 1`, unlocks the login keychain with the supplied password.
3. **Reads the existing value first.** If the read fails, refuses to delete anything — recreating with a different value would derive a different PBKDF2 AES key and permanently destroy every existing Chrome cookie.
4. Deletes every matching item, then re-adds exactly one with the **same** value.
5. Sets the partition list on the surviving item.

To converge manually, quiesce racers first:

```sh
launchctl bootout gui/$(id -u)/dev.agentcookie.sink   # stop the CDP injector
pkill -x "Google Chrome"                              # stop the racer
agentcookie wizard set-keychain-access                # converge + grant
```

## Recover / narrow

Nothing is deleted by the default inline path, so there is no destructive rollback. To narrow access back to the security-CLI tools only (drop Developer-ID CGO readers), re-run the partition set without `teamid:`:

```sh
security set-generic-password-partition-list \
  -S "apple-tool:,apple:" -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

## Boundaries to be honest about

<AccordionGroup>
<Accordion title="pycookiecheat and other unsigned-CGO callers">
`pycookiecheat` reads via the Python `keyring` library — a direct `SecItemCopyMatching` from an unsigned homebrew Python — not the `security` CLI. It is the documented unsigned-CGO class that `apple-tool:` and `teamid:` do not cover. Its `-25308 User interaction is not allowed` over SSH is expected, not a bug. Sign it with your team or run `wizard set-keychain-access --any-app`.
</Accordion>
<Accordion title="ps visibility of the env password">
`AGENTCOOKIE_LOGIN_PASSWORD` is passed as `-k <password>` to `security`. For the lifetime of that call it is visible in `ps`. This is a `security -k` constraint; agentcookie does not log or persist the value. Use this path only in trusted SSH sessions or CI runners.
</Accordion>
<Accordion title="locked login keychain reads after SSH reconnects">
Even after a successful partition set, a fresh bare-SSH session can report `User interaction is not allowed` when reading via `security` because the login keychain has re-locked. The sink daemon runs inside the GUI session where the keychain is auto-unlocked and reads the key fine. Doctor surfaces this as `INFO`, not failure.
</Accordion>
<Accordion title="--write-chrome-sqlite does not silently downgrade">
When the operator explicitly forces universal with `--write-chrome-sqlite`, a failed open prints a WARNING and renders universal `sink.yaml` anyway. The sink daemon will then fail to read Chrome Safe Storage until `set-keychain-access` runs. This is intentional: the operator asked for universal; the install respects it rather than rewriting their config behind their back.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Universal cookie delivery" href="/universal-delivery">
What the one-login-password Safe Storage open buys: any unmodified cookie tool reads the synced Default profile.
</Card>
<Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
The three sink-side delivery paths — Default profile, plaintext sidecar, per-CLI adapter session files.
</Card>
<Card title="Configure source and sink" href="/configure-source-sink">
`sink.yaml` keys including `skip_chrome_sqlite`, `cdp.enabled`, and `delivery`.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
The `Cookie delivery` check states, the `DoctorReport` JSON envelope, and exit-code semantics.
</Card>
<Card title="LaunchAgent management" href="/launchagent-management">
`dev.agentcookie.sink` plist, `launchctl` bootstrap, and how to quiesce the CDP injector during a re-grant.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Stale Chrome `SingletonLock`, missing `~/go/bin`, pairing `connection refused`, and recovering from a duplicate-item race.
</Card>
</CardGroup>

---

## 05. 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.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/05-source-and-sink-topology.md
- Generated: 2026-06-01T03:03:40.108Z

### 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>

---

## 06. Cookie delivery surfaces

> The three sink-side delivery paths: real Chrome Default profile via Safe Storage, the plaintext sidecar at `~/.agentcookie/cookies-plain.db`, and per-CLI adapter session files.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/06-cookie-delivery-surfaces.md
- Generated: 2026-06-01T03:05:42.587Z

### Source Files

- `docs/consumption.md`
- `internal/chrome/sidecar.go`
- `internal/chrome/write.go`
- `internal/sinkpush/registry.go`
- `internal/sinkpush/init.go`
- `pkg/sidecar/reader.go`

---
title: "Cookie delivery surfaces"
description: "The three sink-side delivery paths: real Chrome Default profile via Safe Storage, the plaintext sidecar at `~/.agentcookie/cookies-plain.db`, and per-CLI adapter session files."
---

On every accepted `/sync`, the sink fans the decrypted cookie batch out to up to three concrete delivery surfaces in fixed order: a re-encrypted upsert into the real Chrome Default profile SQLite at `~/Library/Application Support/Google/Chrome/Default/Cookies` (gated by Chrome Safe Storage and `skip_chrome_sqlite`), an atomic write of the Chrome-shaped plaintext sidecar at `~/.agentcookie/cookies-plain.db`, and `sinkpush.RunAll` against the five registered per-CLI adapters under `~/.config/<cli>/`. The three surfaces are independent: a failure in one does not skip the others, and `applySidecarOnlyToSink` is invoked when the operator opts out of the SQLite write entirely.

## Surface inventory

| Surface | On-disk target | Encryption at rest | Gated by |
| --- | --- | --- | --- |
| Chrome Default profile | `~/Library/Application Support/Google/Chrome/Default/Cookies` | `v10` AES-128-CBC under Chrome's Safe Storage key | Safe Storage readable, `skip_chrome_sqlite=false`, Chrome quit |
| Sidecar SQLite | `~/.agentcookie/cookies-plain.db` (mode `0600`) | plaintext, or `agc1:` + base64(AES-256-GCM seal) when master key present | always written when batch is non-empty |
| Adapter session files | `~/.config/<cli>/config.toml`, `cookies.json`, `session.json` | per-adapter; values optionally `agc1:` sealed via `maybeSeal` | `IsInstalled()` + non-empty host-pattern match |

## Surface 1 — real Chrome Default profile via Safe Storage

`chrome.WriteCookies` (internal/chrome/write.go) upserts the decrypted batch into the live Chrome `Cookies` SQLite. The sink reads the per-machine Safe Storage password via `chrome.SafeStoragePassword`, derives Chrome's AES-128 key with `DeriveAESKey` (PBKDF2-HMAC-SHA1, salt `saltysalt`, 1003 iterations, 16-byte key), and re-encrypts each value with `v10` + AES-128-CBC under that key.

Schema is discovered at write time via `PRAGMA table_info(cookies)`, so the same code targets any Chromium version on the sink; `buildUpsert` emits `INSERT ... ON CONFLICT(...) DO UPDATE SET ...` against only the columns actually present, and the conflict target picks the unique-index columns the live schema carries (`host_key, top_frame_site_key, has_cross_site_ancestor, name, path, source_scheme, source_port` on Chrome 134+).

Two compatibility pins live alongside the write:

- `meta.version` is forced to `18` (`writeMetaVersion`) so kooky-style readers do not strip the 32-byte App-Bound SHA256 prefix that agentcookie does not emit on the `v10` plaintext path.
- The Chrome WebKit-epoch delta (`chromeEpochDeltaMicros = 11644473600 * 1000 * 1000`) converts Unix microseconds into the `*_utc` columns.

<Warning>
`WriteCookies` requires Chrome to be quit on the sink — Chrome holds an exclusive lock on `Cookies` when it is up. The sink LaunchAgent's preflight handles the quit/relaunch ceremony; live injection without that ceremony lives on the parallel CDP path in `internal/cdp` and runs after the SQLite write succeeds.
</Warning>

This surface is the one that any unmodified third-party cookie tool (kooky, lite-chrome, pycookiecheat, browser-cookie3) reads transparently — the universal delivery posture. It is the only surface that needs Chrome Safe Storage access, and therefore the only one that is skipped on a headless sink that opts into `skip_chrome_sqlite: true` in `sink.yaml`.

## Surface 2 — plaintext sidecar SQLite

`chrome.WriteCookiesSidecar` (internal/chrome/sidecar.go) writes a Chrome-shaped SQLite at `chromepaths.SidecarCookiesDB()` (defaults to `~/.agentcookie/cookies-plain.db`). The sidecar exists so consumers do not need Chrome Safe Storage at all — every `go install`-built CLI on a sink can read its sessions without a Keychain prompt.

### Schema and reader contract

The sidecar `cookies` table mirrors Chrome 134+ exactly (`creation_utc`, `host_key`, `top_frame_site_key`, `name`, `value`, `encrypted_value`, `path`, `expires_utc`, …, `has_cross_site_ancestor`) with a `UNIQUE INDEX` on `(host_key, top_frame_site_key, has_cross_site_ancestor, name, path, source_scheme, source_port)`. The split-column trick is:

- `value` carries the cookie value as text. When the agentcookie master key is present in the Keychain, the value is rewritten as `agc1:` + base64(`keystore.Seal(masterKey, plaintext)`).
- `encrypted_value` is always a zero-byte BLOB.

Kooky-style libraries that check `encrypted_value` first and fall back to `value` when empty therefore read the plaintext (or sealed envelope) transparently. The official reader is `pkg/sidecar.ReadSidecar`, which lazy-loads the master key only on the first sealed row and unseals `agc1:` entries via `keystore.Unseal`. A fully-plaintext sidecar never touches the Keychain.

### Atomic write and uniqueness

The writer opens a temp DB at `<target>.agentcookie.tmp`, runs the entire upsert inside a single `BEGIN`, sets the file mode to `0600`, and renames into place. Readers either see the old file or the new file, never a half-written intermediate. `UNIQUE constraint failed` errors on duplicate `(host_key, name, path, scheme, port)` rows from the source batch are silently skipped (`isUniqueConstraintError`) — the rest of the batch still commits.

### Reader API

```go
import "github.com/mvanhorn/agentcookie/pkg/sidecar"

path, _ := sidecar.DefaultPath()      // ~/.agentcookie/cookies-plain.db
cookies, err := sidecar.ReadSidecar(path)
// cookies[i].Value is unsealed plaintext regardless of on-disk shape.
```

<ParamField path="path" type="string" required>
Absolute path to the sidecar SQLite. `sidecar.DefaultPath()` returns the canonical `~/.agentcookie/cookies-plain.db`.
</ParamField>

<ResponseField name="cookies" type="[]sidecar.Cookie">
Slice of `{HostKey, Name, Value, Path, ExpiresUTC, IsSecure, IsHTTPOnly}`. Empty when the file is missing — callers should treat that as "nothing synced yet," not as an error.
</ResponseField>

<ResponseField name="err" type="error">
Fails fast when sealed entries are present but the master key cannot be read (`keystore.ReadMasterKey` error). Consumers must not authenticate with an empty list silently.
</ResponseField>

The shell-out path on top of this is `agentcookie cookies --domain <d>`, which adds host scoping (exact host + dotted-suffix subdomains, never look-alikes) and re-applies the sink-side blocklist before printing a `Cookie:` header or `--json` array.

## Surface 3 — per-CLI adapter session files

After the cookie write commits, the sink calls `sinkpush.RunAll(cookies)` (internal/sinkpush/registry.go). The package-level registry is populated in `init.go` with five built-in adapters in registration order:

1. `instacart-pp-cli` — auth-paste strategy
2. `airbnb-pp-cli` — pycookiecheat-style TOML + JSON
3. `ebay-pp-cli` — same
4. `pagliacci-pp-cli` — same
5. `table-reservation-goat-pp-cli` — single `session.json`

### Per-adapter pipeline

For each registered adapter `RunAll` runs the same gate sequence in `runOne`:

```text
IsInstalled() == false
   -> Result{Skipped=true, SkippedReason="CLI not installed"}

filterByHostPatterns(cookies, CookieHostPatterns())  // SQLite-LIKE match on host_key
filtered == 0
   -> Result{Skipped=true, SkippedReason="no matching cookies"}

Validate(c) for each cookie    // RFC-6265 token Name, no control chars in Value, hostname-shape host_key
valid == 0
   -> Result{Skipped=true, SkippedReason="all cookies failed validation"}

Push(valid)
   -> Result{Pushed=len(valid)} or Result{Err=...}
```

One adapter's `Err` does not stop later adapters; the sink logs one stderr line per result via `logAdapterResults` and persists the slice into `SinkState.LastAdapterResults` for `agentcookie status` to read.

### Adapter strategies

```text
internal/sinkpush/
  adapter.go                  -- Adapter interface, filterByHostPatterns, matchLike
  adapter_instacart.go        -- exec "<bin> auth paste" with Cookie header on stdin
  adapter_pycookiecheat.go    -- shared TOML access_token + cookies.json writer
    adapter_airbnb.go         -- "%airbnb%"     -> ~/.config/airbnb-pp-cli/
    adapter_ebay.go           -- "%ebay%"       -> ~/.config/ebay-pp-cli/
    adapter_pagliacci.go      -- "%pagliacci%"  -> ~/.config/pagliacci-pp-cli/
  adapter_tablereservation.go -- "%opentable.com"+"%exploretock.com"
                                 -> session.json with split per-network arrays
  seal.go                     -- maybeSeal(): "agc1:"+base64(seal(masterKey,plain)) when key present
  validate.go                 -- RFC-6265-ish Name/Value/HostKey gate
  init.go                     -- Register builtins in plan-ordered sequence
```

The pycookiecheat-style writer (`PycookiecheatStyleAdapter.Push`) is the shared core for airbnb, ebay, and pagliacci. It:

1. Formats the filtered cookies into a single `name=value; name=value; …` Cookie header (`formatCookieHeader`); empty-value cookies are dropped (they read as cookie-deletes downstream).
2. Calls `maybeSeal(header)` to wrap the header in `agc1:` when the master key is present.
3. `mkdir -p` the config dir at mode `0700`, then atomically rewrites `config.toml` and `cookies.json` (mode `0600`) via temp + rename.
4. For an existing `config.toml`, only the `access_token = '…'` line is patched (regex `^access_token\s*=\s*'[^']*'\s*$`), preserving user-customized `base_url` and adjacent fields. If the line is missing, the new value is prepended rather than silently lost.

The Instacart adapter is the only auth-paste strategy: `InstacartAdapter.Push` runs `<bin> auth paste`, sends the formatted header on stdin, and lets `instacart-pp-cli` parse it and write its own `session.json`. The sink does not write Instacart's session file directly.

The table-reservation adapter produces structured JSON, not a header string. `TableReservationAdapter.Push` requests cookies for `%opentable.com` and `%exploretock.com` together, splits them via `HostSuffixMatch` (label-boundary suffix, so `opentable.com` matches `.opentable.com` but never `xopentable.com`), seals each `value` through `maybeSeal`, fills in `Expires` via `chromeExpiresToRFC3339` (zero `ExpiresUTC` maps to `2099-12-31T23:59:59Z`), and writes:

```json
{
  "version": 1,
  "updated_at": "<RFC3339Nano>",
  "opentable_cookies": [ {"name":"…","value":"agc1:…","domain":"…","path":"…","expires":"…"}, … ],
  "tock_cookies":      [ {"name":"…","value":"agc1:…","domain":"…","path":"…","expires":"…"}, … ]
}
```

### Validation gate

`Validate` (validate.go) is the single bottleneck before any adapter writes. It enforces:

| Field | Rule | Error |
| --- | --- | --- |
| `Name` | non-empty, ASCII `0x21–0x7E`, no RFC-6265 separators `( ) < > @ , ; : \ " / [ ] ? = { }` | `errNameTokenChars` |
| `Value` | any byte except control (`< 0x20` or `0x7F`) | `errValueControl` |
| `HostKey` | non-empty, no control chars, no `..`, no `/`, no `\` | `errHostKeyTraverse` |

Failed cookies are dropped and counted in `Result.Invalid`; the adapter still pushes the valid subset. A non-zero `Invalid` is informational (a source pushing garbage) and is rendered in the sink log as `pushed N cookies (M invalid dropped)`.

### `matchLike` host filtering

`filterByHostPatterns` uses a small SQLite-LIKE subset (`%` matches any sequence; everything else is literal, case-sensitive). Patterns like `%instacart%`, `%opentable.com`, and `%exploretock.com` are matched directly against `host_key` without regex escaping concerns. An empty patterns slice means "give me every cookie" — a deliberate signal, used by no built-in adapter today.

## How the surfaces compose in one `/sync`

```mermaid
flowchart TB
    subgraph wire[Wire boundary]
        env["SyncEnvelope<br/>(AES-256-GCM sealed)"]
    end
    subgraph sink[agentcookie sink handler]
        decrypt["transport.OpenWithSecret"]
        block["blockMatcher.Filter<br/>(opt-out blocklist)"]
        decide{"cfg.SkipChromeSQLite?"}
        sqlite["chrome.WriteCookies<br/>(re-encrypt v10 AES-CBC,<br/>meta.version=18)"]
        sidecar["chrome.WriteCookiesSidecar<br/>(atomic temp+rename, mode 0600,<br/>agc1: seal when master key present)"]
        adapters["sinkpush.RunAll<br/>(5 built-ins, per-adapter validate + push)"]
        cdp["cdp.InjectCookies<br/>(Storage.setCookies, parallel)"]
    end
    subgraph disk[On-disk delivery]
        chrome["~/Library/Application Support/<br/>Google/Chrome/Default/Cookies"]
        sc["~/.agentcookie/cookies-plain.db"]
        cli["~/.config/&lt;cli&gt;/<br/>config.toml, cookies.json, session.json"]
    end
    env --> decrypt --> block --> decide
    decide -- "false" --> sqlite --> chrome
    decide -- "false" --> sidecar --> sc
    decide -- "true"  --> sidecar
    sqlite --> adapters
    sidecar --> adapters
    adapters --> cli
    sqlite -. "if cfg.CDP.Enabled" .-> cdp --> chrome
```

The decision branch is single-bit: `cfg.SkipChromeSQLite` either skips `applyEnvelopeToSink` entirely (sidecar + adapter push only) or runs `applyEnvelopeToSink` with the full SQLite write. Both branches feed `sinkpush.RunAll`, and both branches surface a `writeResult` with `Cookies` (Chrome upsert count) and `SidecarCookies` (sidecar row count) for sink-state reporting.

## Consumption from the sink

A consumer on the sink chooses its surface by what it can read:

<CardGroup cols={2}>
<Card title="Unmodified third-party tool">
Reads the real Chrome `Cookies` SQLite via Safe Storage. Requires the universal-delivery posture (signed sink, `teamid:` partition). No agentcookie awareness.
</Card>
<Card title="agentcookie-aware Go CLI">
Imports `pkg/sidecar` and calls `ReadSidecar(DefaultPath())` directly. Unseals `agc1:` rows transparently via the master key in the Keychain.
</Card>
<Card title="Any tool, any language">
Shells out to `agentcookie cookies --domain <d>` (Cookie header) or `--json`. Honors the sink blocklist, fails open on a missing sidecar, exits 0 with no output when nothing matches.
</Card>
<Card title="Built-in PP CLI">
Reads its own `~/.config/<cli>/` files written by the matching adapter. No agentcookie code path at read time; sealing is detected by the CLI itself via the `agc1:` prefix.
</Card>
</CardGroup>

## Failure modes and observability

- Chrome SQLite write fails when Chrome is up on the sink (`database is locked`); the sink logs `apply envelope: ...` and returns 500. The sidecar and adapter pushes do not run on that branch.
- A missing Safe Storage password (no Keychain ACL, no `teamid:` grant) errors with `chrome.SafeStorageRemediation` text pointing at `agentcookie wizard set-keychain-access`. Operators can flip `skip_chrome_sqlite: true` in `sink.yaml` to drop to sidecar + adapter mode.
- Sidecar write failures bubble up as `sidecar: <wrapped error>`; partial writes are impossible because of the temp + rename pattern.
- Adapter failures are logged per-adapter (`agentcookie sink: adapter <name> FAIL: <err>`) and persisted into `SinkState.LastAdapterResults`. A skipped adapter (CLI not installed, no matching cookies, or every cookie failed validation) is non-fatal and reports `SkippedReason`.
- Replay defense (`seqTracker.Accept`) and protocol-version mismatch reject before any surface is touched (`409` and `400` respectively).

## Related pages

<CardGroup cols={2}>
<Card title="Enable universal cookie delivery" href="/universal-delivery">
The Safe Storage `teamid:` partition path that makes Surface 1 readable by unmodified cookie tools.
</Card>
<Card title="Write a cookie adapter" href="/write-cookie-adapter">
The ~50-line pattern for adding a sixth adapter to `sinkpush` and the validate/seal helpers.
</Card>
<Card title="Configure source and sink" href="/configure-source-sink">
`skip_chrome_sqlite`, `chrome.db_path`, and `cdp.enabled` knobs that decide which surfaces run.
</Card>
<Card title="DBSC handling" href="/dbsc-handling">
How the sink classifies device-bound cookies before they reach any of the three surfaces.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
`agentcookie doctor` and `wizard verify-adapters` checks that exercise each surface end-to-end.
</Card>
<Card title="Wire protocol v1" href="/wire-protocol">
The `SyncEnvelope` shape and `/sync` validation order that feeds these surfaces.
</Card>
</CardGroup>

---

## 07. Secrets bus

> How bearer tokens, API keys, and KEY=VALUE auth blobs ride the same encrypted push, land at `~/.agentcookie/secrets/<cli>/secrets.env`, and the v2 adoption tiers (explicit manifest, PP-CLI auto, legacy v1).

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/07-secrets-bus.md
- Generated: 2026-06-01T03:05:24.490Z

### Source Files

- `docs/spec-agentcookie-secrets-bus-v1.md`
- `docs/spec-agentcookie-secrets-bus-v2-adoption.md`
- `internal/secretsbus/secretsbus.go`
- `internal/secretsbus/discovery.go`
- `internal/secretsbus/writer.go`

---
title: "Secrets bus"
description: "How bearer tokens, API keys, and KEY=VALUE auth blobs ride the same encrypted push, land at `~/.agentcookie/secrets/<cli>/secrets.env`, and the v2 adoption tiers (explicit manifest, PP-CLI auto, legacy v1)."
---

The secrets bus is the second payload agentcookie ships alongside cookies on the same Tailscale-only HTTP push. The source reads per-CLI `KEY=VALUE` data (either from `~/.agentcookie/secrets/<cli>/secrets.env` or from a v2 manifest pointing at the project's own `.env`), applies per-key sync filtering, and packs the result into `protocol.SyncEnvelope.Secrets` as a flat `map[string]map[string]string`. The sink calls `secretsbus.WritePayload`, which atomically rewrites `~/.agentcookie/secrets/<cli>/secrets.env` mode `0600`, optionally writes a `secrets.env.sealed` twin when the v0.12 master key is available, and materializes any carried `[[files]]` items to `0600` files under `~/.agentcookie/`. Consumers read the bus through `pkg/agentcookiesecret` or any mainstream dotenv parser.

## What the bus carries

Three shapes of secret ride the same envelope. They differ only in how they enter the source-side payload; on the wire and on disk they are uniform `KEY=VALUE` pairs (binary files travel as one base64 line plus a reserved companion key).

| Shape | Source example | Wire form | Sink artifact |
|-------|----------------|-----------|---------------|
| Bearer token / API key | `TESLA_OAUTH_BEARER=eyJraWQiOi...` | one `KEY=VALUE` per shipped key | line in `secrets.env` |
| Whole auth blob | `~/.config/last30days/.env` read in place | every line filtered through `[sync.keys]` | merged `secrets.env` |
| Binary or multiline file | `~/.tesla/fleet-private.pem` | base64 under `KEY`, target under `_FILE_<KEY>`, optional `_FILEENV_<KEY>` | `0600` file under `~/.agentcookie/` |

The envelope field is the same `Secrets` map for all three. The sink does not need a manifest copy: every materialization instruction it needs travels alongside the data under reserved underscore-prefixed keys.

## On-disk layout

```
~/.agentcookie/
├── secrets/                          # v1 wire-format root, mode 0700
│   └── <cli>/                        # one dir per consumer, mode 0700
│       ├── manifest.toml             # schema_version=1 sync overrides
│       ├── secrets.env               # 0600, KEY=VALUE per line
│       └── secrets.env.sealed        # 0600, opaque sealed twin (optional)
├── manifests/                        # v2 adoption manifests (priority 1)
│   └── <name>.toml                   # schema_version=2 declarations
├── file-optin/                       # per-CLI opt-in lists for [[files]]
│   └── <cli>.keys                    # one enabled key per line
└── <cli>/                            # carried-file materialization root
    └── config.toml                   # 0600, written by MaterializeFiles
```

`SecretsRoot(homeDir)` is `filepath.Join(homeDir, ".agentcookie", "secrets")`. All writes go through `atomicWrite` (sibling `.tmp` + `fsync` + `rename`) so concurrent readers see either the previous or the new file, never a torn read.

## Source push

`secretsbus.LoadPayloadWithDiscovery(homeDir)` is the v0.14 push-time entry. It composes two passes and merges per-key.

```
LoadPayloadWithDiscovery
├── LoadPayload                       # v1: walk ~/.agentcookie/secrets/<cli>/
│   ├── parseEnvFile  ─ strict v1 dotenv subset, 256 KB cap
│   ├── loadManifest  ─ two-pass TOML to distinguish omitted sync.default
│   └── applySyncPolicy ─ drop keys whose [sync.keys] entry is false
└── Discover                          # v2: well-known manifest paths
    ├── ParseManifestV2  ─ schema_version=2, name slug, [secrets.file], [aliases], [[files]]
    ├── DeriveManifestFromPP  ─ synthesize from .printing-press.json
    └── for each project:
        ├── parseEnvFile(ReadInPlacePath)
        ├── ShouldShipKey filter
        └── CarryFiles  ─ base64 each enabled [[files]] item
```

v1 wins per key on collision (spec section 10.3): when the same `cli` appears in `~/.agentcookie/secrets/<cli>/` and in a v2 manifest, the v1 bus value is preserved and only v2-only keys merge in. Non-fatal errors (missing read-in-place files, oversized payloads, individual manifest parse failures) accumulate in the returned `[]error` rather than aborting the push.

## v2 adoption tiers

`Discover()` walks well-known paths in priority order and labels each registered project with one of three `SourceKind` values. The first occurrence of a given `name` wins; lower-priority occurrences are soft-skipped with a stderr log.

| Priority | Path scanned | `SourceKind` | Use case |
|----------|--------------|--------------|----------|
| 1 | `~/.agentcookie/manifests/*.toml` | `explicit-manifest` | User or installer drops manifest |
| 2 | `~/.config/agentcookie/manifests/*.toml` | `explicit-manifest` | XDG alternative, equal status |
| 3 | `/usr/local/share/agentcookie/manifests/*.toml` | `explicit-manifest` | System-installed (homebrew) |
| 4 | `~/printing-press/library/*/.printing-press.json` | `pp-cli-derived` | PP CLI auto-detect adapter |
| 5 | `agentcookie discover --add-path <dir>` | `explicit-manifest` | Escape hatch |
| 6 | `~/.agentcookie/secrets/<name>/` | `legacy-v1` | v1-imperative `secret import-from` users |

### Tier A: explicit-manifest

A project drops `agentcookie.toml` in one of the tier-1/2/3 paths. The manifest declares `schema_version = 2`, the slug `name`, exactly one `[secrets.file]` block (other `[secrets.*]` kinds are reserved for v2.1 and rejected at runtime), optional `[aliases]`, and zero or more `[[files]]` items. Read-in-place: the file at `[secrets.file].path` is re-read on every push, so a token rotation in the project's own file ships on the next push without further action.

```toml
schema_version = 2
name = "last30days"
display_name = "last30days"
project_kind = "skill"

[secrets.file]
path = "~/.config/last30days/.env"

[sync]
default = true

[sync.keys]
SETUP_COMPLETE = false
FROM_BROWSER = false
```

### Tier B: PP-CLI auto-derived

`DeriveManifestFromPP` reads each `~/printing-press/library/*/.printing-press.json` and synthesizes an in-memory `ManifestV2` (never written to disk). The mapping is fixed:

| Synthesized field | Derived from |
|-------------------|--------------|
| `schema_version` | `2` |
| `name` | `cli_name` (must pass slug validator) |
| `display_name` | `display_name` (fallback to `cli_name`) |
| `project_kind` | `"cli"` |
| `[secrets.file].path` | `~/.config/<cli_name>/config.toml` |
| `[sync].default` | `false` (only `sensitive` keys ship) |
| `[sync.keys][name]` | `auth_env_var_specs[i].sensitive` |

If an explicit manifest already registered the same `name`, the derived entry is suffixed with `-pp` and recorded alongside the explicit one (collision rule 4.2).

### Tier C: legacy v1

A directory `~/.agentcookie/secrets/<name>/` with a `secrets.env` is surfaced as a synthetic `legacy-v1` registry row. There is no v2 manifest; `LoadPayload` reads the bus directory directly. This is the path produced by the imperative `agentcookie secret import-from <path> --as <name>`.

## Manifest filtering and sync policy

Both v1 `manifest.toml` and v2 `agentcookie.toml` use the same `[sync]` shape and the same source-side semantics:

- `[sync].default` omitted ⇒ `true`. The two-pass TOML decoder distinguishes an explicit `false` from an omitted field.
- `[sync.keys].<KEY>` overrides the default per key.
- `[sync.keys]` itself never travels to the sink; filtering is applied source-side before the envelope is sealed.

`applySyncPolicy` sorts keys for deterministic envelope output (stable tests, quieter diffs on inspection) and emits only the keys that resolve to `true`.

## Carried files (`[[files]]`)

`[[files]]` is a v2 construct that coexists with the single `[secrets.*]` block. It carries arbitrary files (multiline PEMs, TOML configs) that cannot ride as one `KEY=VALUE` line. Each item adds two or three keys to the CLI's wire map:

<ParamField body="source" type="string" required>
Path to the file on the source. `~/` expands against the user's home. `..` segments are rejected.
</ParamField>

<ParamField body="key" type="string" required>
Wire envelope key that carries the base64-encoded file bytes. Must be a valid env-var name; duplicates across `[[files]]` items are a hard error.
</ParamField>

<ParamField body="target" type="string" required>
Materialization path on the sink, relative to `~/.agentcookie/`. Absolute paths and `..` segments are rejected at parse and re-rejected at write time.
</ParamField>

<ParamField body="optional" type="boolean" default="false">
When `true`, the item is opt-in. Discovery does not carry it unless `~/.agentcookie/file-optin/<cli>.keys` lists the key (one per line, `#` comments ignored).
</ParamField>

<ParamField body="env" type="string">
Optional env-var name. When set, the sink emits `<ENV>=<absolute materialized path>` into the CLI's `secrets.env`, so a path-reading CLI sees the file's location with no per-machine hardcoding.
</ParamField>

### Wire encoding

Because `envelope.Secrets` is a flat `map[string]map[string]string`, each carried file rides as up to three keys inside its CLI's env map:

| Wire key | Value | Role |
|----------|-------|------|
| `<KEY>` | base64 of file bytes | payload |
| `_FILE_<KEY>` | relative target | materialization instruction (no manifest needed on sink) |
| `_FILEENV_<KEY>` | env var name | optional path companion |

Files cap at 256 KB (encoded source size and decoded payload size). Reserved underscore-prefixed keys pass through the v1 dotenv grammar unchanged.

### Sink materialization

`MaterializeFiles` runs inside `WritePayload` and refuses to write rather than write insecurely:

- The target is re-validated even though the source already validated it. The sink does not trust the wire.
- `safeJoinUnderRoot` enforces that the resolved path stays strictly under `~/.agentcookie/` (no equality with the root itself, must be a descendant separated by `/`).
- Base64 must decode cleanly; decoded payload must be ≤ 256 KB; the file is written `0600` via the atomic `.tmp` + `fsync` + `rename` recipe.
- Every payload key and its `_FILE_` / `_FILEENV_` companions are stripped from `safe` before `secrets.env` is rendered, so a carried file never also leaks as an env-var line.
- A dangling `_FILEENV_<KEY>` whose materialization failed is still consumed so it cannot leak into `secrets.env`.

## Sealing and the sealed twin

When the sink is configured with `sealingEnabled = true` and the v0.12 master key is present in the Keychain, `WritePayload` writes both `secrets.env` and a `secrets.env.sealed` twin in the same per-CLI directory. When sealing is requested but the master key is unavailable, the sink falls back to plaintext-only and surfaces a non-fatal error so `/sync` does not fail.

```go
if masterKey != nil {
    sealed, err := keystore.Seal(masterKey, envBytes)
    if err == nil {
        atomicWrite(filepath.Join(cliDir, "secrets.env.sealed"), sealed, 0o600)
    }
}
```

The sealed twin is opaque to consumers. Its format is owned by `internal/keystore` and may evolve without bumping `schema_version`. The plaintext sibling stays present in v0.13/0.14 so any consumer that uses a raw dotenv parser keeps working; v1 spec section 5.2 lets a sealed-aware reader prefer the twin and fall back to plaintext.

## Reader priority chain

`pkg/agentcookiesecret.LoadDetailed(cliName, fallbackPath)` is the in-process Go API. It builds the env map from lowest priority to highest, so the final value for each key is the highest-priority source seen:

```
4. Process environment              (lowest)
3. Caller-supplied fallback file
2. ~/.agentcookie/secrets/<cli>/secrets.env
1. ~/.agentcookie/secrets/<cli>/secrets.env.sealed  (highest)
```

The bus wins over env: a leftover `EXPORT TESLA_OAUTH_BEARER=...` from a previous workflow never silently masks a freshly synced value. An invalid CLI name returns `ErrInvalidCLIName` before any filesystem touch. A sealed file present without an accessible master key returns an explicit error rather than silently degrading.

## Aliases

`[aliases]` maps a consumer's declared env-var name to the bus key that holds its value, applied at every `agentcookie secret env <name>` call so token refreshes track automatically.

```toml
[aliases]
TESLA_AUTH_TOKEN = "OAUTH_BEARER"
```

Both sides must satisfy `validEnvKey` (initial letter or underscore, then letters/digits/underscores). A local `agentcookie secret alias` entry for the same declared var overrides the manifest alias, so users can always redirect mappings. Aliases whose stored bus key is absent emit nothing for that declared var (no-op).

## Reserved keys

| Prefix | Meaning |
|--------|---------|
| `_unknown_<field>` | `secret import-from` could not map an input field to a canonical key; value preserved verbatim |
| `_BIN_<KEY>` | Original value was raw bytes; payload is base64 (standard alphabet, no wrapping) |
| `_FILE_<KEY>` | Carried-file target path (materialization instruction) |
| `_FILEENV_<KEY>` | Carried-file env-var name (optional absolute-path emission) |
| `_meta_<field>` | Reserved for future metadata; v1 defines no concrete names |
| `__<KEY>` | Double underscore reserved; writers must not emit, readers must ignore silently |

A single underscore inside a key (e.g. `MY_API_KEY`) carries no special meaning; only the leading pattern is significant.

## Validation invariants

The source validates everything it can; the sink re-validates anyway. Both sides apply the same rules:

- CLI name: lowercase ASCII letters/digits/hyphens only, no leading or trailing hyphen, length 1–64. `validCLIName` rejects anything else before touching the filesystem.
- Env-key name: initial letter or underscore, then letters/digits/underscores. Hyphens and dots are rejected because most dotenv consumers refuse them on export.
- `secrets.env` size: 256 KB. Larger files are dropped on the source with a clear error rather than shipped.
- `[secrets.file].path`: no `..`, may start with `~/`, expanded against the user's home.
- `[[files]].target`: relative, no `..`, must clean to a path inside `~/.agentcookie/`. `safeJoinUnderRoot` rejects equality with the root.
- Carried payload size: 256 KB decoded.

## Source-side push flow

```mermaid
flowchart LR
    subgraph Source["Source (laptop)"]
        v1[("~/.agentcookie/secrets/<cli>/")] -->|LoadPayload| merge
        v2dirs[("~/.agentcookie/manifests/<br/>~/.config/agentcookie/manifests/<br/>/usr/local/share/agentcookie/manifests/")] -->|Discover| v2reg[Registry]
        pp[("~/printing-press/library/*/.printing-press.json")] -->|DeriveManifestFromPP| v2reg
        v2reg -->|read-in-place + CarryFiles| merge[LoadPayloadWithDiscovery]
    end
    merge -->|envelope.Secrets| transport[(AES-256-GCM over Tailscale POST /sync)]
    transport --> sink[WritePayload]
    subgraph Sink["Sink (second Mac)"]
        sink -->|atomicWrite 0600| env[("~/.agentcookie/secrets/<cli>/secrets.env")]
        sink -->|keystore.Seal| sealed[("secrets.env.sealed")]
        sink -->|MaterializeFiles| files[("~/.agentcookie/<cli>/<target>")]
    end
```

## Failure modes

| Symptom | Cause | Handling |
|---------|-------|----------|
| `secrets.env is N bytes, over the 262144 byte limit` | A runaway file was renamed into the bus | Source drops that CLI; others ship; non-fatal error |
| `parse manifest.toml: ...` | Malformed v1 manifest | That CLI is excluded from the push; others ship |
| `skip <path>: schema_version must be 2` | A v1 manifest dropped in `~/.agentcookie/manifests/` | Discovery soft-skips with reason recorded in `Registry.Skipped` |
| `explicit-manifest collision on name "X": A vs B; both rejected` | Two manifests declare the same `name` (spec 4.1) | Hard error; both rejected; user must resolve |
| `payload "K" is not valid base64` | Carried-file payload tampered in transit or hand-built | Sink refuses to write; non-fatal error |
| `refusing to materialize "K": target "..." escapes ~/.agentcookie/` | Target traversal attempt | Sink refuses to write; nothing materialized |
| `sealing requested but master key unavailable` | `sealingEnabled=true` but Keychain locked or absent | Sink writes plaintext only; non-fatal error |
| Sealed file present but master key fails | Reader on a sealed-mode machine | `Load` returns an error rather than silently reading plaintext |

## Related pages

<CardGroup>
  <Card title="Secrets bus on-disk layout" href="/secrets-bus-layout">
    File modes, `secrets.env` format, sealed twin, and `[[files]]` materialization paths.
  </Card>
  <Card title="agentcookie.toml manifest reference" href="/manifest-v2-reference">
    The v2 schema: `schema_version`, `[secrets]`, `[aliases]`, `[[files]]`, project kinds, integration tiers.
  </Card>
  <Card title="pkg/agentcookiesecret reader library" href="/go-reader-library">
    In-process Go API for consuming the bus, key resolution rules, refresh semantics.
  </Card>
  <Card title="Adopt a CLI with agentcookie.toml" href="/adopt-a-cli-manifest">
    Authoring a v2 manifest, running `agentcookie discover`, migrating from `secret import-from`.
  </Card>
  <Card title="Wire protocol v1" href="/wire-protocol">
    `SyncEnvelope`, AES-256-GCM seal, POST `/sync`, sink validation order.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Every `agentcookie secret`, `agentcookie discover`, and related subcommand.
  </Card>
</CardGroup>

---

## 08. Pairing and per-peer keys

> X25519 + HKDF-SHA256 handshake, the base32 pairing code, the per-peer key file at `~/.config/agentcookie/keys/<peer>.json`, rate-limiting on `/pair`, and the sealed master key.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/08-pairing-and-per-peer-keys.md
- Generated: 2026-06-01T03:04:40.902Z

### Source Files

- `internal/pairing/pairing.go`
- `internal/pairing/ratelimit.go`
- `internal/keystore/keystore.go`
- `internal/keystore/master.go`
- `internal/keystore/seal.go`
- `internal/cli/pair.go`

---
title: "Pairing and per-peer keys"
description: "X25519 + HKDF-SHA256 handshake, the base32 pairing code, the per-peer key file at `~/.config/agentcookie/keys/<peer>.json`, rate-limiting on `/pair`, and the sealed master key."
---

`agentcookie pair` runs a one-shot HTTP listener on the source and POSTs a single envelope from the sink to derive a 32-byte symmetric key. The source binds its `/pair` endpoint on the Tailscale tailnet address at port `9998`, generates an ephemeral X25519 keypair plus a 12-character base32 pairing code, and waits up to `PairTimeout = 10 * time.Minute` for the sink to connect. The sink POSTs its public key with the operator-pasted code; both sides compute the X25519 shared secret and run `hkdf.Key(sha256.New, shared, salt=code, info="agentcookie-pair-v1", 32)` to produce the per-peer key written to `~/.config/agentcookie/keys/<peer>.json` mode `0600`. The same package also owns the sink-side master key (`agentcookie-master` Keychain item) used by `Seal`/`Unseal` to envelope sidecar databases and adapter session files.

## Handshake protocol v1

The protocol version constant is `pairing.ProtocolVersion = 1`. Both sides reject envelopes with a mismatched version. The salt mixed into HKDF is the normalized pairing code (uppercase, hyphen-separated 4-character groups). The `info` parameter is the literal string `"agentcookie-pair-v1"` and should be bumped together with `ProtocolVersion` on any incompatible change.

```text
  source                                  sink
  ──────                                  ────
  ephemeral X25519 keypair
  pairing code  (12 base32 chars)
  listen on tailnet 100.x:9998
  POST /pair  ◄──── SinkRequest ────  ephemeral X25519 keypair
   verify code (constant time)         (operator pasted the code)
   ECDH( source_priv, sink_pub )
   HKDF-SHA256(shared, code, info)
   fingerprint = hex( sha256(key)[0:4] )
  ─── SourceResponse ─────────►       ECDH( sink_priv, source_pub )
                                       HKDF-SHA256(...)
                                       check fingerprint matches
  write keys/<sink-host>.json         write keys/<source-host>.json
```

### Wire envelopes

Both directions exchange a JSON object on the single `POST /pair` round-trip. Field names below are the JSON keys.

<ParamField body="protocol_version" type="int" required>
Must equal `1` or the source returns `400 protocol mismatch: sink=N source=1`.
</ParamField>

<ParamField body="code" type="string" required>
The pairing code as shown by the source, in any case, with or without hyphens. The source normalizes both sides before a constant-time compare and returns `401 invalid pairing code` on mismatch.
</ParamField>

<ParamField body="sink_public_key" type="bytes" required>
Raw X25519 public key (32 bytes, base64-encoded by Go's `encoding/json` byte slice rule). Bad-shape keys return `400 bad sink public key`.
</ParamField>

<ParamField body="sink_hostname" type="string" required>
The sink's `os.Hostname()` (or `"unknown-host"` fallback). The source persists this as the `peer` filename when it writes its own key file.
</ParamField>

The successful response is a `SourceResponse`:

<ResponseField name="protocol_version" type="int">Always `1` for this build.</ResponseField>
<ResponseField name="source_public_key" type="bytes">Raw X25519 public key, 32 bytes.</ResponseField>
<ResponseField name="source_hostname" type="string">The source's `os.Hostname()`.</ResponseField>
<ResponseField name="fingerprint" type="string">Hex of `sha256(key)[:4]` (8 hex characters). The sink computes the same hash locally and aborts with `fingerprint mismatch (man-in-the-middle?)` if they differ.</ResponseField>

### HTTP error map

| Status | Trigger |
| --- | --- |
| `405` | Non-`POST` method on `/pair` |
| `429` | Per-IP token bucket exhausted (see Rate limiting) |
| `400` | Body decode error, protocol mismatch, or bad sink public key |
| `401` | Pairing code does not match |
| `500` | ECDH or HKDF failed (should not occur on healthy hosts) |

## Pairing code

The code is a 12-character substring of a standard base32 encoding over 8 random bytes (64 bits of entropy). It is displayed and persisted in canonical form `XXXX-XXXX-XXXX`. `pairing.Code.Normalize` strips dashes and whitespace, upper-cases, and re-inserts dashes; `Code.Equal` compares normalized forms with `crypto/subtle.ConstantTimeCompare`.

```go
// internal/pairing/pairing.go
CodeLength      = 12   // v0.12 bumped from 8 (40 bits) to 12 (64 bits)
HKDFInfo        = "agentcookie-pair-v1"
PairTimeout     = 10 * time.Minute
```

The code is the MITM defense even when the transport itself is confidential: an attacker who relays the X25519 exchange without knowing the code derives a different key under HKDF, and the first sealed message past pairing fails its AEAD tag.

## Rate limiting on `/pair`

`internal/pairing/ratelimit.go` runs a per-IP token bucket scoped to the lifetime of the listener (≈10 minutes). State is in-memory only; persisting it would not improve the security model because the listener exits when pairing completes or times out.

<ParamField body="AttemptLimit" type="int" default="5">
Maximum concurrent in-flight tokens per remote IP. The first request from an unseen IP starts with a full bucket and consumes one. Exhausted buckets return `429 too many pairing attempts`.
</ParamField>

<ParamField body="RefillInterval" type="duration" default="500ms">
Time for a single token to regenerate. A legitimate operator typing or pasting the code never hits the limit; an automated guesser is paced into meaningful wall time.
</ParamField>

The client IP comes from `net.SplitHostPort(r.RemoteAddr)`. Parse failures fall back to the raw `RemoteAddr` string so the limiter still applies a stable key.

### Listener hardening

The `/pair` listener uses the `httpserver.Pair` profile, which caps body size at 16 KB and applies per-phase timeouts:

| Setting | Value |
| --- | --- |
| `ReadHeaderTimeout` | `5s` |
| `ReadTimeout` | `30s` |
| `WriteTimeout` | `30s` |
| `IdleTimeout` | `60s` |
| `MaxHeaderBytes` | `16 KiB` |
| `MaxBodyBytes` | `16 KiB` |

The sink-side client uses `httpserver.PairClient` with `ClientTimeout = 30s`.

The listen address is validated by `internal/cli/wizard.validateListenAddr`. Empty triggers `tsclient.RequireTailnetIP` and binds to `100.x.y.z:9998`; an explicit value must be a Tailscale `100.x` address or one of `127.0.0.1`/`::1`/`localhost`. Binding `0.0.0.0`/`::` is refused with a hard error.

## Per-peer key file

`Save`, `Load`, `Delete`, and `List` in `internal/keystore/keystore.go` own the file format. The directory is created with `0700`; each key file is written atomically via tempfile-plus-rename, `Chmod`-ed to `0600`, then renamed into place.

```json
// ~/.config/agentcookie/keys/<peer>.json
{
  "peer": "lab-mini",
  "announced_as": "lab-mini.tail-scale.ts.net",
  "key": "BASE64(32 bytes)",
  "paired_at": "2026-05-31T14:22:18-07:00",
  "fingerprint": "1a2b3c4d",
  "protocol_version": 1
}
```

<ResponseField name="peer" type="string">
Lookup key the running daemon resolves from `peer.hostname` in `source.yaml` / `sink.yaml`. Also the sanitized on-disk filename.
</ResponseField>
<ResponseField name="announced_as" type="string">
What the remote announced itself as during the handshake. Diagnostic only; often differs from `peer` when `peer` is an operator-pasted Tailscale name and `announced_as` is an `os.Hostname` FQDN. Omitted from JSON when empty for backward compatibility with pre-beta.2 files.
</ResponseField>
<ResponseField name="key" type="bytes">
The 32-byte HKDF output. The transport layer hashes it through SHA-256 to derive the AES-GCM key on both sides.
</ResponseField>
<ResponseField name="paired_at" type="time">RFC3339 timestamp of the handshake.</ResponseField>
<ResponseField name="fingerprint" type="string">Same 8-hex-character digest shown during pairing; useful for diagnosing replaced keys.</ResponseField>
<ResponseField name="protocol_version" type="int">Matches `pairing.ProtocolVersion` at pairing time.</ResponseField>

### Filename sanitization

`sanitize` in `keystore.go` replaces every character outside `[A-Za-z0-9._-]` with `_` and strips leading dots, so an attacker-controlled peer name cannot create dotfiles or traverse out of the keys directory. `Path` rejects empty peer names with `peer name cannot be empty`. `List` skips entries whose name does not end in `.json` or that start with `.` (the tempfile pattern `.tmp-*.json` from atomic writes is one example).

### Transport key resolution

`resolveTransportSecret` in `internal/cli/common.go` prefers the paired key over the legacy `security.shared_secret` YAML field. When `peer.hostname` is set and `keystore.Load` succeeds, the raw 32-byte `PeerKey.Key` becomes the AES seed for the wire envelope. If the paired key is missing and no legacy secret is configured, the call fails with `no key for peer "<host>" (run \`agentcookie pair\`)`.

## Running `agentcookie pair`

`internal/cli/pair.go` wires the source and sink flows behind the `--as` flag.

<Steps>

<Step title="Source: start the listener">
```bash
agentcookie pair --as source
```

With no `--listen`, the CLI calls `tsclient.RequireTailnetIP` and binds to `100.x.y.z:9998`. The code, source hostname, and the exact sink command line are printed to stderr; the listener stays open for `PairTimeout = 10m`.
</Step>

<Step title="Sink: complete the handshake">
```bash
agentcookie pair --as sink \
  --peer <source-hostname> \
  --pair-url http://<source-tailnet-ip>:9998/pair \
  --code XXXX-XXXX-XXXX
```

`--peer` is required and becomes the filename of the saved key. `--code` accepts any case and is normalized before transmission.
</Step>

<Step title="Both sides write the key file">
On success each side writes `~/.config/agentcookie/keys/<peer>.json` with mode `0600` and prints the fingerprint. The wizard install path takes the same code over SSH; `wizard install --as sink` skips pairing when an existing key file is present unless `--repair` is passed.
</Step>

</Steps>

### Flags

<ParamField body="--as" type="source | sink" required>
Selects the role. Anything else exits with `--as is required and must be 'source' or 'sink'`.
</ParamField>

<ParamField body="--listen" type="host:port">
Source listener address. Empty auto-detects the tailnet 100.x address. Explicit values must be a Tailscale 100.x address or a loopback alias. Binding every interface is refused.
</ParamField>

<ParamField body="--local-name" type="string">
Hostname announced to the peer. Defaults to `os.Hostname()` (or `"unknown-host"` if that errors).
</ParamField>

<ParamField body="--pair-url" type="url">
Sink-only. Full URL of the source's `/pair` endpoint, e.g. `http://100.64.0.1:9998/pair`.
</ParamField>

<ParamField body="--code" type="string">
Sink-only. The pairing code from the source's stderr.
</ParamField>

<ParamField body="--peer" type="string">
Sink-only. Source hostname; also the filename under `~/.config/agentcookie/keys/`.
</ParamField>

## Sealed master key

`internal/keystore/master.go` and `seal.go` own a second, sink-local store distinct from the per-peer keys: a 32-byte random master key in the macOS login keychain under service `agentcookie-master`, account `agentcookie`, value hex-encoded.

```go
const (
    MasterKeychainService = "agentcookie-master"
    MasterKeychainAccount = "agentcookie"
    MasterKeyBytes        = 32
)
```

The master key wraps the sealed sidecar SQLite and adapter session files so a non-agentcookie process running as the same user cannot read those plaintexts even on a box where Chrome Safe Storage would otherwise hand over its key.

### Creation

`CreateMasterKey(agentcookieBinary, extraBinaries)` is idempotent: it deletes any prior `(service, account)` item, generates 32 fresh random bytes, and reinstalls via `/usr/bin/security add-generic-password` with a `-T` ACL listing each allowlisted binary. The ACL is anchored to each binary's Developer-ID-signed designated requirement rather than its cdhash, so rebuilding the binary with the same identity does not invalidate the ACL. The wizard install is the only intended caller; runtime paths read the existing key.

### Reading

`ReadMasterKey()` shells `security find-generic-password -s agentcookie-master -a agentcookie -w`. Absence is reported as `ErrMasterKeyMissing`, which routes the operator to `agentcookie wizard install --as sink`. Decode errors and unexpected lengths surface as fatal "delete and re-run wizard install" messages because they signal a corrupted Keychain entry. `MasterKeyExists()` checks presence without reading the value; the wizard uses it to keep installs idempotent.

### Envelope format

`Seal` and `Unseal` produce and consume a fixed AES-256-GCM envelope:

```text
+---------------------+----------------------+----------------+
| 12-byte GCM nonce   | ciphertext           | 16-byte tag    |
+---------------------+----------------------+----------------+
        nonceSize             len(plaintext)         gcmTagLen
```

Total overhead is 28 bytes. Both helpers reject any input where the master key length differs from `MasterKeyBytes`. `Unseal` rejects envelopes shorter than `nonceSize+gcmTagLen` before calling `gcm.Open`, and any tag-check failure is surfaced as `envelope corrupt or master key mismatch`. The envelope is safe to write under the user's regular file permissions because the master key itself is gated by the Keychain ACL.

## Repair, rotation, and diagnostics

- **Re-pairing**: delete `~/.config/agentcookie/keys/<peer>.json` (or run the wizard with `--repair`) and re-run `agentcookie pair`. The next handshake overwrites the file atomically.
- **Fingerprint comparison**: the 8-hex-character `fingerprint` is printed on both sides at pairing time and stored in the JSON. Reading both files and comparing the field is the quickest sanity check that a single handshake produced both copies.
- **Rate-limit lockout**: a `429` from `/pair` clears the moment a token refills (`500ms`) or when the listener exits.
- **`announced_as` drift**: when `peer` (operator-pasted Tailscale name) differs from `announced_as` (`os.Hostname` FQDN), the daemon's lookup key is still `peer`; `announced_as` is purely diagnostic.
- **Master key recovery**: `ErrMasterKeyMissing` or any decode/length error from `ReadMasterKey` is unrecoverable at runtime; delete the Keychain item and re-run `wizard install --as sink` to regenerate and re-grant the binary ACL.

## Related pages

<CardGroup>
  <Card title="Quickstart" href="/quickstart">The five-minute laptop + second-Mac pairing walkthrough that wraps these primitives.</Card>
  <Card title="Headless second-Mac install" href="/headless-install">SSH-only pairing with the degraded-mode fallback and the `wizard set-keychain-access` upgrade path.</Card>
  <Card title="Configure source and sink" href="/configure-source-sink">Where `peer.hostname` is set so the daemon picks the right `keys/<peer>.json`, plus listen-address validation rules.</Card>
  <Card title="Wire protocol v1" href="/wire-protocol">How the derived key feeds AES-256-GCM on `/sync` after pairing completes.</Card>
  <Card title="LaunchAgent management" href="/launchagent-management">The post-pairing daemon lifecycle that consumes the per-peer key.</Card>
  <Card title="Troubleshooting" href="/troubleshooting">`connection refused`, sink Keychain prompts, and other pairing failure recovery.</Card>
</CardGroup>

---

## 09. Device-bound cookies (DBSC)

> How agentcookie detects DBSC-suspect cookies, the default warn-and-ship behavior, and the `--skip-dbsc-suspect` / `AGENTCOOKIE_SKIP_DBSC_SUSPECT` drop path.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/09-device-bound-cookies-dbsc.md
- Generated: 2026-06-01T03:05:42.087Z

### Source Files

- `internal/chrome/dbsc.go`
- `internal/chrome/dbsc_test.go`
- `internal/cli/source.go`
- `docs/threat-model.md`

---
title: "Device-bound cookies (DBSC)"
description: "How agentcookie detects DBSC-suspect cookies, the default warn-and-ship behavior, and the `--skip-dbsc-suspect` / `AGENTCOOKIE_SKIP_DBSC_SUSPECT` drop path."
---

The source watcher runs every Chrome cookie it reads through `chrome.ClassifyCookies` (`internal/chrome/dbsc.go`) before sealing the sync envelope. The classifier is a conservative, read-only heuristic: it never reads a site's actual DBSC `credentials` list (which is not exposed in Chrome's Cookies SQLite), it only fingerprints cookies that look device-bound by host or remaining TTL. The default verdict is non-destructive — suspect cookies still ship, paired with a warning that surfaces in `agentcookie doctor` and `source-state.json`. Setting `--skip-dbsc-suspect` on `agentcookie source`, or the environment variable `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1`, switches the verdict to a hard drop on the source so the cookies never reach the sink.

## What DBSC changes

Chrome's Device Bound Session Credentials bind a per-session private key to the source Mac's Secure Enclave and issue short-lived cookies that the browser silently refreshes by signing a server challenge. A cookie copied to the sink works only until that short-lived cookie expires, because the sink cannot sign the refresh. agentcookie cannot read a site's DBSC `credentials` list from the Cookies SQLite, so the detector keys on the observable fingerprint instead: a secure cookie that is either on a known DBSC host or carries an unusually short remaining TTL.

DBSC is opt-in per site. As of May 2026 the one broad adopter is Google's own account and Workspace cookies. Non-DBSC sites and the entire secrets bus (`~/.agentcookie/secrets/<cli>/secrets.env`) are unaffected by this classifier and replicate normally.

## Detection heuristic

`ClassifyDBSC(cookie, now, skip)` is a pure function with no side effects on the cookie. It runs in this order:

1. If `IsSecure == 0`, return `DBSCShip`. DBSC binds session/auth cookies, which are always `Secure`; this also keeps ordinary short-lived preference and analytics cookies clean.
2. Lowercase the cookie's `HostKey` and trim a leading dot. If the host equals or is a subdomain of any entry in `dbscKnownHosts`, the cookie is suspect regardless of TTL. The current list is `google.com` only.
3. Else if the cookie is persistent (`HasExpires == 1`, `ExpiresUTC != 0`) and its expiry is in the future but within `dbscShortTTL` (15 minutes) of `now`, the cookie is suspect.
4. Otherwise return `DBSCShip` with an empty reason.

When the cookie is suspect, the verdict is `DBSCSkip` if `skip == true` and `DBSCShipWarn` otherwise. The reason string is built from the cookie's name, host, and the trigger.

<ParamField body="dbscShortTTL" type="time.Duration" default="15 * time.Minute">
Remaining-lifetime ceiling below which a secure, persistent cookie is treated as DBSC-suspect. DBSC short-lived cookies are typically refreshed every 5–10 minutes; 15 minutes is a deliberately conservative ceiling that catches them without flagging ordinary cookies.
</ParamField>

<ParamField body="dbscKnownHosts" type="[]string" default='["google.com"]'>
Registrable-domain suffixes known to use DBSC today (Google account / Workspace auth). Match is suffix-based on the lower-cased `HostKey` after the leading dot is trimmed.
</ParamField>

<ParamField body="chromeEpochOffsetMicros" type="int64" default="11644473600 * 1_000_000">
Microsecond gap between Chrome's 1601-01-01 epoch (used by `expires_utc`) and the Unix 1970-01-01 epoch. `chromeTimeToUnix` subtracts this offset to convert Chrome `expires_utc` values to a `time.Time` for the TTL check.
</ParamField>

### Why these specific signals

| Signal | Why it is reliable enough | Why it is not perfect |
|---|---|---|
| `IsSecure == 0` short-circuits to `DBSCShip` | DBSC credentials are always `Secure`; non-secure cookies cannot be DBSC. | None — this is a structural property of DBSC. |
| Known host suffix `google.com` | Only broad DBSC adopter as of May 2026. | A new adopter is not flagged until the host is added; agentcookie ships cookies on unknown hosts unless TTL also trips. |
| Remaining TTL ≤ 15 minutes | DBSC refresh cycles are typically 5–10 minutes. | A site that issues short non-DBSC cookies will trip the heuristic; default warn mode never breaks it because the cookie still ships. |
| Session cookies (`HasExpires == 0`) excluded | DBSC short-lived cookies are persistent with an explicit `expires_utc`. | Session auth cookies on a DBSC host still match via the host rule. |
| Already-expired cookies excluded | The normal pipeline handles expiry; no need to re-flag. | None. |

## Decisions

`DBSCDecision` is the routing verdict for one cookie under the heuristic.

| Constant | `String()` | Effect on the push |
|---|---|---|
| `DBSCShip` | `ship` | Cookie is not DBSC-suspect; included in the envelope. |
| `DBSCShipWarn` | `ship+warn` | Cookie looks DBSC-bound; still included in the envelope and a reason is appended to `DBSCResult.Warned`. |
| `DBSCSkip` | `skip` | Cookie looks DBSC-bound and skip-mode is on; dropped from the envelope and a reason is appended to `DBSCResult.Skipped`. |

`ClassifyCookies(cookies, now, skip)` runs `ClassifyDBSC` over a batch and returns a `DBSCResult` whose `Shipped` slice is the cookies that should be pushed. In warn mode every cookie is shipped; in skip mode the suspects are dropped.

```go
// internal/chrome/dbsc.go
type DBSCResult struct {
    Shipped []Cookie
    Warned  []string // one reason per suspect that was still shipped
    Skipped []string // one reason per suspect that was dropped
}
```

## Push integration

`pushOnce` in `internal/cli/source.go` calls `chrome.ClassifyCookies(all, time.Now().UTC(), skipDBSC)` immediately after the blocklist filter and before the envelope is sealed. The classified `Shipped` slice replaces `all`, and per-push counts plus up to three sample reasons are written into `SourceState` for `doctor` and `status` to read.

```text
chrome.ReadCookiesForHost  -->  blocklist filter  -->  ClassifyCookies
                                                            |
                              warn mode: Shipped == all       skip mode: suspects dropped
                                                            v
                                            SyncEnvelope.Cookies = Shipped
```

The skip toggle is resolved once per process:

```go
// internal/cli/source.go
skipDBSC := sourceSkipDBSC || os.Getenv("AGENTCOOKIE_SKIP_DBSC_SUSPECT") == "1"
```

Per-push, the source writer records the tally on `SourceState`:

```go
srcState.LastDBSCWarned  = dbsc.warned
srcState.LastDBSCSkipped = dbsc.skipped
srcState.LastDBSCSample  = dbsc.sample // up to 3 reasons, warns first
```

## Operator controls

<ParamField body="--skip-dbsc-suspect" type="bool" default="false">
Drop cookies that look device-bound (DBSC) instead of shipping them with a warning. Switches every `DBSCShipWarn` verdict to `DBSCSkip`. Equivalent to `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1`.
</ParamField>

<ParamField body="AGENTCOOKIE_SKIP_DBSC_SUSPECT" type="env" default="unset">
When set to `1`, has the same effect as `--skip-dbsc-suspect`. Lets a LaunchAgent opt into skip mode without editing the plist's argv. Any value other than `1` (including unset) keeps default warn-and-ship behavior.
</ParamField>

<ParamField body="--verbose" type="bool" default="false">
Print a one-line summary plus up to three per-cookie reasons on stderr when any suspects are flagged. Without `--verbose`, the per-cookie detail is suppressed in `--watch` mode to avoid flooding the LaunchAgent log for any user with a persistent Google cookie; the per-push human summary still carries `(N DBSC-suspect: W warned, S skipped)` from `dbscNote`.
</ParamField>

<Tabs>
<Tab title="Warn and ship (default)">

```bash
agentcookie source --once
# agentcookie source: posted 87 cookies, sink replied: ok (2 DBSC-suspect: 2 warned, 0 skipped)
```

</Tab>
<Tab title="--skip-dbsc-suspect">

```bash
agentcookie source --once --skip-dbsc-suspect
# agentcookie source: posted 85 cookies, sink replied: ok (2 DBSC-suspect: 0 warned, 2 skipped)
```

</Tab>
<Tab title="LaunchAgent env var">

```xml
<key>EnvironmentVariables</key>
<dict>
  <key>AGENTCOOKIE_SKIP_DBSC_SUSPECT</key>
  <string>1</string>
</dict>
```

</Tab>
</Tabs>

## Observable output

### Per-push JSON

`emit` writes one record per push when `--json` is set. The DBSC counters are stable keys on every push:

```json
{
  "cookies_read": 95,
  "cookies_blocked": 6,
  "cookies_passing": 87,
  "cookies_dbsc_warned": 2,
  "cookies_dbsc_skipped": 0,
  "secrets_clis": 4,
  "dry_run": false,
  "sink_url": "http://...:8443/sync",
  "posted": true,
  "sink_status": 200,
  "sink_response": "ok"
}
```

### `agentcookie doctor`

`checkDBSCFrom(st)` in `internal/cli/doctor.go` reports the DBSC posture of the last push:

| Condition | Severity | Detail |
|---|---|---|
| No `SourceState` yet, or `LastDBSCWarned == 0 && LastDBSCSkipped == 0` | `OK` | `no device-bound (DBSC) cookies flagged in the last push` |
| Either counter > 0 | `WARN` | `last push flagged N shipped-with-warning, M skipped DBSC-suspect cookie(s): <first sample reason>` plus remediation pointing at signing the sink's Chrome into the same Google account |

### Verbose stderr (warn mode example)

```
agentcookie source: 2 cookie(s) look device-bound (DBSC); shipping with a warning. These likely will not work on the sink. See README: DBSC.
  - cookie "SID" on known DBSC host ".google.com" is device-bound to the source Mac and will not survive on the sink; sign the sink's Chrome into the same account instead (see README: DBSC)
  - secure cookie "auth" on ".example.com" expires within 15m0s; if this site uses DBSC it will not refresh on the sink
```

## Behavior matrix

`internal/chrome/dbsc_test.go` pins the contract. The cases below match named test entries:

| Cookie (vs `now = 2026-05-29 12:00 UTC`) | `skip` | Verdict | Reason substring |
|---|---|---|---|
| `.instacart.com` `session`, secure, expires `+720h` | false | `DBSCShip` | (empty) |
| `.google.com` `SID`, secure, expires `+720h` | false | `DBSCShipWarn` | `DBSC` |
| `.example.com` `auth`, secure, expires `+7m` | false | `DBSCShipWarn` | `expires within` |
| `.example.com` `prefs`, **not secure**, expires `+7m` | false | `DBSCShip` | (empty) |
| `.example.com` `auth`, secure, expires `-5m` (already expired) | false | `DBSCShip` | (empty) |
| `.example.com` `auth`, secure, **session cookie** (`HasExpires=0`) | false | `DBSCShip` | (empty) |
| `.example.com` `auth`, secure, expires `+7m` | **true** | `DBSCSkip` | `expires within` |
| `.instacart.com` `session`, secure, expires `+720h` | **true** | `DBSCShip` | (empty) |
| `ACCOUNTS.GOOGLE.COM` `__Secure-1PSID`, secure, expires `+720h` | false | `DBSCShipWarn` | `DBSC` (suffix match is case-insensitive) |

Batch-mode invariants from `TestClassifyCookies`:

- Warn mode ships all input cookies and records one warning per suspect; `Skipped` is always empty.
- Skip mode ships only clean cookies; `Skipped` carries one reason per dropped suspect; warnings are not also recorded for skipped cookies.

## Scope and limits

<Warning>
The heuristic is a proxy signal, not a guarantee.
</Warning>

- agentcookie does not read a site's DBSC `credentials` list. A site that has adopted DBSC but is not in `dbscKnownHosts` and issues long-lived suspect cookies will pass as `DBSCShip` until the TTL falls under 15 minutes or the host is added.
- A site that issues short-TTL non-DBSC cookies will be flagged. The default verdict (warn) leaves the cookie in the envelope, so a false positive never breaks a working non-DBSC site; only `--skip-dbsc-suspect` turns it into a drop.
- DBSC binds cookies, not the secrets bus. Bearer tokens, API keys, and OAuth refresh tokens that land in `~/.agentcookie/secrets/<cli>/secrets.env` are out of scope for this classifier.
- For Google sessions specifically, copying cookies was never the right tool. Sign the sink's Chrome into the same Google account once; it establishes its own device-bound session locally, and the agent on the sink reads that session.
- `dbscKnownHosts` lives in source and is not configurable at runtime; extending it currently requires a code change in `internal/chrome/dbsc.go`.

## Related pages

<CardGroup>
  <Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">The three sink-side delivery paths the warn-and-ship verdict feeds into.</Card>
  <Card title="doctor and adapter verification" href="/doctor-health-checks">The `DBSC` check category and the `DoctorReport` JSON envelope.</Card>
  <Card title="CLI reference" href="/cli-reference">All `agentcookie source` flags, including `--skip-dbsc-suspect`.</Card>
  <Card title="Troubleshooting" href="/troubleshooting">DBSC-suspect drops, stale sessions on Google hosts, and recovery guidance.</Card>
</CardGroup>

---

## 10. 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.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/10-configure-source-and-sink.md
- Generated: 2026-06-01T03:08:28.553Z

### 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>

---

## 11. Enable universal cookie delivery

> The one-login-password Safe Storage open, the `teamid:` partition list, duplicate-Keychain-item convergence, and unsigned-CGO boundary so any unmodified cookie tool reads the synced Default profile.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/11-enable-universal-cookie-delivery.md
- Generated: 2026-06-01T03:08:09.499Z

### Source Files

- `docs/runbook-v0.13-one-password-keychain.md`
- `docs/runbook-v0.10-keychain-access.md`
- `internal/chrome/keychain.go`
- `internal/chrome/keychain_keybase.go`
- `internal/chrome/launchagent_helper.go`
- `internal/cli/wizard_keychain.go`

---
title: "Enable universal cookie delivery"
description: "The one-login-password Safe Storage open, the `teamid:` partition list, duplicate-Keychain-item convergence, and unsigned-CGO boundary so any unmodified cookie tool reads the synced Default profile."
---

Universal cookie delivery on a sink means the real Chrome `Default` profile is written and the Chrome Safe Storage key is readable by unmodified cookie tools (yt-dlp, gallery-dl, pycookiecheat, browser_cookie3, the agentcookie daemon itself). The v0.13 onboarding path opens that read access over SSH with **one** macOS login-password entry, no GUI SecurityAgent prompt, and no destructive rewrite of the keychain item — driven by `agentcookie wizard set-keychain-access` and its inline partition strategy in `internal/cli/wizard_keychain.go` and `internal/chrome/keychain.go`.

## Outcome and verification

A successful open lands the sink in **universal** delivery. `agentcookie doctor` reports the cookie-delivery check as `OK`:

```text
[ok ] Cookie delivery: universal (delivery=universal): real Default profile written and
       Chrome Safe Storage readable; any unmodified cookie CLI works here
```

Independent shell verification:

```bash
agentcookie doctor                                                          # expect: Cookie delivery: universal
security find-generic-password -s "Chrome Safe Storage" -a Chrome -w        # readable via the apple-tool: partition
```

A direct `security` read from a fresh bare-SSH session can still return `User interaction is not allowed` if the login keychain has re-locked; that is a session-lock artifact, not a partition failure. The sink daemon runs in the GUI session where the login keychain is unlocked, so its CGO read via `SecItemCopyMatching` succeeds once the partition is set.

## The one-password Safe Storage open

Earlier versions deleted and recreated the Chrome Safe Storage item from a one-shot LaunchAgent (`-A` or per-binary `-T`), which on modern macOS triggers GUI prompts a headless sink cannot answer. v0.13 replaces that with a single non-destructive `security` call:

```bash
security set-generic-password-partition-list \
  -S "apple-tool:,apple:,teamid:<TEAM>" \
  -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

The argv shape is built by `buildPartitionListArgv` and dispatched by `SetSafeStoragePartitionListWithPassword` in `internal/chrome/keychain.go`. The `-k` flag both authorizes the ACL change (`SecKeychainItemSetAccessWithPassword` requires the login password — there is no headless bypass) and unlocks the login keychain for the single call, which is what makes the partition update succeed over SSH where the keychain is otherwise locked (`-25308 User interaction is not allowed`).

Crucial properties of this path:

<ParamField body="No delete, no rewrite" type="invariant">
The Safe Storage **value** is structurally untouched; only the item's access partition list changes. Existing Chrome cookies stay decryptable because Chromium derives its AES-128 key from this value via PBKDF2 (`saltysalt`, 1003 iterations) in `DeriveAESKey`.
</ParamField>

<ParamField body="No LaunchAgent dispatch" type="invariant">
`runInlinePartitionAccess` runs the call inline in the current process. No GUI session is required and no SecurityAgent prompt fires.
</ParamField>

<ParamField body="Password is ephemeral" type="invariant">
The login password is passed as a single discrete argv element to `security` and never logged, persisted, or echoed. It is briefly visible in `ps` for the lifetime of the call — unavoidable for `security -k`.
</ParamField>

<Warning>
Zero password entries is **not** achievable on macOS. One terminal entry (no GUI dialog) is the floor; `-k` is the only way to make the call succeed without a Keychain Access GUI click.
</Warning>

## The `teamid:` partition list

The wizard composes the partition list from the running agentcookie binary's own code signature. `TeamPartitionList(teamID)` in `internal/chrome/keychain.go` returns:

| Entry | Covers | Read path |
| --- | --- | --- |
| `apple-tool:` | `/usr/bin/security` and the popular unmodified cookie tools | `find-generic-password` via the Security CLI |
| `apple:` | Apple-signed system binaries | `SecItemCopyMatching` from Apple binaries |
| `teamid:<TEAM>` | Developer-ID-signed binaries from `<TEAM>` | `SecItemCopyMatching` (CGO/`go-keychain`) |

`<TEAM>` is resolved at runtime from `codesign -d --verbose=2 <self>` by `BinaryTeamID`, which parses the `TeamIdentifier=` line. An ad-hoc or unsigned agentcookie binary yields `("", nil)` and the wizard falls back to `DefaultPartitionList` (`apple-tool:,apple:`) with a stderr warning:

```text
agentcookie wizard: WARNING this agentcookie binary is not Developer-ID-signed; the
partition covers security-CLI cookie tools (apple-tool) but not Dev-ID CGO readers.
The sink daemon needs a signed binary to read via teamid.
```

The Apple-tool entry covers the most common cookie CLIs because they shell out to `security`:

| Unmodified cookie tool | Read path used | Partition entry that grants it |
| --- | --- | --- |
| `yt-dlp` | `security find-generic-password` | `apple-tool:` |
| `gallery-dl` | `security find-generic-password` | `apple-tool:` |
| `browser_cookie3` | `security find-generic-password` | `apple-tool:` |
| `pycookiecheat` | Python `keyring` → `SecItemCopyMatching` (unsigned CGO) | **none** (see boundary) |
| `agentcookie` daemon | `SecItemCopyMatching` via `keybase/go-keychain` | `teamid:<TEAM>` (Developer-ID-signed) |

## The unsigned-CGO boundary

`teamid:` only covers binaries signed with the matching Developer ID team. `apple-tool:` only covers callers that go through `/usr/bin/security`. A binary that is **both** unsigned (or signed with a different team) **and** calls `SecItemCopyMatching` directly is the documented uncovered class: pycookiecheat (Python `keyring` from an unsigned Homebrew python), a locally-compiled `kooky`/`rookie`, any ad-hoc-built Go CGO reader. Its `-25308` over SSH on the daemon-side read is expected, not a bug.

Three options to cover the long tail, in order of preference:

<Steps>
<Step title="Sign the tool with your team">
Re-sign the binary with the same Developer ID team `<TEAM>` so the existing `teamid:` entry covers it. No partition change needed.
</Step>
<Step title="Add the tool to a per-binary trust list">
The legacy `-T <path>` trust-list strategies in `buildStrategies` still apply individual binaries via `--extra-binary <abs path>` to `agentcookie wizard set-keychain-access --recreate`.
</Step>
<Step title="Open to any application (sink-only)">
`agentcookie wizard set-keychain-access --any-app` opts into the legacy delete-and-recreate chain that adds `-A` (any application). It preserves the existing key value via the read-then-reuse guard in `delete-and-recreate-with-A`, but any local process on the box can then read Chrome cookies — only appropriate on a dedicated sink.
</Step>
</Steps>

<Note>
The signed daemon path does **not** require an Always-Allow GUI click. The live macOS 15.3.1 verification confirmed the partition (`apple-tool:,apple:,teamid:NM8VT393AR`) is sufficient for the Developer-ID-signed agentcookie daemon to read Safe Storage in its GUI session.
</Note>

## Duplicate Keychain item convergence

The install-time race that previously stalled sinks at `delivery: degraded`: a CDP-injecting degraded sink relaunches Chrome; Chrome recreates **its own** Chrome Safe Storage item; the keychain now holds more than one item; the partition is set on one while a reader hits another; the verification read fails.

`CountSafeStorageItems` in `internal/chrome/keychain.go` detects this from a locked SSH session — `security dump-keychain` returns metadata only (no secret values), so it succeeds without unlocking. Items are counted by the `"svce"<blob>="Chrome Safe Storage"` marker line.

`convergeSafeStorageToOneItem` (in `internal/cli/wizard_keychain.go`) collapses duplicates **before** the partition set:

```text
flowchart TD
  count[CountSafeStorageItems] --> dup{n > 1?}
  dup -- no --> noop[No-op return 0]
  dup -- yes --> unlock[unlock-keychain -p pw]
  unlock --> read[SafeStoragePassword<br/>read existing value]
  read -- fail --> refuse[Refuse-to-delete guard:<br/>return error, change nothing]
  read -- ok --> del[delete-generic-password x n]
  del --> readd[add-generic-password -w existing<br/>same value, never random]
  readd --> done[return n-1]
```

The cookie-safety invariant (KTD3/KTD4): the existing value is read **first** and the function refuses to delete anything if that read fails, because recreating the item with a different value would permanently destroy all existing Chrome cookies (PBKDF2-derived AES key changes). The surviving item is always re-added with the **same** value, never a fresh random one. The same read-then-reuse guard appears in the `--any-app` `delete-and-recreate-with-A` strategy.

Doctor surfaces the race directly via `checkCookieDelivery` (`internal/cli/doctor.go`):

```text
[WARN] Cookie delivery: race: N Chrome Safe Storage keychain items exist;
       the install-time Chrome-relaunch race left duplicates ...
       Remediation: converge to one item and re-grant: `agentcookie wizard set-keychain-access`
       (quiesces Chrome, collapses duplicates value-preserved, re-sets the partition)
```

To converge manually when something is actively recreating the item, quiesce first:

```bash
launchctl bootout gui/$(id -u)/dev.agentcookie.sink   # stop the CDP injector
pkill -x "Google Chrome"                              # stop the racer
agentcookie wizard set-keychain-access                # converge + grant
```

## Acquiring the login password

`acquireLoginPassword` in `internal/cli/login_password.go` resolves the password in this order:

<ParamField body="AGENTCOOKIE_LOGIN_PASSWORD" type="env" required={false}>
Read straight from the environment if set. Used by fully non-interactive installs (CI, no PTY). Never logged or persisted; briefly visible in `ps` for the `security -k` call.
</ParamField>

<ParamField body="No-echo terminal prompt" type="fallback">
Falls back to a single `bufio` read with terminal echo disabled via `/bin/stty -echo`, prompting:
```text
macOS login password (grants Chrome Safe Storage access; entered once, never stored):
```
Requires `os.Stdin.Stat()` to report a character-device (PTY-allocated SSH session).
</ParamField>

<ParamField body="errNoInteractivePassword" type="error">
Returned when neither the env override nor an interactive terminal is available. Triggers the non-fatal downgrade to degraded described below.
</ParamField>

## Install paths

<Tabs>
<Tab title="Interactive SSH (default)">
```bash
ssh sink 'agentcookie wizard install --as sink ...'
```
You are prompted once for your macOS login password on the SSH TTY. The sink lands universal.
</Tab>
<Tab title="Non-interactive (CI)">
```bash
ssh sink 'AGENTCOOKIE_LOGIN_PASSWORD=… agentcookie wizard install --as sink ...'
```
No prompt fires. The env var is consumed by `acquireLoginPassword` and forwarded to `security -k`.
</Tab>
<Tab title="No password available">
```bash
ssh sink 'agentcookie wizard install --as sink ...'   # no TTY, no env var
```
Install **does not fail**. The keychain step downgrades non-fatally to **degraded** (sidecar + adapters still work) and prints the upgrade instruction:
```text
agentcookie wizard: WARNING universal keychain open did not complete: ...
agentcookie wizard:   downgrading this install to degraded ...
agentcookie wizard:   upgrade to universal over SSH with one password:
                       agentcookie wizard set-keychain-access
```
</Tab>
</Tabs>

The downgrade is governed by `resolveSinkDeliveryWithKeychain` in `internal/cli/wizard.go`. An explicit `--write-chrome-sqlite` forces universal and surfaces a warning instead of downgrading; an explicit `--skip-keychain-access` or `--skip-keychain-prompt` renders universal config without opening the keychain at all.

## Strategy routing reference

`keychainStrategyMode` in `internal/cli/wizard_keychain.go` selects between the inline path and the legacy LaunchAgent chain:

| Flags | Mode | Strategy used |
| --- | --- | --- |
| (none) | `inline` | `runInlinePartitionAccess` — one-password partition with `teamid:` |
| `--any-app` | `recreate` | `delete-and-recreate-with-A` (value-preserving) then T-list |
| `--recreate` | `recreate` | `delete-and-recreate-with-T` then `partition-list:apple-tool,apple` |
| `--extra-binary <path>` | adds `trust-list:<basename>` strategies in the recreate chain | — |
| `--skip-keychain-access` | (no open) | universal config rendered, keychain left untouched |

Inner strategies in the recreate chain are dispatched as a one-shot LaunchAgent via `RunOneShotLaunchAgent` in `internal/chrome/launchagent_helper.go` and probed after each step with `KeybaseKeychainProbe` (3-second cap) so a hung SecurityAgent prompt fails fast instead of burning ~30s per attempt.

## Narrow back

Nothing is deleted on the inline path, so there is no destructive rollback. To narrow access back to the security-CLI tools only (drop Dev-ID CGO readers), re-run the partition set without `teamid:`:

```bash
security set-generic-password-partition-list \
  -S "apple-tool:,apple:" -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

## Related pages

<CardGroup>
<Card title="Headless second-Mac install" href="/headless-install">
The SSH-only sink install flow, the degraded-mode fallback, and the `wizard set-keychain-access` upgrade path.
</Card>
<Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
The three sink-side delivery paths: real Chrome Default profile, the plaintext sidecar, and per-CLI adapter session files.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
The `Cookie delivery` check, duplicate-item race detection, and exit-code semantics for agent consumption.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Stuck-at-degraded sinks, `-25308 User interaction is not allowed`, and the duplicate-item race recovery sequence.
</Card>
</CardGroup>

---

## 12. Adopt a CLI with agentcookie.toml

> Authoring a v2 adoption manifest, declaring `[secrets]`, aliases, and `[[files]]`, running `agentcookie discover`, and migrating from imperative `secret import-from` to manifest-driven sync.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/12-adopt-a-cli-with-agentcookie.toml.md
- Generated: 2026-06-01T03:08:55.646Z

### Source Files

- `docs/spec-agentcookie-secrets-bus-v2-adoption.md`
- `docs/runbook-adoption-manifest-author.md`
- `docs/runbook-secrets-bus-adoption.md`
- `examples/adoption-third-party-cli/agentcookie.toml`
- `examples/adoption-last30days/agentcookie.toml`
- `internal/secretsbus/manifest_v2.go`

---
title: "Adopt a CLI with agentcookie.toml"
description: "Authoring a v2 adoption manifest, declaring `[secrets]`, aliases, and `[[files]]`, running `agentcookie discover`, and migrating from imperative `secret import-from` to manifest-driven sync."
---

A v2 adoption manifest is a single TOML file named `agentcookie.toml` that declares a project's participation in the agentcookie secrets bus. The source machine scans well-known directories on startup, parses each manifest with `internal/secretsbus.ParseManifestV2`, applies the precedence and collision rules from spec section 4, and the next `agentcookie source` push reads the secrets in place and ships them to the sink — no `agentcookie secret import-from` call required. The schema, validator, and discovery loop are all v2-only; v1 imperative paths continue to work unchanged and surface in the registry as the `legacy-v1` tier.

## File location and discovery order

The manifest is always named `agentcookie.toml` (never `manifest.toml` — that name is taken by the v1 per-CLI sync override inside `~/.agentcookie/secrets/<name>/`). `Discover` walks the following roots in priority order and the first occurrence of a given `name` wins; lower-priority hits are recorded as soft-skipped.

| Priority | Path | Tier (`SourceKind`) |
|----------|------|---------------------|
| 1 | `~/.agentcookie/manifests/*.toml` | `explicit-manifest` |
| 2 | `~/.config/agentcookie/manifests/*.toml` | `explicit-manifest` |
| 3 | `/usr/local/share/agentcookie/manifests/*.toml` | `explicit-manifest` |
| 4 | `~/printing-press/library/*/.printing-press.json` | `pp-cli-derived` |
| 5 | User-added directories via `agentcookie discover` extra paths | `explicit-manifest` |
| 6 | `~/.agentcookie/secrets/<name>/` (v1 bus dirs) | `legacy-v1` |

<Info>
A malformed manifest is soft-skipped at discovery so a single bad file never blocks the loop. The imperative `agentcookie secret import-from` path stays strict and hard-fails on the same input.
</Info>

## Authoring the manifest

### Minimum schema

```toml agentcookie.toml
schema_version = 2                             # required; must be exactly 2
name = "my-tool"                               # required; slug rules below
display_name = "My Tool"                       # required; <= 200 printable UTF-8
description = "Internal tool"                  # optional; <= 200 chars
project_kind = "cli"                           # optional; cli|skill|service|other
homepage = "https://example.com/my-tool"       # optional

[secrets.file]
path = "~/.config/my-tool/.env"                # required when [secrets.file] is present
```

A manifest must declare exactly one `[secrets.*]` block **or** at least one `[[files]]` item. Two `[secrets.*]` blocks are a hard error (`at most one [secrets.*] block allowed`). The two reserved kinds, `[secrets.command]` and `[secrets.keychain]`, parse successfully but fail validation with `not yet supported in v2.0 (reserved for v2.1)`.

### Field reference

<ParamField body="schema_version" type="int" required>
Must be exactly `2`. `schema_version = 1` is rejected with a pointer to the v1 per-CLI sync override format. Future v3 manifests will be hard-rejected by v2-aware parsers.
</ParamField>

<ParamField body="name" type="string" required>
Lowercase ASCII letters, digits, and hyphens. 1–64 characters. Must start and end with a letter or digit. Pattern: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$`. `..` substrings are rejected as a defense-in-depth traversal check. Identical validator as v1 `validCLIName`.
</ParamField>

<ParamField body="display_name" type="string" required>
Any printable UTF-8, 1–200 chars. Shown in `agentcookie discover` and `agentcookie secret list` headers. Never used as a path segment.
</ParamField>

<ParamField body="description" type="string">
One-line summary, ≤ 200 chars.
</ParamField>

<ParamField body="project_kind" type="enum">
One of `cli`, `skill`, `service`, `other`. Any other value is a hard parse error.
</ParamField>

<ParamField body="homepage" type="string">
Free-form URL; not validated beyond TOML parse.
</ParamField>

<ParamField body="signed_by" type="string">
Reserved for v2.1. Parsers accept the field and emit a `signed_by field is reserved for v2.1; ignored in v2.0` warning.
</ParamField>

<ParamField body="[secrets.file].path" type="string" required>
Path to the env-shaped file the agent reads on every push. `~/` expands to the user's home; absolute paths outside the home are accepted but warned as "may not be portable". `..` segments are a hard error. The file format is the v1 `secrets.env` dotenv subset.
</ParamField>

<ParamField body="[sync].default" type="bool" default="true">
Default-ship every key in the source file. Omitting the entire `[sync]` table is equivalent to `default = true`.
</ParamField>

<ParamField body="[sync.keys]" type="map[string]bool">
Per-key overrides. `KEY = false` excludes the key from the push envelope; `KEY = true` includes it when `default = false`. Applied source-side before envelope build; the sink never sees excluded keys.
</ParamField>

<ParamField body="[aliases]" type="map[string]string">
Declares `CONSUMER_ENV_VAR = "BUS_KEY"` mappings. `agentcookie secret env <name>` rewrites bus keys to consumer vars live on every call, so token rotation tracks automatically. Both sides must be valid env-var names (initial letter or underscore, then letters/digits/underscores). A local `agentcookie secret alias` for the same declared var overrides the manifest alias.
</ParamField>

### Sync filter

```toml
[sync]
default = true                                 # omitted => true

[sync.keys]
SETUP_COMPLETE   = false                       # exclude config-shaped keys
FROM_BROWSER     = false
INCLUDE_SOURCES  = false
```

Per-key entries always override `default`. Filter intent is source-side only; per v1 spec §4.3, the `[sync.keys]` table does not travel in the wire envelope.

### Aliases

```toml
[aliases]
TESLA_AUTH_TOKEN = "OAUTH_BEARER"              # CLI reads TESLA_AUTH_TOKEN; bus stores OAUTH_BEARER
```

A CLI that hardcodes `TESLA_AUTH_TOKEN` connects to a bearer agentcookie imported as `OAUTH_BEARER` for any user, with no per-user `agentcookie secret alias` call. An alias whose bus key is absent emits nothing for that declared var (no-op).

## Carried files (`[[files]]`)

`[[files]]` ships arbitrary file-shaped artifacts (multiline PEMs, TOML configs) that cannot ride as a single `KEY=VALUE` pair. It is a NEW construct that **coexists** with the single `[secrets.*]` block — declaring `[[files]]` does not count toward the "exactly one `[secrets.*]`" rule.

```toml
[[files]]
source   = "~/.config/tesla-pp-cli/config.toml"   # required; ~/ expands; no ".."
key      = "TESLA_CONFIG_TOML"                    # required; valid env-var name; unique per manifest
target   = "tesla-pp-cli/config.toml"             # required; relative, must stay inside ~/.agentcookie/
optional = false                                  # default false; true = opt-in (see below)
env      = "TESLA_CONFIG"                         # optional; absolute materialized path exported by `secret env`

[[files]]
source   = "~/.tesla/fleet-private.pem"
key      = "TESLA_FLEET_KEY_PEM"
target   = "tesla-pp-cli/fleet-key.pem"
optional = true
env      = "TESLA_FLEET_KEY_FILE"
```

### Carriage mechanism

Because the wire envelope is a flat `map[string]map[string]string`, each carried file adds two keys to its CLI's env map:

| Wire key | Value |
|----------|-------|
| `<key>` | Base64 encoding of the file bytes (single-line, dotenv-safe) |
| `_FILE_<key>` | The relative `target` — materialization instruction for the sink |

On the sink, the secrets writer decodes each `_FILE_<key>` / `<key>` pair, writes the decoded bytes mode `0600` to `<target>` resolved under `~/.agentcookie/`, and strips both keys from the per-CLI `secrets.env` so a carried file does not also leak as an env var. A sink that does not understand `_FILE_*` companions simply persists them as ordinary env keys (forward-degradation).

### Validation rules

- `source` is required, no `..` traversal, `~/` expands.
- `key` is required, must be a valid env-var name, must be unique across `[[files]]` entries (`[[files]] duplicate key "X"`).
- `target` is required, must be relative, must not contain `..`, must not resolve to an absolute path, and must stay strictly inside `~/.agentcookie/` after `filepath.Clean`.
- `env`, when set, must be a valid env-var name.
- Decoded payload size is capped at **256 KB** per file (`maxCarriedFileBytes`); larger payloads are refused by the sink rather than written.

### Opt-in items

An item with `optional = true` is **not** carried by default. The user enables it one-per-line in:

```
~/.agentcookie/file-optin/<name>.keys
```

Blank lines and `#` comments are ignored. This gates deliberate exposures (e.g. a command-signing key landing on a headless sink) behind explicit per-user consent.

<Warning>
Carried files are encrypted in transit and at rest in the bus, but the materialized file on the sink is a `0600` plaintext file owned by the sink user. On-sink protection is file permissions. Files are written only under `~/.agentcookie/`, never world- or group-readable, never outside that directory.
</Warning>

## Authoring workflow

<Steps>
<Step title="Copy a template">
Start from one of the in-repo examples:

```bash
cp examples/adoption-third-party-cli/agentcookie.toml ./agentcookie.toml
# or, for a dotenv-shaped skill:
cp examples/adoption-last30days/agentcookie.toml ./agentcookie.toml
```
</Step>

<Step title="Edit the four required fields">
Set `name` (slug), `display_name`, `[secrets.file].path`, and decide `[sync].default`. Add `[sync.keys]` to exclude config-shaped keys that are not real secrets.
</Step>

<Step title="Validate before shipping">
Either install locally and run the discover loop, or call the helper library from Go tooling:

<CodeGroup>
```bash CLI
mkdir -p ~/.agentcookie/manifests
install -m 0644 agentcookie.toml ~/.agentcookie/manifests/my-tool.toml
agentcookie discover --verbose
```

```go Go
import "github.com/mvanhorn/agentcookie/pkg/agentcookieadoption"

if err := agentcookieadoption.Validate(m); err != nil {
    log.Fatal(err)
}
_ = agentcookieadoption.WriteTo(m, "agentcookie.toml")
```
</CodeGroup>

`agentcookieadoption.Validate` applies the same rules as the in-tree parser at discovery time.
</Step>

<Step title="Ship the manifest from your installer">
Drop the file into `~/.agentcookie/manifests/` only when agentcookie is already present, so non-agentcookie users get a no-op:

```bash install.sh
if [ -d "$HOME/.agentcookie" ]; then
    mkdir -p "$HOME/.agentcookie/manifests"
    install -m 0644 agentcookie.toml "$HOME/.agentcookie/manifests/my-tool.toml"
fi
```
</Step>

<Step title="Push and verify on the sink">
```bash
agentcookie source --once
ssh other-mac "agentcookie secret list"
```

Your CLI's slug should appear with the expected key set.
</Step>
</Steps>

## `agentcookie discover`

`agentcookie discover` is the inspection command for the v2 registry. It does not push or modify anything; it only reads the well-known paths and prints the result.

```text
NAME           TIER               READ-IN-PLACE                       KEYS  COVERAGE
last30days     explicit-manifest  /Users/me/.config/last30days/.env   3     OK
my-tool        explicit-manifest  /Users/me/.config/my-tool/auth.env  0     OK
tesla-pp-cli   pp-cli-derived     /Users/me/.config/tesla-pp-cli/config.toml  1  OK
legacy-cli     legacy-v1          (legacy bus dir)                    2     OK
```

| Flag | Behavior |
|------|----------|
| `--verbose`, `-v` | Append a `skipped:` section listing every soft-skipped manifest with its reason, and a `discovery errors:` section for accumulated errors. |
| `--json` (root flag) | Emit `discoverJSONOutput { projects, skipped }` instead of the human table. `skipped` is only present with `--verbose`. |

The JSON shape per row:

<ResponseField name="name" type="string">Resolved slug after collision handling.</ResponseField>
<ResponseField name="kind" type="string">`explicit-manifest`, `pp-cli-derived`, or `legacy-v1`.</ResponseField>
<ResponseField name="source_path" type="string">Where the manifest came from (file path, or v1 bus subdirectory).</ResponseField>
<ResponseField name="read_in_place_path" type="string">Expanded `[secrets.file].path`; empty for `legacy-v1`.</ResponseField>
<ResponseField name="display_name" type="string">From the manifest; empty for legacy entries.</ResponseField>
<ResponseField name="key_count" type="int">Number of `[sync.keys]` overrides parsed.</ResponseField>
<ResponseField name="sync_summary" type="string">`default=<bool>, N overrides|allowed`.</ResponseField>
<ResponseField name="coverage" type="string">`OK` or `MISMATCH` when synced keys do not match the CLI's declared auth env var.</ResponseField>

## Collision rules

Two explicit manifests declaring the same `name` is a **hard error**: both are rejected from the registry and listed in `Skipped` with the collision message. Silent-skip would let an attacker shadow a real project by dropping a same-named manifest.

| Collision | Resolution |
|-----------|------------|
| Two explicit manifests, same `name` | Both rejected; collision message names both source paths. |
| Explicit vs. PP-CLI auto-derived | Explicit wins. The derived entry is renamed with a `-pp` suffix and both appear in the registry. |
| Two PP-CLI derivations, same `name` | Should be impossible (PP generator owns uniqueness). If it happens, first-by-path-sort wins; subsequent collisions get a 6-char sha256 suffix. |
| v1 bus dir vs. v2 manifest with same `name` | v2 wins for registry visibility; at push time, v1 bus values still win per-key over read-in-place values (preserving explicit user intent from a prior `import-from`). |

## Migrating from `agentcookie secret import-from`

The v1 imperative path remains supported:

```bash
agentcookie secret import-from ~/.config/my-tool/config.toml --as my-tool
```

It copies values into `~/.agentcookie/secrets/<name>/secrets.env`. Discovery recognizes that directory as a synthetic `legacy-v1` registry entry, so it continues to ship without further action.

<Tabs>
<Tab title="When to migrate">
- The project's file path is stable and the user wants live-rotation behavior.
- You author the CLI and can ship the manifest from your installer.
- You want zero per-machine, per-user setup.
</Tab>
<Tab title="When to stay imperative">
- The source file path is dynamic or computed at runtime.
- The user wants a stable snapshot rather than live values.
- The user wants to edit values in the bus without touching the project file.
</Tab>
</Tabs>

### Switchover with no re-login

1. Author and install `agentcookie.toml` pointing at the project's existing config file path.
2. Run `agentcookie discover`. The same slug should now appear under tier `explicit-manifest`. Per the collision rules, the v2 entry takes registry visibility while v1 bus values continue to win per-key — both data sources are honored simultaneously.
3. Push and verify on the sink: `agentcookie source --once` then `ssh sink "agentcookie secret list"`.
4. When confident the read-in-place flow is healthy, remove the old bus directory: `agentcookie secret rm <name>`. Discovery drops the `legacy-v1` entry on the next scan; the `explicit-manifest` entry remains.

<Tip>
Read-in-place means rotating a token in the project's own file ships on the next push automatically — no `agentcookie secret import-from` re-run, no manual sync command.
</Tip>

## Worked examples

<AccordionGroup>
<Accordion title="examples/adoption-last30days — skill reading a dotenv">

```toml
schema_version = 2
name = "last30days"
display_name = "last30days"
description = "Brand intelligence skill"
project_kind = "skill"
homepage = "https://github.com/mvanhorn/last30days-skill"

[secrets.file]
path = "~/.config/last30days/.env"

[sync]
default = true

[sync.keys]
SETUP_COMPLETE  = false
FROM_BROWSER    = false
INCLUDE_SOURCES = false
```

Ships every key in `.env` except three config-shaped ones the skill writes alongside its real secrets.
</Accordion>

<Accordion title="examples/adoption-third-party-cli — minimal CLI manifest">

```toml
schema_version = 2
name = "my-tool"
display_name = "My Tool"
description = "Example third-party CLI that ships secrets via agentcookie"
project_kind = "cli"

[secrets.file]
path = "~/.config/my-tool/auth.env"

[sync]
default = true
```

Smallest viable manifest: every key in the dotenv ships, no overrides.
</Accordion>

<Accordion title="Carried files alongside a dotenv">

```toml
schema_version = 2
name = "tesla-pp-cli"
display_name = "Tesla"
project_kind = "cli"

[secrets.file]
path = "~/.config/tesla-pp-cli/.env"

[aliases]
TESLA_AUTH_TOKEN = "OAUTH_BEARER"

[[files]]
source   = "~/.config/tesla-pp-cli/config.toml"
key      = "TESLA_CONFIG_TOML"
target   = "tesla-pp-cli/config.toml"
env      = "TESLA_CONFIG"

[[files]]
source   = "~/.tesla/fleet-private.pem"
key      = "TESLA_FLEET_KEY_PEM"
target   = "tesla-pp-cli/fleet-key.pem"
optional = true
env      = "TESLA_FLEET_KEY_FILE"
```

The dotenv rides via `[secrets.file]`; the `config.toml` always rides as a carried file; the PEM is opt-in and only ships when its `key` line appears in `~/.agentcookie/file-optin/tesla-pp-cli.keys`.
</Accordion>
</AccordionGroup>

## Edge cases

| Situation | Manifest answer |
|-----------|-----------------|
| Secrets live in macOS Keychain only | `[secrets.keychain]` is reserved for v2.1 and rejected today. Export keychain entries to a `~/.config/<tool>/.env` your tool also reads, point the manifest there. |
| Auth is a short-lived JWT minted per session | Bus is best for stable tokens. Ship the refresh token or OAuth `client_id`/`client_secret` instead and let each side mint its own JWT. |
| Secrets spread across multiple files | `[secrets.file]` is single-file. Either consolidate at install time, or ship two manifests with distinct slugs (`my-tool-auth`, `my-tool-config`). |
| File-shaped artifacts (PEMs, certs) | Use `[[files]]` (≤ 256 KB each) for portable artifacts, or keep machine-bound material outside the bus entirely. |
| Multiple installs of one tool (dev + prod) | Bus is single-account. Give each install a distinct slug and ship two manifests. |

## What not to do

- **Don't put real secrets in the manifest.** It is a *pointer* to where secrets live; the secrets stay in the file `[secrets.file].path` references.
- **Don't ship the manifest from an untrusted source.** `[secrets.file].path` is a path-traversal vector that the parser validates, but `curl | bash` from a public CDN bypasses your usual trust boundary.
- **Don't sign the manifest yet.** v2.0 ignores `signed_by` with a stderr warning; v2.1 will define the verification flow.
- **Don't declare a second `[secrets.*]` block.** That's a hard error. Use `[[files]]` for additional non-env-shaped artifacts.
- **Don't rename `agentcookie.toml` to `manifest.toml`.** The bare name `manifest.toml` is structurally different and already used by v1 per-CLI sync overrides inside `~/.agentcookie/secrets/<name>/`.

## Next

<CardGroup cols={2}>
<Card title="agentcookie.toml manifest reference" href="/manifest-v2-reference">
Full schema, validation rules, reserved fields, and the three integration tiers in one place.
</Card>
<Card title="Secrets bus on-disk layout" href="/secrets-bus-layout">
Where the sink writes `secrets.env`, sealed twins, and materialized `[[files]]` payloads.
</Card>
<Card title="Secrets bus overview" href="/secrets-bus">
How the bus rides the same encrypted push as cookies and the v2 adoption tiers.
</Card>
<Card title="pkg/agentcookiesecret reader library" href="/go-reader-library">
In-process Go API for consuming the bus from a CLI instead of shelling out.
</Card>
</CardGroup>

---

## 13. Write a cookie adapter

> The ~50-line pattern for a new sink adapter: `Register()` into the adapter registry, the validate hook, the seal helper, and the five built-in PP-CLI adapters as templates.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/13-write-a-cookie-adapter.md
- Generated: 2026-06-01T03:08:22.459Z

### Source Files

- `docs/runbook-v0.11-adapter-cookie-push.md`
- `internal/sinkpush/adapter.go`
- `internal/sinkpush/registry.go`
- `internal/sinkpush/adapter_instacart.go`
- `internal/sinkpush/adapter_airbnb.go`
- `internal/sinkpush/adapter_tablereservation.go`

---
title: "Write a cookie adapter"
description: "The ~50-line pattern for a new sink adapter: `Register()` into the adapter registry, the validate hook, the seal helper, and the five built-in PP-CLI adapters as templates."
---

A sink-side cookie adapter is a Go type that implements `sinkpush.Adapter` (five methods), registers itself from a package `init()` function, and writes the filtered subset of decrypted Chrome cookies into one target CLI's local session cache. After each cookie sync the sink calls `sinkpush.RunAll`, which iterates the package registry, filters cookies per adapter, runs the central `Validate` gate, optionally seals secret-bearing fields through `maybeSeal`, and routes errors into per-adapter `Result` entries without aborting the sync. The five built-ins under `internal/sinkpush/` (`adapter_instacart.go`, `adapter_airbnb.go`, `adapter_ebay.go`, `adapter_pagliacci.go`, `adapter_tablereservation.go`) cover the two stable templates — auth-paste stdin and pycookiecheat-style TOML+JSON — plus one bespoke single-file session JSON.

## The Adapter interface

Every adapter satisfies one interface defined in `internal/sinkpush/adapter.go`. Five methods, no lifecycle, no constructor contract:

```go
type Adapter interface {
    Name() string
    CLIBinary() string
    IsInstalled() bool
    CookieHostPatterns() []string
    Push(cookies []chrome.Cookie) error
}
```

<ParamField body="Name" type="string" required>
Unique identifier used in sink logs, `Result.Name`, and `wizard verify-adapters` output. By convention the target CLI's binary name (e.g. `"instacart-pp-cli"`).
</ParamField>

<ParamField body="CLIBinary" type="string" required>
Absolute path to the target CLI on disk. Used by `IsInstalled` and, for auth-paste adapters, as the exec target.
</ParamField>

<ParamField body="IsInstalled" type="bool" required>
Reports whether the binary exists. `RunAll` short-circuits to `Skipped: true, SkippedReason: "CLI not installed"` when this returns false — a missing CLI is never an error.
</ParamField>

<ParamField body="CookieHostPatterns" type="[]string" required>
SQLite-LIKE patterns matched against `chrome.Cookie.HostKey` before `Push` is called. Returning `nil` or `[]string{}` means "give me every cookie" — used only by greedy adapters. A single `"%vendor%"` pattern is the norm.
</ParamField>

<ParamField body="Push" type="func([]chrome.Cookie) error" required>
Writes the filtered + validated cookies into the CLI's session cache. Receives only cookies that passed `Validate`. Errors land in `Result.Err`; one adapter's failure does not stop subsequent adapters.
</ParamField>

The runtime input shape adapters receive is `chrome.Cookie` from `internal/chrome/read.go`:

```go
type Cookie struct {
    HostKey       string  // ".instacart.com"
    Name          string
    Value         string
    Path          string
    ExpiresUTC    int64   // microseconds since 1601-01-01 (WebKit epoch)
    IsSecure      int
    IsHTTPOnly    int
    LastAccessUTC int64
    HasExpires    int
    IsPersistent  int
    Priority      int
    SameSite      int
    SourceScheme  int
    SourcePort    int
}
```

## Registry, lifecycle, and execution order

The registry is a package-level slice in `internal/sinkpush/registry.go`. `Register` is append-only — no `Deregister` exists by design — so adapters run in the order they were registered. `internal/sinkpush/init.go` registers the five built-ins at package load:

```go
func init() {
    Register(NewInstacart())
    Register(NewAirbnb())
    Register(NewEbay())
    Register(NewPagliacci())
    Register(NewTableReservation())
}
```

`RunAll(cookies)` walks the registry and per adapter:

1. Calls `IsInstalled()`. If false → `Skipped: true, SkippedReason: "CLI not installed"`.
2. Calls `filterByHostPatterns(cookies, a.CookieHostPatterns())`. If the filtered slice is empty → `Skipped: true, SkippedReason: "no matching cookies"`.
3. Runs `Validate` on each filtered cookie. Failures bump `Result.Invalid` and are dropped; if every cookie fails → `Skipped: true, SkippedReason: "all cookies failed validation"`.
4. Calls `Push(valid)`. The error (if any) lands in `Result.Err`; success sets `Result.Pushed = len(valid)`.

```mermaid
flowchart TB
    subgraph Sink["sink LaunchAgent process"]
        Cookies["[]chrome.Cookie<br/>(decrypted from sync)"]
        RunAll["sinkpush.RunAll"]
    end

    subgraph Registry["registry (registration order)"]
        A1["NewInstacart()"]
        A2["NewAirbnb()"]
        A3["NewEbay()"]
        A4["NewPagliacci()"]
        A5["NewTableReservation()"]
    end

    subgraph PerAdapter["per-adapter pipeline (runOne)"]
        Installed{"IsInstalled?"}
        Filter["filterByHostPatterns<br/>(SQLite LIKE)"]
        Val["Validate<br/>name / value / host_key"]
        Seal["maybeSeal<br/>(agc1: envelope when<br/>master key present)"]
        Push["Push() writes session file"]
    end

    Cookies --> RunAll
    RunAll --> Registry
    Registry --> Installed
    Installed -- yes --> Filter
    Filter -- non-empty --> Val
    Val --> Seal
    Seal --> Push
    Push --> Result["Result{Name, Pushed, Skipped, Invalid, Err}"]
```

## Validate hook

`sinkpush.Validate` (in `validate.go`) is the single gate every cookie crosses before reaching an adapter's `Push`. It exists because adapters write `Name=Value` pairs into TOML, JSON, and stdin streams whose parsers can be confused or hijacked by control characters or quoting.

| Field | Rule | Rejection reason |
|---|---|---|
| `Name` | RFC 6265 token chars only: `0x21..0x7E` minus `( ) < > @ , ; : \ " / [ ] ? = { }` | `errEmptyName`, `errNameTokenChars` |
| `Value` | Any byte ≥ `0x20` except `0x7F` (DEL) | `errValueControl` |
| `HostKey` | Non-empty, no control chars, no `..`, `/`, `\` | `errHostKeyEmpty`, `errHostKeyControl`, `errHostKeyTraverse` |

Adapters do not call `Validate` themselves — `runOne` does it. A failing cookie increments `Result.Invalid` and is dropped; the adapter still runs on the remaining valid cookies.

The same file exports one helper adapters do call: `HostSuffixMatch(hostKey, suffix)`. Use it to bin cookies between sibling services without the "string `HasSuffix` matches unrelated host" bug — the character before the suffix must be a `.` label boundary.

## Seal helper

`internal/sinkpush/seal.go` exposes `maybeSeal(plain string) (string, error)`. Adapters call it on every secret-bearing field they write to disk:

```go
sealed, err := maybeSeal(headerOrCookieValue)
if err != nil {
    return fmt.Errorf("seal: %w", err)
}
```

Behavior:

- When `keystore.MasterKeyExists()` is false → returns `plain, nil`. v0.11 plaintext shape is preserved so partial installs work.
- When the master key is in the macOS Keychain → returns `SealedPrefix + base64(seal(masterKey, plaintext))`, where `SealedPrefix = "agc1:"`.
- Only returns an error when the master key item is present but unreadable, or `keystore.Seal` itself fails.

Downstream PP CLIs detect the `agc1:` prefix and unseal via `pkg/sidecar`'s keystore. Adapters do not seal `HostKey`, `Path`, or `Name` — only the secret-carrying field per format (the Cookie-header string for pycookiecheat-style, each cookie `Value` for the table-reservation session JSON).

## Built-in templates

The five built-ins are three patterns. Pick the closest one and copy it.

### Template 1: auth-paste stdin (instacart)

`adapter_instacart.go` shells out to `<cli> auth paste` with a `Cookie:` header value on stdin. The CLI parses and writes its own session.json. Use this template when the target CLI ships an `auth paste` (or equivalent) import subcommand.

```go
type InstacartAdapter struct { binary string }

func NewInstacart() *InstacartAdapter {
    home, _ := os.UserHomeDir()
    return &InstacartAdapter{binary: filepath.Join(home, "go", "bin", "instacart-pp-cli")}
}

func (a *InstacartAdapter) Name() string              { return "instacart-pp-cli" }
func (a *InstacartAdapter) CLIBinary() string         { return a.binary }
func (a *InstacartAdapter) IsInstalled() bool         { fi, err := os.Stat(a.binary); return err == nil && !fi.IsDir() }
func (a *InstacartAdapter) CookieHostPatterns() []string { return []string{"%instacart%"} }

func (a *InstacartAdapter) Push(cookies []chrome.Cookie) error {
    header := formatCookieHeader(cookies)
    if header == "" { return nil }
    cmd := exec.Command(a.binary, "auth", "paste")
    cmd.Stdin = strings.NewReader(header)
    var stderr bytes.Buffer
    cmd.Stderr = &stderr
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("instacart-pp-cli auth paste: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
    }
    return nil
}
```

`formatCookieHeader` (also in the file) drops cookies whose `Value` is empty — those carry no auth and the CLI parser would treat them as deletes that clobber existing state.

### Template 2: pycookiecheat-style config.toml + cookies.json (airbnb, ebay, pagliacci)

Three of the five built-ins share `PycookiecheatStyleAdapter` (in `adapter_pycookiecheat.go`). The concrete constructors are trivial — `adapter_airbnb.go` is the canonical example:

```go
func NewAirbnb() *PycookiecheatStyleAdapter {
    return newPycookiecheatStyleAdapter(
        "airbnb-pp-cli",          // Name + binary basename in ~/go/bin
        "%airbnb%",               // single SQLite LIKE pattern
        "airbnb-pp-cli",          // ~/.config/<configDir>/
        "https://www.airbnb.com", // base_url written into a fresh config.toml
    )
}
```

`adapter_ebay.go` and `adapter_pagliacci.go` are the same six lines with different vendor strings. The shared implementation handles:

- Mode-0700 `MkdirAll` on `~/.config/<cli>/`.
- Atomic write of `config.toml`. If the file already exists, only the `access_token = '...'` line is rewritten via the `accessTokenLine` regex — user-set `base_url` and other fields are preserved. If the file is fresh, `freshConfigTOML` writes the canonical layout (`base_url`, `auth_header`, `access_token`, `refresh_token`, `token_expiry`, `client_id`, `client_secret`).
- Atomic write of `cookies.json` with the shape `{"cookies": "<cookie header>"}`.
- `maybeSeal` on the entire cookie-header string before either write, so both files carry the same `agc1:`-prefixed payload when the master key is present.
- `escapeTOMLSingleQuoted` strips embedded `'` (rare in cookie values; Chrome never emits them) so the literal-string TOML form stays valid.
- `atomicWriteFile` writes to `<path>.agentcookie.tmp` then renames, so readers never see a half-written file.

### Template 3: bespoke single-file session JSON (table-reservation)

When the target CLI has neither an `auth paste` import nor a pycookiecheat-shaped config, model on `adapter_tablereservation.go`. It demonstrates the cleanest one-off pattern:

- Two host patterns in `CookieHostPatterns`: `"%opentable.com"` and `"%exploretock.com"`. Two networks share one session file.
- `chromeToSessionCookie` converts each `chrome.Cookie` into the per-cookie JSON shape (`name`, `value`, `domain`, `path`, `expires`). `chromeExpiresToRFC3339` translates `ExpiresUTC` (microseconds since the WebKit epoch) into RFC3339Nano, mapping the session-cookie sentinel `0` to a far-future date so the CLI's required-`expires` invariant is preserved. The WebKit-to-Unix delta is the local constant `chromeEpochDeltaMicros`.
- `HostSuffixMatch(c.HostKey, "opentable.com")` and `"exploretock.com"` bin cookies into `opentable_cookies` and `tock_cookies` arrays inside the `sessionEnvelope` (`version: 1`, `updated_at` RFC3339Nano).
- `maybeSeal` is applied to each individual `sessionCookie.Value`, not the envelope.
- Empty-value cookies are dropped.
- Final write is atomic, mode `0600`, into `~/.config/table-reservation-goat-pp-cli/session.json`.

## End-to-end recipe for a new adapter

<Steps>
<Step title="Inspect the target CLI's session-file shape">
On a Mac where the target CLI has been authenticated through its own native flow (e.g. `<cli> auth login --chrome`), capture the files it writes: usually under `~/.config/<cli>/`. Note the field names, encoding, whether multiple files share the same header, and which fields are secret-bearing. The three patterns above cover most PP CLIs; deviations imply Template 3.
</Step>

<Step title="Add internal/sinkpush/adapter_<name>.go">
For pycookiecheat-style targets, a six-line constructor wrapping `newPycookiecheatStyleAdapter` is enough. For auth-paste, copy `adapter_instacart.go` and change the binary name, `Name`, host pattern, and exec args. For a bespoke session file, copy `adapter_tablereservation.go` and replace the `sessionCookie` / `sessionEnvelope` types with the discovered schema. Keep the new file under ~50 lines whenever the pattern allows.
</Step>

<Step title="Wire into init()">
Append one `Register(NewYourAdapter())` line to `internal/sinkpush/init.go`. Adapters run in registration order — there is no priority field.
</Step>

<Step title="Write a unit test against a temp config dir">
Adapters that write files should be testable by constructing the struct directly (bypassing `New*`) so `configDir` and `binary` point at a `t.TempDir()`. See `adapter_instacart_test.go`, `adapter_pycookiecheat_test.go`, and `adapter_tablereservation_test.go` for the established patterns: feed synthetic `chrome.Cookie` slices to `Push`, assert files on disk, and round-trip the sealed envelope when the master key is installed.
</Step>

<Step title="Verify on a real sink">
Rebuild and redeploy the binary, then trigger a sync and inspect:

```bash
ssh sink-mac '/Users/<user>/bin/agentcookie wizard verify-adapters'
```

Expect a row showing `ok` and a non-zero `PUSHED` count. Confirm the CLI itself runs without prompts:

```bash
ssh sink-mac '/Users/<user>/go/bin/<your-cli> doctor'
```
</Step>
</Steps>

## Per-adapter behavior cheatsheet

| Adapter | Host pattern(s) | Target file(s) | Push strategy |
|---|---|---|---|
| `instacart-pp-cli` | `%instacart%` | written by CLI itself | exec `auth paste`, header on stdin |
| `airbnb-pp-cli` | `%airbnb%` | `~/.config/airbnb-pp-cli/config.toml` + `cookies.json` | `PycookiecheatStyleAdapter` |
| `ebay-pp-cli` | `%ebay%` | `~/.config/ebay-pp-cli/config.toml` + `cookies.json` | `PycookiecheatStyleAdapter` |
| `pagliacci-pp-cli` | `%pagliacci%` | `~/.config/pagliacci-pp-cli/config.toml` + `cookies.json` | `PycookiecheatStyleAdapter` |
| `table-reservation-goat-pp-cli` | `%opentable.com`, `%exploretock.com` | `~/.config/table-reservation-goat-pp-cli/session.json` | bespoke envelope, per-value seal |

## Result shape and skip semantics

`Push` is the only point where an adapter can fail meaningfully. Everything else routes into `Result` fields without returning an error:

<ResponseField name="Name" type="string">
The adapter's `Name()` — always populated.
</ResponseField>

<ResponseField name="Skipped" type="bool">
True for benign no-ops: CLI not installed, host filter empty, all cookies invalid.
</ResponseField>

<ResponseField name="SkippedReason" type="string">
`"CLI not installed"`, `"no matching cookies"`, or `"all cookies failed validation"`.
</ResponseField>

<ResponseField name="Pushed" type="int">
Count of validated cookies handed to `Push`. Zero on skip or error.
</ResponseField>

<ResponseField name="Invalid" type="int">
Count of cookies dropped by `Validate` before `Push`. Non-zero is interesting (a source pushing garbage) but does not itself fail the adapter.
</ResponseField>

<ResponseField name="Err" type="error">
Non-nil only when `Push` returned an error. `Result.OK()` is true for success and skip; false only here.
</ResponseField>

## Design constraints to respect

<Warning>
Do not call `Validate`, `filterByHostPatterns`, or maintain your own host filter inside `Push`. The runtime does both before `Push` is reached, and a second pass risks divergence between `Result.Pushed` and the adapter's actual write count.
</Warning>

<Warning>
Do not panic from `Push`. `runOne` does not recover. A panic in one adapter aborts the rest of the sweep for that sync cycle.
</Warning>

<Tip>
Write file outputs atomically. Every built-in uses the `atomicWriteFile(path, body, 0o600)` helper in `adapter_pycookiecheat.go` — temp file plus rename, mode 0600. Concurrent reads by the target CLI must see either the prior state or the new state, never a half-written intermediate.
</Tip>

<Tip>
Empty-value cookies should be dropped, not written. The pycookiecheat-style header builder and the table-reservation per-cookie loop both skip `c.Value == ""` — those cookies carry no auth signal and downstream parsers treat the empty value as a delete that clobbers good session state.
</Tip>

<Note>
The user-facing YAML schema for runtime-registered adapters in `~/.config/agentcookie/sink.yaml` is not yet shipped — new adapters live in the agentcookie source tree and require a rebuild + redeploy of the sink LaunchAgent binary.
</Note>

## Related pages

<CardGroup cols={2}>
  <Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
    Where adapter outputs fit alongside Chrome Safe Storage and the plaintext sidecar.
  </Card>
  <Card title="Universal cookie delivery" href="/universal-delivery">
    The unmodified-Chrome read path that adapters complement.
  </Card>
  <Card title="doctor and adapter verification" href="/doctor-health-checks">
    `wizard verify-adapters`, the `DoctorReport` envelope, and exit codes for agent consumption.
  </Card>
  <Card title="v0.11 adapter cookie-push runbook" href="/troubleshooting">
    Common adapter failure modes and recovery steps after a sync.
  </Card>
</CardGroup>

---

## 14. Drive install from an agent

> Using the bundled Claude Code skill to install agentcookie on source + sink unattended: required inputs, the SSH-driven flow, success signals, and error recovery.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/14-drive-install-from-an-agent.md
- Generated: 2026-06-01T03:10:25.554Z

### Source Files

- `skill/SKILL.md`
- `skill/prompts/install-on-both-machines.md`
- `internal/cli/wizard.go`
- `internal/cli/wizard_verify_adapters.go`

---
title: "Drive install from an agent"
description: "Using the bundled Claude Code skill to install agentcookie on source + sink unattended: required inputs, the SSH-driven flow, success signals, and error recovery."
---

The repo ships a Claude Code skill at `skill/SKILL.md` (name `agentcookie-install`, version `0.2.0`) that an AI coding agent — Claude Code, OpenClaw, Hermes, Codex, Cursor, or any agent that runs local shell + SSH — uses to install agentcookie on both the source (laptop) and sink (Mac mini / cloud VM / second Mac) in a single user prompt. The skill orchestrates two `agentcookie wizard install` invocations, one local and one over SSH, and hands the pairing handshake between them through `~/.agentcookie/pairing.json`. End-to-end run takes about 30 seconds; the user does not need to touch the sink's screen or answer Keychain prompts on it.

## When the skill triggers

The skill's frontmatter `description` is what tells the agent harness when to invoke it:

> Install agentcookie on the user's source (laptop) and sink (Mac mini / cloud VM / second Mac) machines and pair them so Chrome cookies sync continuously over their Tailscale tailnet. Use when the user says "install agentcookie", "set up cookie sync", "share my Chrome sessions with my Mac mini", or "make my agent log in as me".

The companion file `skill/prompts/install-on-both-machines.md` is the prompt template the user pastes into the agent:

> Install agentcookie on this laptop and my Mac mini so my Chrome sessions sync continuously. Use Tailscale to find the Mac mini. Confirm with me which machine is the source and which is the sink, then run the full install end to end. After install, verify both daemons are running and tell me what you see.

## Required inputs

The agent must have all three before it starts. The skill instructs the agent to stop and ask if any are missing.

| Input | Purpose | How the agent learns it |
| --- | --- | --- |
| `source` hostname | Machine the user logs into Chrome on (usually the laptop running the agent). | Current host, confirmed against `tailscale status` output (the "active" / top entry). |
| `sink` hostname | Machine where AI agents act and need synced cookies. | Other macOS entries in `tailscale status`, confirmed back with the user. |
| Passwordless SSH from source to sink | The skill SSHes from the laptop to the sink to run the sink-side wizard. | `ssh -o ConnectTimeout=5 -o BatchMode=yes <sink> whoami`. |

Tailscale must be up on both sides (the wizard refuses to bind on `0.0.0.0` and requires a `100.x` address; see `validateListenAddr` in `internal/cli/wizard.go`).

## SSH-driven flow

<Steps>

<Step title="Detect the lay of the land">

The skill instructs the agent to probe the current host:

```bash
which agentcookie 2>/dev/null || echo "missing"
/Applications/Tailscale.app/Contents/MacOS/Tailscale status 2>&1 | head -20
ssh -o ConnectTimeout=5 -o BatchMode=yes <suspected-sink> 'whoami' 2>&1
```

The current host is whichever entry is marked "active" or appears first in `tailscale status`. Every other macOS entry is a candidate sink.

</Step>

<Step title="Confirm source vs sink with the user">

The skill requires a blocking confirmation before any install runs:

> I see you're on `<current-hostname>`. Looks like `<other-hostname>` (Tailscale IP `100.x.y.z`) is your other Mac. Should I install agentcookie with `<current-hostname>` as the source (your logged-in Chrome) and `<other-hostname>` as the sink (where your agents run)?

</Step>

<Step title="Install on the source in the background">

```bash
go install github.com/mvanhorn/agentcookie/cmd/agentcookie@latest
agentcookie wizard install --as source \
  --peer <sink-hostname> \
  --local-name <source-hostname> &
WIZARD_PID=$!
```

The source wizard blocks until the sink completes the pairing handshake, so the agent runs it in the background and polls.

</Step>

<Step title="Read pairing info from ~/.agentcookie/pairing.json">

The wizard's `pairingInfoWriter` intercepts the pairing announcement and writes a JSON sibling file at `~/.agentcookie/pairing.json` (mode `0600`) the moment the code is generated:

```json
{
  "code": "BASE32CODE",
  "peer": "<source-hostname>",
  "pair_url": "http://100.x.y.z:9998/pair",
  "sink_run": "agentcookie wizard install --as sink --peer <source-hostname> --code <code> --pair-url http://100.x.y.z:9998/pair"
}
```

The agent polls for the file (up to 10 seconds, every 250 ms) and parses out `code` and `pair_url`. The wizard removes the file again on successful pairing.

</Step>

<Step title="Install on the sink over SSH">

```bash
ssh <sink-hostname> "go install github.com/mvanhorn/agentcookie/cmd/agentcookie@latest && \
  agentcookie wizard install --as sink \
    --peer <source-hostname> \
    --code <code-from-pairing.json> \
    --pair-url <pair_url-from-pairing.json> \
    --local-name <sink-hostname>"
```

The sink-side `wizard install`:

1. Writes `~/.config/agentcookie/sink.yaml` with a tailnet listen address resolved via `tsclient.RequireTailnetIP`.
2. Attempts the one-password universal Chrome Safe Storage open (set `AGENTCOOKIE_LOGIN_PASSWORD=…` to keep it fully non-interactive over SSH). On a non-interactive box with no password available, the install **non-fatally downgrades to degraded mode** (`skip_chrome_sqlite: true` + CDP-managed Chrome), so the daemon still starts.
3. Runs the X25519 + HKDF-SHA256 pairing handshake against the source's `pair_url` and saves the per-peer key to `~/.config/agentcookie/keys/<peer>.json`.
4. Installs `dev.agentcookie.sink` via `launchctl` and starts the daemon.

</Step>

<Step title="Verify both daemons">

```bash
launchctl list | grep dev.agentcookie
ssh <sink-hostname> 'launchctl list | grep dev.agentcookie'
```

Each side reports `dev.agentcookie.source` (laptop) or `dev.agentcookie.sink` (sink) with a numeric PID.

</Step>

<Step title="Verify a real sync round-trip">

```bash
agentcookie status --json
ssh <sink-hostname> 'agentcookie status --json'
```

Source state should show `source_state.last_push` within seconds of a Chrome write. Sink state should show `sink_state.last_write` and `total_writes > 0`. If both are zero, opening a tab on the source's Chrome (any allowlisted domain) forces a write within ~2 seconds.

</Step>

</Steps>

## Success signals an agent can parse

The agent does not screen-scrape human prose. It checks structured signals.

<ResponseField name="~/.agentcookie/pairing.json" type="file (transient)">
Exists for a few seconds during source-side pairing. Contains `code`, `peer`, `pair_url`, `sink_run`. Removed by the wizard on successful handshake.
</ResponseField>

<ResponseField name="~/.config/agentcookie/keys/<peer>.json" type="file (persistent)">
Per-peer key written on successful pairing. Presence means the handshake completed; the file mode is `0600`.
</ResponseField>

<ResponseField name="launchctl list | grep dev.agentcookie" type="string">
A populated line with a non-`-` PID means the LaunchAgent (`dev.agentcookie.source` or `dev.agentcookie.sink`) is bootstrapped and the daemon process is alive.
</ResponseField>

<ResponseField name="agentcookie status --json" type="JSON envelope">
Top-level fields: `version`, `config_dir`, `source_config`, `sink_config`, `allowlist`, `source_state`, `sink_state`, `errors`. The agent watches `source_state.last_push` (recent timestamp) and `sink_state.last_write` / `sink_state.total_writes` (non-zero) to declare a real round-trip.
</ResponseField>

<ResponseField name="agentcookie --json wizard verify-adapters" type="JSON envelope">
Reads `~/.agentcookie/sink-state.json` and emits `{status, results: [...]}`. `status` is one of `ok`, `no_state`, `no_runs`. Each result names a registered v0.11 adapter and reports `Err`, `Skipped`, `SkippedReason`, `Pushed`, `RanAt`. Exit code `1` if any adapter failed (informational; sink does not abort on adapter failures).
</ResponseField>

## Reporting back to the user

After verification the skill instructs the agent to give a plain-language summary like:

> Done. agentcookie is running on both `<source>` and `<sink>`. The source pushes cookies as soon as they change in Chrome on `<source>`; the sink writes them into a dedicated Chrome instance at `~/.agentcookie/chrome-profile` on `<sink>`. Your agents on `<sink>` connect to that Chrome via CDP at `~/.agentcookie/chrome-profile`. After this install, the user does not run agentcookie commands by hand again.

## Error recovery

The skill enumerates the failure modes the agent must recognize and remediate without escalating to the user when possible.

<AccordionGroup>

<Accordion title="agentcookie: command not found on the sink">
After `go install` on the sink, `~/go/bin` is not on the non-interactive SSH `$PATH`. Either source the shell rc (`ssh <sink> 'source ~/.zshrc && agentcookie ...'`) or invoke by absolute path (`~/go/bin/agentcookie ...`). If Go is not installed at all, fix with `brew install go` on the sink, or `scp` a prebuilt binary from the laptop.
</Accordion>

<Accordion title="Sink pairing returns connection refused">
Tailscale ACLs are blocking tailnet-internal traffic. Default Tailscale ACLs allow everything between a user's own devices; custom ACLs must permit ports **9998 (pairing)** and **9999 (sync)**. Confirm with `tailscale status` that the source is reachable.
</Accordion>

<Accordion title="Sink wizard hangs at 'Chrome did not publish DevToolsActivePort'">
The managed Chrome subprocess failed to start. Most likely: Google Chrome is not installed at `/Applications/Google Chrome.app`. Install Chrome, or set `cdp.chrome_binary` in `sink.yaml`.
</Accordion>

<Accordion title="agentcookie status reports zero pushes after install">
The source watcher has not seen a Chrome write yet. Open a tab on the source's Chrome (any allowlisted domain) and refresh — push should appear in `source_state.last_push` within 2 seconds.
</Accordion>

<Accordion title="Sink Chrome subprocess crashes repeatedly">
Inspect `~/.agentcookie/logs/sink.err.log`. Most common cause: stale `SingletonLock` from a prior Chrome session sharing the user-data-dir. Remediation: `rm ~/.agentcookie/chrome-profile/SingletonLock` and let the supervisor restart.
</Accordion>

<Accordion title="Universal keychain open did not complete (headless SSH)">
On a sink with no GUI session and no `AGENTCOOKIE_LOGIN_PASSWORD` env var, the inline partition-list open fails. The wizard **does not abort** — it downgrades to degraded delivery (sidecar + adapter push + CDP-managed Chrome) and prints a one-line upgrade instruction:

```bash
AGENTCOOKIE_LOGIN_PASSWORD=… agentcookie wizard set-keychain-access
```

After that, the sink reads the real Default Chrome profile and any unmodified cookie tool on the sink sees the synced cookies.
</Accordion>

</AccordionGroup>

The wizard is idempotent: existing configs, keys, and LaunchAgents are detected and reused unless `--repair` or `--force` is passed. After fixing a failure, the agent can re-paste the prompt and the install will pick up where it left off.

## What the skill does not do

Stated out-of-scope in `skill/SKILL.md`:

- Code-signing the binary so Keychain access is granted without any prompt.
- Web Store extension install (planned for v0.3).
- Linux sink support (planned for v0.3).
- Bidirectional sync (planned for v0.3).
- Adding new allowlist domains after install — the user edits `~/.config/agentcookie/allowlist.yaml` on each side; LaunchAgents pick up the change on next restart (every 10 seconds after a config save).

## Flow at a glance

```text
laptop (source)                                        Mac mini (sink)
──────────────                                         ────────────────
agent prompt                                                 │
    │                                                        │
    ├─ which agentcookie / tailscale status / ssh probe ─────│
    │  AskUser: "source=<a>, sink=<b>?"                      │
    │                                                        │
    ├─ go install ...@latest                                 │
    ├─ agentcookie wizard install --as source &              │
    │       │                                                │
    │       └─ writes ~/.agentcookie/pairing.json            │
    │           { code, pair_url, sink_run, peer }           │
    │                                                        │
    ├─ poll pairing.json (up to 10s, 250 ms)                 │
    │                                                        │
    └─ ssh <sink> "go install ...@latest &&                  │
                   agentcookie wizard install --as sink      │
                     --peer <source> --code … --pair-url …" ─►
                                                             ├─ drop sink.yaml (+ allowlist)
                                                             ├─ universal keychain open
                                                             │     (or non-fatal downgrade)
                                                             ├─ X25519 + HKDF handshake ◄─── http://<source>:9998/pair
                                                             ├─ keys/<source>.json
                                                             └─ launchctl bootstrap dev.agentcookie.sink

verify on both sides:
  launchctl list | grep dev.agentcookie
  agentcookie status --json        →  source_state.last_push   sink_state.last_write
  agentcookie --json wizard verify-adapters  →  {status:"ok", results:[…]}
```

## Related pages

<CardGroup>
  <Card title="Headless second-Mac install" href="/headless-install">
    Manual SSH-only install path the skill is built on, with the one-password Safe Storage open and the `wizard set-keychain-access` upgrade.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    Five-minute laptop + second-Mac pairing done by hand, useful when the agent flow needs debugging.
  </Card>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">
    The X25519 + HKDF-SHA256 handshake, the base32 pairing code, and what gets written to `keys/<peer>.json`.
  </Card>
  <Card title="doctor and adapter verification" href="/doctor-health-checks">
    Deeper post-install checks and the `DoctorReport` JSON envelope for agent consumption.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Full failure-mode catalog the skill's error-recovery section is a subset of.
  </Card>
  <Card title="LaunchAgent management" href="/launchagent-management">
    What `dev.agentcookie.source` / `dev.agentcookie.sink` look like on disk, log paths, and lifecycle.
  </Card>
</CardGroup>

---

## 15. CLI reference

> Every `agentcookie` subcommand and its flags: `source`, `sink`, `pair`, `wizard`, `doctor`, `status`, `secret`, `discover`, `cookies`, `version`.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/15-cli-reference.md
- Generated: 2026-06-01T03:12:01.744Z

### Source Files

- `internal/cli/root.go`
- `internal/cli/source.go`
- `internal/cli/sink.go`
- `internal/cli/pair.go`
- `internal/cli/wizard.go`
- `internal/cli/secret.go`
- `internal/cli/cookies.go`
- `internal/cli/discover.go`

---
title: "CLI reference"
description: "Every `agentcookie` subcommand and its flags: `source`, `sink`, `pair`, `wizard`, `doctor`, `status`, `secret`, `discover`, `cookies`, `version`."
---

`agentcookie` is a single cobra-rooted binary defined in `internal/cli/root.go`. The root command registers ten subcommands — `source`, `sink`, `pair`, `status`, `version`, `wizard`, `doctor`, `secret`, `discover`, `cookies` — plus a hidden `internal` group. Two persistent flags apply to every invocation, and every subcommand that emits structured output respects `--json`.

## Persistent flags

These are declared on `rootCmd` and apply to every subcommand.

<ParamField body="--config-dir" type="string" default="~/.config/agentcookie">
  Directory holding `source.yaml`, `sink.yaml`, `allowlist.yaml`, `blocklist.yaml`, and the paired-key directory `keys/<peer>.json`.
</ParamField>

<ParamField body="--json" type="bool" default="false">
  Emit a machine-readable JSON document where the subcommand supports it (`source --once`, `status`, `doctor`, `secret list`, `discover`, `version`, `cookies`, `wizard verify-adapters`).
</ParamField>

## Subcommand inventory

| Subcommand | Purpose |
| --- | --- |
| `source` | Read allowlisted cookies + secrets and push to the sink (`--once` or `--watch`). |
| `sink` | Long-lived HTTP listener that decrypts envelopes and writes Chrome state. |
| `pair` | X25519 + HKDF-SHA256 pairing handshake; writes `keys/<peer>.json`. |
| `wizard` | One-command install/uninstall and operational subcommands (`set-keychain-access`, `verify-adapters`). |
| `doctor` | Runs 15 health checks and emits `DoctorReport` JSON. |
| `status` | Prints loaded config + last-known daemon state. |
| `secret` | Manage the per-CLI secrets bus (`list`, `get`, `set`, `rm`, `env`, `alias`, `import-from`, `revoke`). |
| `discover` | List adopted projects from the v2 secrets-bus registry. |
| `cookies` | Print synced cookies for a domain from the local sidecar; keychain-free. |
| `version` | Print the linker-injected version string. |

## source

```text
agentcookie source --once    # one read+push cycle, then exit
agentcookie source --watch   # long-lived fsnotify watcher (LaunchAgent default)
```

Exactly one of `--once` or `--watch` is required. `--watch` debounces Chrome `Cookies` writes by 500 ms, rate-caps pushes at one per 2 seconds, and additionally watches `~/.agentcookie/secrets/` and the v2 discovery roots so a new `secrets.env` or a freshly discovered manifest fires the same push pipeline.

<ParamField body="--once" type="bool">
  Single read + filter + push cycle. Bound by the `SyncClient` timeout plus a 30 s slack.
</ParamField>
<ParamField body="--watch" type="bool">
  Long-running fsnotify watcher across Chrome Cookies, Local Storage, IndexedDB, the secrets bus, and the v2 discovery roots.
</ParamField>
<ParamField body="--verbose" type="bool">
  Log per-pattern decisions and DBSC detail to stderr.
</ParamField>
<ParamField body="--dry-run" type="bool">
  Read + filter but do not contact the sink.
</ParamField>
<ParamField body="--skip-dbsc-suspect" type="bool">
  Drop cookies that look device-bound (DBSC) instead of shipping them with a warning. Also honored via `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1`.
</ParamField>

Environment toggles read by `source`:

- `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1` — same as `--skip-dbsc-suspect`.
- `AGENTCOOKIE_SYNC_INDEXEDDB=1` — opt in to packing the IndexedDB tarball (off by default; per-file 5 MiB cap with skipped-list reporting).

`--json` (root flag) emits the per-push result map:

```json
{
  "cookies_read": 1240,
  "cookies_blocked": 12,
  "cookies_passing": 1228,
  "cookies_dbsc_warned": 0,
  "cookies_dbsc_skipped": 0,
  "secrets_clis": 3,
  "dry_run": false,
  "sink_url": "http://mini:9999/sync",
  "posted": true,
  "sink_response": "ok: wrote ...",
  "sink_status": 200
}
```

## sink

```text
agentcookie sink              # long-lived HTTP listener
agentcookie sink --dry-run    # accept + decrypt only; dump batches to stderr
```

The sink reads `sink.yaml`, validates `listen.addr` against the v0.12 binding policy (tailnet 100.x or an explicit loopback; `0.0.0.0` is rejected), and serves two HTTP routes:

- `GET /healthz` → `ok`.
- `POST /sync` → accepts an AES-256-GCM-sealed `SyncEnvelope`, runs sequence/blocklist/version checks, then either writes Chrome SQLite + LevelDB + IndexedDB (legacy path) or sidecar-only (`skip_chrome_sqlite: true`), runs registered `sinkpush` adapters, optionally invokes CDP injection, and persists secrets-bus payloads to `~/.agentcookie/secrets/<cli>/`.

<ParamField body="--dry-run" type="bool">
  Skip Chrome Safe Storage, SQLite, LevelDB, IndexedDB, sidecar, and adapter writes. Each accepted batch is dumped to stderr as JSON. Useful for debugging the wire format and for running the sink over SSH without the GUI Keychain prompt.
</ParamField>

The sink does not accept `--listen` on the command line; the bind address is fixed in `sink.yaml` so that the wizard's resolved tailnet address is the durable record.

<Warning>
The sink refuses to start when `sink.yaml`'s `listen.addr` is `0.0.0.0`, `::`, an empty host, or a non-tailnet/non-loopback IP. Run `tailscale status` for a 100.x address or re-run `agentcookie wizard install --as sink`.
</Warning>

## pair

```text
agentcookie pair --as source        # on the source machine
agentcookie pair --as sink \\
  --peer <source-hostname> \\
  --pair-url http://<source>:9998/pair \\
  --code <code>                     # on the sink machine
```

`pair` performs the X25519 + HKDF-SHA256 handshake and writes the derived 32-byte key to `<config-dir>/keys/<peer>.json` with mode 0600. The same peer hostname must also appear in `source.yaml`/`sink.yaml` `peer.hostname`, so the sync runtime looks up the key by filename.

<ParamField body="--as" type="enum" required>
  `source` or `sink`. Required.
</ParamField>
<ParamField body="--listen" type="host:port">
  Source-only. Empty triggers tailnet auto-detection (`100.x:9998`). Explicit values must satisfy the tailnet-or-loopback policy; `0.0.0.0` is refused.
</ParamField>
<ParamField body="--local-name" type="string" default="os.Hostname()">
  Hostname this side announces to the peer.
</ParamField>
<ParamField body="--pair-url" type="url">
  Sink-only. Full URL of the source's `/pair` endpoint.
</ParamField>
<ParamField body="--code" type="string">
  Sink-only. Base32 pairing code printed by the source.
</ParamField>
<ParamField body="--peer" type="string">
  Sink-only. Source machine's hostname; also the filename for the derived key.
</ParamField>

## wizard

`wizard` is a subcommand group. Four child commands ship:

| Command | Purpose |
| --- | --- |
| `install` | Drop configs, run the pair handshake, install a LaunchAgent. |
| `uninstall` | Remove the LaunchAgent; optional `--purge` deletes configs and paired keys. |
| `set-keychain-access` | Grant agentcookie + named binaries access to Chrome Safe Storage. |
| `verify-adapters` | Print the most recent `sinkpush` adapter results from `sink-state.json`. |

### wizard install

```text
agentcookie wizard install --as source --peer <sink-hostname>
agentcookie wizard install --as sink   --peer <source-hostname> \
                                       --code <pairing-code>    \
                                       --pair-url <source-pair-url>
```

<ParamField body="--as" type="enum" required>
  `source` or `sink`.
</ParamField>
<ParamField body="--peer" type="string" required>
  The OTHER machine's hostname.
</ParamField>
<ParamField body="--listen" type="host:port">
  Source-only pairing listener bind address. Empty → tailnet auto-detect.
</ParamField>
<ParamField body="--local-name" type="string" default="os.Hostname()">
  Hostname this side announces.
</ParamField>
<ParamField body="--sink-url" type="url">
  Source-only override for the sink URL (default `http://<peer>:9999/sync`).
</ParamField>
<ParamField body="--code" type="string">
  Sink-only pairing code from the source's wizard output.
</ParamField>
<ParamField body="--pair-url" type="url">
  Sink-only source pair URL.
</ParamField>
<ParamField body="--repair" type="bool">
  Force a fresh pairing handshake even if a key already exists.
</ParamField>
<ParamField body="--force" type="bool">
  Overwrite existing `source.yaml`, `sink.yaml`, `blocklist.yaml`. Also reconciles a `peer.hostname` mismatch.
</ParamField>
<ParamField body="--skip-daemon" type="bool">
  Skip installing the LaunchAgent (configs + pairing only).
</ParamField>
<ParamField body="--skip-exit-node-hint" type="bool">
  Do not detect Tailscale or print the `tailscale set --advertise-exit-node` / `--exit-node=` suggestions.
</ParamField>
<ParamField body="--skip-keychain-prompt" type="bool">
  Sink-only. Do not trigger the Chrome Safe Storage Keychain prompt during install; the sink daemon prompts on first sync instead.
</ParamField>
<ParamField body="--skip-partition-list" type="bool">
  Sink-only. Do not expand the Chrome Safe Storage Keychain partition list.
</ParamField>
<ParamField body="--skip-keychain-access" type="bool">
  Sink-only. Do not run the v0.10 `set-keychain-access` strategies.
</ParamField>
<ParamField body="--skip-bridge-hint" type="bool">
  Sink-only. Do not print the cookie-bridge env-var integration hint.
</ParamField>
<ParamField body="--skip-chrome-sqlite" type="bool">
  Sink-only opt-out of universal delivery: the sink never reads Chrome Safe Storage or writes Chrome SQLite/leveldb. Forces degraded mode and overrides `--write-chrome-sqlite` when both are passed.
</ParamField>
<ParamField body="--write-chrome-sqlite" type="bool">
  Sink-only. Force universal delivery and honor it even if the keychain open cannot complete; does not silently downgrade.
</ParamField>
<ParamField body="--no-cdp" type="bool">
  Sink-only. Do not enable CDP injection alongside `skip_chrome_sqlite`. Default headless installs enable CDP injection.
</ParamField>

The v0.13 default for `wizard install --as sink` is universal delivery regardless of TTY. The keychain open runs before `sink.yaml` is rendered; on a default install where the open fails, the wizard non-fatally downgrades to degraded (skip + CDP) and prints the one-line upgrade instruction.

### wizard uninstall

```text
agentcookie wizard uninstall --as source [--purge]
agentcookie wizard uninstall --as sink   [--purge]
```

<ParamField body="--as" type="enum" required>
  `source` or `sink`. Removes the matching LaunchAgent plist via `launchctl`.
</ParamField>
<ParamField body="--purge" type="bool">
  Also delete `source.yaml`, `sink.yaml`, `allowlist.yaml`, and the `keys/` directory.
</ParamField>

### wizard set-keychain-access

Broadens Chrome Safe Storage access so unmodified cookie tools and kooky-CGO PP CLIs read without per-binary prompts.

```text
agentcookie wizard set-keychain-access                 # default: inline one-password partition
AGENTCOOKIE_LOGIN_PASSWORD=… agentcookie wizard set-keychain-access   # non-interactive
agentcookie wizard set-keychain-access --recreate      # legacy LaunchAgent strategy chain
agentcookie wizard set-keychain-access --any-app       # delete-and-recreate with -A
agentcookie wizard set-keychain-access --extra-binary /Users/x/go/bin/foo-pp-cli
```

<ParamField body="--extra-binary" type="path[]">
  Absolute path to a kooky-using CLI binary; added to the trust-list fallback. Repeatable.
</ParamField>
<ParamField body="--any-app" type="bool">
  Recreate Chrome Safe Storage with `-A` (any application). Preserves the existing key value. Security: any local process can then read Chrome cookies.
</ParamField>
<ParamField body="--recreate" type="bool">
  Use the legacy LaunchAgent delete-and-recreate trust-list strategy chain instead of the inline one-password partition default.
</ParamField>

Hidden flags retained for the wizard and tests: `--inner-runner` (runs the strategy loop in this process when invoked from a one-shot LaunchAgent) and `--enable-sealing` (creates the `agentcookie-master` Keychain item used by sidecar / adapter writers).

`AGENTCOOKIE_LOGIN_PASSWORD=…` substitutes for the interactive login-password prompt so the command runs cleanly over SSH.

### wizard verify-adapters

Prints one row per registered `sinkpush` adapter from `~/.agentcookie/sink-state.json`. With the root `--json` flag, emits an envelope of `state.AdapterResult`s and a `status` of `ok`, `no_state`, or `no_runs`. Exits non-zero only when at least one recorded adapter result has a non-empty `err`.

## doctor

```text
agentcookie doctor          # human output
agentcookie doctor --json   # DoctorReport envelope
```

`doctor` runs 15 checks in order. Each row carries a `Severity` of `ok`, `warn`, `fail`, `info`, or `skipped`. Exit code is `0` only when no check is `fail`.

| # | Name | Notes |
| --- | --- | --- |
| 1 | Binary signature | Looks for Developer ID team `NM8VT393AR` via `codesign -d -r-`. |
| 2 | Tailscale | Requires a tailnet 100.x address; FAIL otherwise. |
| 3 | Config | Parses `source.yaml` and/or `sink.yaml`. |
| 4 | Keystore | Confirms `keys/<peer>.json` exists with mode 0600. |
| 5 | Sink listener | Tries to bind the configured `listen.addr`; bind success = sink not running = FAIL. |
| 6 | Sink state | Freshness of `sink-state.json`. |
| 7 | Source state | Freshness of `source-state.json`. |
| 8 | DBSC | Count of DBSC-suspect cookies in the last push. |
| 9 | Sealing | Reports presence of the `agentcookie-master` Keychain item. |
| 10 | Adapter coverage | Sidecar host_keys not matched by any registered adapter. |
| 11 | CDP injector | Verifies `cdp.profile_dir` exists and Chrome.app is installed. |
| 12 | Secrets bus | Counts CLIs, keys, sealed/plaintext mode, newest mtime. |
| 13 | Secret coverage | Flags CLIs whose synced store does not provide the declared auth env var. |
| 14 | Binary install | Detects multiple diverging `agentcookie` binaries on the machine. |
| 15 | Cookie delivery | Reports universal vs degraded delivery and probes Chrome Safe Storage. |

### DoctorReport (`--json`)

```json
{
  "version": "0.13.0",
  "exit_code": 0,
  "checks": [
    {
      "name": "Tailscale",
      "severity": "ok",
      "detail": "100.64.0.5 reachable on local tailnet interface"
    }
  ]
}
```

`Severity` field constants: `ok`, `warn`, `fail`, `info`, `skipped`. `Remediation` is populated for `warn`/`fail` rows and rendered as a hanging line in the human output.

## status

```text
agentcookie status
agentcookie status --json
```

Loads `source.yaml`, `sink.yaml`, `allowlist.yaml`, and the last-known `source-state.json` + `sink-state.json` and prints a compact summary. Parse errors land in the JSON envelope's `errors` array and are echoed to stderr in human mode. The JSON envelope shape:

```json
{
  "version": "...",
  "config_dir": "/Users/x/.config/agentcookie",
  "source_config": { "...": "..." },
  "sink_config":   { "...": "..." },
  "allowlist":     { "...": "..." },
  "source_state":  { "...": "..." },
  "sink_state":    { "...": "..." },
  "errors":        []
}
```

## secret

`secret` manages the per-CLI secrets bus under `~/.agentcookie/secrets/<cli-name>/`. CLI names match `[a-z0-9-]{1,64}` (no leading/trailing dash); keys match identifier syntax (`[A-Za-z_][A-Za-z0-9_]*`).

| Subcommand | Arguments | Behavior |
| --- | --- | --- |
| `list` | — | Print every registered CLI and its key names (no values). `--json` returns `[{name, keys[]}]`. |
| `get` | `<cli> <key>` | Print the value to stdout. Sealed twin (`secrets.env.sealed`) wins over plaintext. |
| `set` | `<cli> <key>` | Read value from stdin (pipe) or prompt (TTY); rewrites `secrets.env` atomically with mode 0600. |
| `rm` | `<cli> [<key>]` | Remove a single key or the whole CLI directory. |
| `env` | `<cli>` | Print all keys as `KEY=VALUE` lines, with `aliases.env` and manifest aliases applied. Suitable for `eval $(agentcookie secret env <cli>)`. |
| `alias` | `<cli> [<declared> <stored>]` | Map a consumer's declared env var to a stored key. List with one arg. Resolved live by `secret env`. |
| `import-from` | `<path> --as <cli>` | Ingest a JSON, TOML, or env file, applying canonical field-name heuristics. |
| `revoke` | `<name> [--force]` | Undo project adoption based on its `RegisteredProject.Kind`. |

### secret import-from

<ParamField body="--as" type="string" required>
  CLI name to file the imported secrets under.
</ParamField>

Supported extensions: `.env`, `.json`, `.toml`. JSON/TOML are flattened. Field-name canonicalization includes `access_token` → `OAUTH_BEARER`, `refresh_token` → `OAUTH_REFRESH`, `api_key` → `API_KEY`, `client_id` → `OAUTH_CLIENT_ID`, `client_secret` → `OAUTH_CLIENT_SECRET`, `bearer` → `OAUTH_BEARER`, `token` → `TOKEN`, `auth_header` → `AUTH_HEADER`, `(token_)expiry/expires_at` → `OAUTH_EXPIRES_AT`, `base_url` → `BASE_URL`. Unknown but env-shaped keys are upper-cased; otherwise prefixed `_unknown_` for review.

### secret revoke

<ParamField body="--force" type="bool">
  Skip the confirmation prompt when deleting an explicit-manifest project's `agentcookie.toml`.
</ParamField>

Behavior depends on the discovered adoption tier:

- `explicit-manifest` — deletes the manifest file (requires `--force`).
- `pp-cli-derived` — prints the `sync.default = false` manifest stub to drop; never touches the PP CLI itself.
- `legacy-v1` — `rm -rf` of the bus directory under `~/.agentcookie/secrets/<name>/`.

## discover

```text
agentcookie discover
agentcookie discover -v       # include skipped manifests + discovery errors
agentcookie discover --json
```

<ParamField body="--verbose, -v" type="bool">
  Include skipped manifests with the reason and any discovery errors.
</ParamField>

Human output is a `tabwriter` table with columns `NAME`, `TIER`, `READ-IN-PLACE`, `KEYS`, `COVERAGE`. CLIs whose synced secrets do not match the declared auth env var print a `MISMATCH` coverage status and a follow-up block listing the gaps. `--json` emits:

```json
{
  "projects": [
    {
      "name": "tesla-pp-cli",
      "kind": "explicit-manifest",
      "source_path": "/Users/x/.agentcookie/manifests/tesla-pp-cli.toml",
      "read_in_place_path": "/Users/x/.config/tesla-pp-cli/auth.json",
      "display_name": "Tesla PP CLI",
      "key_count": 4,
      "sync_summary": "default=true, 0 overrides",
      "coverage": "OK"
    }
  ],
  "skipped": []
}
```

`tier` values are the `secretsbus.RegisteredProject.Kind` strings: `explicit-manifest`, `pp-cli-derived`, `legacy-v1`.

## cookies

```text
agentcookie cookies --domain .amazon.com
agentcookie cookies --domain .amazon.com --json
```

<ParamField body="--domain" type="string" required>
  Cookie domain to fetch. A leading dot is stripped; both `amazon.com` and `www.amazon.com` host_keys match `.amazon.com`, but `evilamazon.com` never does.
</ParamField>

Reads the local plaintext sidecar (`~/.agentcookie/cookies-plain.db`) via `pkg/sidecar.ReadSidecar`, which transparently unseals `agc1:` values when sealing is on. Applies the local `blocklist.yaml` so a blocked domain never leaks out. Default output is a single-line `Cookie:`-header-shaped string (`name=value; name=value`). With `--json`, an array of `{name, value, domain, path, secure}`.

A missing sidecar is not an error — `cookies` exits 0 with empty output so a caller can fall through to its own auth path.

## version

```text
agentcookie version
agentcookie version --json   # {"version":"..."}
```

The version string is set via `-ldflags '-X github.com/mvanhorn/agentcookie/internal/cli.Version=…'` at link time and defaults to `0.0.1-dev` for `go run`/dev builds.

## Hidden commands

`agentcookie internal` is hidden from `--help`. The only documented child is `internal keychain-probe`, which calls Chrome Safe Storage via the same kooky-CGO Keychain API path and prints `ok len=N` or `fail: <error>`. It accepts `--timeout-seconds <int>` (default 5).

## Common error and exit semantics

| Surface | Failure | Behavior |
| --- | --- | --- |
| `source` / `sink` | `0.0.0.0` or non-tailnet listen address | Refuses to start; prints the v0.12 binding policy message. |
| `source` | No paired key, no `security.shared_secret` | Errors with `no transport credential available`. |
| `sink` | `POST /sync` fails AES-GCM open | Responds `401`. |
| `sink` | Envelope protocol version out of range | Responds `400`. |
| `sink` | Sequence ≤ last seen for source | Responds `409` (replay defense). |
| `doctor` | Any check is `fail` | Exit code 1; remediation lines are printed under each non-OK row. |
| `wizard verify-adapters` | Any adapter recorded `err != ""` | Exit code 1. |

## Related pages

<CardGroup>
  <Card title="Configuration files reference" href="/config-files-reference">
    Schemas for `source.yaml`, `sink.yaml`, `allowlist.yaml`, and `blocklist.yaml`, including the tailnet-only listen check.
  </Card>
  <Card title="doctor and adapter verification" href="/doctor-health-checks">
    Full check categories, `DoctorReport` JSON shape, and `wizard verify-adapters` semantics.
  </Card>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">
    Details on the X25519 + HKDF-SHA256 handshake `pair` performs and the on-disk key format.
  </Card>
  <Card title="LaunchAgent management" href="/launchagent-management">
    What `wizard install` writes under `~/Library/LaunchAgents/` and how the daemons are bootstrapped.
  </Card>
  <Card title="Secrets bus" href="/secrets-bus">
    What the `secret`, `discover`, and source-side bus push pipelines move between peers.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Recovery paths for pairing `connection refused`, sink Keychain prompts, stale `SingletonLock`, DBSC drops, and other CLI surfaces.
  </Card>
</CardGroup>

---

## 16. Configuration files reference

> Schemas and validation rules for `source.yaml`, `sink.yaml`, `allowlist.yaml`, and `blocklist.yaml` (SQLite LIKE patterns, tilde expansion, listen-address checks).

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/16-configuration-files-reference.md
- Generated: 2026-06-01T03:11:27.446Z

### Source Files

- `internal/config/config.go`
- `internal/config/allowlist.go`
- `examples/source.yaml`
- `examples/sink.yaml`
- `examples/allowlist.yaml`
- `examples/blocklist.yaml`

---
title: "Configuration files reference"
description: "Schemas and validation rules for `source.yaml`, `sink.yaml`, `allowlist.yaml`, and `blocklist.yaml` (SQLite LIKE patterns, tilde expansion, listen-address checks)."
---

agentcookie reads three YAML files from `~/.config/agentcookie/` (overridable with the `--config-dir` persistent flag): `source.yaml` on the laptop, `sink.yaml` on the second Mac, and an optional `blocklist.yaml` that either side may keep. The loaders live in `internal/config/`, decode with `gopkg.in/yaml.v3` in strict mode (`KnownFields(true)`), expand a leading `~/` in path values via `ExpandTilde`, enforce a 32-byte floor on the legacy `security.shared_secret`, and reject sink `listen.addr` values that are not a Tailscale 100.64.0.0/10 address or an explicit loopback.

## Common loader behavior

All three loaders (`LoadSource`, `LoadSink`, `LoadBlocklist`) share the same plumbing:

- YAML is decoded with `dec.KnownFields(true)`, so unknown top-level keys cause `decode <path>: ...` errors. Use the field names below verbatim.
- Path-shaped fields (`chrome.db_path`, `cdp.profile_dir`) accept a leading `~/`. `ExpandTilde` only rewrites that exact prefix — a bare `~other` is left untouched, absolute paths are passed through, and an empty `db_path` falls back to `DefaultChromeCookiesPath()` (`~/Library/Application Support/Google/Chrome/Default/Cookies`).
- `source.yaml` and `sink.yaml` are required by their owning role; missing → `not found: <path> (start from examples/ in this repo)`.
- `blocklist.yaml` is optional; a missing file yields an empty `Blocklist{Version: 1}` and the sync-all default kicks in.
- `--config-dir` defaults to `~/.config/agentcookie/` and is set on the root cobra command, so every subcommand sees the same directory.

<Note>
`source.yaml`, `sink.yaml`, and `blocklist.yaml` are each independently optional from the binary's point of view — `agentcookie status` reports partial state when only one is present. A role's runtime command (`agentcookie source`, `agentcookie sink`) still requires its own file.
</Note>

## source.yaml

Lives on the machine where Chrome is logged in interactively. Tells the source watcher where to push, which Chrome SQLite to read from, and which key to seal with.

```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

# security:
#   shared_secret: legacy-pre-pair-only-32-bytes-min
```

### Fields

<ParamField body="sink.url" type="string" required>
Full URL of the sink's `/sync` endpoint. Required; an empty value fails with `sink.url is required`. Usually `http://<tailnet-host>:9999/sync`.
</ParamField>

<ParamField body="chrome.db_path" type="string">
Absolute or `~/`-prefixed path to Chrome's `Cookies` SQLite file. Empty falls back to `~/Library/Application Support/Google/Chrome/Default/Cookies`. Set explicitly to read from a non-`Default` profile.
</ParamField>

<ParamField body="peer.hostname" type="string">
Filename (without extension) under `~/.config/agentcookie/keys/`. After `agentcookie pair --as source` writes `<hostname>.json`, this field tells the source watcher which 32-byte AES-GCM key to seal `/sync` payloads with.
</ParamField>

<ParamField body="security.shared_secret" type="string">
Legacy pre-pairing credential. Optional, but at load time **at least one of `peer.hostname` or `security.shared_secret` must be set** or the loader returns `either peer.hostname (paired key) or security.shared_secret (legacy) is required`. When present, the value must be **≥ 32 bytes**; a shorter secret fails with `security.shared_secret must be at least 32 bytes (got N); prefer pairing (`agentcookie pair`) over a typed secret`. Never marshalled into JSON output. Delete the field after pairing.
</ParamField>

## sink.yaml

Lives on the second Mac where agents act. Configures the HTTP listener, the cookie-delivery surfaces (Chrome SQLite, managed-CDP Chrome, sidecar, adapters), and the peer key.

```yaml
listen:
  addr: 100.80.229.80:9999

cdp:
  enabled: true
  managed: true
  # profile_dir: ~/.agentcookie/chrome-profile

# chrome:
#   db_path: ~/Library/Application Support/Google/Chrome/Default/Cookies

peer:
  hostname: my-laptop.tailnet.ts.net

# skip_chrome_sqlite: true   # v0.12.0-beta.3 headless mode
# delivery: universal         # v0.13 wizard intent marker
```

### Fields

<ParamField body="listen.addr" type="string" required>
`host:port` the sink binds its HTTP server to. **Required** since v0.12 — empty fails with `listen.addr is required (run `agentcookie wizard install --as sink` ...)`. The value is enforced twice: by `validateListenAddr` at sink startup and by the same function inside the wizard when `--listen` is passed.
</ParamField>

<ParamField body="chrome.db_path" type="string">
Only set when running in legacy SQLite-write mode (`cdp.enabled: false` and `skip_chrome_sqlite: false`). Tilde-expanded; defaults to the Chrome `Default/Cookies` path when omitted.
</ParamField>

<ParamField body="peer.hostname" type="string">
Source machine's hostname. Used to look up the per-peer key under `~/.config/agentcookie/keys/<hostname>.json`. As with `source.yaml`, either this or `security.shared_secret` must be present.
</ParamField>

<ParamField body="security.shared_secret" type="string">
Legacy fallback; same ≥ 32-byte rule as `source.yaml`.
</ParamField>

<ParamField body="skip_chrome_sqlite" type="bool" default="false">
v0.12.0-beta.3 headless flag. When true, the sink never reads Chrome Safe Storage and never writes Chrome's SQLite/leveldb/indexeddb files; only the sidecar at `~/.agentcookie/cookies-plain.db` and per-CLI adapter session files remain. Designed for SSH-only Mac mini installs where no GUI session can answer the Keychain prompt. Omitted/false keeps the legacy behavior — installed users see no flip on binary upgrade.
</ParamField>

<ParamField body="cdp.enabled" type="bool" default="false">
When true, the sink launches its own Chrome via chromedp after each `/sync` and pushes cookies through `Storage.setCookies`. Chrome owns its own Safe Storage key on this path; agentcookie never reads the macOS Keychain item.
</ParamField>

<ParamField body="cdp.profile_dir" type="string">
Tilde-aware profile directory passed as `--user-data-dir`. Defaults to `~/.agentcookie/chrome-profile` when omitted.
</ParamField>

<ParamField body="delivery" type="string">
v0.13 wizard-intent marker; accepts `universal` (real Default Chrome profile + any-app Keychain open) or `degraded` (the `-T`/`skip_chrome_sqlite` opt-out). `omitempty` — a `sink.yaml` written before this field loads with `Delivery == ""` and unchanged behavior. The flag exists so `agentcookie doctor` can report intent without re-inferring from `skip_chrome_sqlite` plus a Keychain probe.
</ParamField>

### Listen-address policy

`internal/cli/wizard.go:validateListenAddr` is the single source of truth, called by the sink, the pair listener, and the wizard's `--listen` flag.

| Input | Result | Reason |
|---|---|---|
| `0.0.0.0:9999`, `[::]:9999`, `:9999` | Rejected | `refuses to bind on "..." (every interface)` |
| Non-`host:port` strings (e.g. `no-colon-here`) | Rejected | `parse host:port: ...` |
| `192.168.1.5:9999`, `100.63.0.5:9999` | Rejected | `not a Tailscale 100.x address` |
| `127.0.0.1:9999`, `[::1]:9999`, `localhost:9999` | Accepted | Explicit loopback for local dev |
| Any IPv4 in `100.64.0.0/10` (e.g. `100.64.0.1`, `100.80.229.80`, `100.127.255.254`) | Accepted | Tailscale CGNAT range |

`agentcookie wizard install --as sink` calls `tsclient.RequireTailnetIP` to write a concrete 100.x address into `sink.yaml`; `ErrAmbiguousTailnetIP` is returned when multiple 100.x interfaces are present, prompting the operator to pin one manually.

<Warning>
A pre-v0.12 `sink.yaml` that omits `listen.addr` used to silently fall through to `127.0.0.1:9999`, which masked the wizard's auto-detection failures. v0.12 makes empty a hard error and re-running `agentcookie wizard install --as sink` is the supported repair.
</Warning>

## blocklist.yaml

Optional opt-out file read by both source and sink. Patterns are matched against Chrome's `host_key`; a match means **drop**. Missing file or empty `domains:` means sync everything.

```yaml
version: 1

domains:
  - pattern: "%chase.com"
    description: Chase bank
  - pattern: "%1password.com"
  - pattern: "%irs.gov"
```

### Schema

<ParamField body="version" type="int" required>
Must equal `1`. Any other value fails with `unsupported blocklist version N (this binary speaks version 1)`.
</ParamField>

<ParamField body="domains" type="[]BlocklistEntry">
List of opt-out entries. Empty or omitted = sync-all.
</ParamField>

<ParamField body="domains[].pattern" type="string" required>
SQLite-LIKE pattern. `%` matches any sequence (including empty); all other characters match literally. Empty pattern fails with `domains[i].pattern is empty`. Matching is **case-insensitive** and runs against Chrome's `host_key` column, which includes a leading dot for subdomain cookies (so `%chase.com` covers both `chase.com` and `.www.chase.com`).
</ParamField>

<ParamField body="domains[].description" type="string">
Free-form human note. Not used by the matcher.
</ParamField>

### Matching semantics

`internal/protocol/allowlist.go:matchLike` implements the subset of SQLite LIKE that agentcookie uses:

```text
'%'            wildcard (matches zero or more characters)
any other char literal match
case-insensitive  (input and pattern are both lowercased before compare)
```

There is **no `_` single-character wildcard, no LIKE-ESCAPE clause, and no glob metacharacters** (`*`, `?`, `[...]` are literal characters). Both source and sink build a `BlocklistMatcher` and run it independently — the sink reapplies the filter as defense in depth so the receiver always has the final say on what lands on disk.

```text
"%chase.com"      matches ".chase.com", "www.chase.com", ".secure.chase.com"
"%.example.com"   matches ".example.com", "x.example.com"; does NOT match "example.com"
"chase.com"       matches only the literal string "chase.com" (rare — host_key usually has a leading dot)
```

### allowlist.yaml (legacy)

v0.3 inverted the v0.2 allowlist semantic to opt-out blocking. The package keeps a thin shim:

- `LoadAllowlist(dir)` is an alias for `LoadBlocklist(dir)` and returns the new `Blocklist` shape.
- `AllowlistEntry = BlocklistEntry` and `Allowlist = Blocklist` Go type aliases keep older call sites compiling.
- If `blocklist.yaml` is missing **and** `allowlist.yaml` exists, the loader renames the legacy file to `allowlist.yaml.v2.bak` (only when no `.v2.bak` already exists), prints a one-line stderr warning, and returns an empty blocklist. The legacy patterns are **not** carried over — sync-all becomes the new default and the old file is preserved for manual review.

<Info>
`agentcookie wizard install` writes a starter `blocklist.yaml` with all patterns commented out, so a fresh install runs in explicit sync-all mode. Add entries on either side; the sink owner's blocklist wins because the sink filters after decrypt.
</Info>

## Error reference

| Loader | Trigger | Error substring |
|---|---|---|
| `LoadSource` | file missing | `not found: <path> (start from examples/ in this repo)` |
| `LoadSource` | `sink.url` empty | `sink.url is required` |
| `LoadSource` / `LoadSink` | no peer & no shared secret | `either peer.hostname (paired key) or security.shared_secret (legacy) is required` |
| `LoadSource` / `LoadSink` | `shared_secret` < 32 bytes | `security.shared_secret must be at least 32 bytes (got N)` |
| `LoadSink` | `listen.addr` empty | `listen.addr is required (run \`agentcookie wizard install --as sink\` ...)` |
| Sink/wizard | `listen.addr` is `0.0.0.0`/`::`/empty host | `refuses to bind on "..." (every interface)` |
| Sink/wizard | non-tailnet IP | `refuses to bind on "...": not a Tailscale 100.x address` |
| Any loader | unknown YAML field | `decode <path>: ... field ... not found in type ...` |
| `LoadBlocklist` | `version` ≠ 1 | `unsupported blocklist version N (this binary speaks version 1)` |
| `LoadBlocklist` | empty pattern | `domains[i].pattern is empty` |

## File layout

```text
~/.config/agentcookie/
├── source.yaml        # role: source
├── sink.yaml          # role: sink
├── blocklist.yaml     # optional, both roles
├── allowlist.yaml.v2.bak  # legacy v0.2 file, renamed on first v0.3 load
└── keys/
    └── <peer-hostname>.json   # per-peer 32-byte key, mode 0600
```

Both source and sink load `blocklist.yaml` from this same directory; the file is not pushed over the wire — each side maintains its own.

## Related pages

<CardGroup cols={2}>
  <Card title="Configure source and sink" href="/configure-source-sink">
    Editing the same YAML files in a guided walk-through — peer hostname, Chrome DB path, CDP managed-Chrome toggle, and `skip_chrome_sqlite` for headless boxes.
  </Card>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">
    What populates `~/.config/agentcookie/keys/<peer>.json` and how `peer.hostname` is resolved at sync time.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Flags for `agentcookie source`, `agentcookie sink`, `agentcookie wizard install`, and how `--config-dir` overrides the default directory.
  </Card>
  <Card title="Wire protocol v1" href="/wire-protocol">
    How the AES-256-GCM seal uses the key indexed by `peer.hostname` and the sink validation order applied after decrypt.
  </Card>
</CardGroup>

---

## 17. agentcookie.toml manifest reference

> The v2 adoption manifest schema: `schema_version`, `[secrets]`, `[aliases]`, `[[files]]`, project kinds, discovery roots, and the three integration tiers.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/17-agentcookie.toml-manifest-reference.md
- Generated: 2026-06-01T03:11:55.874Z

### Source Files

- `docs/spec-agentcookie-secrets-bus-v2-adoption.md`
- `internal/secretsbus/manifest_v2.go`
- `internal/secretsbus/discovery.go`
- `internal/secretsbus/pp_cli_adapter.go`
- `pkg/agentcookieadoption/adoption.go`

---
title: "agentcookie.toml manifest reference"
description: "The v2 adoption manifest schema: `schema_version`, `[secrets]`, `[aliases]`, `[[files]]`, project kinds, discovery roots, and the three integration tiers."
---

`agentcookie.toml` is the v2 adoption manifest parsed by `internal/secretsbus/manifest_v2.go` and rendered by `pkg/agentcookieadoption/adoption.go`. Each manifest declares one project's participation in the secrets bus, points at the env-shaped file (or `[[files]]` payloads) that should ride to the sink, and is consumed by `secretsbus.Discover` from a fixed priority list of well-known directories. The wire envelope shape from v1 is unchanged; v2 only adds the declarative onramp.

## Top-level schema

A manifest is strict TOML 1.0. The decoder is `BurntSushi/toml`, the validator is `validateManifestV2`, and the renderer is `pkg/agentcookieadoption.Render`. Unknown top-level fields trigger a stderr warning but do not fail parse, so v2.0 clients tolerate v2.1+ additions.

<ParamField body="schema_version" type="int" required>
  Must be exactly `2`. `schema_version = 1` is rejected with a pointer at the v1 per-CLI sync override format; anything else fails with `schema_version must be 2`.
</ParamField>

<ParamField body="name" type="string" required>
  Project slug. Pattern: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$`. Lowercase ASCII letters, digits, and hyphen only; no leading or trailing hyphen; 1-64 chars. `..` substrings are explicitly rejected as defense-in-depth.
</ParamField>

<ParamField body="display_name" type="string" required>
  Human label, 1-200 chars, any printable UTF-8. Surfaces in `agentcookie discover` and `agentcookie secret list` headers. Never used as a path segment.
</ParamField>

<ParamField body="description" type="string">
  Optional one-liner, 200 char ceiling.
</ParamField>

<ParamField body="project_kind" type="string">
  Optional. One of `cli`, `skill`, `service`, `other`. Any other value is a hard parse error.
</ParamField>

<ParamField body="homepage" type="string">
  Optional URL. Not validated; surfaced verbatim.
</ParamField>

<ParamField body="signed_by" type="string">
  Reserved for v2.1 signature verification. v2.0 parses the field but emits a `signed_by field is reserved for v2.1; ignored in v2.0` warning.
</ParamField>

## File layout

```toml
# agentcookie.toml: secrets-bus adoption manifest v2
schema_version = 2
name = "last30days"
display_name = "last30days"
description = "Brand intelligence skill"
project_kind = "skill"
homepage = "https://github.com/mvanhorn/last30days-skill"

[secrets.file]
path = "~/.config/last30days/.env"

[sync]
default = true

[sync.keys]
SETUP_COMPLETE = false
FROM_BROWSER = false
INCLUDE_SOURCES = false

[aliases]
TESLA_AUTH_TOKEN = "OAUTH_BEARER"

[[files]]
source = "~/.config/tesla-pp-cli/config.toml"
key = "TESLA_CONFIG_TOML"
target = "tesla-pp-cli/config.toml"
optional = false
env = "TESLA_CONFIG"
```

## `[secrets.*]` source block

Exactly one of `[secrets.file]`, `[secrets.command]`, or `[secrets.keychain]` is allowed. Declaring more than one is a hard error (`at most one [secrets.*] block allowed; N found`). A manifest with no `[secrets.*]` block is rejected unless it declares at least one `[[files]]` item.

| Block | Status in v2.0 | Reject message |
|-------|----------------|----------------|
| `[secrets.file]` | Supported | — |
| `[secrets.command]` | Reserved | `[secrets.command] source kind not yet supported in v2.0 (reserved for v2.1)` |
| `[secrets.keychain]` | Reserved | `[secrets.keychain] source kind not yet supported in v2.0 (reserved for v2.1)` |

### `[secrets.file]`

```toml
[secrets.file]
path = "~/.config/my-tool/auth.env"
```

<ParamField body="path" type="string" required>
  Path to an env-shaped file in v1 `secrets.env` grammar. `~/` expands to the user's home via `ResolveSecretsPath`. Bare `~` resolves to the home root. Must not contain `..`. Absolute paths outside the home are accepted but logged as a soft warning. Missing at push time omits the project (one stderr warning per push).
</ParamField>

The file is read fresh on every source-side push. Nothing is mirrored into `~/.agentcookie/secrets/<name>/`; the project file remains source of truth, so token rotation lands on the sink with the next push.

## `[sync]` and `[sync.keys]`

Filter what leaves the source machine. Same semantics as the v1 manifest `[sync]` table; filtering is applied source-side before the envelope is built, and `[sync.keys]` itself does not travel on the wire.

<ParamField body="sync.default" type="bool" default="true">
  Whether unlisted keys ship by default. The whole `[sync]` table or just `default` may be omitted; both are treated as `true`. The parser does a second map-decode to distinguish "omitted" from "explicitly false."
</ParamField>

<ParamField body="sync.keys" type="map[string]bool">
  Per-key overrides. `true` ships, `false` excludes, regardless of `default`.
</ParamField>

```toml
[sync]
default = false

[sync.keys]
TESLA_AUTH_TOKEN = true
```

`ShouldShipKey(key)` returns the per-key override when present, otherwise the resolved default.

## `[aliases]`

Maps a consumer-declared environment variable name to the synced bus key whose value it should receive. A CLI that reads `TESLA_AUTH_TOKEN` but whose bearer was imported under the bus key `OAUTH_BEARER` is wired automatically without any per-user `agentcookie secret alias` command.

```toml
[aliases]
TESLA_AUTH_TOKEN = "OAUTH_BEARER"
```

- Both the declared var (key) and the bus key (value) must satisfy `validEnvKey`: initial letter or underscore, then letters, digits, or underscores. Invalid names are a hard parse error.
- `agentcookie secret env <name>` applies aliases live on every call, so refreshed tokens track without restart.
- An explicit local `agentcookie secret alias` entry for the same declared var overrides the manifest alias.
- An alias whose bus key is absent emits nothing for that declared var (no-op).

## `[[files]]` carried-file items

Some secrets are not env-shaped: multiline PEMs and TOML configs do not fit one `KEY=VALUE` line. `[[files]]` carries arbitrary files sealed end-to-end and materializes them on the sink as 0600 files under `~/.agentcookie/`. `[[files]]` coexists with the single `[secrets.*]` block; it is not a second secret-source block, and a manifest may declare zero or more items alongside one `[secrets.*]` block (or use `[[files]]` alone).

```toml
[[files]]
source   = "~/.config/tesla-pp-cli/config.toml"
key      = "TESLA_CONFIG_TOML"
target   = "tesla-pp-cli/config.toml"
optional = false
env      = "TESLA_CONFIG"

[[files]]
source   = "~/.tesla/fleet-private.pem"
key      = "TESLA_FLEET_KEY_PEM"
target   = "tesla-pp-cli/fleet-key.pem"
optional = true
```

### Item fields

<ParamField body="source" type="string" required>
  Path to the file to read and base64-encode. `~/` expands. `..` is rejected.
</ParamField>

<ParamField body="key" type="string" required>
  Wire envelope key the base64 payload rides under. Must be a valid env var name. Duplicate `key` across items in the same manifest is a hard error.
</ParamField>

<ParamField body="target" type="string" required>
  Materialization path on the sink, relative to `~/.agentcookie/`. Validated by `validateMaterializeTarget`: must be non-empty, non-absolute, free of `..` segments, and stay inside the root after `filepath.Clean`. The sink re-applies this check before writing.
</ParamField>

<ParamField body="optional" type="bool" default="false">
  When `true`, the item is opt-in: discovery does not carry it unless its key is listed in `~/.agentcookie/file-optin/<name>.keys` (one key per line; blanks and `#` comments ignored).
</ParamField>

<ParamField body="env" type="string">
  Optional env-var name. When set, the sink emits `<env>=<absolute materialized path>` from `secret env`, pointing a path-reading consumer CLI at the carried file without per-machine path hardcoding.
</ParamField>

### Carriage on the wire

The envelope stays `map[string]map[string]string`. Each carried file rides as a single base64 payload plus reserved companion keys; a v2-aware sink decodes and strips them, a sink that does not understand the prefixes simply persists them (forward-degradation).

```text
per-CLI env map:
  <key>            = base64(file bytes)        # the payload
  _FILE_<key>      = <target>                  # materialization path
  _FILEENV_<key>   = <env>                     # optional env-var-name hint
```

Decoded payloads are capped at 256 KB (`maxCarriedFileBytes`); oversized files are refused, not truncated. The materialized file on the sink is plaintext 0600 owned by the sink user; on-sink protection is filesystem permissions.

## Discovery roots and priority

`secretsbus.Discover` walks these locations in order. Within a directory, files are scanned in lexicographic order; the first registration of a given `name` wins. Lower-priority occurrences become soft-skipped registry entries with a stderr log.

| Priority | Path | `SourceKind` |
|----------|------|--------------|
| 1 | `~/.agentcookie/manifests/*.toml` | `explicit-manifest` |
| 2 | `~/.config/agentcookie/manifests/*.toml` | `explicit-manifest` |
| 3 | `/usr/local/share/agentcookie/manifests/*.toml` | `explicit-manifest` |
| 4 | `~/printing-press/library/*/.printing-press.json` | `pp-cli-derived` (synthesized in memory) |
| 5 | `--add-path <dir>` (via `agentcookie discover`) | `explicit-manifest` |
| 6 | `~/.agentcookie/secrets/<name>/` (no manifest) | `legacy-v1` |

Discovery is forgiving by design: a malformed manifest is soft-skipped with a per-file reason; `agentcookie secret import-from` remains the strict imperative path that hard-fails on bad input. In `agentcookie source --watch`, an fsnotify watcher rescans on create/write/rename with a 250 ms debounce.

## Collision rules

| Case | Outcome | Source |
|------|---------|--------|
| Two explicit manifests with the same `name` | Both rejected, hard error names both source paths | `tryRegisterExplicit` |
| Explicit manifest collides with PP-derived | Explicit wins; derived renamed to `<name>-pp` | `tryRegisterPP` |
| Two PP-derived collide | First-by-sort wins; later collisions get a 6-char sha256 suffix | `tryRegisterPP` |
| v2 wins, v1 bus dir for same name exists | v1 keys override per-key at push (preserves explicit user intent) | spec §10.3 |

## Project kinds

`project_kind` is an enum tag for `agentcookie discover` output and downstream tooling. Authoring guidance follows the values the validator accepts:

| Value | When to use |
|-------|-------------|
| `cli` | A Printing-Press CLI or any third-party command-line tool. |
| `skill` | A Claude or agent skill that reads its env from a dotenv. |
| `service` | A daemon, LaunchAgent, or background worker. |
| `other` | Anything else; still appears in the registry. |

Omit the field if none fit; an empty value is allowed.

## The three integration tiers

The discovery registry tags every entry with one of three `SourceKind` values, surfaced as the `TIER` column in `agentcookie discover`.

```text
+-----------------------+   +----------------------+   +---------------------+
| explicit-manifest     |   | pp-cli-derived       |   | legacy-v1           |
| author drops          |   | synthesized in mem   |   | bus dir only        |
| agentcookie.toml      |   | from .printing-press |   | (pre-v2 import-from)|
+----------+------------+   +----------+-----------+   +----------+----------+
           |                           |                          |
           v                           v                          v
                       secretsbus.Registry.Projects
```

### Tier A — `explicit-manifest`

An author-shipped `agentcookie.toml` lands in any of the priority-1/2/3 directories (or via `discover --add-path`). Tier A wins collisions, supports all v2 fields (`[aliases]`, `[[files]]`, `[sync]` filters), and is the recommended path for both first-party and third-party adopters.

### Tier B — `pp-cli-derived`

`DeriveManifestFromPP` synthesizes a `ManifestV2` from `~/printing-press/library/<cli>/.printing-press.json`. Field mapping:

| v2 field | Source |
|----------|--------|
| `schema_version` | always `2` |
| `name` | PP `cli_name` (must match the v2 slug rules) |
| `display_name` | PP `display_name` (falls back to `cli_name`) |
| `description` | PP `description` |
| `project_kind` | always `cli` |
| `[secrets.file].path` | `~/.config/<cli_name>/config.toml` |
| `sync.default` | `false` |
| `sync.keys[name]` | `auth_env_var_specs[i].sensitive` (else `true` when only `auth_env_vars` is present) |

The synthesized manifest is never written to disk. A PP CLI that ships its own `agentcookie.toml` in `~/printing-press/library/<cli>/` (or any priority-1/2/3 dir) upgrades itself to Tier A; the derived entry is then renamed `<name>-pp` and both rows appear in `discover` for comparison.

### Tier C — `legacy-v1`

A directory at `~/.agentcookie/secrets/<name>/` with no manifest behind it. These are entries created by the v1 imperative `agentcookie secret import-from`. The registry pointer has a nil `Manifest`; the source push pipeline reads keys directly from the bus directory rather than read-in-place. v2 entries for the same name still take precedence at the row level, but the v1 directory wins per-key during merge to preserve explicit user intent.

## Validation pipeline

`ParseManifestV2` runs two TOML decode passes: a typed decode against `ManifestV2`, then a generic `map[string]interface{}` pass used solely to distinguish an omitted `[sync].default` from an explicit `false`. After parse, `validateManifestV2` enforces:

<Steps>
  <Step title="schema_version == 2">
    `schema_version=1` returns the v1-spec pointer; any other integer fails with the upgrade hint.
  </Step>
  <Step title="name and display_name">
    Slug rules, `..` rejection, 200-char ceiling on `display_name` and `description`.
  </Step>
  <Step title="project_kind enum">
    Empty or one of `cli|skill|service|other`.
  </Step>
  <Step title="Exactly one [secrets.*] (or [[files]] only)">
    Multi-block rejected. Reserved blocks rejected with a v2.1 hint. Empty manifests with no `[[files]]` rejected.
  </Step>
  <Step title="[secrets.file].path">
    Required, no `..` segments.
  </Step>
  <Step title="[aliases]">
    Both sides of each pair must satisfy `validEnvKey`.
  </Step>
  <Step title="[[files]]">
    Per item: `source` required (no `..`), `key` env-var-shaped and unique within the manifest, `target` validated by `validateMaterializeTarget`, `env` env-var-shaped when set.
  </Step>
</Steps>

Unknown nested keys under `secrets.command` and `secrets.keychain` are filtered from the unknown-field warning list because their parent blocks are reserved.

## Authoring with `pkg/agentcookieadoption`

The `pkg/agentcookieadoption` package mirrors the manifest schema with a stable public-API surface so installers can author `agentcookie.toml` from Go. The package is intentionally duplicated rather than importing from `internal/` so external code has no internal-package dependency.

```go
import "github.com/mvanhorn/agentcookie/pkg/agentcookieadoption"

m := &agentcookieadoption.Manifest{
    SchemaVersion: 2,
    Name:          "my-tool",
    DisplayName:   "My Tool",
    ProjectKind:   "cli",
    Secrets: agentcookieadoption.Secrets{
        File: &agentcookieadoption.SecretsFile{Path: "~/.config/my-tool/.env"},
    },
    Sync: agentcookieadoption.Sync{Default: true},
}
if err := agentcookieadoption.Validate(m); err != nil { /* ... */ }
_ = agentcookieadoption.WriteTo(m, "/path/to/agentcookie.toml")
```

`WriteTo` calls `Validate`, renders canonical TOML via `Render` (deterministic key ordering, sorted `[sync.keys]`), and writes `0644`. The validator is a strict subset of `validateManifestV2`; richer constructs (`[aliases]`, `[[files]]`, the second-pass omitted-default detection) belong to the internal parser used by discovery.

## Verification

```bash
agentcookie discover
agentcookie discover --verbose          # list skipped manifests and reasons
agentcookie discover --json             # machine-readable: projects[] + skipped[]
agentcookie source --once               # push to the configured sink
ssh other-mac "agentcookie secret list" # confirm the slug appears with the expected keys
```

`discover` prints `NAME / TIER / READ-IN-PLACE / KEYS / COVERAGE`. A `MISMATCH` row in `COVERAGE` flags a CLI whose synced bus keys do not match the env var it actually reads — typically a missing `[aliases]` entry.

## Reserved and forward-compatibility behavior

| Surface | v2.0 behavior |
|---------|---------------|
| Unknown top-level field | Warned, ignored. |
| `signed_by` | Parsed, warned, ignored. |
| `[secrets.command]` / `[secrets.keychain]` | Schema-reserved; rejected at validate with the v2.1 hint. |
| `schema_version = 3+` | Hard-rejected per spec §12 (`schema version not supported; upgrade agentcookie`). |
| New `[secrets.*]` kinds in v2.1+ | Hard-rejected by v2.0 parsers (`source kind X not supported`). |

## Related pages

<CardGroup>
  <Card title="Secrets bus" href="/secrets-bus">
    How the manifests' values ride the encrypted push to `~/.agentcookie/secrets/<cli>/secrets.env`.
  </Card>
  <Card title="Secrets bus on-disk layout" href="/secrets-bus-layout">
    The v1 sink layout, file modes, and where `[[files]]` items materialize under `~/.agentcookie/`.
  </Card>
  <Card title="Adopt a CLI with agentcookie.toml" href="/adopt-a-cli-manifest">
    Author-side walkthrough: write the manifest, run `agentcookie discover`, migrate off `import-from`.
  </Card>
  <Card title="pkg/agentcookiesecret reader library" href="/go-reader-library">
    In-process Go API consumers use to read the bus values the manifest delivered.
  </Card>
  <Card title="Wire protocol v1" href="/wire-protocol">
    The `SyncEnvelope` shape that carries `[[files]]` payloads and companion keys.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Flags and subcommands for `agentcookie discover`, `agentcookie secret`, and friends.
  </Card>
</CardGroup>

---

## 18. Wire protocol v1

> HTTP-over-Tailscale POST `/sync`, AES-256-GCM seal, `SyncEnvelope` JSON fields, sink validation order, and the response semantics (401/400/409).

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/18-wire-protocol-v1.md
- Generated: 2026-06-01T03:13:26.743Z

### Source Files

- `docs/protocol.md`
- `internal/protocol/envelope.go`
- `internal/protocol/sequence.go`
- `internal/protocol/allowlist.go`
- `internal/transport/crypto.go`

---
title: "Wire protocol v1"
description: "HTTP-over-Tailscale POST `/sync`, AES-256-GCM seal, `SyncEnvelope` JSON fields, sink validation order, and the response semantics (401/400/409)."
---

The agentcookie wire protocol is a single HTTP request: the source POSTs an AES-256-GCM-sealed `SyncEnvelope` to `http://<sink-tailnet-ip>:<port>/sync` over the Tailscale tailnet, with `Content-Type: application/octet-stream`. The sink decrypts, validates a strict sequence (`SyncEnvelope.protocol_version`, monotonic `sequence`, blocklist filter), applies the payload to Chrome state + sidecar + adapters + the secrets bus, and responds with a plain-text body summarizing what landed. Versioning is in-band: sources always emit the highest version they speak (`protocol.Version = 2`), sinks accept any version in `[protocol.MinVersion, protocol.Version]` (currently `1..2`); v1 envelopes simply leave the v2-introduced tarball and secrets fields nil.

## Layers, outside to inside

```text
+----------------------------------------------------------+
| Transport: HTTP/1.1 over Tailscale tailnet (100.x:port) |
|   POST /sync   Content-Type: application/octet-stream    |
+----------------------------------------------------------+
| Seal:       AES-256-GCM (12-byte nonce || ciphertext || tag) |
|   key = SHA-256(secret),  secret = pairing-derived 32 B  |
|                          OR legacy security.shared_secret |
+----------------------------------------------------------+
| Envelope:   JSON SyncEnvelope                            |
|   protocol_version, source_hostname, sequence,           |
|   cookies[], local_storage_tarball?, indexed_db_tarball?,|
|   indexed_db_skipped?, secrets?                          |
+----------------------------------------------------------+
```

The transport address is enforced to be a Tailscale CGNAT IP (`100.64.0.0/10`) at install/config time; `sink.yaml`'s `listen.addr` may not be empty and the wizard writes the host's detected 100.x address. The sink also exposes `GET /healthz` returning `ok`.

## Seal: AES-256-GCM with SHA-256-derived key

The transport seal is `internal/transport.SealWithSecret` / `OpenWithSecret`:

- Key derivation: `key = sha256.Sum256([]byte(secret))`, producing a 32-byte AES-256 key. SHA-256 is applied unconditionally, even when the input is already a uniformly random 32-byte pairing-derived key, so v0.11 sinks and v0.12+ sources interoperate on a single derivation path.
- Cipher: `cipher.NewGCM(aes.NewCipher(key))`, 12-byte nonce, 16-byte GCM tag, no additional data.
- Wire encoding: a fresh nonce is generated per envelope and prepended to the ciphertext: `nonce(12) || ciphertext || tag(16)`. The whole blob is the HTTP request body.
- Failure mode: any tampering or wrong-secret payload fails the AEAD tag and `OpenWithSecret` returns `"decrypt (wrong secret or tampered payload): ..."`, which the sink converts to `401 Unauthorized`.

Two key sources are supported, both feeding the same `secret` parameter:

| Source | Where | Notes |
| --- | --- | --- |
| Pairing-derived | `~/.config/agentcookie/keys/<peer>.json` | X25519 + HKDF-SHA256 handshake, 32 raw bytes. Canonical. |
| Legacy shared secret | `security.shared_secret` in `source.yaml` / `sink.yaml` | Must be ≥ 32 bytes; rejected at config load otherwise. |

## SyncEnvelope JSON

`internal/protocol.SyncEnvelope` is the JSON shape inside the seal:

<ResponseField name="protocol_version" type="int" required>
  The wire version. Source emits `protocol.Version` (currently `2`). Sink accepts any version in `[MinVersion, Version]` (currently `1..2`); anything outside is rejected `400`. v1 envelopes are valid: the v2-only fields decode as nil/empty.
</ResponseField>

<ResponseField name="source_hostname" type="string" required>
  The source's announced hostname (`pairing.LocalHostname()`). The sink uses this as the key in its replay-defense tracker and matches it to the paired key file under `~/.config/agentcookie/keys/`.
</ResponseField>

<ResponseField name="sequence" type="int64" required>
  Monotonically increasing per source. The source emits `time.Now().UnixNano()` so rapid syncs do not collide. The sink rejects any value not strictly greater than the highest accepted value for `source_hostname`.
</ResponseField>

<ResponseField name="cookies" type="array<chrome.Cookie>" required>
  One element per cookie. Each cookie carries `host_key`, `name`, `value` (plaintext after the source's Safe Storage decrypt), `path`, `expires_utc`, `is_secure`, `is_httponly`, `last_access_utc`, `has_expires`, `is_persistent`, `priority`, `samesite`, `source_scheme`, `source_port`. All fields are strings or integers; values are never raw bytes.
</ResponseField>

<ResponseField name="local_storage_tarball" type="bytes (base64 in JSON)">
  v2-only. Packed LevelDB tarball of Chrome's `Local Storage/leveldb` (`internal/chromedirsync.Pack`). Omitted on v1 envelopes; sink unpacks via `chromedirsync.Unpack` + `AtomicReplaceDir`.
</ResponseField>

<ResponseField name="indexed_db_tarball" type="bytes (base64 in JSON)">
  v2-only, opt-in via `AGENTCOOKIE_SYNC_INDEXEDDB=1`. Per-origin LevelDB directories larger than 5 MB are skipped at pack time.
</ResponseField>

<ResponseField name="indexed_db_skipped" type="string[]">
  v2-only. The origins skipped by the IndexedDB packer's size cap, surfaced for visibility.
</ResponseField>

<ResponseField name="secrets" type="map<cli, map<key, value>>">
  v2-only (`v0.13`). The secrets-bus payload, one map per registered CLI under `~/.agentcookie/secrets/`, post-filter against the manifest's sync policy. v0.12 sinks deserialize unchanged because the field is `omitempty`.
</ResponseField>

The Go declaration in `internal/protocol/envelope.go`:

```go
type SyncEnvelope struct {
    ProtocolVersion     int             `json:"protocol_version"`
    SourceHostname      string          `json:"source_hostname"`
    Sequence            int64           `json:"sequence"`
    Cookies             []chrome.Cookie `json:"cookies"`
    LocalStorageTarball []byte          `json:"local_storage_tarball,omitempty"`
    IndexedDBTarball    []byte          `json:"indexed_db_tarball,omitempty"`
    IndexedDBSkipped    []string        `json:"indexed_db_skipped,omitempty"`
    Secrets             map[string]map[string]string `json:"secrets,omitempty"`
}
```

A v1 envelope is literally the same struct with only the first four fields populated.

## Sink validation order

The `/sync` handler walks the envelope through a fixed sequence; each step rejects on failure with a status code that uniquely identifies the layer that failed.

```mermaid
sequenceDiagram
    participant Src as Source
    participant Net as Tailscale tailnet
    participant H as Sink /sync handler
    participant Seq as SequenceTracker (sequence.json)
    participant BL as BlocklistMatcher
    participant Sk as Sink writers

    Src->>Net: POST /sync (sealed body, octet-stream)
    Net->>H: HTTP request
    H->>H: Method check (POST only -> 405 otherwise)
    H->>H: LimitedReader cap (default 256 MiB)
    H->>H: io.ReadAll(body) -> 400 on read error
    H->>H: transport.OpenWithSecret -> 401 on AEAD fail
    H->>H: json.Unmarshal(SyncEnvelope) -> 400 on parse fail
    H->>H: ProtocolVersion in [MinVersion, Version] -> 400 otherwise
    H->>Seq: Accept(source_hostname, sequence)
    Seq-->>H: false (<=last seen) -> 409
    Seq-->>H: true (persist write-through)
    H->>BL: Filter(cookies)
    BL-->>H: passed[], droppedHosts{}
    H->>Sk: writers (sqlite/leveldb, sidecar, CDP, adapters, secrets)
    Sk-->>H: writeResult or err -> 500 on write fail
    H-->>Src: 200 OK + summary line
```

Step by step, in handler order (`internal/cli/sink.go`):

<Steps>
  <Step title="Method gate">
    Anything other than `POST` returns `405 Method Not Allowed` with body `POST only`.
  </Step>
  <Step title="Body cap">
    `httpserver.LimitedReader` wraps `r.Body` with `http.MaxBytesReader(..., MaxBodyBytes)`. Default cap for the `SinkSync` profile is `256 * 1024 * 1024` bytes (256 MiB), configurable via `sink.yaml`. Reads beyond the cap return `400 Bad Request` with `read body: http: request body too large`.
  </Step>
  <Step title="AEAD open">
    `transport.OpenWithSecret(sealed, transportSecret)`. Any failure (truncated body, wrong nonce length, wrong key, tamper) returns `401 Unauthorized` with `open payload: ...`.
  </Step>
  <Step title="JSON unmarshal">
    Parse failure returns `400 Bad Request` with `unmarshal envelope: ...`.
  </Step>
  <Step title="Protocol version range">
    `envelope.ProtocolVersion` must satisfy `MinVersion <= v <= Version`. Out-of-range returns `400 Bad Request` with `protocol version mismatch: got N, sink speaks M-N`.
  </Step>
  <Step title="Replay defense">
    `seqTracker.Accept(envelope.SourceHostname, envelope.Sequence)` requires the value be strictly greater than the highest accepted for that source. Rejection returns `409 Conflict` with `sequence N not greater than last seen for "<host>" (replay defense)`. State is persisted to `~/.agentcookie/sequence.json` (mode 0600, atomic rename) on every accept; a failed persist rolls back the in-memory update and rejects the request.
  </Step>
  <Step title="Sink-side blocklist filter">
    `BlocklistMatcher.Filter(cookies)` returns the passed cookies and a per-host drop count. An empty blocklist (sync-all default) drops nothing. Patterns use SQLite LIKE semantics (`%` wildcard), case-insensitive, against `host_key` (which carries a leading dot for subdomain cookies). Drops are logged on stderr; the count is included in the response body.
  </Step>
  <Step title="Apply payload">
    Cookies write via the SQLite/LevelDB path or, when `skip_chrome_sqlite: true`, the sidecar-only path (`~/.agentcookie/cookies-plain.db`). When `cdp.enabled` is set the sink also spawns headless Chrome and pushes via `Storage.setCookies`; CDP failures are logged but do not fail the sync because the sidecar write already succeeded. PP-CLI adapters run after the cookie write. If `envelope.Secrets` is non-empty the secrets-bus writer persists per-CLI `secrets.env` files. Any unrecoverable write returns `500 Internal Server Error` with `apply envelope: ...`.
  </Step>
</Steps>

<Note>
  The validation order is load-bearing for the response code contract: a 401 means the seal failed, a 400 means a structurally invalid envelope (unparsable, wrong version), and a 409 means a sequence that would re-open the replay window. Callers that retry should retry only on 5xx and transient transport errors — 4xx codes mean a coordinated source/sink fix is required.
</Note>

## Response semantics

The sink emits plain-text responses (`http.Error` for failures, `fmt.Fprintf(w, ...)` for success). The status code is the wire contract; the body is human-readable diagnostics.

| Status | When | Body shape |
| --- | --- | --- |
| `200 OK` | Envelope accepted and applied | `ok: wrote N cookies (M sidecar), L localStorage origins, I indexedDB origins; dropped D non-allowlisted cookies\n` |
| `200 OK` (dry-run) | `--dry-run` sink, envelope accepted | `dry-run ok: accepted N cookies; dropped D non-allowlisted\n` |
| `400 Bad Request` | Body read error, JSON parse error, or `protocol_version` out of `[MinVersion, Version]` | `read body: ...`, `unmarshal envelope: ...`, or `protocol version mismatch: got N, sink speaks M-N\n` |
| `401 Unauthorized` | AEAD open failed (wrong key, tampered payload, short body) | `open payload: decrypt (wrong secret or tampered payload): ...\n` |
| `405 Method Not Allowed` | Anything other than POST | `POST only\n` |
| `409 Conflict` | `sequence` not strictly greater than last seen for `source_hostname` | `sequence N not greater than last seen for "<host>" (replay defense)\n` |
| `500 Internal Server Error` | Apply step failed (SQLite/LevelDB write, sidecar write, etc.) | `apply envelope: ...\n` |

The source treats any non-200 status as a hard error and returns it from the per-tick push (`sink returned <status>: <body>`), surfacing it to `agentcookie status` and the source log. The source does not retry inside the push; the next watcher tick is the natural retry surface.

## Replay defense persistence

`SequenceTracker` is process-wide and write-through. The on-disk store is `~/.agentcookie/sequence.json`, mode `0600`, written atomically (`os.CreateTemp` + `os.Rename`). The JSON is a flat `{ "source_hostname": last_seq, ... }` map.

| Behavior | Outcome |
| --- | --- |
| File absent on boot | Empty map; first envelope of any sequence wins. |
| File corrupt on boot | Sink refuses to start with `parse sequence state ...: ... (delete this file to reset replay-defense state)`. |
| Save fails during `Accept` | In-memory update rolled back; `Accept` returns false; handler returns `409`. |
| Operator reset | Delete `~/.agentcookie/sequence.json` (only do this if you accept the brief replay window). |

Because `Sequence` is sourced at nanosecond granularity (`time.Now().UnixNano()`), rapid back-to-back syncs do not collide. Legacy 1-second-granularity sources still interoperate because the tracker requires only strict monotonic increase.

## Source side: how an envelope is constructed

The source push path (`internal/cli/source.go`) assembles, seals, and POSTs in this exact order:

```go
envelope := protocol.SyncEnvelope{
    ProtocolVersion:     protocol.Version,
    SourceHostname:      pairing.LocalHostname(),
    Sequence:            time.Now().UnixNano(),
    Cookies:             all,
    LocalStorageTarball: lsTarball,    // v2; nil for v1-shaped envelopes
    IndexedDBTarball:    idbTarball,   // v2; opt-in
    IndexedDBSkipped:    idbSkipped,   // v2
}
if secretsPayload != nil && len(secretsPayload.CLIs) > 0 {
    envelope.Secrets = secretsPayload.CLIs
}
payload, _ := json.Marshal(envelope)
sealed, _  := transport.SealWithSecret(payload, secret)

req, _ := http.NewRequestWithContext(postCtx, "POST", cfg.Sink.URL, bytes.NewReader(sealed))
req.Header.Set("Content-Type", "application/octet-stream")
resp, _ := httpserver.Client(httpserver.SyncClient).Do(req)
```

The `SyncClient` profile bounds the POST at a 5-minute client timeout to accommodate large LevelDB tarballs over a slow tailnet link.

## Versioning posture

- v1 is the original envelope: `protocol_version`, `source_hostname`, `sequence`, `cookies`. It remains wire-compatible. A v1-only client may POST envelopes with the other fields omitted.
- v2 adds `local_storage_tarball`, `indexed_db_tarball`, `indexed_db_skipped` (v0.7), and `secrets` (v0.13), all as `omitempty` fields.
- A breaking change bumps both `MinVersion` and `Version`, drops the prior version from the accepted range, and requires a coordinated source/sink upgrade. Out-of-band fields land first as additional optional v1/v2 envelope fields and may later graduate to required in a future v3.
- Listeners are tailnet-only: `sink.yaml`'s `listen.addr` must be set explicitly and the wizard derives it from `tsclient.RequireTailnetIP` (a 100.64.0.0/10 address). Pre-v0.12 silent fall-through to `127.0.0.1:9999` is intentionally removed.

## Replay window edge cases

<AccordionGroup>
  <Accordion title="Sink restart">
    Sequence state is persisted, so a captured payload cannot be replayed across a sink restart — the high-water marks for every `source_hostname` survive in `sequence.json`.
  </Accordion>
  <Accordion title="Source restart">
    The source sets `Sequence = time.Now().UnixNano()` on every push; restarts keep the value monotonically increasing because wall-clock nanoseconds keep advancing. No source-side state is required.
  </Accordion>
  <Accordion title="Clock skew">
    The tracker requires strict monotonic increase per source, not wall-clock truth. Any source that hands out monotonically increasing int64 values is acceptable; the sink does not interpret `Sequence` as time.
  </Accordion>
  <Accordion title="Multiple sources">
    Each `source_hostname` has its own high-water mark in the tracker, so two paired laptops with overlapping sequence ranges are tracked independently.
  </Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">
    Where the AES-GCM `secret` comes from: X25519 + HKDF-SHA256 handshake and `~/.config/agentcookie/keys/<peer>.json`.
  </Card>
  <Card title="Configure source and sink" href="/configure-source-sink">
    `sink.yaml` `listen.addr` validation, `peer.hostname`, `skip_chrome_sqlite`, and the CDP toggle.
  </Card>
  <Card title="Source and sink topology" href="/source-sink-topology">
    How the source watcher and the sink listener fit on either side of `/sync`.
  </Card>
  <Card title="Secrets bus" href="/secrets-bus">
    The `secrets` envelope field, the per-CLI `secrets.env` layout, and the v2 adoption tiers.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Pairing `connection refused`, 401/400/409 walkthroughs, and the sink Keychain prompt FAQ.
  </Card>
</CardGroup>

---

## 19. Secrets bus on-disk layout

> The v1 standard layout under `~/.agentcookie/secrets/<cli>/`, file modes (0600), `secrets.env` format, optional sealed twin, and the `[[files]]` materialization path.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/19-secrets-bus-on-disk-layout.md
- Generated: 2026-06-01T03:13:42.001Z

### Source Files

- `docs/spec-agentcookie-secrets-bus-v1.md`
- `internal/secretsbus/secretsbus.go`
- `internal/secretsbus/writer.go`
- `internal/secretsbus/filecarriage.go`
- `internal/sinkpush/seal.go`

---
title: "Secrets bus on-disk layout"
description: "The v1 standard layout under `~/.agentcookie/secrets/<cli>/`, file modes (0600), `secrets.env` format, optional sealed twin, and the `[[files]]` materialization path."
---

The secrets bus is a per-CLI on-disk contract rooted at `~/.agentcookie/secrets/`. The sink (`internal/secretsbus/writer.go`) materializes everything the source ships: one mode-`0700` directory per CLI containing an optional `manifest.toml`, a plaintext `secrets.env` (`0600`), an opaque `secrets.env.sealed` twin when sealing is on, plus any carried files written under `~/.agentcookie/<target>` via the `[[files]]` companion-key path. Any tool that speaks the v1 dotenv grammar can consume the directory directly; the format is the public boundary, not the Go reader.

## Directory layout

```
~/.agentcookie/                         # 0700
├── secrets/                            # 0700, root of the bus
│   ├── <cli-name>/                     # 0700, one per consumer
│   │   ├── manifest.toml               # 0600, v1 metadata + sync policy
│   │   ├── secrets.env                 # 0600, plaintext KEY=VALUE lines
│   │   └── secrets.env.sealed          # 0600, optional sealed twin
│   └── …
├── file-optin/                         # opt-in gate for [[files]]
│   └── <cli-name>.keys                 # one carry-key per line
└── <cli-name>/                         # 0700, [[files]] materialization root
    └── <file-target>                   # 0600, e.g. config.toml, fleet.pem
```

`<cli-name>` rules are enforced symmetrically by `validCLIName` in `internal/secretsbus/secretsbus.go` and by the sink writer before any path is touched:

<ResponseField name="character set" type="string">Lowercase ASCII letters, digits, and hyphen only.</ResponseField>
<ResponseField name="boundaries" type="string">May not begin or end with a hyphen.</ResponseField>
<ResponseField name="length" type="int">1 to 64 characters inclusive.</ResponseField>
<ResponseField name="rejected tokens" type="string">`.`, `..`, slashes, backslashes, whitespace, any other punctuation.</ResponseField>

<Warning>
Both source and sink refuse a payload whose name fails these checks and write nothing outside the secrets root. This is the path-traversal defense; readers must not normalize names silently.
</Warning>

## File modes and atomic writes

Every file the writer creates is `0600`, every directory is `0700`. Writes go through `atomicWrite` in `internal/secretsbus/writer.go`:

1. Open a sibling temp file `<path>.tmp` with `O_WRONLY|O_CREATE|O_TRUNC` at `0600`.
2. Write payload bytes, then `fsync`.
3. `Close`, then `os.Rename(tmp, path)`.
4. On any failure, remove the temp and surface the error.

A reader following the section 5.2 priority chain (sealed first, plaintext fallback) therefore observes the previous value or the new value, never a torn read. The bus assumes a single writer per directory at a time — the sink during `/sync` and `agentcookie secret set` invoked manually — coordinated only by atomic rename; there is no lock file.

## `secrets.env` format

`secrets.env` is UTF-8, line-oriented, and parsed by `parseEnvFile` against a strict dotenv subset. The sink writes the canonical render via `renderEnvFile` and prepends two informational comments:

```text
# Written by agentcookie sink. See docs/spec-agentcookie-secrets-bus-v1.md for format.
# Do not hand-edit while a sync is in progress: the next sync overwrites this file.
EXAMPLE_API_KEY=ex_live_3f4a2c91d8e6b5a07c1f9e4b6d0a8c2e
TESLA_CONFIG=/Users/you/.agentcookie/tesla-pp-cli/config.toml
```

### Grammar

| Construct | Form | Rule |
| --- | --- | --- |
| Comment | `# …` | Whole-line only. No trailing comments. |
| Blank | empty line | Ignored. |
| Entry | `KEY=VALUE` | `=` flush against key and value. No whitespace around `=`. |
| Key | `[A-Za-z_][A-Za-z0-9_]*` | Case-sensitive. Hyphens and dots are invalid in keys. |
| Bare value | printable, no whitespace, no `#`, `=`, `\`, `"` | Reader returns it verbatim. |
| Double-quoted | `"…"` | Escapes `\"`, `\\`, `\n`, `\r`, `\t`. |
| Single-quoted | `'…'` | Verbatim — no escapes recognized. |
| Continuation | trailing `\` | Joins next line; backslash and newline are dropped. |

<Note>
The 256 KiB cap (`maxEnvFileBytes`) is enforced before parse on the source. Larger files are skipped and surfaced as non-fatal errors; other CLIs in the same `LoadPayload` walk still ship.
</Note>

### Explicit forbiddens

The reader rejects these dotenv-family extensions outright:

- Variable interpolation (`$OTHER`, `${OTHER}`) — value is taken literally.
- Command substitution (`$(cmd)`).
- `export KEY=…` prefix.
- Heredoc / triple-quoted blocks.
- Whitespace around the `=` sign.

`parseEnvFile` returns an error citing the offending line number; conforming readers must not skip a bad line silently because the next-priority source would then leak in.

### Reserved key prefixes

| Prefix | Meaning | Behavior |
| --- | --- | --- |
| `_unknown_<field>` | Field that `secret import-from` could not map to a canonical key. | Surfaced as an ordinary entry so the user can rename. |
| `_BIN_<KEY>` | Original value was raw bytes. | Stored as base64. Consumers that recognize the prefix may decode. |
| `_meta_<field>` | Reserved for future metadata. | Reader ignores or passes through. |
| `_FILE_<K>` | Carried-file companion holding the materialization target. | Consumed by the sink; never lands in `secrets.env`. |
| `_FILEENV_<K>` | Carried-file companion holding an env-var name. | Consumed by the sink; never lands in `secrets.env`. |
| `__…` | Reserved double-underscore namespace. | Writers must not emit; readers ignore silently. |

## `manifest.toml`

The v1 manifest lives at `~/.agentcookie/secrets/<cli>/manifest.toml` and describes the per-CLI dataset together with its sync policy. The Go type and loader live in `secretsbus.go`:

```toml
schema_version = 1
display_name = "Example PP CLI"

[sync]
default = true

[sync.keys]
EXAMPLE_OAUTH_REFRESH = false
_BIN_EXAMPLE_SIGNING_PRIVATE_KEY = false
```

<ParamField path="schema_version" type="int" required>Always `1` for the format described here. Higher versions surface a warning; the reader may return `error` or best-effort.</ParamField>
<ParamField path="display_name" type="string" required>UTF-8, 1–128 characters. Used in `agentcookie secret list` and doctor output.</ParamField>
<ParamField path="sync.default" type="bool" default="true">When omitted entirely, the loader applies `true`. `loadManifest` uses a two-pass decode to distinguish explicit `false` from the absent case.</ParamField>
<ParamField path="sync.keys" type="map<string,bool>">Per-key override. `true` always ships, `false` never ships, regardless of `[sync] default`. Keys absent here inherit `default`.</ParamField>

`applySyncPolicy` walks the env map in sorted order so the resulting wire payload is deterministic. The `[sync.keys]` table never travels; only the filtered key/value set does. The sink therefore never sees a key the source's policy already dropped.

## `secrets.env.sealed` — the optional twin

When the source pushes with sealing requested AND a v0.12 master key is available in the Keychain, `WritePayload` writes a sealed twin in addition to the plaintext file:

```go
if masterKey != nil {
    sealed, err := keystore.Seal(masterKey, envBytes)
    // …
    sealedPath := filepath.Join(cliDir, "secrets.env.sealed")
    atomicWrite(sealedPath, sealed, 0o600)
}
```

The sealed body is opaque. Adapters that bake sealed values into per-CLI config files share the same envelope: `internal/sinkpush/seal.go` advertises `SealedPrefix = "agc1:"` and `maybeSeal()` produces `"agc1:" + base64(seal(masterKey, plaintext))`, transparently degrading to plaintext when the master key is absent. For the bus twin itself, only the filename and the resolution order are part of the public contract.

### Resolution order

A reader that understands sealing checks the per-CLI directory in this order:

<Steps>
<Step title="`secrets.env.sealed`">
If present, decrypt via the agentcookie reader library or `agentcookie secret get`. On success the resulting map is the dataset; the plaintext sibling, if it exists, is ignored.
</Step>
<Step title="`secrets.env`">
Used only when the sealed twin is absent. Parsed under the v1 grammar above.
</Step>
<Step title="Caller-registered fallback file">
Optional second argument naming the consumer's pre-existing config file. Keys not already provided by the bus may be filled in via a reader-defined heuristic mapping.
</Step>
<Step title="Process environment">
Last-resort source so adopting the bus does not break existing setups. **Bus wins over env** — this is the single most important non-obvious rule in the spec.
</Step>
</Steps>

<Warning>
A reader that finds an unreachable sealed file (sealing on but master key unavailable) MUST return an error rather than fall back to plaintext on a sealed machine. The plaintext sibling on a sealed machine is normally absent, and silent fallback would mask a real misconfiguration.
</Warning>

Sealing policy in `WritePayload` resolves three cases:

| `sealingEnabled` | Master key present | Result |
| --- | --- | --- |
| `false` | — | Plaintext only. |
| `true` | yes | Plaintext + sealed twin in the same atomic-rename window. |
| `true` | no | Plaintext only, with a non-fatal error so `/sync` still succeeds. |

## `[[files]]` materialization

A v2 adoption manifest may declare `[[files]]` items that carry arbitrary files — multiline PEMs, TOML configs — from source to sink and materialize them under `~/.agentcookie/<target>` at mode `0600`. Because the wire envelope is `map[string]map[string]string`, each item rides as two reserved companion keys plus an optional env-name pointer.

### Manifest item

```toml
[[files]]
source = "~/.config/tesla-pp-cli/config.toml"
key    = "TESLA_CONFIG_TOML"
target = "tesla-pp-cli/config.toml"
env    = "TESLA_CONFIG"
optional = false
```

<ParamField path="source" type="string" required>Absolute path or `~/`-prefixed path on the source machine. Must exist, be a file, and stay under 256 KiB.</ParamField>
<ParamField path="key" type="string" required>Wire envelope key under which the base64-encoded payload travels. Must be a valid env-var name; must be unique across `[[files]]`.</ParamField>
<ParamField path="target" type="string" required>Relative path under `~/.agentcookie/` on the sink. No `..`, no absolute paths, no escape from the root.</ParamField>
<ParamField path="env" type="string">Env-var name the sink emits via `secret env`, pointing at the ABSOLUTE materialized path of the carried file. Lets a path-reading CLI consume the synced file without hardcoding a per-machine path.</ParamField>
<ParamField path="optional" type="bool" default="false">When `true`, discovery does NOT carry the item unless the user opts in via `~/.agentcookie/file-optin/<cli>.keys`.</ParamField>

### On-wire shape

`CarryFiles` (`internal/secretsbus/filecarriage.go`) produces three keys per item in the CLI's flat env map:

```text
<key>                = <base64(file bytes)>
_FILE_<key>          = <target relative path>
_FILEENV_<key>       = <env var name>       # only when item.Env is set
```

`CarryFileKey()` and `CarryFileEnvKey()` are the helpers that produce those reserved names. The opt-in gate lives at `~/.agentcookie/file-optin/<cli>.keys`, one key per line; `#` comments and blank lines are ignored. A missing file means "nothing opted in," so an `optional = true` item is silent until the user adds its `key` there. Default (non-optional) items skip this gate entirely.

### Sink materialization

`MaterializeFiles` runs before `secrets.env` is rendered, so a carried file never also leaks as an env-var payload:

```text
sink: WritePayload(homeDir, payload, sealing)
        for each cli in payload:
            safe := drop invalid key names
            res, consumed, errs := MaterializeFiles(homeDir, cli, safe)
            delete consumed keys from safe
            for envName, absPath := range res.EnvAdditions:
                safe[envName] = absPath         # points e.g. TESLA_CONFIG at the file
            atomicWrite(secrets.env, render(safe), 0600)
            if masterKey != nil: atomicWrite(secrets.env.sealed, seal(...), 0600)
```

Each `_FILE_<K>` companion triggers:

1. Re-validate `<target>` with `validateMaterializeTarget` (relative, no `..`, no absolute, no root escape).
2. Base64-decode the payload under `<K>`.
3. Refuse if decoded bytes exceed 256 KiB.
4. `safeJoinUnderRoot(~/.agentcookie, target)` — rejects anything outside the agentcookie root after `filepath.Clean`.
5. `os.MkdirAll(dir, 0700)`, then `atomicWrite(dest, decoded, 0600)`.
6. Mark `<K>`, `_FILE_<K>`, and (when present) `_FILEENV_<K>` consumed so they never reach `secrets.env`.

If an `env` companion is set and names a valid env-var, `MaterializeResult.EnvAdditions[envName] = absPath` is merged into the CLI's env map before render, so `secret env` emits e.g. `TESLA_CONFIG=/Users/you/.agentcookie/tesla-pp-cli/config.toml`.

<Tip>
The sink does not trust the wire. Every target is re-validated even though the source's `ParseManifestV2` already validated the manifest. A hand-built payload that bypasses parse cannot escape `~/.agentcookie/`.
</Tip>

### Counters

`WriteResult` reports the per-`/sync` outcome the sink uses for logging:

<ResponseField name="CLIsWritten" type="int">CLIs that received at least one file write.</ResponseField>
<ResponseField name="KeysWritten" type="int">Total KEY=VALUE pairs persisted across all CLIs.</ResponseField>
<ResponseField name="SealedWritten" type="int">Number of `secrets.env.sealed` files written.</ResponseField>
<ResponseField name="PlaintextWritten" type="int">Number of `secrets.env` files written.</ResponseField>
<ResponseField name="FilesMaterialized" type="int">Number of carried files written under `~/.agentcookie/`.</ResponseField>

## Defensive validation surface

| Layer | Check | Failure mode |
| --- | --- | --- |
| Source `LoadPayload` | `validCLIName(<dir>)` | Skip dir, append non-fatal error. |
| Source `LoadPayload` | `secrets.env` size ≤ 256 KiB | Skip CLI, ship others. |
| Source `parseEnvFile` | Whitespace around `=`, invalid key | Return error with line number. |
| Source `applySyncPolicy` | `[sync.keys]` overrides | Per-key drop before wire. |
| Source `CarryFiles` | Source exists, not a directory, size ≤ 256 KiB | Item dropped, error surfaced. |
| Sink `WritePayload` | `validCLIName(<from wire>)` | Refuse to write that CLI. |
| Sink `WritePayload` | `validKeyName(<each k>)` | Drop key, log error. |
| Sink `MaterializeFiles` | `validateMaterializeTarget` + `safeJoinUnderRoot` | Refuse, write nothing. |
| Sink `MaterializeFiles` | Decoded size ≤ 256 KiB | Refuse, log error. |
| Sink `MaterializeFiles` | Strip dangling `_FILEENV_*` | Companion never lands in `secrets.env`. |

## Worked example

After a successful `/sync` for a Tesla PP CLI that carries an OAuth bearer and a fleet key file:

```text
~/.agentcookie/
├── secrets/
│   └── tesla-pp-cli/
│       ├── manifest.toml          0600  schema_version = 1, [sync] default = true
│       ├── secrets.env            0600  TESLA_OAUTH_BEARER=…
│       │                                TESLA_CONFIG=/Users/you/.agentcookie/tesla-pp-cli/config.toml
│       │                                TESLA_FLEET_KEY_FILE=/Users/you/.agentcookie/tesla-pp-cli/fleet.pem
│       └── secrets.env.sealed     0600  agc1-sealed bytes (when master key present)
└── tesla-pp-cli/
    ├── config.toml                0600  carried from source ~/.config/tesla-pp-cli/config.toml
    └── fleet.pem                  0600  carried from source PEM
```

The plaintext `secrets.env` contains only the OAuth bearer plus the two pointer env vars; the raw base64 payload key and `_FILE_*` / `_FILEENV_*` companions were consumed during materialization and never reach disk.

## Related pages

<CardGroup cols={2}>
<Card title="Secrets bus" href="/secrets-bus">The end-to-end push: how bearer tokens, API keys, and KEY=VALUE blobs ride the same encrypted transport into this layout.</Card>
<Card title="Manifest v2 reference" href="/manifest-v2-reference">Full schema for `agentcookie.toml`, including `[secrets]`, `[aliases]`, and `[[files]]`.</Card>
<Card title="pkg/agentcookiesecret reader" href="/go-reader-library">In-process Go API that applies the sealed → plaintext → fallback → env priority chain for a consuming CLI.</Card>
<Card title="Wire protocol v1" href="/wire-protocol">`POST /sync`, `SyncEnvelope` JSON fields, and AES-256-GCM seal that wraps the bus payload.</Card>
<Card title="Adopt a CLI with agentcookie.toml" href="/adopt-a-cli-manifest">Authoring `[secrets]` plus `[[files]]` for a new consumer and running `agentcookie discover`.</Card>
<Card title="Configuration files reference" href="/config-files-reference">Schemas and validation rules for the surrounding `source.yaml`, `sink.yaml`, allow/block lists.</Card>
</CardGroup>

---

## 20. pkg/agentcookiesecret reader library

> In-process Go API for consuming the secrets bus from a CLI: `Load`, key resolution rules, refresh semantics, and how a CLI uses it instead of shelling out.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/20-pkg-agentcookiesecret-reader-library.md
- Generated: 2026-06-01T03:14:41.430Z

### Source Files

- `pkg/agentcookiesecret/doc.go`
- `pkg/agentcookiesecret/load.go`
- `pkg/agentcookiesecret/load_test.go`
- `pkg/agentcookieadoption/adoption.go`
- `docs/runbook-secrets-bus-gh-example.md`
- `examples/gh-shim/gh`

---
title: "pkg/agentcookiesecret reader library"
description: "In-process Go API for consuming the secrets bus from a CLI: `Load`, key resolution rules, refresh semantics, and how a CLI uses it instead of shelling out."
---

`pkg/agentcookiesecret` is the canonical in-process Go reader for the agentcookie secrets bus. A consuming CLI imports `github.com/mvanhorn/agentcookie/pkg/agentcookiesecret`, calls `Load("<cli-name>")` once at startup, and receives the merged `KEY=VALUE` map drawn from the v1 bus layout under `~/.agentcookie/secrets/<cli>/`, an optional caller fallback file, and process environment. The package is read-only against the bus, has no non-stdlib dependency outside `internal/keystore` (used only to unseal `secrets.env.sealed`), and never writes — rotation, format, and sealing transitions remain agentcookie's responsibility.

## API surface

The package exposes three loader entry points, one structured result type, one error sentinel, and an enum identifying provenance.

<ParamField path="Load" type="func(cliName string) (map[string]string, error)">
Resolves the bus + process env for `cliName` and returns the merged map. Equivalent to `LoadWithFallback(cliName, "")`. Use this in CLIs that have no legacy config file to honor during migration.
</ParamField>

<ParamField path="LoadWithFallback" type="func(cliName, fallbackPath string) (map[string]string, error)">
Same as `Load` but also reads `fallbackPath` (a KEY=VALUE file in the bus grammar) at a lower priority than the bus and a higher priority than process env. Use during a transition from an existing config file to the bus.
</ParamField>

<ParamField path="LoadDetailed" type="func(cliName, fallbackPath string) (*LoadResult, error)">
Returns the full `LoadResult` with per-key `Sources`. Use this in debug commands or tooling that must show provenance ("did this value come from the sealed bus, the plaintext bus, fallback, or env?").
</ParamField>

<ParamField path="LoadResult" type="struct">
`Env map[string]string` is the merged map most callers read. `Sources map[string]Source` records the originating layer for every key in `Env`.
</ParamField>

<ParamField path="ErrInvalidCLIName" type="error">
Returned when `cliName` violates the v1 naming rules. Distinguishes a malformed argument from a CLI that simply has no entry on the bus.
</ParamField>

<ParamField path="Source" type="int enum">
`SourceBusSealed`, `SourceBusPlain`, `SourceFallback`, `SourceEnv`. Reported in `LoadResult.Sources` for each key.
</ParamField>

## Resolution chain

Resolution merges four layers from lowest priority to highest. Later layers overwrite earlier ones, so the final value for each key is the highest-priority layer that defined it. **Bus values always win over caller fallback and process env**: if a key exists on the bus, the bus owns its value and a stale env var cannot silently shadow it.

| # | Layer | Path | `Source` tag |
| --- | --- | --- | --- |
| 1 | Sealed bus (highest) | `~/.agentcookie/secrets/<cli>/secrets.env.sealed` | `SourceBusSealed` |
| 2 | Plaintext bus | `~/.agentcookie/secrets/<cli>/secrets.env` | `SourceBusPlain` |
| 3 | Caller fallback | `fallbackPath` argument | `SourceFallback` |
| 4 | Process env (lowest) | `os.Environ()` | `SourceEnv` |

A small set of noisy shell-internal keys is dropped from the env layer: `PWD`, `OLDPWD`, `SHLVL`, and `_`. Everything else from the process environment is included so callers can compose bus values with arbitrary runtime env, with bus precedence intact.

```text
┌─────────────────────────────────────────────────────────────┐
│ result.Env / result.Sources                                 │
├─────────────────────────────────────────────────────────────┤
│ 1. sealed bus       secrets.env.sealed   (highest, if open) │
│ 2. plaintext bus    secrets.env                             │
│ 3. fallback file    fallbackPath (LoadWithFallback)         │
│ 4. process env      os.Environ() minus shell-internal keys  │
└─────────────────────────────────────────────────────────────┘
            ▲ each layer overwrites the layers above it
```

### Sealed-file handling

When `secrets.env.sealed` exists, `LoadDetailed` reads the master key via `keystore.ReadMasterKey()` (the macOS Keychain item with service `agentcookie-master`), unseals the file, parses the plaintext bytes through the same env grammar, and tags those keys `SourceBusSealed`.

The sealed-file path is the **only** path that surfaces a hard error from a healthy invocation:

- If `secrets.env.sealed` is present and the master key is missing, `LoadDetailed` returns `(res, error)` — the partial result is preserved, and the error wraps `keystore.ErrMasterKeyMissing` with a `read master key for sealed file at <path>` prefix.
- Read or unseal failures are returned with `read sealed file ...` / `unseal ...` / `parse unsealed ...` prefixes.
- Plain-bus and fallback file errors are silently treated as "not present"; missing bus directories never raise.

### Key-name resolution rules

`Load` does **not** transform keys. The map is keyed by the literal `KEY` from `secrets.env`, the fallback file, or `os.Environ()`. There is no alias resolution inside the Go reader; consumer-declared aliases (the `[aliases]` block from `agentcookie.toml`) are applied by the `agentcookie secret env <cli>` command at print time, not by the in-process reader. A CLI that wants alias-aware values should either consume the canonical key under the alias or invoke its own mapping logic on top of the returned `Env`.

## CLI name validation

`Load*` rejects malformed `cliName` with `ErrInvalidCLIName` before touching the filesystem. The rules mirror the v1 spec and the bus's path-traversal defense:

- Lowercase ASCII letters, digits, and the hyphen `-` only.
- 1 to 64 characters.
- Must not start or end with a hyphen.
- No uppercase, underscore, dot, slash, whitespace, or other punctuation.

Examples (from `TestValidCLIName`):

| Input | Accepted |
| --- | --- |
| `foo` | yes |
| `foo-bar` | yes |
| `foo-pp-cli` | yes |
| `Foo` | no |
| `foo_bar` | no |
| `foo.bar` | no |
| `-foo` / `foo-` | no |
| `` (empty) | no |

`../etc` and `Bad-Name` both yield `errors.Is(err, ErrInvalidCLIName) == true`.

## Env-file grammar

The reader implements a strict subset of dotenv — the same subset documented in the v1 spec — so any conforming producer round-trips through the reader without surprises.

- Lines are scanned with `bufio.Scanner`, max line size 256 KiB.
- Blank lines and lines whose first non-space character is `#` are skipped.
- An entry is `KEY=VALUE`. The `=` is the first one on the line.
- Whitespace **around** the `=` is rejected (`KEY = value` raises an error).
- Keys must start with an ASCII letter or `_`, then letters, digits, or `_`. Invalid keys raise an error naming the line number.
- A value wrapped in matching `"` or `'` quotes has the quotes stripped.
- A line ending in `\` continues onto the next line; the trailing backslash is dropped and the next line is appended. An unterminated continuation raises an error.

<CodeGroup>
```env secrets.env
# v1 grammar examples
GH_TOKEN=ghu_xxxxxxxxxxxxxxxxxxxx
QUOTED_DQ="hello world"
QUOTED_SQ='also quoted'
LONG=part1\
part2
```

```go reader behavior
// QUOTED_DQ == "hello world"
// QUOTED_SQ == "also quoted"
// LONG     == "part1part2"
```
</CodeGroup>

## Refresh semantics

`Load*` is a one-shot read. There is no goroutine, no watcher, no cache invalidation, and no auto-refresh inside the package. The library deliberately holds no state across calls.

- A CLI that runs as a short-lived subprocess (the common case for `gh`, `aws`, PP-CLIs, shims) calls `Load` once at startup and uses the returned map for the lifetime of the process. The sink writes the next push to disk and the next subprocess sees the new values on its next `Load`.
- A long-lived daemon that needs to pick up rotated credentials must call `Load` again — typically on a timer, on a SIGHUP, or when a downstream call returns 401. Reusing the result map across rotations is the caller's bug, not the reader's.
- Because bus layers always overwrite env, a value that was previously env-only and is now on the bus flips to the bus value on the next `Load` without requiring the caller to clear or reset anything.

The library also makes no assumption that the underlying files exist atomically together; readers see a single consistent snapshot per `Load` because each file is read sequentially with the standard library. The sink is responsible for writing the bus atomically (see `secrets-bus-layout`).

## How a CLI uses it instead of shelling out

A non-PP CLI can pull bus values either by shelling out to `agentcookie secret env <cli>` (the shim pattern, used by `examples/gh-shim/gh`) or by importing `pkg/agentcookiesecret` directly when the CLI is itself Go.

The shim pattern is appropriate when the target CLI is upstream-maintained and must not be modified — the shim sits on `$PATH` ahead of the real binary, sources `agentcookie secret env <cli>`, exports the keys it cares about, and `exec`s the real binary. This costs a `fork+exec` of `agentcookie` per invocation and depends on `agentcookie` being on `$PATH`.

The in-process reader is appropriate when the CLI's own author owns the source. Calling `agentcookiesecret.Load("<cli>")` at startup is one map lookup away from the same KEY=VALUE pairs the shim would have exported, with no subprocess, no PATH dependency, and no need to install the `agentcookie` binary alongside the consumer.

### Pattern: bus-first, file-fallback (PP CLI migration)

This is the recommended adoption shape for a CLI that already has its own config file. The bus wins for keys it has; the existing loader still answers for keys it doesn't.

```go
import (
    "errors"
    "log"
    "os"
    "path/filepath"

    "github.com/mvanhorn/agentcookie/pkg/agentcookiesecret"
)

func loadAuth() *Config {
    busEnv, err := agentcookiesecret.LoadWithFallback(
        "my-pp-cli",
        filepath.Join(os.Getenv("HOME"), ".config", "my-pp-cli", "config.toml"),
    )
    if err != nil && !errors.Is(err, agentcookiesecret.ErrInvalidCLIName) {
        // sealed file present but master key missing, or similar -
        // fall through to your existing loader
        log.Printf("agentcookiesecret: %v; falling back", err)
    }

    // ... feed busEnv into your existing TOML/JSON loader; the bus's
    // KEY=VALUE pairs take precedence for keys it owns.
    return mergeWithFileConfig(busEnv)
}
```

### Pattern: provenance-aware loader

When the CLI needs to log where each key came from, switch to `LoadDetailed` and read `Sources`:

```go
res, err := agentcookiesecret.LoadDetailed("demo-cli", "")
if err != nil {
    return err
}
for k, src := range res.Sources {
    log.Printf("%s <- %v", k, src) // SourceBusSealed, SourceBusPlain, ...
}
```

### Pattern: shim vs reader, side by side

<CodeGroup>
```bash gh-shim (subprocess)
# examples/gh-shim/gh
if bus_env="$(agentcookie secret env gh 2>/dev/null)"; then
    while IFS= read -r line; do
        case "$line" in
            GH_TOKEN=*|GITHUB_TOKEN=*|GH_HOST=*|GH_ENTERPRISE_TOKEN=*)
                export "${line?}"
                ;;
        esac
    done <<< "$bus_env"
fi
exec "$real_gh" "$@"
```

```go in-process reader
env, err := agentcookiesecret.Load("gh")
if err == nil {
    if v, ok := env["GH_TOKEN"]; ok {
        os.Setenv("GH_TOKEN", v)
    }
    if v, ok := env["GITHUB_TOKEN"]; ok && os.Getenv("GH_TOKEN") == "" {
        os.Setenv("GH_TOKEN", v)
    }
}
```
</CodeGroup>

Both surfaces consume the same on-disk format; the reader is the in-process equivalent of the shim's `agentcookie secret env <cli>` call.

## Error model

`Load*` returns `(value, nil)` whenever the bus is simply absent — that is the normal degraded path for a sink with no entry for this CLI, and the CLI is expected to fall through to its own auth source. The cases where an error is returned:

| Condition | Returned error |
| --- | --- |
| `cliName` violates naming rules | `fmt.Errorf("%w: %q", ErrInvalidCLIName, cliName)` |
| `secrets.env.sealed` exists, master key missing | wraps `keystore.ErrMasterKeyMissing` |
| `secrets.env.sealed` exists, OS read fails | `read sealed file <path>: <err>` |
| `secrets.env.sealed` exists, unseal fails | `unseal <path>: <err>` |
| Unsealed plaintext fails to parse | `parse unsealed <path>: <err>` |
| Plaintext `secrets.env` or fallback file has bad grammar | scanner error from the parser (line number included) |

Notably, `LoadDetailed` returns the partial `*LoadResult` together with sealed-path errors, so a caller that wants to keep the env/plaintext layers it already gathered can do so while still surfacing the sealed-file failure.

<Warning>
Callers should not treat `err != nil` as "no bus available." Use `errors.Is(err, agentcookiesecret.ErrInvalidCLIName)` for the bad-name case, and treat the remaining errors as the sealed-file failure modes above — typically logged and fallen-through to the CLI's existing loader.
</Warning>

## Constraints and what the library does not do

- **No writes.** The package only opens files for reading. Producing values on the bus is the source side's job and uses `agentcookie secret set` or `agentcookie secret import-from`.
- **No alias resolution.** `[aliases]` from `agentcookie.toml` is applied by the `agentcookie secret env` command, not here. The Go reader returns canonical keys.
- **No platform-specific keystore lookup.** The only non-stdlib import is `internal/keystore` for unsealing `secrets.env.sealed` on macOS. The reader is otherwise platform-independent and only resolves `$HOME` via `os.UserHomeDir()`.
- **No background refresh.** Each `Load` is independent; the caller decides when to call again.
- **No internal dependency on the rest of `internal/`** except `internal/keystore`, keeping the public package surface narrow and stable across releases.

## Related pages

<CardGroup cols={2}>
  <Card title="Secrets bus" href="/secrets-bus">
    What the bus carries end to end: bearer tokens, KEY=VALUE blobs, sealed twins, and the v2 adoption tiers.
  </Card>
  <Card title="Secrets bus on-disk layout" href="/secrets-bus-layout">
    The v1 directory layout under `~/.agentcookie/secrets/<cli>/`, file modes, and `secrets.env` grammar.
  </Card>
  <Card title="Adopt a CLI with agentcookie.toml" href="/adopt-a-cli-manifest">
    The v2 adoption manifest: `[secrets]`, `[aliases]`, `[[files]]`, and the manifest-driven sync model.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `agentcookie secret env`, `secret list`, `secret set`, `secret import-from`, and adjacent subcommands.
  </Card>
  <Card title="agentcookie.toml manifest reference" href="/manifest-v2-reference">
    Schema, project kinds, and integration tiers for the manifest the discovery loop reads.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    Common failures: missing master key on a headless sink, stale shell env shadowing the bus, and recovery steps.
  </Card>
</CardGroup>

---

## 21. doctor and adapter verification

> The fifteen `agentcookie doctor` check categories, the `DoctorReport` JSON envelope, `wizard verify-adapters` output, and exit-code semantics for agent consumption.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/21-doctor-and-adapter-verification.md
- Generated: 2026-06-01T03:14:01.766Z

### Source Files

- `internal/cli/doctor.go`
- `internal/cli/wizard_verify_adapters.go`
- `internal/cli/status.go`
- `internal/cli/secret_coverage.go`
- `internal/state/state.go`

---
title: "doctor and adapter verification"
description: "The fifteen `agentcookie doctor` check categories, the `DoctorReport` JSON envelope, `wizard verify-adapters` output, and exit-code semantics for agent consumption."
---

`agentcookie doctor` runs fifteen role-aware self-checks against the local install and emits one row per check; `agentcookie wizard verify-adapters` reads the last sink-side adapter run out of `~/.agentcookie/sink-state.json`. Both surfaces are designed for unattended agent use: each supports a JSON envelope under the global `--json` flag, and exit codes are derived strictly from the severities of the check rows (no partial failures, no human-only signals).

## Command surface

| Command | Reads | Side effects | Exit semantics |
| --- | --- | --- | --- |
| `agentcookie doctor` | Tailscale daemon, `~/.config/agentcookie/`, `~/.agentcookie/`, Chrome Safe Storage probe | None (binds the sink listen address briefly to verify occupancy, then closes) | `0` if no row is `FAIL`; `1` if any row is `FAIL`. `WARN`, `INFO`, and `SKIPPED` never raise exit. |
| `agentcookie doctor --json` | Same | None | Same exit semantics; payload is `DoctorReport`. |
| `agentcookie wizard verify-adapters` | `~/.agentcookie/sink-state.json` | None | `0` on success or no-state-yet; `1` if any adapter result has a non-empty `error`. |
| `agentcookie wizard verify-adapters --json` | Same | None | Same exit semantics; payload is `{status, results}`. |

<Note>
`doctor` never phones home. The only network surface it touches is the local `tailscaled` introspection socket to learn the tailnet IP.
</Note>

## The fifteen check categories

Each row carries a `name`, a `severity` (`ok`, `warn`, `fail`, `info`, `skipped`), a `detail` one-liner, and an optional `remediation` string. Checks gated on role return `skipped` on the wrong role rather than being omitted from the report, so the row count is stable across source-only, sink-only, and dual installs.

| # | Name | Role | Severities used | What it verifies |
| --- | --- | --- | --- | --- |
| 1 | `Binary signature` | both | `ok`, `warn` | `codesign -d -r-` output contains Team ID `NM8VT393AR`. Ad-hoc / unsigned local builds always `warn`, never `fail`. |
| 2 | `Tailscale` | both | `ok`, `fail` | `tsclient.RequireTailnetIP` returns a tailnet address. Remediation pins `tailscale up`. |
| 3 | `Config` | both | `ok`, `fail` | At least one of `source.yaml` / `sink.yaml` exists in `ConfigDir` and parses. Missing both or parse error is `fail`. |
| 4 | `Keystore` | both | `ok`, `fail`, `skipped` | Every configured peer hostname has a key file at `~/.config/agentcookie/keys/<peer>.json` with mode `0600`. |
| 5 | `Sink listener` | sink | `ok`, `fail`, `skipped` | A `net.Listen("tcp", addr)` on `sink.listen.addr` fails (i.e. something else is bound). A successful bind means nothing is listening. |
| 6 | `Sink state` | sink | `ok`, `warn`, `fail`, `skipped` | `~/.agentcookie/sink-state.json` exists, parses, and `LastWrite` is within 24h. Missing state is `fail`; >24h is `warn`. |
| 7 | `Source state` | source | `ok`, `warn`, `fail`, `skipped` | `~/.agentcookie/source-state.json` exists, parses, `LastPush` is within 24h, and `TotalFailures == 0`. |
| 8 | `DBSC` | source | `ok`, `warn`, `skipped` | Informational. `warn` when the last push flagged any device-bound (DBSC-suspect) cookies; carries one sample reason in `detail`. |
| 9 | `Sealing` | sink | `ok`, `skipped` | Reports whether the `agentcookie-master` Keychain item exists. Never raises exit; `disabled` is the v0.12 closed-beta default. |
| 10 | `Adapter coverage` | sink | `ok`, `warn`, `skipped` | Each unique `host_key` in the sidecar matches at least one adapter's `CookieHostPatterns`. Up to 3 uncovered hosts are listed. |
| 11 | `CDP injector` | sink | `ok`, `warn`, `skipped` | When `cdp.enabled`, `cdp.profile_dir` exists and `Google Chrome.app` is installed in `/Applications` or `~/Applications`. |
| 12 | `Secrets bus` | both | `ok`, `warn`, `fail`, `skipped` | Walks `~/.agentcookie/secrets/<cli>/` and reports CLI count, key count, sealed/plaintext/mixed mode, and freshness of the newest file. |
| 13 | `Secret coverage` | both | `ok`, `warn` | For every registered v2 manifest, the synced `secrets.env` either covers each declared `[secrets]` key or there is an `agentcookie secret alias` for it. |
| 14 | `Binary install` | both | `ok`, `warn` | Resolves `~/go/bin/agentcookie`, `~/bin/agentcookie`, `/usr/local/bin/agentcookie`, `/opt/homebrew/bin/agentcookie`, the on-PATH `agentcookie`, and the running binary; `warn` when two non-symlinked copies differ in size or mtime. |
| 15 | `Cookie delivery` | sink | `ok`, `warn`, `info`, `skipped` | Composite verdict over `skip_chrome_sqlite`, the live Chrome Safe Storage probe, and the Safe Storage Keychain item count. See [Cookie delivery verdict table](#cookie-delivery-verdict). |

<Warning>
The check list is part of the agent-facing contract. Names appear verbatim in the `DoctorReport.Checks[].name` field and should be matched by string. Severity strings (`ok`, `warn`, `fail`, `info`, `skipped`) are lowercase in JSON; the human renderer maps them to `[OK]`, `[WARN]`, `[FAIL]`, `[INFO]`, `[--]` for display only.
</Warning>

## DoctorReport JSON envelope

The envelope is intentionally small and stable. The `version` field carries the binary's `Version` string at build time; `exit_code` is the same value the process will exit with.

<ResponseField name="version" type="string" required>
Build version of the `agentcookie` binary that produced the report.
</ResponseField>

<ResponseField name="exit_code" type="integer" required>
`0` when no check has severity `fail`; `1` otherwise. `warn`, `info`, and `skipped` never raise the exit code.
</ResponseField>

<ResponseField name="checks" type="array" required>
Ordered list of check rows. Order is stable across runs and matches the table above.
</ResponseField>

<ResponseField name="checks[].name" type="string" required>
Human-readable check label, e.g. `Binary signature`, `Cookie delivery`.
</ResponseField>

<ResponseField name="checks[].severity" type="string" required>
One of `ok`, `warn`, `fail`, `info`, `skipped`.
</ResponseField>

<ResponseField name="checks[].detail" type="string" required>
One-line description of the observed state. Embeds counts, ages, addresses, and sample identifiers; safe for log capture but not stable across versions.
</ResponseField>

<ResponseField name="checks[].remediation" type="string">
Concrete next command or one-line fix. Omitted when severity is `ok`, `info`, or `skipped` (and sometimes when no remediation is meaningful).
</ResponseField>

<ResponseExample>
```json doctor --json (sink-side, all green)
{
  "version": "v0.13.2",
  "exit_code": 0,
  "checks": [
    {"name": "Binary signature", "severity": "ok", "detail": "Developer ID Application (NM8VT393AR)"},
    {"name": "Tailscale", "severity": "ok", "detail": "100.x.y.z reachable on local tailnet interface"},
    {"name": "Config", "severity": "ok", "detail": "sink.yaml present, parses OK"},
    {"name": "Keystore", "severity": "ok", "detail": "peer key for laptop present (mode 0600)"},
    {"name": "Sink listener", "severity": "ok", "detail": "bound on 100.x.y.z:8443"},
    {"name": "Sink state", "severity": "ok", "detail": "last write 12s ago, mode=cdp, 0 rejected"},
    {"name": "Source state", "severity": "skipped", "detail": "sink-only install"},
    {"name": "Sealing", "severity": "ok", "detail": "disabled (default in v0.12 closed beta)"},
    {"name": "Adapter coverage", "severity": "ok", "detail": "all 42 host_keys in sidecar covered by an adapter"},
    {"name": "CDP injector", "severity": "ok", "detail": "profile_dir=~/.agentcookie/chrome-profile is the synced/logged-in profile, Chrome=/Applications/Google Chrome.app"},
    {"name": "Secrets bus", "severity": "ok", "detail": "3 cli(s), 11 key(s), mode=plaintext, newest 12s ago"},
    {"name": "Secret coverage", "severity": "ok", "detail": "synced secrets match the auth env var each CLI reads"},
    {"name": "Binary install", "severity": "ok", "detail": "single agentcookie binary on this machine"},
    {"name": "Cookie delivery", "severity": "ok", "detail": "universal (delivery=universal): real Default profile written and Chrome Safe Storage readable; any unmodified cookie CLI works here"}
  ]
}
```
</ResponseExample>

<Note>
The `DBSC` row is source-only, so the example above (sink-only) does not contain it. On a source install it appears between `Source state` and `Sealing`. The 15th check exists in every report; `skipped` rows take the place of role-inapplicable checks rather than being omitted.
</Note>

## Cookie delivery verdict {#cookie-delivery-verdict}

`Cookie delivery` is the only composite check. It folds three signals into one row so an agent can branch on a single verdict.

```text
                              skip_chrome_sqlite?
                                      |
                  +-------------------+-------------------+
                  | true                                   | false
                  v                                        v
            [INFO] degraded                          count Safe Storage items
            (sidecar-only; not                              |
            universal)                                      +-- n > 1 -> [WARN] race
                                                            |               (duplicate items)
                                                            |
                                                            v
                                                   probe Chrome Safe Storage
                                                            |
                                +---------------------------+-------------------+
                                | keyLen > 0, no error                          | error or keyLen == 0
                                v                                               v
                          [OK] universal                                  keychain locked?
                          (any cookie CLI works)                                |
                                                                     +----------+-----------+
                                                                     | yes                  | no
                                                                     v                      v
                                                              [INFO] universal-       [WARN] partial
                                                              config-locked            (one-password grant
                                                              (SSH false negative)     has not run)
```

| Input | Result | Severity |
| --- | --- | --- |
| `skip_chrome_sqlite=true` | `degraded`: only agentcookie-aware tools see synced cookies | `info` |
| `n > 1` Safe Storage items | `race`: install-time Chrome relaunch left duplicates | `warn` |
| Probe returns `keyLen > 0` | `universal`: any unmodified cookie CLI works | `ok` |
| Probe error + login keychain locked | `universal config; key can't be verified from this session` | `info` |
| Probe error / `keyLen == 0`, single item | `partial`: real profile written, key not granted | `warn` |

The canonical remediation for the `partial` and `degraded` rows is the one-password grant `agentcookie wizard set-keychain-access` (non-interactive form: `AGENTCOOKIE_LOGIN_PASSWORD=… agentcookie wizard set-keychain-access`).

## Exit-code semantics for agents

The exit code is derived after every check runs, so a single `fail` does not short-circuit the rest of the report.

<Steps>
<Step title="Iterate Checks in order">
The runner walks `checks[]` and sets `exit_code = 1` on the first `severity == "fail"`.
</Step>
<Step title="WARN/INFO/SKIPPED never affect exit">
`Adapter coverage` warnings, `DBSC` warnings, `Binary install` divergence, locked-keychain `Cookie delivery` info, and any `skipped` row leave exit at `0`. An agent that wants to gate on these must inspect `severity` per row.
</Step>
<Step title="Process exits before any deferred work">
`runDoctor` calls `os.Exit(report.ExitCode)` directly after rendering. Any wrapper that expects Go's `RunE` error return to set the exit code should switch to inspecting the JSON envelope's `exit_code` field instead.
</Step>
</Steps>

<Tip>
For automated gating, prefer `agentcookie --json doctor` and pivot on `exit_code` plus a name/severity filter. The human renderer footer (`all green` / `N FAIL, M WARN`) is for operators, not parsers.
</Tip>

## wizard verify-adapters

`verify-adapters` is the sink-side post-mortem of the most recent sinkpush adapter run. It does not re-run adapters; it reads the `last_adapter_results` array out of `~/.agentcookie/sink-state.json`, which the sink populates after each accepted `/sync` write.

### Text output

```text
$ agentcookie wizard verify-adapters
ADAPTER                          STATUS  PUSHED  DETAIL
-------                          ------  ------  ------
instacart-pp-cli                 ok      14
airbnb-pp-cli                    ok      3
ebay-pp-cli                      skip            no ebay cookies in this batch
pagliacci-pp-cli                 FAIL            seal: master key not in Keychain
table-reservation-goat-pp-cli    ok      6

last run: 1m42s ago
```

Status values:

| Value | Meaning |
| --- | --- |
| `ok` | Adapter ran and wrote without error. `PUSHED` is the count of session entries written. |
| `skip` | Adapter ran but had nothing to do; `DETAIL` carries the `SkippedReason`. |
| `FAIL` | Adapter ran and returned an error; `DETAIL` carries the error string. Exit code becomes `1`. |

### JSON output

`agentcookie --json wizard verify-adapters` emits one of three envelopes:

```json no-state-yet
{"status": "no_state"}
```

```json no-runs-yet
{"status": "no_runs"}
```

```json ok
{
  "status": "ok",
  "results": [
    {"name": "instacart-pp-cli", "pushed": 14, "ran_at": "2026-05-31T10:14:21Z"},
    {"name": "airbnb-pp-cli", "pushed": 3, "ran_at": "2026-05-31T10:14:21Z"},
    {"name": "ebay-pp-cli", "skipped": true, "skipped_reason": "no ebay cookies in this batch", "ran_at": "2026-05-31T10:14:21Z"},
    {"name": "pagliacci-pp-cli", "error": "seal: master key not in Keychain", "ran_at": "2026-05-31T10:14:21Z"},
    {"name": "table-reservation-goat-pp-cli", "pushed": 6, "ran_at": "2026-05-31T10:14:21Z"}
  ]
}
```

<Info>
`verify-adapters` returns exit `0` for `no_state` and `no_runs`, since both are valid pre-first-sync conditions on a freshly paired sink. An agent gating on adapter health should treat `no_runs` as “run `agentcookie source --once` first”, not as a failure.
</Info>

### AdapterResult shape

The `results[]` entries are `state.AdapterResult` records, persisted by the sink writer and re-emitted verbatim.

<ResponseField name="name" type="string" required>
Registered adapter identifier (e.g. `instacart-pp-cli`).
</ResponseField>

<ResponseField name="pushed" type="integer">
Number of session entries written to the adapter's target file. Omitted when zero.
</ResponseField>

<ResponseField name="invalid" type="integer">
Entries the adapter's validate hook rejected. Omitted when zero.
</ResponseField>

<ResponseField name="skipped" type="boolean">
`true` when the adapter ran but had no work; `skipped_reason` carries the explanation.
</ResponseField>

<ResponseField name="skipped_reason" type="string">
Human-readable skip reason, e.g. `no ebay cookies in this batch`.
</ResponseField>

<ResponseField name="error" type="string">
Adapter error string. Non-empty drives `FAIL` status and exit `1`. The sink itself does not abort on adapter failures: the cookie write to Chrome's Default profile and the sidecar still succeed, so adapter `FAIL` is a partial-delivery signal, not a sync failure.
</ResponseField>

<ResponseField name="ran_at" type="string (RFC 3339)" required>
Wall-clock time the adapter run started. All entries from one sinkpush invocation share the same `ran_at`.
</ResponseField>

## What doctor does not check

These are intentionally out of scope and live in other commands:

- **Live sync correctness.** `doctor` does not POST to `/sync`. Use `agentcookie source --once` to force a push and re-run `doctor` to confirm `Sink state.last_write` advanced.
- **End-to-end secrets sync.** `Secrets bus` and `Secret coverage` describe on-disk state, not whether a CLI can read it. Use `agentcookie discover` to view the per-CLI declared keys, resolved aliases, and the live coverage verdict for one CLI at a time.
- **Adapter rerun.** `wizard verify-adapters` does not re-execute adapters. To re-run them, push again from the source side.
- **Pairing.** `Keystore` confirms a key file exists for each configured peer; it does not handshake. A missing or wrong-mode key is the signal to re-run `agentcookie pair`.

## Related pages

<CardGroup>
<Card title="CLI reference" href="/cli-reference">Every `agentcookie` subcommand and flag, including `doctor`, `wizard`, `status`, and `secret`.</Card>
<Card title="Configuration files reference" href="/config-files-reference">Schemas and validation rules `doctor` exercises against `source.yaml` and `sink.yaml`.</Card>
<Card title="Universal cookie delivery" href="/universal-delivery">The one-password grant and `teamid:` partition list behind the `Cookie delivery` verdict.</Card>
<Card title="Troubleshooting" href="/troubleshooting">Recovery paths for the most common `WARN`/`FAIL` remediations doctor emits.</Card>
<Card title="Write a cookie adapter" href="/write-cookie-adapter">How an adapter's `CookieHostPatterns` and validate hook flow into `Adapter coverage` and `verify-adapters`.</Card>
<Card title="LaunchAgent management" href="/launchagent-management">When `Sink listener` reports nothing bound, this is the bootstrap path.</Card>
</CardGroup>

---

## 22. LaunchAgent management

> How `wizard install` writes `dev.agentcookie.source` / `dev.agentcookie.sink` plists, bootstrap with `launchctl`, log paths, and the v0.9 soup-to-nuts lifecycle.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/22-launchagent-management.md
- Generated: 2026-06-01T03:15:57.339Z

### Source Files

- `internal/launchd/plist.go`
- `examples/launchd-sink.plist`
- `docs/runbook-v0.9-soup-to-nuts.md`
- `internal/cli/wizard.go`

---
title: "LaunchAgent management"
description: "How `wizard install` writes `dev.agentcookie.source` / `dev.agentcookie.sink` plists, bootstrap with `launchctl`, log paths, and the v0.9 soup-to-nuts lifecycle."
---

`agentcookie wizard install` writes a per-user LaunchAgent plist to `~/Library/LaunchAgents/dev.agentcookie.<role>.plist` and bootstraps it into the GUI domain (`gui/<uid>`) via `launchctl`, so the source watcher or sink listener runs inside the user's login session with Keychain and Chrome lifecycle privileges that SSH-launched processes lack. Plist generation lives in `internal/launchd/plist.go`; the wizard wraps it with role-specific args, a fixed log directory at `~/.agentcookie/logs/`, and a `launchctl kickstart -k` fallback when bootstrap races another job.

## Plist labels, paths, and roles

| Role | Label | ProgramArguments | Plist path |
| --- | --- | --- | --- |
| source | `dev.agentcookie.source` | `<binary> source --watch` | `~/Library/LaunchAgents/dev.agentcookie.source.plist` |
| sink | `dev.agentcookie.sink` | `<binary> sink` | `~/Library/LaunchAgents/dev.agentcookie.sink.plist` |

The label is derived from `Spec.Role` (`"dev.agentcookie." + role`) and the plist file lives under the per-user `~/Library/LaunchAgents/` directory, so there is no cross-user collision risk. `Spec.BinaryPath` must be absolute; the wizard fills it from `os.Executable()` and `filepath.Abs`, so the LaunchAgent always points at the `agentcookie` binary that ran `wizard install` (typically `~/go/bin/agentcookie`).

<Note>
Source-side `--watch` is passed as `ExtraArgs` from the wizard; the sink takes no extra args. The plist template renders one `<string>` per argv element, so `ProgramArguments` is `[BinaryPath, role, ...ExtraArgs]`.
</Note>

## Generated plist shape

The template in `internal/launchd/plist.go` produces the same fixed `<dict>` keys for both roles, with `Label`, `ProgramArguments`, `StandardOutPath`, and `StandardErrorPath` substituted per `Spec`.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>dev.agentcookie.source</string>

  <key>ProgramArguments</key>
  <array>
    <string>/Users/you/go/bin/agentcookie</string>
    <string>source</string>
    <string>--watch</string>
  </array>

  <key>RunAtLoad</key>
  <true/>

  <key>KeepAlive</key>
  <dict>
    <key>SuccessfulExit</key>
    <false/>
    <key>Crashed</key>
    <true/>
  </dict>

  <key>ThrottleInterval</key>
  <integer>10</integer>

  <key>ProcessType</key>
  <string>Background</string>

  <key>StandardOutPath</key>
  <string>/Users/you/.agentcookie/logs/source.out.log</string>

  <key>StandardErrorPath</key>
  <string>/Users/you/.agentcookie/logs/source.err.log</string>
</dict>
</plist>
```

### Lifecycle keys

<ParamField body="RunAtLoad" type="bool" required>
Always `true`. The agent starts at login and as soon as the plist is bootstrapped.
</ParamField>

<ParamField body="KeepAlive" type="dict" required>
Conditional restart: `SuccessfulExit=false` and `Crashed=true`. A clean exit (`os.Exit(0)`) does **not** trigger relaunch — only crashes do. This is intentional so a successful `agentcookie source --once` style invocation could end, but the long-running daemons do not exit on their own under normal operation.
</ParamField>

<ParamField body="ThrottleInterval" type="int" default="10">
Minimum seconds between restart attempts.
</ParamField>

<ParamField body="ProcessType" type="string" default="Background">
Signals macOS that the agent is a long-running background job, not interactive.
</ParamField>

<ParamField body="StandardOutPath / StandardErrorPath" type="path" required>
Absolute paths under `LogDir`. The wizard always sets `LogDir = ~/.agentcookie/logs`.
</ParamField>

## Log paths

`Render` derives standard out/err paths by joining `LogDir` with `<role>.out.log` and `<role>.err.log`. With the wizard's default `LogDir = ~/.agentcookie/logs`:

:::files
~/
└── .agentcookie/
    └── logs/
        ├── source.out.log
        ├── source.err.log
        ├── sink.out.log
        └── sink.err.log
:::

`Install` creates `~/Library/LaunchAgents/` and `LogDir` with `0o755` before writing the plist (mode `0o644`). Tail the err log to watch sink activity (`agentcookie sink: wrote N cookies …`, `probe ok`, or a `probe FAIL` stop-the-line); doctor checks point at the same path when the listener is unbound.

<Warning>
The committed `examples/launchd-sink.plist` is a **manual-install fallback**, not what `wizard install` writes. It uses `/tmp/agentcookie-sink.out.log` and an unconditional `KeepAlive`, and requires you to replace `REPLACE_WITH_FULL_PATH_TO_AGENTCOOKIE` and `REPLACE_WITH_USERNAME` before `launchctl bootstrap`. The wizard-generated plist supersedes it.
</Warning>

## Install, bootstrap, uninstall

`launchd.Install(spec)` performs the full write-and-load sequence and is idempotent — re-running it produces a refresh, not a duplicate.

```text
Install(spec):
  1. Render plist XML (validates Role, BinaryPath, LogDir).
  2. mkdir -p ~/Library/LaunchAgents          (0755)
  3. mkdir -p spec.LogDir                     (0755)
  4. write plist file                         (0644)
  5. launchctl bootout  gui/<uid>/<label>     (errors ignored: idempotent)
  6. launchctl bootstrap gui/<uid> <plistPath>
  7. return plistPath
```

The wizard calls `Install` through `installLaunchAgent`, which adds one race-recovery step: when `launchctl bootstrap` reports the job is already loaded, it falls back to `launchctl kickstart -k gui/<uid>/<label>` to force-restart the existing job.

```go
// internal/cli/wizard.go
func installLaunchAgent(spec launchd.Spec) error {
    _, err := launchd.Install(spec)
    if err != nil {
        if errIsAlreadyLoaded(err) {
            uid := fmt.Sprintf("%d", os.Getuid())
            return exec.Command("launchctl", "kickstart", "-k",
                "gui/"+uid+"/"+spec.Label()).Run()
        }
        return err
    }
    return nil
}
```

`launchd.Uninstall(spec)` is the inverse: `launchctl bootout gui/<uid>/<label>` followed by `os.Remove` of the plist. Missing files are tolerated; both halves of the call are idempotent.

`launchd.IsInstalled(spec)` shells out to `launchctl print gui/<uid>/<label>` and returns true when the label appears in the output. The wizard does not currently call this; it is exposed for external tooling and tests.

## Wizard install flow

The wizard chooses `Spec.ExtraArgs` per role and reuses `binPath` from `os.Executable()` so the running binary is what gets installed.

<Steps>
<Step title="Resolve binary and log dir">
`binPath, _ = os.Executable(); binPath, _ = filepath.Abs(binPath)` and `logDir = ~/.agentcookie/logs`. Both paths are inserted verbatim into the plist.
</Step>
<Step title="Source side: --watch agent">
After dropping `source.yaml`/`blocklist.yaml` and completing pairing, install:

```go
launchd.Spec{
  Role:       launchd.RoleSource,
  BinaryPath: binPath,
  LogDir:     logDir,
  ExtraArgs:  []string{"--watch"},
}
```

stderr prints `agentcookie wizard: source LaunchAgent installed and started`.
</Step>
<Step title="Sink side: bare daemon">
After rendering `sink.yaml` (with `skip_chrome_sqlite` / `cdp` / `delivery` resolved by the keychain-open path) and completing the sink half of the handshake, install:

```go
launchd.Spec{
  Role:       launchd.RoleSink,
  BinaryPath: binPath,
  LogDir:     logDir,
}
```

stderr prints `agentcookie wizard: sink LaunchAgent installed and started`.
</Step>
<Step title="Skip on demand">
`--skip-daemon` returns without calling `installLaunchAgent`, leaving configs and pairing on disk so the operator can run `agentcookie source --once` or `agentcookie sink` interactively for diagnostics.
</Step>
</Steps>

### Wizard uninstall

```bash
agentcookie wizard uninstall --as source        # remove LaunchAgent, keep configs
agentcookie wizard uninstall --as source --purge  # also delete configs and paired keys
```

`runWizardUninstall` calls `launchd.Uninstall(spec)` for the requested role and prints `agentcookie wizard: <role> LaunchAgent removed`. With `--purge`, it additionally deletes `source.yaml`, `sink.yaml`, `allowlist.yaml`, and the entire `keys/` directory under `~/.config/agentcookie/`.

## Manual operation reference

These are the same primitives the wizard wraps; useful for diagnostics or for the manual install path documented in `docs/quickstart.md`.

```bash
UID=$(id -u)

# Inspect the currently loaded job:
launchctl print gui/$UID/dev.agentcookie.sink

# Reload after editing the plist:
launchctl bootout    gui/$UID/dev.agentcookie.sink
launchctl bootstrap  gui/$UID ~/Library/LaunchAgents/dev.agentcookie.sink.plist

# Force-restart without unloading:
launchctl kickstart -k gui/$UID/dev.agentcookie.sink

# Tail logs:
tail -f ~/.agentcookie/logs/sink.err.log
tail -f ~/.agentcookie/logs/sink.out.log
```

`agentcookie doctor` surfaces an unbound sink listener with the same advice in its remediation field: `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.agentcookie.sink.plist`.

## v0.9 soup-to-nuts lifecycle

The v0.9 shipping signal is an end-to-end sink lifecycle in which an agent on the second Mac uses a PP CLI as you with zero manual paste. The LaunchAgent is the long-lived piece that makes it possible from an SSH session.

```mermaid
sequenceDiagram
    participant Op as Operator / agent (SSH)
    participant Wiz as agentcookie wizard
    participant LD as launchd (gui/<uid>)
    participant Sink as agentcookie sink
    participant Src as MBP source
    participant CLI as instacart-pp-cli

    Op->>Wiz: wizard install --as sink --peer ... --code ... --pair-url ...
    Wiz->>Wiz: render sink.yaml, pair, expand partition list
    Wiz->>LD: launchctl bootout gui/<uid>/dev.agentcookie.sink (ignored)
    Wiz->>LD: launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.agentcookie.sink.plist
    LD->>Sink: spawn `agentcookie sink` (StandardOutPath/StandardErrorPath)
    Op->>Src: agentcookie source --once
    Src->>Sink: POST /sync (encrypted envelope)
    Sink-->>Sink: wrote N cookies + probe ok (logged to sink.err.log)
    Op->>CLI: ssh mac-mini 'instacart doctor'
    CLI->>Sink: reads Chrome Safe Storage + Cookies SQLite silently
    CLI-->>Op: [ok] api: logged in as <user>
```

Verification points pulled directly from the runbook:

- `~/.agentcookie/logs/sink.err` contains `agentcookie sink: wrote N cookies (+ N sidecar)` and `probe ok: 3 cookies round-tripped, meta.version=18`.
- A `probe FAIL` line is a stop-the-line; the bridge is unhealthy and the soup-to-nuts run will not succeed.
- The SSH session output must contain no `auth paste`, `dump-instacart`, or clipboard step — the CLI reads Chrome Safe Storage and the Cookies SQLite directly through the bridge.

### Why a LaunchAgent (and not nohup or SSH)

- LaunchAgents run inside the user's GUI login session, so the login keychain is unlocked. SSH-launched processes see a locked keychain and Chrome Safe Storage reads fail with `exit status 36`.
- `KeepAlive { Crashed: true }` makes the sink resilient to subprocess crashes (e.g., a stale `~/.agentcookie/chrome-profile/SingletonLock` that takes Chrome down) without restart-looping on a clean exit.
- A fixed `dev.agentcookie.<role>` label gives `launchctl print`, `kickstart -k`, and `bootout` a stable target for diagnostics and reinstalls.

## Troubleshooting checklist

<AccordionGroup>
<Accordion title="Sink LaunchAgent runs but listener does not bind">
Dry-run friction #18 (2026-05-19) traced this to a Keychain read failing under SSH-bootstrapped contexts. `lsof -nP -iTCP:9999` shows nothing while `sink.out.log` keeps writing. Re-run `agentcookie wizard install --as sink ...` at the GUI console once, or use `agentcookie wizard set-keychain-access` to upgrade trust; the LaunchAgent then binds on next start.
</Accordion>
<Accordion title="bootstrap reports the job is already loaded">
`installLaunchAgent` falls back to `launchctl kickstart -k gui/<uid>/<label>`, which is also a safe manual recovery: it force-restarts the existing job without requiring a clean `bootout`.
</Accordion>
<Accordion title="Logs missing or empty">
`Install` creates `~/.agentcookie/logs/` with `0o755`. If the directory was removed under a running agent, `launchctl bootout` + `launchctl bootstrap` recreates it. Empty logs typically mean the agent has not started yet (check `launchctl print gui/$(id -u)/dev.agentcookie.sink`).
</Accordion>
<Accordion title="Example plist installed by hand">
`examples/launchd-sink.plist` writes to `/tmp/agentcookie-sink.{out,err}.log` and uses unconditional `KeepAlive` — useful for first-touch testing, but `agentcookie wizard uninstall --as sink` only targets the wizard-installed `dev.agentcookie.sink.plist`. Remove the manual copy explicitly if you switch to the wizard path.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Installation" href="/installation">
The full `wizard install` flow that produces the source and sink LaunchAgents.
</Card>
<Card title="Headless second-Mac install" href="/headless-install">
SSH-only sink install: degraded-mode fallback and `wizard set-keychain-access` upgrade.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
`agentcookie doctor` checks that surface unbound listeners and a `launchctl bootstrap` remediation.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Pairing refused, sink Keychain prompts, stale Chrome `SingletonLock`, and recovery steps.
</Card>
<Card title="Source and sink topology" href="/source-sink-topology">
Role split and what each LaunchAgent reads and writes at runtime.
</Card>
<Card title="CLI reference" href="/cli-reference">
`wizard install`, `wizard uninstall`, `--skip-daemon`, and related flags.
</Card>
</CardGroup>

---

## 23. Release, signing, and notarization

> The goreleaser pipeline, the `sign.sh` / `notarize.sh` / `release-tarball.sh` scripts, Developer ID signing for `teamid:` partition trust, and CI secret renewal.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/23-release-signing-and-notarization.md
- Generated: 2026-06-01T03:16:08.191Z

### Source Files

- `.goreleaser.yaml`
- `scripts/sign.sh`
- `scripts/notarize.sh`
- `scripts/release-tarball.sh`
- `docs/runbook-v0.12-codesign.md`
- `docs/runbook-v0.12-security-hardening.md`

---
title: "Release, signing, and notarization"
description: "The goreleaser pipeline, the `sign.sh` / `notarize.sh` / `release-tarball.sh` scripts, Developer ID signing for `teamid:` partition trust, and CI secret renewal."
---

`make release` is the single command that builds, Developer-ID signs, and Apple-notarizes `bin/agentcookie` on macOS. It shells through three scripts under `scripts/`: `sign.sh` (codesign with Hardened Runtime + secure timestamp), `notarize.sh` (xcrun notarytool round-trip), and `release-tarball.sh` (closed-beta bundle wrapping the notarized binary with the install script and quickstart). GoReleaser drives the same `sign.sh` post-hook on darwin/arm64 and darwin/amd64 cross-builds, then the `release` workflow attaches the beta tarball to the GitHub release. The default signing identity is `Developer ID Application: Matthew Charles Van Horn (NM8VT393AR)`; that Team ID is the linchpin that flows into Chrome Safe Storage's `teamid:NM8VT393AR` partition entry for universal cookie delivery.

## Pipeline at a glance

```text
make release
├─ make build           go build -o bin/agentcookie ./cmd/agentcookie
├─ make sign            scripts/sign.sh bin/agentcookie
│                       └─ codesign --options runtime --timestamp --sign "$IDENTITY"
│                          codesign --verify --deep --strict
└─ make notarize        scripts/notarize.sh bin/agentcookie
                        ├─ ditto -c -k --keepParent  (zip wrap)
                        └─ xcrun notarytool submit --wait --keychain-profile

scripts/release-tarball.sh v0.X.Y      (NOT part of make release)
└─ dist/agentcookie-<ver>-darwin-arm64.tar.gz
   ├─ agentcookie         (notarized)
   ├─ install-beta.sh
   └─ quickstart-beta.md

GoReleaser (CI, tagged push)
├─ darwin/arm64 + darwin/amd64 builds, CGO_ENABLED=1
├─ post-hook: scripts/sign.sh {{ .Path }}        on each binary
└─ archives: agentcookie_<ver>_darwin_<arch>.tar.gz + checksums.txt
```

## Build matrix and ldflags

`.goreleaser.yaml` defines one build (`id: agentcookie`) targeting `darwin/arm64` and `darwin/amd64` with `CGO_ENABLED=1` (required because the Keychain bridge in `internal/chrome` uses Security.framework). The `Version` symbol is set via `-ldflags`:

```yaml
ldflags:
  - -s -w -X github.com/mvanhorn/agentcookie/internal/cli.Version={{ .Version }}
```

The matching variable lives at `internal/cli/root.go` (`var Version = "0.0.1-dev"`). Local `make build` does not pass `-ldflags`, so a dev binary reports `0.0.1-dev`; goreleaser builds report the tag.

GoReleaser archive contents (`archives[].files`):

```text
LICENSE
README.md
docs/quickstart.md
docs/threat-model.md
docs/architecture.md
docs/protocol.md
docs/faq.md
examples/source.yaml
examples/sink.yaml
examples/allowlist.yaml
examples/launchd-sink.plist
```

The signing post-hook runs once per binary, before archive assembly:

```yaml
hooks:
  post:
    - cmd: scripts/sign.sh {{ .Path }}
      output: true
```

## scripts/sign.sh

Signs each binary in place with Developer ID + Hardened Runtime + secure timestamp. `--force` lets it re-sign over a previous signature, so the same script covers fresh `go install` output and the goreleaser build path.

```bash
codesign \
  --force \
  --options runtime \
  --timestamp \
  --sign "$IDENTITY" \
  "$binary"
codesign --verify --deep --strict --verbose=2 "$binary"
```

<ParamField path="AGENTCOOKIE_SIGN_IDENTITY" type="env">
codesign identity (CN or SHA-1 fingerprint). Default: `Developer ID Application: Matthew Charles Van Horn (NM8VT393AR)`. Contributors override with their own Developer ID for fork builds.
</ParamField>

Preflight check: `security find-identity -v -p codesigning | grep -qF "$IDENTITY"`. If the identity is absent, the script exits **2** with a pointer to `docs/runbook-v0.12-codesign.md`. Other exits: `1` usage, `3` codesign/verify failed.

The signature uses a stable designated requirement that does **not** include the cdhash, so every rebuild produces a byte-identical requirement that the wizard install's per-binary Keychain ACL continues to match:

```text
identifier "agentcookie" and anchor apple generic
  and certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */
  and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */
  and certificate leaf[subject.OU] = NM8VT393AR
```

## scripts/notarize.sh

Wraps the binary in a zip (notarytool rejects bare Mach-O), submits via `xcrun notarytool submit --wait`, and requires a JSON status of `Accepted`. Single Mach-O binaries are intentionally **not stapled** — stapling targets bundles, dmgs, and installers. Gatekeeper performs an online check at first launch against the accepted record.

```bash
ditto -c -k --keepParent "$binary" "$tmpzip"
xcrun notarytool submit "$tmpzip" \
  --keychain-profile "$PROFILE" \
  --wait \
  --output-format json > /tmp/notary-result.json
grep -q '"status": *"Accepted"' /tmp/notary-result.json
```

<ParamField path="AGENTCOOKIE_NOTARY_PROFILE" type="env">
Name of the `xcrun notarytool store-credentials` profile in the login keychain. Default `agentcookie-notary`.
</ParamField>

Exit codes: `0` accepted, `1` usage, `2` profile missing, `3` Apple rejected, `4` staple failed (reserved; not exercised today).

### One-time notary credentials setup

```bash
xcrun notarytool store-credentials agentcookie-notary \
  --apple-id mvanhorn@gmail.com \
  --team-id NM8VT393AR \
  --password <app-specific-password>
```

The app-specific password is created at `appleid.apple.com` → Sign-In and Security → App-Specific Passwords. notarytool persists it in the login keychain; cleartext lives nowhere else.

## scripts/release-tarball.sh

Produces the closed-beta bundle that friends extract and run. Not part of `make release` — CI runs `make release` first, then this script.

```text
dist/agentcookie-<version>-darwin-arm64.tar.gz
└─ agentcookie-<version>-darwin-arm64/
   ├─ agentcookie         (notarized, +x)
   ├─ install-beta.sh     (+x)
   └─ quickstart-beta.md
```

Preflight: `bin/agentcookie`, `scripts/install-beta.sh`, `docs/quickstart-beta.md` must exist; `codesign -d -r- bin/agentcookie` must return a signature (notarization is left to spctl on the consumer Mac). Architecture is derived from `uname -m` (`arm64` → `darwin-arm64`, otherwise `darwin-<arch>`). The script prints a SHA-256 for inclusion in release notes.

The consumer side, `install-beta.sh`, re-verifies the signature with `codesign -d -r-` and grep-asserts `subject.OU. = NM8VT393AR` before placing the binary. Mismatch produces a warning, not a hard fail; an unsigned/unverifiable binary continues with a Gatekeeper warning.

## Why Developer ID matters: `teamid:` partition trust

Universal cookie delivery on the sink writes the synced session into Chrome's real `Default` profile by reading Chrome Safe Storage. v0.13 grants that access with a single non-destructive partition update:

```bash
security set-generic-password-partition-list \
  -S "apple-tool:,apple:,teamid:<TEAM_ID>" \
  -k "<login-password>" \
  -s "Chrome Safe Storage" -a Chrome
```

The `<TEAM_ID>` is resolved from agentcookie's own code signature at install time. `internal/chrome/keychain.go` composes it:

```go
// TeamPartitionList composes the partition string ...
// (teamid:<teamID>). The teamid entry is what covers agentcookie's own
// CGO read path (SecItemCopyMatching) and any tool the operator signs
// with the same Developer ID team.
return DefaultPartitionList + ",teamid:" + teamID
```

Implications for the release pipeline:

| Partition entry | What it grants | Source |
|---|---|---|
| `apple-tool:` | `/usr/bin/security` reads (covers `yt-dlp`, `pycookiecheat`, `browser_cookie3`, `gallery-dl`) | Apple-shipped |
| `apple:` | Apple-signed system binaries | Apple-shipped |
| `teamid:NM8VT393AR` | Any binary signed with the maintainer's Developer ID team | `scripts/sign.sh` output |

An **unsigned** agentcookie binary (e.g. a contributor build without a Developer ID) does not benefit from the `teamid:` entry on the sink. CGO reads via `SecItemCopyMatching` will then be rejected. This is why `make sign` is part of the default `make` target and why the goreleaser post-hook is not optional.

## GitHub Actions release workflow

`.github/workflows/release.yml` runs on `push` of tags matching `v*` on `macos-latest`. The job is gated by the repo variable `RELEASE_CI_ENABLED == 'true'`; until that is flipped, releases are cut locally.

<Steps>
<Step title="Import the Developer ID cert">
`apple-actions/import-codesign-certs@v3` decodes the base64 `.p12` from `secrets.CERTIFICATE_OSX_APPLICATION` (unlocked with `CERTIFICATE_OSX_APPLICATION_PASSWORD`) into an ephemeral keychain added to the search list.
</Step>
<Step title="List identities">
`security find-identity -v -p codesigning` runs as a sanity check; the run log shows the NM8VT393AR identity when the secret is set correctly.
</Step>
<Step title="Provision notary credentials">
If `AC_NOTARY_PASSWORD` is set, `xcrun notarytool store-credentials agentcookie-notary --apple-id mvanhorn@gmail.com --team-id NM8VT393AR --password "$AC_NOTARY_PASSWORD"` writes the profile into the runner's login keychain.
</Step>
<Step title="Build, sign, notarize">
`make release` produces `bin/agentcookie` signed and accepted by Apple.
</Step>
<Step title="Build the beta bundle">
`scripts/release-tarball.sh "${{ github.ref_name }}"` writes `dist/agentcookie-<ref>-darwin-arm64.tar.gz`.
</Step>
<Step title="Run goreleaser">
`goreleaser/goreleaser-action@v6` with `args: release --clean`, exporting `AGENTCOOKIE_SIGN_IDENTITY` so the build post-hook signs every darwin/arch binary with the same identity.
</Step>
<Step title="Attach the beta bundle">
`gh release upload "${{ github.ref_name }}" dist/agentcookie-*-darwin-arm64.tar.gz --clobber` adds the closed-beta tarball alongside goreleaser's per-arch archives.
</Step>
</Steps>

## CI secrets reference

| Secret / variable | Kind | Used by | Notes |
|---|---|---|---|
| `CERTIFICATE_OSX_APPLICATION` | Secret | `apple-actions/import-codesign-certs@v3` | base64 of the `.p12` containing cert + private key. |
| `CERTIFICATE_OSX_APPLICATION_PASSWORD` | Secret | same | Password for the `.p12`. |
| `AC_NOTARY_PASSWORD` | Secret | `xcrun notarytool store-credentials` | App-specific password from `appleid.apple.com`. |
| `RELEASE_CI_ENABLED` | Variable | Job-level `if:` | Set to `true` to enable CI-driven releases; otherwise the job is a no-op so tagged pushes do not pollute history. |
| `GITHUB_TOKEN` | Auto | `goreleaser-action`, `gh release upload` | Standard Actions-provided token; no manual setup. |

## Secret renewal and lifecycle

### Developer ID Application cert (5-year validity)

A renewed cert from the Apple Developer portal keeps the same Team ID and the same Common Name shape. Because `scripts/sign.sh` emits a designated requirement that asserts only `subject.OU = NM8VT393AR` and Apple's anchor, **a renewed cert produces the identical designated requirement** — existing per-binary Keychain ACLs and the `teamid:NM8VT393AR` partition entry continue to match. No re-trust pass on sink machines is required.

<Steps>
<Step title="Issue the new cert">
Apple Developer portal → Certificates → new Developer ID Application. Provide a CSR from a fresh private key (Keychain Access → Certificate Assistant → Request a Certificate from a Certificate Authority).
</Step>
<Step title="Install on the build Mac">
Double-click the downloaded `.cer` to bind it to the new private key in the login keychain.
</Step>
<Step title="Export the .p12">
Keychain Access → expand the disclosure triangle so the export includes both the cert and its private key → Export 2 items → `.p12` with a strong password.
</Step>
<Step title="Rotate the CI secret">
<CodeGroup>
```bash gh CLI
base64 -i agentcookie-codesign.p12 -o agentcookie-codesign.p12.b64
gh secret set CERTIFICATE_OSX_APPLICATION < agentcookie-codesign.p12.b64
gh secret set CERTIFICATE_OSX_APPLICATION_PASSWORD
rm agentcookie-codesign.p12 agentcookie-codesign.p12.b64
```
</CodeGroup>
</Step>
<Step title="Revoke the old cert">
Optional, once one tagged release has succeeded with the new cert.
</Step>
</Steps>

<Warning>
Delete the local `.p12` and its base64 copy after upload. Never commit either to the repo.
</Warning>

### Notarytool app-specific password

Apple does not auto-expire app-specific passwords, but rotate on user role changes or compromise.

```bash
# 1. Generate a new password at https://account.apple.com → App-Specific Passwords
# 2. Update CI
gh secret set AC_NOTARY_PASSWORD
# 3. Update the local login keychain
xcrun notarytool store-credentials agentcookie-notary \
  --apple-id mvanhorn@gmail.com \
  --team-id NM8VT393AR \
  --password <new-password>
# 4. Revoke the old password from appleid.apple.com
```

## Verifying a release locally

```bash
security find-identity -v -p codesigning
# Expect: one line containing "NM8VT393AR"

make build && codesign -d -r- bin/agentcookie > /tmp/req1
make clean && make build && codesign -d -r- bin/agentcookie > /tmp/req2
diff /tmp/req1 /tmp/req2          # empty: byte-stable designated requirement

make verify                       # prints the designated requirement
# Last line: subject.OU = NM8VT393AR
```

For a release candidate, verify the bundle the way `install-beta.sh` will:

```bash
codesign --verify --strict --verbose=2 bin/agentcookie
codesign -d -r- bin/agentcookie | grep 'subject.OU. = NM8VT393AR'
spctl --assess --type install bin/agentcookie     # info-only; CLI binaries are not "apps"
xcrun notarytool history --keychain-profile agentcookie-notary | head
```

## Troubleshooting

<AccordionGroup>
<Accordion title="codesign: errSecInternalComponent">
The login keychain is locked, or the LaunchAgent/SSH session has no access. From the GUI user session: `security unlock-keychain login.keychain-db`, then re-run `make sign`.
</Accordion>
<Accordion title="sign.sh exit 2: identity not available">
`security find-identity -v -p codesigning` returned nothing matching the configured identity. Re-import the `.p12` (with private key) into the login keychain, or override via `AGENTCOOKIE_SIGN_IDENTITY` for a contributor build.
</Accordion>
<Accordion title="The timestamp service is not available">
Transient outage at `timestamp.apple.com`. Retry. Do not drop `--timestamp`; notarization requires it.
</Accordion>
<Accordion title="notarize.sh exit 2: notary profile not found">
The login keychain has no `agentcookie-notary` profile (or `$AGENTCOOKIE_NOTARY_PROFILE`). Run `xcrun notarytool store-credentials agentcookie-notary --apple-id mvanhorn@gmail.com --team-id NM8VT393AR --password <app-specific>`.
</Accordion>
<Accordion title="Apple rejected the submission (exit 3)">
Inspect with `xcrun notarytool log <submission-id> --keychain-profile agentcookie-notary`. Common causes: Hardened Runtime missing (re-run `make sign`), timestamp missing (re-run `make sign`), an unsigned framework (not applicable to the single-file binary).
</Accordion>
<Accordion title="CI passes locally but fails with 'identity not found'">
The `CERTIFICATE_OSX_APPLICATION` secret is unset or the imported `.p12` lacks the private key. Keychain Access must show the private key under the cert's disclosure triangle before re-exporting.
</Accordion>
<Accordion title="release-tarball.sh: missing bin/agentcookie">
The script is intentionally separate from `make release`. Run `make release` first, then `scripts/release-tarball.sh <version>`.
</Accordion>
<Accordion title="install-beta.sh warns 'Developer ID OU does not match NM8VT393AR'">
The consumer downloaded a fork build or an unsigned build. Install continues, but on the sink the `teamid:NM8VT393AR` partition entry will not grant Safe Storage access to the new binary; universal cookie delivery downgrades to sidecar/adapter delivery.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Universal cookie delivery" href="/universal-delivery">
How the `teamid:` partition entry, produced by the Developer ID signature here, lets unmodified cookie tools read the synced Chrome Default profile.
</Card>
<Card title="LaunchAgent management" href="/launchagent-management">
Why a stable, byte-identical designated requirement matters for the per-binary Keychain ACL that survives every `go install` cycle.
</Card>
<Card title="doctor and adapter verification" href="/doctor-health-checks">
Post-install signals (`Cookie delivery: universal`, master-key presence) that confirm the signed/notarized binary is trusted on the sink.
</Card>
<Card title="Headless second-Mac install" href="/headless-install">
The SSH-only install path that depends on the one-password partition update unlocked by Developer ID trust.
</Card>
</CardGroup>

---

## 24. Troubleshooting

> Common failures and recovery: pairing `connection refused`, sink Keychain prompts, missing `~/go/bin` on the sink, stale Chrome `SingletonLock`, DBSC-suspect drops, and the FAQ.

- Page Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/pages/24-troubleshooting.md
- Generated: 2026-06-01T03:16:41.689Z

### Source Files

- `docs/faq.md`
- `skill/SKILL.md`
- `internal/cli/doctor.go`
- `docs/runbook-v0.10-keychain-access.md`
- `internal/cli/wizard_verify_adapters.go`

---
title: "Troubleshooting"
description: "Common failures and recovery: pairing `connection refused`, sink Keychain prompts, missing `~/go/bin` on the sink, stale Chrome `SingletonLock`, DBSC-suspect drops, and the FAQ."
---

`agentcookie doctor` is the primary triage surface: it walks fifteen check categories (binary signature, Tailscale, config, keystore, sink listener, sink/source state, sealing, adapter coverage, CDP injector, secrets bus, secret coverage, binary install, DBSC, cookie delivery) and prints one line per check with a `Remediation:` hanging below every FAIL or WARN. Exit code is `1` if any check FAILs, `0` otherwise. Most of the failure modes below are first surfaced by doctor; the resolution paths come from the install skill, the v0.10 keychain runbook, and the dry-run incident logs that drove them.

## Run doctor first

```bash
agentcookie doctor                     # human output
agentcookie doctor --json              # stable JSON envelope (DoctorReport)
ssh <sink-hostname> 'agentcookie doctor'
```

The JSON envelope is shaped for agent consumption:

```json
{
  "version": "v0.14.x",
  "exit_code": 1,
  "checks": [
    {"name": "Sink listener", "severity": "fail",
     "detail": "nothing bound on 100.80.229.80:9999",
     "remediation": "launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.agentcookie.sink.plist"}
  ]
}
```

`Severity` is one of `ok`, `warn`, `fail`, `info`, `skipped`. The `Adapter coverage`, `Secret coverage`, `Binary install`, `DBSC`, and `Cookie delivery` checks are deliberately WARN-only — they describe recoverable misconfigurations, not blockers.

<Tip>
After every `wizard install` over SSH, run `ssh <sink> 'agentcookie doctor'` and `agentcookie status --json` on both sides. The combination of (port bound) + (recent state file) is what proves the LaunchAgent is actually the listener, not a competing process.
</Tip>

## Pairing fails with `connection refused`

Symptoms: the sink wizard exits at the pairing handshake step, the source's push loop logs `connect: connection refused`, or `nothing bound on …:9999` appears in doctor's `Sink listener` row.

There are three distinct ports/paths to keep separate:

| Port  | Role   | Owner                | Purpose                                      |
| ----- | ------ | -------------------- | -------------------------------------------- |
| 9998  | source | `agentcookie wizard install --as source` | one-shot pairing listener (X25519 + HKDF)    |
| 9999  | sink   | `agentcookie sink` daemon              | long-running `/sync` listener (tailnet-only) |

Resolution order:

<Steps>
<Step title="Confirm tailnet reachability">
On the source, run `tailscale status` and confirm the sink hostname is online. The sink's listener binds to its tailnet IP (e.g. `100.80.229.80:9999`); a `127.0.0.1` listen address indicates a botched install — re-run `agentcookie wizard install --as sink` so listen-address validation rejects the fallback.
</Step>
<Step title="Confirm the listener is actually bound">
On the sink: `lsof -nP -iTCP:9999 -sTCP:LISTEN`. If empty, `launchctl list | grep dev.agentcookie` to see whether the LaunchAgent is loaded. Bootstrap it with `launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.agentcookie.sink.plist`.
</Step>
<Step title="Check the sink log for startup-time aborts">
`tail -50 ~/.agentcookie/logs/sink.err.log`. A historical failure mode: the sink process runs and processes adapters but never binds the listener because a Keychain read failed under SSH/LaunchAgent context. Doctor's `Sink listener` check is the operational truth — if it FAILs while the process exists, the daemon silently skipped binding.
</Step>
<Step title="Check Tailscale ACLs">
If the sink listener is bound but the source still hits `connection refused`, your tailnet ACLs may be blocking traffic on the sync port. Confirm `tailscale ping <sink-hostname>` works from the source.
</Step>
</Steps>

## Sink Keychain prompts on first cookie read

Symptoms: a `kooky`-using CLI on the sink (`instacart-pp-cli`, `yt-dlp`, `gallery-dl`) hangs on first read, or a GUI **Keychain Access wants to use the login keychain** dialog fires on the sink's desktop. Doctor's `Cookie delivery` row reports `partial: real Default profile written but the Chrome Safe Storage key has not been granted to cookie readers`.

The wizard's auto-grant runs three strategies in order; if all fail, install does **not** abort. It prints a loud warning and points at the runbook.

| Strategy                          | Notes                                                                                  |
| --------------------------------- | -------------------------------------------------------------------------------------- |
| `delete-and-recreate-with-A`      | Primary. Best-effort delete of the Chrome Safe Storage item, then `security add-generic-password -A` with a fresh random password. No login-password prompt when the item is freshly created. |
| `partition-list:apple-tool,apple` | Fallback. On macOS 15+ typically requires the login keychain password and fails from a LaunchAgent context. |
| `trust-list:<binary>`             | Per-binary fallback via `--extra-binary`. Adds one binary to the existing item's trust list. |

The supported recovery paths:

<Tabs>
<Tab title="One-password SSH grant (preferred)">

The v0.13 path: one login-password entry over SSH, no GUI click.

```bash
ssh <sink> 'agentcookie wizard set-keychain-access'
# non-interactive form for automation:
ssh <sink> 'AGENTCOOKIE_LOGIN_PASSWORD=… agentcookie wizard set-keychain-access'
```

This collapses any duplicate Chrome Safe Storage items value-preserved, then re-sets the partition list so the signed daemon (`teamid:NM8VT393AR`) and the `apple-tool:` partition (yt-dlp, gallery-dl) both read. Verify with:

```bash
ssh <sink> 'agentcookie internal keychain-probe'   # prints "ok len=N", N>0
ssh <sink> 'agentcookie doctor'                    # Cookie delivery: universal
```
</Tab>
<Tab title="One-time GUI fallback">

Use when `set-keychain-access` reports `FAILED` (typically when the item already exists with a restrictive ACL from a prior install). At the sink's desktop (Screen Sharing is fine):

1. Open **Keychain Access.app**.
2. Search for `Chrome Safe Storage`.
3. Double-click the row, open the **Access Control** tab.
4. Select **Allow all applications to access this item**.
5. Click **Save Changes**, type the login keychain password once.

Then re-run `agentcookie wizard set-keychain-access` over SSH; the `delete-and-recreate-with-A` strategy now succeeds because the GUI click cleared the prior ACL restrictions.
</Tab>
<Tab title="Stay degraded">

Pass `--skip-keychain-access` to the wizard. Universal cookie delivery is off; only agentcookie-aware tools (sidecar/adapter readers) see synced cookies. `kooky`-using CLIs prompt for Always-Allow on first run, per binary.
</Tab>
</Tabs>

<Warning>
Doctor surfaces a related WARN: `race: N Chrome Safe Storage keychain items exist`. The install-time Chrome-relaunch race can leave duplicate items, and a later reader may hit the un-granted duplicate. `set-keychain-access` quiesces Chrome and converges the duplicates — this is the only fix; manually deleting an item risks losing the encryption key.
</Warning>

## `agentcookie: command not found` on the sink after `go install`

The sink's `$PATH` lacks `~/go/bin`. SSH non-interactive shells do not source `~/.zshrc` by default, so `go install` puts the binary in a directory the SSH session cannot see on the next invocation.

Three fixes, ordered by durability:

```bash
# 1. Invoke by absolute path (always works, no shell config change)
ssh <sink> '~/go/bin/agentcookie wizard install --as sink ...'

# 2. Source the shell rc explicitly for the SSH command
ssh <sink> 'source ~/.zshrc && agentcookie wizard install --as sink ...'

# 3. Add ~/go/bin to the sink's login PATH (durable)
ssh <sink> "echo 'export PATH=\$HOME/go/bin:\$PATH' >> ~/.zprofile"
```

<Note>
A subtler variant: two diverging `agentcookie` binaries on the same machine — for example `~/go/bin/agentcookie` (stale, on PATH) and `~/bin/agentcookie` (the one the LaunchAgent actually runs). Doctor's `Binary install` check WARNs when the binaries differ in size or mtime, listing every location. The fix is to reinstall so every location is the same build, or delete the stale copy.
</Note>

## Sink Chrome subprocess crashes (`SingletonLock`)

Symptoms: the sink writes appear to succeed but no Chrome subprocess survives, `~/.agentcookie/logs/sink.err.log` shows repeated startup failures, or the wizard hangs at `Chrome did not publish DevToolsActivePort`.

The managed Chrome subprocess shares `~/.agentcookie/chrome-profile/` as its `--user-data-dir`. If a prior Chrome process crashed without cleaning up, the stale `SingletonLock` file blocks the supervisor from spawning the next instance.

```bash
ssh <sink> 'rm ~/.agentcookie/chrome-profile/SingletonLock'
# Let the LaunchAgent's supervisor restart on its own (10s timer)
# Or force a restart immediately:
ssh <sink> 'launchctl kickstart -k gui/$UID/dev.agentcookie.sink'
```

The related failure path is "Chrome.app missing": the wizard's `CDP injector` doctor check WARNs when `/Applications/Google Chrome.app` and `~/Applications/Google Chrome.app` both do not exist. Install Chrome stable, or set `cdp.chrome_binary` in `sink.yaml` to a non-default path, or pass `--no-cdp` at wizard install to disable CDP injection entirely.

## DBSC-suspect cookies dropped or refresh-failing

`agentcookie doctor` reports `DBSC: last push flagged N shipped-with-warning, M skipped DBSC-suspect cookie(s): <sample-domain>`. This means the source's heuristic flagged cookies that look like Chrome's Device Bound Session Credentials — short-lived secure cookies whose refresh requires signing a server challenge with the source Mac's Secure Enclave private key.

| Posture                            | Source flag                                   | Sink effect                                                                 |
| ---------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------- |
| Ship-with-warning (default)        | none                                          | Cookie reaches the sink but expires within ~minutes; agent gets logged out  |
| Hard-skip                          | `--skip-dbsc-suspect` / `AGENTCOOKIE_SKIP_DBSC_SUSPECT=1` | Cookie never leaves the source; sink never sees it                          |

The heuristic keys on two signals (`internal/chrome/dbsc.go`):

- A secure cookie on a known DBSC host (currently the `google.com` suffix).
- A secure persistent cookie with remaining TTL < 15 minutes.

Recommended handling per the FAQ: for Google sessions, do not copy cookies at all — sign the sink's Chrome into the same Google account once and it establishes its own device-bound session locally. For every other site, the default ship-with-warning is correct; a false positive does not break a working non-DBSC site.

To opt the LaunchAgent into hard-skip without editing the source plist:

```bash
launchctl setenv AGENTCOOKIE_SKIP_DBSC_SUSPECT 1
launchctl kickstart -k gui/$UID/dev.agentcookie.source
```

## Sink never accepted a write

Doctor: `Sink state: sink-state.json missing; sink has never accepted a write` (FAIL) or `last write Xh ago (>24h)` (WARN). The remediation is the same: force a real round-trip.

```bash
# On the source, push once:
agentcookie source --once

# Then re-check both sides:
agentcookie status --json
ssh <sink> 'agentcookie status --json'
```

If `agentcookie status` reports zero pushes after install, the source watcher has not seen a Chrome write yet. Open any allowlisted tab on the source's Chrome and refresh — a push should appear within ~2 seconds.

## Adapter coverage gaps

Doctor: `Adapter coverage: N of M host_keys in sidecar have no adapter: <host1>, <host2>, ...` (WARN).

This is not a failure. The plaintext sidecar at `~/.agentcookie/cookies-plain.db` always covers sidecar-aware PP CLIs. The WARN only matters for `kooky`-only readers that do not link `pkg/sidecar`, and those fall back to Chrome's encrypted store anyway. Inspect with:

```bash
agentcookie wizard verify-adapters           # one row per registered adapter
agentcookie wizard verify-adapters --json    # for SSH agents
```

Output is a tabular `ADAPTER STATUS PUSHED DETAIL` snapshot from `sink-state.json`'s `LastAdapterResults`. Exit code is `1` if any adapter reported an error in the most recent run.

## FAQ

<AccordionGroup>
<Accordion title="Will syncing cookies log me out of sites on the source machine?">
No. The source reads cookies with `immutable=1` (Chrome's recommended read-only flag), and `agentcookie source` never writes to the source's Cookies SQLite. The only writes happen on the sink.
</Accordion>

<Accordion title="My agent gets logged out on a particular site after syncing — what happened?">
Two causes. First, a few sites bind a session to a device fingerprint (canvas, screen size, accept-language, sometimes TLS JA3); replicating the cookie alone is not enough and the site invalidates the session. Second, the site uses Chrome's DBSC, which ties session refresh to a private key in the source Mac's Secure Enclave. Workarounds: remove the site from your allowlist and re-auth in the sink's Chrome directly, or use a pair-agent remote-browser pattern for those sites.
</Accordion>

<Accordion title="Does Chrome's DBSC break agentcookie?">
No, not for the sites you use today. DBSC is opt-in per site; as of May 2026 the one broad adopter is Google's own account/Workspace cookies. The secrets bus is untouched — bearer tokens, API keys, and OAuth refresh tokens replicate normally. For Google sessions, sign the sink's Chrome into the same account once and it establishes its own device-bound session locally.
</Accordion>

<Accordion title="Why is the sink's allowlist independent from the source's?">
Defense in depth. The source filters before sending (bandwidth + privacy optimization), but the sink owner ultimately controls what state lands in their Chrome. If the source is compromised and an attacker tries to push cookies for new domains, the sink-side allowlist drops them. Keep both allowlists in sync for the simplest behavior; let them diverge if you want the sink to be more conservative.
</Accordion>

<Accordion title="What about durable replay defense?">
In v0.1, the sink rejects an envelope whose Sequence is not strictly greater than the highest seen for that source — but the state is in-memory, so a sink restart clears it and an attacker who captured a payload could replay it once before the next legitimate sync. v0.2 adds a nonce-or-timestamp window for durable replay defense.
</Accordion>

<Accordion title="Is the `shared_secret` fallback safe?">
Safer than nothing if you use a strong randomly-generated secret and never reuse it. Strictly worse than pairing: the secret sits in two YAML files unencrypted (file mode 0600), rotation requires editing both files, and the MITM defense (pairing code in HKDF salt) is missing. After pairing once, delete `security.shared_secret` from both YAMLs.
</Accordion>

<Accordion title="Can I run the sink in Docker on a cloud VM?">
The sink can run anywhere Chrome stable runs (when CDP is enabled and Chrome is reachable) OR anywhere you can mount the destination's Chrome Cookies SQLite. Linux sink support is on the roadmap. On macOS-in-the-cloud (e.g. MacStadium), it works today if you can SSH in and the tailnet reaches the host.
</Accordion>

<Accordion title="What's in the keystore on disk?">
`~/.config/agentcookie/keys/<peer>.json` is a JSON file at mode `0600` containing the 32-byte paired key (base64), the peer hostname, paired_at timestamp, key fingerprint, and protocol version. macOS Keychain storage is a v0.2 hardening item; today the file mode + OS user separation is the protection.
</Accordion>

<Accordion title="Why not just use a Chrome extension to sync cookies?">
Existing extensions assume a human will click "Merge" or open Chrome periodically. agentcookie's target is the opposite: continuous one-way replication from a laptop to a Mac mini or cloud VM where AI agents act with no human in the loop on the sink side. agentcookie covers the agent-operator workflow without requiring a browser running on the sink at all.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="doctor and adapter verification" href="/doctor-health-checks">Every check category, the JSON envelope, and exit-code semantics agents consume.</Card>
<Card title="Headless second-Mac install" href="/headless-install">The SSH-only install path, degraded-mode fallback, and the one-password Safe Storage open.</Card>
<Card title="Device-bound cookies (DBSC)" href="/dbsc-handling">The detection heuristic, default ship-with-warning posture, and `--skip-dbsc-suspect` drop path.</Card>
<Card title="LaunchAgent management" href="/launchagent-management">Plist locations, log paths, and the v0.9 lifecycle the doctor remediations reference.</Card>
<Card title="Pairing and per-peer keys" href="/pairing-and-keys">The X25519 + HKDF handshake, per-peer key file, and rate-limited `/pair` endpoint.</Card>
<Card title="Universal cookie delivery" href="/universal-delivery">The one-login-password Safe Storage open and duplicate-Keychain-item convergence.</Card>
</CardGroup>

---
