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

- 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

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