Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ One source can now push the same cookies and secrets to several sinks.
- `agentcookie status` and `agentcookie doctor` report per-sink push state.
- Example: `examples/source-multi-sink.yaml`.

### PP CLI config carriage (#118)

Auto-discovered Printing Press CLIs were pointed at the env-shaped `[secrets.file]` slot, so their `config.toml` went through a strict `KEY=VALUE` parser that cannot read the TOML they actually write. Every one of them failed to sync, and CLIs that were simply never authenticated were reported as errors on every push.

- A PP CLI's `config.toml` now rides as a `[[files]]` carriage item. Bytes are carried verbatim, so nested tables and comments survive and nothing is parsed.
- Carriage is gated on the manifest declaring at least one sensitive key, so a preference-only config is not swept in.
- A never-authenticated CLI is skipped quietly instead of erroring; a hand-written manifest naming a missing file still errors, because its author chose the path.
- `agentcookie secret link-configs` bridges carried configs into `~/.config/<cli>/config.toml`, where the installed fleet actually reads them (only binaries built after roughly 2026-07 honor an env pointer). Read-only planning, dry run until `--apply`. It never replaces an existing config, and the link is confined to `~/.config` so no symlink along the destination path can redirect it out of that tree.
- Measured across 59 installed CLIs: `secrets-bus` error lines 40 to 0, CLIs contributing secrets 2 to 9.

## [1.0.0] - 2026-08-13

### Featured: Mac to Linux continuous sync
Expand Down
2 changes: 2 additions & 0 deletions docs/audits/2026-05-22-pp-cli-auth-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ The format spec at `docs/spec-agentcookie-secrets-bus-v1.md` was written before

1. **Multi-account namespacing.** Superhuman (account email keys) and Slack (team ID keys) hold one set of secrets per account in a single file. The v1 spec assumes one secret set per CLI. v1.1 needs either an `accounts/<account-id>/secrets.env` subdirectory convention or namespaced keys in a single file. Recommend the subdirectory convention so a friend can opt in/out of syncing specific accounts.
2. **Per-file (not just per-key) `local-only` markers.** Tesla's `snowflake-private.pem` is local-only, but its `snowflake-public.pem` half is safe-to-sync. The v1 spec's `[sync.keys]` is per-key inside `secrets.env`; v1.1 needs a `[sync.files]` table for non-env-shaped artifacts like `.pem` files that live alongside the env file.

> **Partly addressed 2026-08-13.** v2 `[[files]]` carries non-env-shaped artifacts, and the PP adapter now uses it for `config.toml` rather than the env-shaped `[secrets.file]` slot (see spec §7.4). Carriage is gated on the manifest declaring at least one sensitive key, so preference-only configs are not swept in. The per-file `local-only` marker itself is still open: carriage today is whole-file with no per-key or per-field filter, so a config holding both a credential and something the user would rather not replicate ships wholesale. Companion files (`cookies.json`, `browser-session-proof.json`, Linear's `LINEAR_API_KEY`) are also still uncarried.
3. **Third sync-safety classification: `device-bound-but-shippable`.** The browser-session-proof JSON used by eBay, OpenArt, and Suno is technically device-bound (it captures fingerprint timing) but the threat model would tolerate sync to a single trusted second machine. The v1 spec only has two buckets (safe-to-sync, local-only). v1.1 needs the middle category with a `caution` marker that warns but does not block.

PII observation: ordertogo's `config.toml` carries customer name, phone, and Stripe customer ID alongside auth tokens. The bus replicates whatever's in the file, not just "secrets." Friends should know that. This is documentation territory, not a spec change; the v1 spec's security boundary statement may want a paragraph about PII-replication once v1.1 lands.
317 changes: 317 additions & 0 deletions docs/plans/2026-08-13-2206-fix-pp-cli-toml-secrets-carriage-plan.md

Large diffs are not rendered by default.

16 changes: 14 additions & 2 deletions docs/spec-agentcookie-secrets-bus-v2-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ The discovery loop synthesizes an in-memory v2 manifest from `.printing-press.js
| `description` | `.printing-press.json` `description` |
| `project_kind` | Always `"cli"` |
| `homepage` | Omitted (not present in PP metadata) |
| `[secrets.file].path` | `~/.config/<cli_name>/config.toml` (PP CLI canonical location per [PP audit](audits/2026-05-22-pp-cli-auth-inventory.md)) |
| `[secrets.file]` | **Never set.** The PP CLI canonical auth location is TOML, and `[secrets.file]` is env-shaped (§5.1), read by the strict `KEY=VALUE` parser. See §7.4 |
| `[[files]]` | One item, but only when at least one key is sensitive (§7.4): `source = ~/.config/<cli_name>/config.toml` (PP CLI canonical location per [PP audit](audits/2026-05-22-pp-cli-auth-inventory.md)), `target = <cli_name>/config.toml`, `optional = false`, `key` = the slug upper-cased with non-alphanumerics folded to `_`, suffixed `_CONFIG_TOML` |
| `[sync.keys]` (per key) | For each `auth_env_var_specs[i]` entry: if `sensitive = true`, key is default-shipped; if `sensitive = false`, `[sync.keys].<name> = false` |

### 7.2 Override
Expand All @@ -262,7 +263,18 @@ A PP CLI may ship an explicit `agentcookie.toml` (recommended for tier-A integra

### 7.3 Adapter authority

The adapter never reads the actual secrets file. It only synthesizes a manifest pointing at where the secrets live. The read-in-place step at push time is identical to any other v2 manifest.
The adapter never reads the actual secrets file. It only synthesizes a manifest pointing at where the secrets live. The carriage step at push time is identical to any other v2 manifest's `[[files]]` item.

### 7.4 Why carriage, not read-in-place

A PP CLI's `config.toml` is TOML: values carry whitespace around `=`, and some CLIs open with a `[table]` header. `[secrets.file]` is env-shaped by contract (§5.1) and is read by the strict `KEY=VALUE` parser, which rejects both. Pointing the adapter at that slot meant every discovered PP CLI failed to sync — configured ones on a parse error, unconfigured ones as a missing file. This is the case §5.4 already anticipated: "a TOML `config.toml` cannot ride as a single `KEY=VALUE` value."

Two consequences follow from carrying the whole file:

- **Sensitivity gate.** Whole-file carriage has no per-key filter, so `[sync.keys]` cannot drop anything once the file ships. The adapter therefore emits no `[[files]]` item at all unless at least one declared key is `sensitive = true`. A CLI whose config holds only preferences (espn: a `[favorites]` list, no credentials) carries nothing.
- **Consumption is a separate step.** Carried files materialize under `~/.agentcookie/` (§5.4 invariant), but a PP CLI reads `~/.config/<slug>/config.toml`. Only binaries built after roughly 2026-07 honor `XDG_CONFIG_HOME` or `<envName(api_name)>_CONFIG_DIR`, so an env pointer does not reach the installed fleet. Rather than widen the bus's write authority, `agentcookie secret link-configs` bridges it as an explicit opt-in step: read-only planning, dry run by default, refusing to replace an existing config or to write through a symlink pointing outside `~/.agentcookie/`. The refusal covers the whole destination path, not just its last component: every lookup and both writes go through an `os.Root` anchored at `~/.config`, so a symlinked `~/.config/<slug>` directory cannot redirect the link out of that tree while the plan claims otherwise. `~/.config` is in turn opened through a root anchored at the home directory, so the containment decision and the open are one operation rather than a resolve followed by an open that a symlink planted between them could make disagree. A symlinked `~/.config` itself is honored — the CLI reads through it too — but only while it resolves inside the home directory.

Note the env-name asymmetry if you do rely on the pointer: the config *directory* derives from `cli_name` (`juneoven-pp-cli`), while the env *variable* derives from `api_name` (`JUNEOVEN_CONFIG_DIR`).

## 8. Discovery semantics

Expand Down
16 changes: 15 additions & 1 deletion internal/cli/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,21 @@ func runDiscover(cmd *cobra.Command, _ []string) error {
row := projectToRow(rp)
readPath := row.ReadInPlacePath
if readPath == "" {
readPath = "(legacy bus dir)"
// A project with no env read-in-place is not automatically a
// legacy bus entry: a manifest that ships its secrets as carried
// files (every auto-detected PP CLI does, since config.toml is
// TOML rather than env-shaped) has no read-in-place path at all.
switch {
case rp.Manifest != nil && len(rp.Manifest.Files) > 0:
readPath = rp.Manifest.Files[0].Source
if extra := len(rp.Manifest.Files) - 1; extra > 0 {
readPath = fmt.Sprintf("%s (+%d more)", readPath, extra)
}
case rp.Kind == secretsbus.SourceKindLegacyV1:
readPath = "(legacy bus dir)"
default:
readPath = "(none)"
}
}
coverage := row.Coverage
if row.Coverage == "MISMATCH" {
Expand Down
66 changes: 65 additions & 1 deletion internal/cli/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,73 @@ var secretEnvCmd = &cobra.Command{
RunE: runSecretEnv,
}

var secretLinkConfigsApply bool

var secretLinkConfigsCmd = &cobra.Command{
Use: "link-configs",
Short: "Link carried CLI configs from ~/.agentcookie/ into ~/.config/ (dry run by default)",
Long: `Carried configs materialize under ~/.agentcookie/<cli>/config.toml, but a
PP CLI reads ~/.config/<cli>/config.toml. This links the second to the first.

The bus deliberately never writes outside ~/.agentcookie/, so this is a
separate, explicit step rather than something a manifest can trigger.

It prints what it would do and changes nothing unless --apply is passed. An
existing config file is never replaced, and a symlink pointing anywhere other
than ~/.agentcookie/ is refused rather than written through. That applies to
the whole destination path: a symlinked ~/.config/<cli> directory would carry
the link out of ~/.config, so it is refused too.`,
Args: cobra.NoArgs,
RunE: runSecretLinkConfigs,
}

func init() {
secretCmd.AddCommand(secretListCmd, secretGetCmd, secretSetCmd, secretRmCmd, secretImportFromCmd, secretEnvCmd, secretAliasCmd)
secretCmd.AddCommand(secretListCmd, secretGetCmd, secretSetCmd, secretRmCmd, secretImportFromCmd, secretEnvCmd, secretAliasCmd, secretLinkConfigsCmd)
secretImportFromCmd.Flags().StringVar(&secretImportAs, "as", "", "cli-name to file the imported secrets under (required)")
secretLinkConfigsCmd.Flags().BoolVar(&secretLinkConfigsApply, "apply", false, "actually create the links (default: dry run)")
}

func runSecretLinkConfigs(cmd *cobra.Command, _ []string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home: %w", err)
}
plan, err := secretsbus.PlanConfigLinks(home)
if err != nil {
return err
}
out := cmd.OutOrStdout()
if len(plan) == 0 {
fmt.Fprintln(out, "no carried configs materialized yet; nothing to link")
return nil
}

for _, e := range plan {
switch e.Action {
case secretsbus.LinkActionLink:
verb := "would link"
if secretLinkConfigsApply {
verb = "linking"
}
fmt.Fprintf(out, " %s %s -> %s\n", verb, e.Destination, e.Materialized)
case secretsbus.LinkActionAlreadyLinked:
fmt.Fprintf(out, " ok %s (already linked)\n", e.Destination)
case secretsbus.LinkActionRefuse:
fmt.Fprintf(out, " SKIP %s: %s\n", e.Destination, e.Reason)
}
}

if !secretLinkConfigsApply {
fmt.Fprintln(out, "\ndry run; re-run with --apply to create the links")
return nil
}

applied, errs := secretsbus.ApplyConfigLinks(home, plan)
fmt.Fprintf(out, "\nlinked %d config(s)\n", applied)
for _, e := range errs {
fmt.Fprintf(cmd.ErrOrStderr(), " skipped: %v\n", e)
}
return nil
}

// secretsRoot resolves to the v1 standard path. Kept as a helper for tests.
Expand Down
4 changes: 1 addition & 3 deletions internal/cli/secret_revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,12 @@ To silence the auto-detect, drop a manifest with sync.default = false:
schema_version = 2
name = "%s"
display_name = "%s (silenced)"
[secrets.file]
path = "%s"
[sync]
default = false
EOF

Then re-run `+"`agentcookie discover`"+` to confirm.
`, name, rp.SourcePath, home, name, name, name, rp.ReadInPlacePath)
`, name, rp.SourcePath, home, name, name, name)
return nil

case secretsbus.SourceKindLegacyV1:
Expand Down
8 changes: 8 additions & 0 deletions internal/secretsbus/discover_merge.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package secretsbus

import (
"errors"
"fmt"
"maps"
"os"
Expand Down Expand Up @@ -69,6 +70,13 @@ func LoadPayloadWithDiscovery(homeDir string) (*Payload, []error) {
enabled := LoadEnabledFileKeys(homeDir, slug)
carried, carryErrs := CarryFiles(rp.Manifest.Files, enabled, homeDir)
for _, e := range carryErrs {
// A derived manifest asserts a conventional path, so an absent
// source just means the CLI was never configured -- a normal
// state, not an error. A hand-written manifest names a path its
// author chose, where absence is a real misconfiguration.
if rp.Kind == SourceKindPPCLIDerived && errors.Is(e, ErrCarrySourceMissing) {
continue
}
errs = append(errs, fmt.Errorf("discovered project %q: %w", slug, e))
}
maps.Copy(filtered, carried)
Expand Down
Loading
Loading