Skip to content

fix(secretsbus): carry PP CLI config.toml as a file instead of parsing it as dotenv - #118

Merged
mvanhorn merged 11 commits into
mainfrom
fix/pp-cli-toml-secrets-carriage
Sep 12, 2026
Merged

fix(secretsbus): carry PP CLI config.toml as a file instead of parsing it as dotenv#118
mvanhorn merged 11 commits into
mainfrom
fix/pp-cli-toml-secrets-carriage

Conversation

@mvanhorn

Copy link
Copy Markdown
Owner

Problem

Every auto-discovered Printing Press CLI failed to sync its secrets. agentcookie source --once printed 40 secrets-bus errors in two classes:

  • Parse failuresread ~/.config/<cli>/config.toml: line 1: whitespace around '=' is not allowed, or missing '=' (expected KEY=VALUE) where the file opens with a [table] header. These CLIs had real credentials on disk that silently never reached the sink.
  • Missing filesread-in-place file missing for ~26 CLIs that were simply never authenticated. A normal state, reported as an error on every push.

Root cause

DeriveManifestFromPP pointed [secrets.file] at ~/.config/<cli_name>/config.toml. But [secrets.file] is env-shaped by contract and is read by the strict KEY=VALUE parser, while PP CLIs write actual TOML there. The spec already knew this — §5.4 says "a TOML config.toml cannot ride as a single KEY=VALUE value" — but §7 mapped the PP adapter to the env-shaped slot anyway. The adapter took the canonical path from the PP audit and did not carry across its format finding.

Fix

Derive a [[files]] carriage item instead — machinery the repo already ships for exactly this case, whose doc example is literally a pp-cli config.toml. Bytes are carried verbatim, so nested tables and comments survive; nothing is parsed.

  • U1 — carry config.toml as a file, gated on the manifest declaring at least one sensitive key so preference-only configs (espn's [favorites]) are not swept in
  • U2ErrCarrySourceMissing lets never-configured CLIs skip quietly, while hand-written manifests (whose author chose the path) still error
  • U3agentcookie secret link-configs bridges carried configs into ~/.config/
  • U4 — end-to-end coverage of the three real shapes
  • U5 — spec §7.4 + audit follow-through

Why a link step instead of an env pointer

Carried files materialize under ~/.agentcookie/, but PP CLIs read ~/.config/<slug>/config.toml. I measured the installed fleet:

Binary Built XDG_CONFIG_HOME / <API>_CONFIG_DIR
espn, tesla, prediction-goat 2026-05 ignored
ordertogo 2026-06-24 ignored
human-goat, juneoven 2026-07 honored

Only 2 of 59 installed binaries can follow an env pointer. Writing directly into ~/.config/ would reach all 59 but would break the containment invariant validateMaterializeTarget exists to enforce — the sink's defense against a manifest naming an arbitrary write path. So linking is a separate explicit step: read-only planning, dry run by default, refuses to replace an existing config or write through a symlink pointing outside ~/.agentcookie/.

Verification

Measured on a real machine, not just fixtures:

  • secrets-bus error lines: 40 -> 0
  • CLIs contributing secrets: 2 (main) -> 9 (branch) — seven configured CLIs that were silently shipping nothing now ship
  • Carried bytes are byte-identical to source, [table] headers and comments intact
  • go build, go vet, gofmt clean; secretsbus 115 passing

Four tests fail on this branch (TestInstacartAdapter_IsInstalled_*, TestCheckDaemonBinaryPath) — all four fail identically on main, verified by checkout.

Known limitations

  • The gate is only as good as the PP metadata. booking-com and ebay declare no auth_env_vars and no auth_env_var_specs, so they are excluded despite holding access tokens. Not a regression (they did not sync before either), but the fix does not reach them. The gate cannot be loosened, because espn declares nothing either and metadata alone cannot separate "has no secrets" from "did not say." The fix belongs in those CLIs' .printing-press.json.
  • Whole-file carriage has no per-key filter, so it ships more than [sync.keys] would. The audit's per-file local-only marker is the real answer and is still open.
  • Sink-side materialization was not verified end to end — the sink was unreachable throughout (an unrelated network problem).
  • Companion files (cookies.json, browser-session-proof.json) are still uncarried.

Review note

Two regressions from this change were caught and fixed before this PR: secret revoke emitted an invalid path = "" in its copy-pasteable silencing manifest, and discover mislabelled carriage-based manifests as (legacy bus dir). Both stemmed from consumers assuming a non-empty ReadInPlacePath.

Plan: docs/plans/2026-08-13-2206-fix-pp-cli-toml-secrets-carriage-plan.md

🤖 Generated with Claude Code

https://claude.ai/code/session_015AGvauBWJUf3EyrHSotKdL

…in-place

The canonical PP CLI auth location is TOML, but [secrets.file] is an
env-shaped slot read by the strict dotenv parser, so every discovered CLI
failed to sync: configured ones on a parse error, unconfigured ones as a
missing file.

Derive a [[files]] carriage item instead, gated on the manifest declaring
at least one sensitive key so preference-only configs (espn's [favorites])
are not swept in.
A derived manifest asserts a conventional path, so an absent config.toml
just means the CLI was never authenticated. Add ErrCarrySourceMissing so
LoadPayloadWithDiscovery can skip that case quietly for derived manifests
while a hand-written manifest, whose author chose the path, still errors.
Sources that exist but cannot be carried keep erroring either way.
Carried configs materialize under ~/.agentcookie/, but PP CLIs read
~/.config/<cli>/config.toml and only 2 of 59 installed binaries honor
XDG_CONFIG_HOME or <API>_CONFIG_DIR. Rather than widen the bus's write
authority, linking is an explicit opt-in step: read-only planning, dry run
by default, refuses to replace a real config or write through a symlink
pointing outside ~/.agentcookie/.
Canonical credential-bearing scaffold, espn-style preference-only config
(including a nested table dotenv could never express), and an installed-
but-never-authenticated CLI, discovered together. Also pins that v1 still
wins per-key over a carried key.
Section 7 mapped the PP adapter to [secrets.file] while section 5.4 already
said a TOML config.toml cannot ride as KEY=VALUE. Correct the mapping, add
7.4 explaining the sensitivity gate and why consumption is a separate step,
and mark the audit's non-env-shaped-artifact finding as partly addressed.
Derived PP CLI manifests no longer set [secrets.file], so ReadInPlacePath
is empty for them. Two consumers assumed otherwise:

- secret revoke printed a copy-pasteable silencing manifest containing
  path = "", which is invalid. The block is unnecessary; drop it.
- discover fell back to labelling any empty path '(legacy bus dir)', which
  is wrong for a carriage-based manifest. Show the carried source instead.
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@greptile-apps

greptile-apps Bot commented Aug 14, 2026

Copy link
Copy Markdown

Greptile Summary

The PR moves auto-discovered Printing Press CLI TOML configurations from env-file parsing to verbatim file carriage and adds an explicit command for linking materialized configurations into the locations consumed by those CLIs.

  • Derives carriage entries only for CLIs declaring sensitive keys and quietly skips missing convention-based sources.
  • Adds dry-run and apply flows for safely linking carried configurations under ~/.config.
  • Updates discovery, revoke guidance, specifications, audit notes, and end-to-end coverage.

Confidence Score: 5/5

The PR appears safe to merge because both previously reported containment failures are fixed and no blocking failure remains.

No blocking failure remains.

Important Files Changed

Filename Overview
internal/secretsbus/linkconfigs.go Adds home- and config-root-anchored planning and application of configuration links, addressing both previously reported symlink escape paths.
internal/secretsbus/pp_cli_adapter.go Changes derived PP CLI manifests to carry credential-bearing TOML files verbatim rather than exposing them to dotenv parsing.
internal/secretsbus/discover_merge.go Quietly suppresses missing carriage sources only for convention-derived PP CLI manifests while retaining errors for explicit manifests.
internal/cli/secret.go Adds the dry-run-by-default secret link-configs command and delegates filesystem safety decisions to the secretsbus implementation.
internal/secretsbus/linkconfigs_test.go Covers existing destinations, parent and config-root symlinks, post-planning replacements, internal config-root links, and containment races.
internal/secretsbus/discover_merge_files_test.go Adds end-to-end coverage for configured, preference-only, and never-authenticated PP CLI shapes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    A[Printing Press metadata] --> B{Sensitive key declared?}
    B -->|No| C[Skip carriage]
    B -->|Yes| D[Carry config.toml verbatim]
    D --> E[Materialize under ~/.agentcookie]
    E --> F[Plan link-configs]
    F --> G{Destination safe and absent?}
    G -->|No| H[Refuse or report already linked]
    G -->|Yes, with --apply| I[Create link through config-root os.Root]
Loading

Reviews (4): Last reviewed commit: "docs: note that ~/.config is opened thro..." | Re-trigger Greptile

Comment thread internal/secretsbus/linkconfigs.go Outdated
cursoragent and others added 3 commits September 12, 2026 18:28
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
…ing the leaf

classifyDestination inspected only ~/.config/<slug>/config.toml. That leaf
reads as absent just as readily when ~/.config/<slug> is a symlink into
another tree, so the MkdirAll and Symlink that followed walked the symlink and
left the carried config outside ~/.config while --apply reported success. The
plan printed a path the bytes never reached.

Route every lookup and both writes through an os.Root anchored at ~/.config,
which refuses to follow a symlink whose target leaves that tree rather than
redirecting the write. A symlinked ~/.config itself is still honored -- the CLI
reads through it too, and pointing the config tree at a dotfiles checkout is a
normal arrangement -- but only while it stays inside the home directory.

Because the write path no longer depends on the classification being right,
re-classifying before writing is now a source of actionable messages rather
than the only barrier; a parent symlink swapped in between plan and apply is
refused by the root itself.

ApplyConfigLinks also re-validates the slug and requires the link source to be
under ~/.agentcookie/, so a hand-built plan cannot aim the one privileged write
somewhere else.

Tests cover the escaping parent (absolute and ../-climbing), the swap between
plan and apply, a config root pointed out of the home directory, a hand-built
plan with a foreign source or a traversal slug, and the dotfiles arrangement
that must keep working. All six refusal tests fail against the previous
classification.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
…angelog

The command help and spec §7.4 both described the refusal as being about the
destination's last component, which is the gap the previous classification
actually had. Say that the whole path is covered, and add the changelog entry
for carriage and link-configs that this branch never wrote.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Comment thread internal/secretsbus/linkconfigs.go Outdated
cursoragent and others added 2 commits September 12, 2026 18:54
Greptile caught a race left by the previous commit. openConfigRootForWrite
checked whether ~/.config existed, created it, tolerated EEXIST, and then
opened the path by name. A symlink planted in that window made the tolerated
EEXIST and the open disagree: os.OpenRoot followed the symlink, and every link
afterwards was contained relative to a root outside the home directory while
being reported as applied.

Resolving a path by name and then opening the result cannot be made safe --
they are separate lookups. So ~/.config is now opened through an os.Root
anchored at the home directory, which makes the containment decision and the
open the same operation, and a tolerated EEXIST sends the loop back through
classification instead of to an open by name.

os.Root rejects an absolute symlink even when its target is inside the root, so
`ln -s ~/dotfiles/config ~/.config` needed handling: such a target is rewritten
as a home-relative path and the open retried, keeping resolution inside the
same confined walk. filepath.EvalSymlinks is now used only to recognize the
home directory reached by another name (/var vs /private/var), never to pick
what to open.

The window has no single-threaded state that reproduces it, so the new test
interleaves ~/.config appearing and disappearing against ApplyConfigLinks and
asserts nothing lands outside the home directory. It cannot fail on a correct
implementation; it failed 8 of 8 runs against the previous commit. The dotfiles
test now covers both spellings of the symlink.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
@mvanhorn
mvanhorn merged commit d1827cc into main Sep 12, 2026
5 of 6 checks passed
shawnhamby added a commit to shawnhamby/agentcookie that referenced this pull request Sep 12, 2026
….toml)

Brings in upstream 994abf2 (fan out cookie and secret sync to multiple
sinks, mvanhorn#122) and d1827cc (carry PP CLI config.toml as a file instead of
parsing it as dotenv, mvanhorn#118).

The only content conflict was internal/config/config.go, in the
SourceConfig struct: the fork added EnabledProducts there while upstream
added the Sinks fan-out list and made the legacy scalar Sink omitempty.
Resolved by keeping both with their own comments and yaml/json tags --
upstream's Sinks/Sink pair and legacy single-sink synthesis are taken
verbatim, and the fork's EnabledProducts sits after Browser, ahead of
Peer and Security, so DefaultEnabledProducts and ResolveEnabledProducts
keep their meaning. Multi-sink repeats only sealing and transport, so it
does not re-open extra-profile discovery: internal/chromepaths and the
fork's admission rule are untouched by the merge.

go build, go vet, and go test -race are green across the module.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants