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

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

## Source Files

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