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

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