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
105 changes: 105 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,111 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.0] - 2026-09-08

### Added

- **The vault is now something an agent looks in.** Sixty-five keys were in it,
and the agent working in your repo still asked *you* for
`CLOUDFLARE_API_TOKEN` — or wrote `<your-token-here>` and moved on, or
reported the variable as missing. Nothing refused it. It never looked, and
two things made that the rational move. A key was named only by slug
(`cf-gh-actions-deploy`), so the name code actually reads had no path to the
entry that holds it; the mapping existed as prose inside a `--purpose`
string, which is not an index. And an agent that did look dead-ended anyway,
because `get_key` is gated and there was no way to *use* a key without
reading it. A vault you cannot search and cannot spend gets worked around.

So keys have a second name. `KeyEntry.env` is the environment variable the
key is exposed as — `CLOUDFLARE_API_TOKEN`, `NEON_API_KEY` — validated as
UPPER_SNAKE_CASE (A–Z, digits, `_`, starting with a letter, at most 64
characters), which is the shape a shell and every dotenv parser accept. A
refusal spells the corrected name rather than restating the rule, so
`cf-token` comes back with "try `CF_TOKEN`" and the fix is a copy-paste. It
is optional on purpose: an Apple issuer id or a D-U-N-S number belongs in the
vault as something you would otherwise go hunting for, but it is not a
variable any program reads and inventing a name for it would be inventing a
fact. Two entries may share a name — a dev and a production
`LANGFUSE_SECRET_KEY` are two secrets under one identifier, which is the
normal shape — so nothing refuses it and a lookup returns both. A `keys.json`
written before the field existed parses unchanged, and the field is omitted
from the JSON when unset.

The convention the docs now state, because a name only helps if it is the
name the other end already uses: **use the name code already reads.** If the
repo, the CI secret or the vendor's SDK spells it `CLOUDFLARE_API_TOKEN`,
that is the name; patchbay's job is to be findable by what exists, not to
impose a taxonomy on it. Only when nothing has named it yet do you compose
one as `<PROVIDER>_<THING>_<KIND>` — `NEON_API_KEY`, `APPLE_ASC_ISSUER_ID`,
`GITHUB_APP_PRIVATE_KEY`, `R2_SECRET_ACCESS_KEY` — with the kind drawn from
`_API_KEY`, `_API_TOKEN`, `_SECRET`, `_SECRET_KEY`, `_PRIVATE_KEY`,
`_PASSWORD`, `_ID`, `_URL`. The `id` stays the lowercase slug and stays what
you type in a command; `env` is what everything else searches by.

- **`pb key run <id>... -- <cmd>` — spend a key without reading it.** The only
way a value came out of the vault was `pb key copy`, which goes to the
clipboard: right for a human with a browser tab open, useless to a process,
and not something an agent should be doing at all. `run` resolves each id,
takes the value from the keychain, and starts the command with those
variables added to the environment it inherits. The value goes keychain →
child and touches nothing else — not stdout, not a log, not argv, not a file,
and not the context of whatever model asked for it. stderr names the
variables and the ids they came from, so you can see *what* was injected
without seeing what was injected. `--as NAME=<id>` covers the case where this
program wants a different name than the entry's, and an id with no `env` and
no `--as` is refused by name with the `pb key edit … --env` that fixes it,
because a run that silently dropped a credential fails somewhere much less
obvious. This is the second way a value leaves the vault, and the split is
deliberate: `copy` for the human, `run` for everything else.

- **`pb key edit <id>` — metadata, and only metadata.** Every key registered
before this release has no `env` name, and a vault is worth the fraction of
it that is findable, so backfilling had to be one command that is obviously
safe to run. `edit` never opens the keychain and cannot rotate anything:
`--env NAME` / `--no-env`, plus `--provider`, `--label`, `--purpose`,
`--scopes`, `--expires`, `--endpoint`, with `--no-purpose`, `--no-expires`
and `--no-endpoint` to clear the nullable ones. `id`, `last4` and the
registration date stay uneditable — they describe the value in the keychain,
and editing them here would only make the registry lie about it. Rotation
stays `pb key add --overwrite`, where a secret is actually being handled and
the command should look like it.

- **`pb key list` filters.** `--env NAME` answers "who holds the name my code
reads", `--provider P` narrows to one issuer, and `--grep TEXT` matches id,
label, purpose, provider and env name. The table gained an ENV column. A
listing you have to read in full is a listing an agent reads in full.

- **MCP: `resolve_env_vars`, `update_key`, `list_keys` filters, and `env` on
`store_key`.** `resolve_env_vars` is the tool the two rules above hang off:
give it the variable names the code reads and it answers per name with `keys`
(exact `env` matches), `suggestions` when there are none (entries whose
purpose, label or id mention the name or its non-generic tokens), `projects`
(env-vault environments defining it), a `status` of `key` / `project_var` /
`both` / `suggested` / `missing`, and `use` — the exact `pb key run …` or
`pb env run …` that supplies it. It reads two local JSON files and returns no
value, which is why it is ungated, and it is the reason the answer to "I need
this credential" can be a command rather than a secret. `update_key` is
`pb key edit`'s twin over MCP — metadata only, with a `clear` list for
`purpose`, `expires_at`, `endpoint` and `env` — ungated for the same reason:
it cannot reach the keychain. Rotation stays `store_key` with `overwrite`.
`list_keys` takes `provider`, `env` and `query`.

- **Two rules in the MCP server's instructions.** "LOOK HERE BEFORE ASKING FOR
A CREDENTIAL": before asking the human, writing a placeholder or calling a
variable missing, call `resolve_env_vars`; the answer is how to use the key
via `pb key run`, never the value itself. And "NAME THE VARIABLE": register
with `env` set, following the convention above, and backfill what lacks one
with `update_key`. Instructions are the only part of an MCP server a model
reads before deciding what to do, and every gate in this vault was already
strong enough — what was missing was the sentence telling it to try.

### Changed

- **The manifest's `KeyRecord` carries `env`.** `pb manifest` exists so a new
machine's agent can plan against what the old one used, and "which variable
is this key" is exactly the kind of thing that plan needs. It is a name, not
a value, so it changes nothing about what the file is safe to commit.

## [0.6.0] - 2026-08-28

### Added
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
- **Switch** — change profile/context from the panel, the CLI, or an AI — including the traps (`gcloud` ADC).
- **Permissions** — see what your tokens can actually do (`gh` scopes today) and fix missing scopes with one hint.
- **[MCP client management](docs/mcp-clients.md)** — every MCP server registered in Claude Code, Claude Desktop, Cursor, Codex, Windsurf and VS Code in one matrix; copy a server between clients without hand-editing four files in two formats.
- **[Key vault](docs/key-vault.md)** — standalone API keys no CLI tracks: values in the macOS Keychain, metadata on disk, provider-aware `pb key verify`, and AI registration over MCP.
- **[Key vault](docs/key-vault.md)** — standalone API keys no CLI tracks: values in the macOS Keychain, metadata on disk, provider-aware `pb key verify`, AI registration over MCP, and each key filed under the env-var name your code already reads (`CLOUDFLARE_API_TOKEN`) so `pb key run` — or an agent that needs it — can find and inject it without anyone reading the value.
- **[Project env vault](docs/env-vault.md)** — a project's environment variables without a plaintext `.env`: pull from Infisical, keep hand-set local overrides that never sync back, run a command with the merged result. A project is a portable name, not a path — `pb export` carries the manifest to a new machine (or copy the one file), clone the repo, pull.
- **[Keeping CLIs current](#keeping-clis-current)** — which tools are outdated, which were renamed out from under you, and the exact command to update each one.
- **[Migrate](docs/migration.md)** — export to a new machine; whatever can't travel, your AI walks you through re-authing. Or `pb manifest`: the secret-free record of what you use, safe to commit, and enough for an agent to rebuild a machine from.
Expand All @@ -52,6 +52,7 @@ pb status # the whole board in your terminal
pb use gcloud work # switch a profile
pb verify gh # actually check a token against its API
pb key list # your registered API keys
pb key run cf-deploy -- wrangler deploy # one credential into one process, never on screen
pb env run -- bun dev # this directory's env vars, from the Keychain, no .env file
```

Expand Down Expand Up @@ -141,7 +142,7 @@ table →](docs/migration.md)**
{ "mcpServers": { "patchbay": { "command": "/usr/local/bin/patchbay-mcp" } } }
```

Your agent gets `list_connections`, `switch_profile`, `verify`, `get_permissions`, `store_key`, `plan_setup`, and friends — "switch to the work gcloud account and deploy" becomes one sentence, and a key your AI creates mid-task gets registered instead of rotting in a chat log. Where permissions are granted per resource rather than per credential, `get_permissions` takes a `scope` and `list_permission_scopes` says what the choices are — a Google account has no roles of its own, only roles on a project, so patchbay reads the IAM policy of the one you name. Reading secret values back is **off by default** (`PATCHBAY_ALLOW_SECRET_READ=1` to opt in).
Your agent gets `list_connections`, `switch_profile`, `verify`, `get_permissions`, `store_key`, `plan_setup`, and friends — "switch to the work gcloud account and deploy" becomes one sentence, and a key your AI creates mid-task gets registered instead of rotting in a chat log. It also gets `resolve_env_vars`: hand it the variable names your code reads and it says which vault key or env-vault project holds each one, and the exact `pb key run` / `pb env run` that supplies it — so an agent that needs `CLOUDFLARE_API_TOKEN` looks in your vault instead of asking you for it or writing a placeholder, and still never sees the value. Where permissions are granted per resource rather than per credential, `get_permissions` takes a `scope` and `list_permission_scopes` says what the choices are — a Google account has no roles of its own, only roles on a project, so patchbay reads the IAM policy of the one you name. Reading secret values back is **off by default** (`PATCHBAY_ALLOW_SECRET_READ=1` to opt in).

## Showcase

Expand Down
7 changes: 6 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ async fn key_add(
scopes: Vec<String>,
expires: Option<String>,
endpoint: Option<String>,
env: Option<String>,
secret: String,
overwrite: bool,
) -> CmdResult<KeyRow> {
Expand All @@ -212,7 +213,11 @@ async fn key_add(
.collect(),
)
.expires_at(expires_at)
.endpoint(some_text(endpoint));
.endpoint(some_text(endpoint))
// The variable name code reads this key as. Validated by the registry,
// not here, so the panel shows the same corrected-name suggestion the
// CLI does instead of a second opinion written in TypeScript.
.env(some_text(env));

let registry = KeyRegistry::detect()?;
let entry = registry.add(new, &secret, overwrite)?;
Expand Down
27 changes: 26 additions & 1 deletion app/src/components/KeysView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function KeysView() {
<tr>
<th>id</th>
<th>provider</th>
<th>env</th>
<th>label</th>
<th>last 4</th>
<th>expiry</th>
Expand All @@ -141,6 +142,9 @@ export function KeysView() {
<tr>
<td className="cell-id">{k.id}</td>
<td className="cell-mono">{k.provider}</td>
{/* The second name: what code reads this key as, and what
`pb key run` injects it under. A name, never a value. */}
<td className="cell-mono">{k.env ?? <span className="muted">—</span>}</td>
<td>{k.label}</td>
{/* The only thing on this page derived from a secret value. */}
<td className="cell-mono cell-last4">··{k.last4}</td>
Expand Down Expand Up @@ -168,7 +172,7 @@ export function KeysView() {
</tr>
{confirming === k.id && (
<tr className="confirm-row">
<td colSpan={7}>
<td colSpan={8}>
<div className="confirm">
<span className="confirm-why">
Remove <b>{k.id}</b>? This removes the entry and its keychain value; the
Expand Down Expand Up @@ -228,6 +232,7 @@ function AddKeyForm({
onAdded: (row: KeyRow) => void | Promise<void>;
}>) {
const [id, setId] = useState("");
const [env, setEnv] = useState("");
const [provider, setProvider] = useState("");
const [label, setLabel] = useState("");
const [secret, setSecret] = useState("");
Expand Down Expand Up @@ -270,6 +275,7 @@ function AddKeyForm({
.filter(Boolean),
expires: expires.trim() || null,
endpoint: endpoint.trim() || null,
env: env.trim() || null,
overwrite,
},
value,
Expand Down Expand Up @@ -322,6 +328,25 @@ function AddKeyForm({
</span>
</label>

{/* Not behind the fold: this is the entry's second name, and the one
anything other than a human looks it up by. */}
<label className="field">
<span className="field-key">env</span>
<input
className="field-input"
value={env}
onChange={(e) => setEnv(e.target.value)}
placeholder="CLOUDFLARE_API_TOKEN"
spellCheck={false}
autoCapitalize="characters"
autoCorrect="off"
/>
<span className="muted small">
the variable code reads it as — UPPER_SNAKE_CASE; what{" "}
<code>pb key run</code> injects and what an agent looks it up by
</span>
</label>

<label className="field">
<span className="field-key">provider</span>
<input
Expand Down
13 changes: 13 additions & 0 deletions app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,16 @@ export interface KeyRow {
last4: string;
/** Who registered it: `"cli"`, `"mcp:<client>"`, `"gui"`. */
source: string;
/**
* The environment variable this key is exposed as — `CLOUDFLARE_API_TOKEN`.
* A name, never a value: it is the second name for the entry, the one code
* and CI already spell, and the one `pb key run` injects the secret under.
* `null` for entries that are not variables (an issuer id, a D-U-N-S number)
* and for anything registered before the field existed. Core omits the field
* entirely when it is unset rather than writing `null`, so read it with `??`
* — every consumer here treats absent and null the same way.
*/
env: string | null;
expiry_state: KeyExpiryState;
}

Expand All @@ -317,6 +327,9 @@ export interface NewKeyInput {
scopes: string[];
expires: string | null;
endpoint: string | null;
/** UPPER_SNAKE_CASE, or null. Core validates and its refusal spells the
* corrected name, so the panel does not pre-empt it. */
env: string | null;
/** Replace an existing entry under this id — a rotation, not an accident. */
overwrite: boolean;
}
Expand Down
Loading
Loading