From 1ef8672badcc75c0b31d8a69bac39e6e986323ba Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Tue, 8 Sep 2026 00:23:13 +0800 Subject: [PATCH 1/3] [feature] key vault: env name on every entry, validated as UPPER_SNAKE_CASE, with a shared filter --- crates/patchbay-cli/src/keys.rs | 1 + crates/patchbay-core/src/keys.rs | 328 +++++++++++++++++++ crates/patchbay-core/src/keys_verify.rs | 1 + crates/patchbay-core/src/lib.rs | 2 +- crates/patchbay-core/src/migrate/export.rs | 1 + crates/patchbay-core/src/migrate/manifest.rs | 7 + crates/patchbay-core/src/migrate/setup.rs | 1 + crates/patchbay-mcp/src/keys.rs | 3 + 8 files changed, 343 insertions(+), 1 deletion(-) diff --git a/crates/patchbay-cli/src/keys.rs b/crates/patchbay-cli/src/keys.rs index 7579d0b..5cfe96b 100644 --- a/crates/patchbay-cli/src/keys.rs +++ b/crates/patchbay-cli/src/keys.rs @@ -523,6 +523,7 @@ mod tests { last4: "1234".into(), source: "cli".into(), endpoint: None, + env: None, } } diff --git a/crates/patchbay-core/src/keys.rs b/crates/patchbay-core/src/keys.rs index dd12f5c..92bb333 100644 --- a/crates/patchbay-core/src/keys.rs +++ b/crates/patchbay-core/src/keys.rs @@ -151,6 +151,20 @@ pub struct KeyEntry { /// `keys.json` written before this field existed keeps parsing. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, + /// The environment variable this key is conventionally exposed as — + /// `CLOUDFLARE_API_TOKEN`, `NEON_API_KEY` — in the shape + /// [`validate_env_name`] enforces. This is the name code, CI secrets and + /// vendor SDKs already read, and it is what lets a consumer that needs + /// `CLOUDFLARE_API_TOKEN` find `cf-gh-actions-deploy` without guessing: + /// `pb key run` injects the value under it, and an agent resolves a + /// required variable to a vault entry through it. + /// + /// Optional, because a key registered before the field existed has none, + /// and because not every entry is a variable (an issuer id, a D-U-N-S + /// number). Two entries may share one name — a dev and a production key + /// for the same service — and a lookup returns both. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option, } impl KeyEntry { @@ -207,6 +221,8 @@ pub struct NewKey { pub source: String, /// Instance URL, for providers that have more than one address. pub endpoint: Option, + /// The variable name the key is exposed as; see [`KeyEntry::env`]. + pub env: Option, } impl NewKey { @@ -223,6 +239,7 @@ impl NewKey { expires_at: None, source: source.into(), endpoint: None, + env: None, } } @@ -257,6 +274,13 @@ impl NewKey { self.endpoint = endpoint.map(|e| normalize_endpoint(&e)); self } + + /// The variable name, trimmed. Validated by [`KeyRegistry::add`], not + /// here, so a bad name is reported next to the other registration errors. + pub fn env(mut self, env: Option) -> Self { + self.env = env.map(|e| e.trim().to_string()).filter(|e| !e.is_empty()); + self + } } /// Trim trailing slashes and surrounding whitespace so an endpoint can be @@ -275,6 +299,8 @@ pub struct KeyPatch { pub scopes: Option>, pub expires_at: Option>>, pub endpoint: Option>, + /// `Some(Some(name))` sets the variable name, `Some(None)` clears it. + pub env: Option>, } impl KeyPatch { @@ -285,6 +311,7 @@ impl KeyPatch { && self.scopes.is_none() && self.expires_at.is_none() && self.endpoint.is_none() + && self.env.is_none() } } @@ -394,6 +421,9 @@ impl KeyRegistry { /// date of the rotation). pub fn add(&self, new: NewKey, secret: &str, overwrite: bool) -> anyhow::Result { validate_id(&new.id)?; + if let Some(env) = &new.env { + validate_env_name(env)?; + } if secret.is_empty() { anyhow::bail!("refusing to register `{}` with an empty secret", new.id); } @@ -420,6 +450,7 @@ impl KeyRegistry { last4: last4(secret), source: new.source, endpoint: new.endpoint.as_deref().map(normalize_endpoint), + env: new.env, }; match existing { @@ -480,6 +511,13 @@ impl KeyRegistry { if let Some(endpoint) = patch.endpoint { entry.endpoint = endpoint.as_deref().map(normalize_endpoint); } + if let Some(env) = patch.env { + let env = env.map(|e| e.trim().to_string()).filter(|e| !e.is_empty()); + if let Some(name) = &env { + validate_env_name(name)?; + } + entry.env = env; + } let updated = entry.clone(); self.save(&file)?; Ok(updated) @@ -629,6 +667,111 @@ pub fn expiring_within_at(entries: &[KeyEntry], now: DateTime, days: i64) - hits } +/// Upper bound on a variable name. The same as an id's: long enough for any +/// real `NEXT_PUBLIC_…` name, short enough to keep a table readable. +const MAX_ENV_NAME_LEN: usize = 64; + +/// The shape of a [`KeyEntry::env`] name: `UPPER_SNAKE_CASE`, starting with a +/// letter — `CLOUDFLARE_API_TOKEN`, not `cloudflare_api_token`, not +/// `CF-TOKEN`, not `1PASSWORD_TOKEN`. +/// +/// Stricter than what a POSIX shell can export (the env vault's rule, which +/// also allows lowercase) on purpose. The point of the field is that two +/// people — or a person and an agent — spell the same variable the same way +/// without coordinating, and one shape is the only thing that makes that +/// true. The error suggests the corrected spelling where there is one. +pub fn validate_env_name(name: &str) -> anyhow::Result<()> { + if name.is_empty() { + anyhow::bail!("env name cannot be empty"); + } + if name.len() > MAX_ENV_NAME_LEN { + anyhow::bail!("env name `{name}` is longer than {MAX_ENV_NAME_LEN} characters"); + } + let suggested: String = name + .chars() + .map(|c| match c { + '-' | ' ' | '.' => '_', + c => c.to_ascii_uppercase(), + }) + .collect(); + if name.chars().any(|c| c.is_ascii_lowercase()) { + anyhow::bail!( + "env names are UPPER_SNAKE_CASE (the shape code reads them in); try `{suggested}`" + ); + } + if let Some(bad) = name + .chars() + .find(|c| !(c.is_ascii_uppercase() || c.is_ascii_digit() || *c == '_')) + { + anyhow::bail!( + "env name `{name}` contains `{bad}`; use A-Z, digits and `_` only, like `{suggested}`" + ); + } + if !name.starts_with(|c: char| c.is_ascii_uppercase()) { + anyhow::bail!("env name `{name}` must start with a letter, like `CLOUDFLARE_API_TOKEN`"); + } + Ok(()) +} + +/// The questions a caller narrows a listing by. All optional, all ANDed; a +/// default filter matches everything. +#[derive(Debug, Clone, Default)] +pub struct KeyFilter { + /// Provider, compared case-insensitively. + pub provider: Option, + /// Exact variable name ([`KeyEntry::env`]), compared case-insensitively so + /// a caller may ask for `cloudflare_api_token` and still find the entry. + pub env: Option, + /// Free text, matched case-insensitively against id, label, purpose, + /// provider and env name. + pub query: Option, +} + +impl KeyFilter { + pub fn is_empty(&self) -> bool { + self.provider.is_none() && self.env.is_none() && self.query.is_none() + } + + pub fn matches(&self, entry: &KeyEntry) -> bool { + let eq = |want: &Option, have: Option<&str>| match want { + None => true, + Some(w) => have.is_some_and(|h| h.eq_ignore_ascii_case(w.trim())), + }; + if !eq(&self.provider, Some(&entry.provider)) { + return false; + } + if !eq(&self.env, entry.env.as_deref()) { + return false; + } + match self.query.as_deref().map(str::trim).filter(|q| !q.is_empty()) { + None => true, + Some(q) => { + let q = q.to_lowercase(); + [ + Some(entry.id.as_str()), + Some(entry.label.as_str()), + Some(entry.provider.as_str()), + entry.purpose.as_deref(), + entry.env.as_deref(), + ] + .into_iter() + .flatten() + .any(|field| field.to_lowercase().contains(&q)) + } + } + } +} + +/// The entries a filter keeps, in registry order. Pure, so the CLI, the MCP +/// server and the panel narrow a listing by one rule. +pub fn filter_keys(entries: &[KeyEntry], filter: &KeyFilter) -> Vec { + entries + .iter() + .filter(|e| filter.matches(e)) + .cloned() + .collect() +} + /// Ids are lowercase slugs. Beyond keeping the board readable, this is what /// keeps an id from being mistaken for an option when it is handed to /// `security` as an argument. @@ -963,6 +1106,7 @@ mod tests { last4: "0000".into(), source: "cli".into(), endpoint: None, + env: None, }; let entries = vec![ @@ -1000,6 +1144,7 @@ mod tests { last4: "0000".into(), source: "cli".into(), endpoint: None, + env: None, }; assert!(e.time_to_expiry(now).is_none()); assert!(!e.is_expired(now)); @@ -1203,6 +1348,7 @@ mod tests { last4: "1234".into(), source: "cli".into(), endpoint: None, + env: None, }; assert_eq!(with(None).expiry_state(now), KeyExpiryState::NoExpiry); @@ -1363,4 +1509,186 @@ mod tests { ); assert_eq!(entries[0].expires_at, Some(expires)); } + + #[test] + fn test_env_names_are_upper_snake_and_the_error_suggests_the_fix() { + assert!(validate_env_name("CLOUDFLARE_API_TOKEN").is_ok()); + assert!(validate_env_name("NEXT_PUBLIC_GA4_ID").is_ok()); + assert!(validate_env_name("A").is_ok()); + + let err = validate_env_name("cloudflare-api-token").unwrap_err().to_string(); + assert!(err.contains("UPPER_SNAKE_CASE"), "{err}"); + assert!(err.contains("`CLOUDFLARE_API_TOKEN`"), "{err}"); + + let err = validate_env_name("CF-TOKEN").unwrap_err().to_string(); + assert!(err.contains("`-`"), "{err}"); + assert!(err.contains("`CF_TOKEN`"), "{err}"); + + let err = validate_env_name("_TOKEN").unwrap_err().to_string(); + assert!(err.contains("start with a letter"), "{err}"); + let err = validate_env_name("1PASSWORD").unwrap_err().to_string(); + assert!(err.contains("start with a letter"), "{err}"); + + assert!(validate_env_name("").is_err()); + assert!(validate_env_name(&"A".repeat(65)).is_err()); + } + + #[test] + fn test_add_stores_a_valid_env_name_and_rejects_a_bad_one_before_writing() { + let v = vault(); + let entry = v + .registry + .add( + sample("cf-api").env(Some(" CLOUDFLARE_API_TOKEN ".into())), + "secret-value", + false, + ) + .unwrap(); + assert_eq!(entry.env.as_deref(), Some("CLOUDFLARE_API_TOKEN")); + + let err = v + .registry + .add(sample("bad").env(Some("bad name".into())), "x", false) + .unwrap_err() + .to_string(); + assert!(err.contains("UPPER_SNAKE_CASE"), "{err}"); + assert!(v.registry.get("bad").unwrap().is_none()); + assert!(v.store.get("bad").unwrap().is_none()); + + // A blank name is no name, not an error. + let entry = v + .registry + .add(sample("blank").env(Some(" ".into())), "x", false) + .unwrap(); + assert_eq!(entry.env, None); + } + + #[test] + fn test_patch_sets_validates_and_clears_the_env_name() { + let v = vault(); + v.registry.add(sample("cf-api"), "secret", false).unwrap(); + + let patch = KeyPatch { + env: Some(Some("CLOUDFLARE_API_TOKEN".into())), + ..Default::default() + }; + assert!(!patch.is_empty()); + let updated = v.registry.update_metadata("cf-api", patch).unwrap(); + assert_eq!(updated.env.as_deref(), Some("CLOUDFLARE_API_TOKEN")); + + let err = v + .registry + .update_metadata( + "cf-api", + KeyPatch { + env: Some(Some("nope".into())), + ..Default::default() + }, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("`NOPE`"), "{err}"); + // A refused patch leaves the previous name in place. + assert_eq!( + v.registry.get("cf-api").unwrap().unwrap().env.as_deref(), + Some("CLOUDFLARE_API_TOKEN") + ); + + let cleared = v + .registry + .update_metadata( + "cf-api", + KeyPatch { + env: Some(None), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.env, None); + } + + #[test] + fn test_an_entry_without_env_reads_back_and_writes_no_env_field() { + let v = vault(); + v.registry.add(sample("cf-api"), "secret", false).unwrap(); + let raw = std::fs::read_to_string(v.registry.path()).unwrap(); + assert!(!raw.contains("\"env\""), "{raw}"); + // keys.json written before the field existed parses the same way. + assert_eq!(v.registry.get("cf-api").unwrap().unwrap().env, None); + } + + #[test] + fn test_filter_narrows_by_provider_env_and_free_text() { + let now = Utc::now(); + let entry = |id: &str, provider: &str, env: Option<&str>, purpose: &str| KeyEntry { + id: id.into(), + provider: provider.into(), + label: id.replace('-', " "), + purpose: Some(purpose.into()), + scopes: vec![], + created_at: now, + expires_at: None, + last4: "0000".into(), + source: "cli".into(), + endpoint: None, + env: env.map(Into::into), + }; + let entries = vec![ + entry("cf-deploy", "cloudflare", Some("CLOUDFLARE_API_TOKEN"), "deploy workers"), + entry("cf-r2", "Cloudflare", Some("R2_SECRET_ACCESS_KEY"), "R2 uploads"), + entry("neon-ci", "neon", Some("NEON_API_KEY"), "control plane for peregrine"), + entry("duns", "dnb", None, "not a secret, an index entry"), + ]; + let ids = |f: KeyFilter| -> Vec { + filter_keys(&entries, &f).into_iter().map(|e| e.id).collect() + }; + + assert!(KeyFilter::default().is_empty()); + assert_eq!(ids(KeyFilter::default()).len(), 4); + assert_eq!( + ids(KeyFilter { + provider: Some("CLOUDFLARE".into()), + ..Default::default() + }), + vec!["cf-deploy", "cf-r2"] + ); + // env is exact but case-insensitive: an agent asking in lowercase still finds it. + assert_eq!( + ids(KeyFilter { + env: Some("neon_api_key".into()), + ..Default::default() + }), + vec!["neon-ci"] + ); + assert_eq!( + ids(KeyFilter { + query: Some("peregrine".into()), + ..Default::default() + }), + vec!["neon-ci"] + ); + assert_eq!( + ids(KeyFilter { + query: Some("r2".into()), + ..Default::default() + }), + vec!["cf-r2"] + ); + // Filters AND together. + assert!(ids(KeyFilter { + provider: Some("neon".into()), + query: Some("workers".into()), + ..Default::default() + }) + .is_empty()); + // A blank query matches everything rather than nothing. + assert_eq!( + ids(KeyFilter { + query: Some(" ".into()), + ..Default::default() + }) + .len(), + 4 + ); + } } diff --git a/crates/patchbay-core/src/keys_verify.rs b/crates/patchbay-core/src/keys_verify.rs index 582bb57..02aac75 100644 --- a/crates/patchbay-core/src/keys_verify.rs +++ b/crates/patchbay-core/src/keys_verify.rs @@ -653,6 +653,7 @@ mod tests { last4: "1234".into(), source: "cli".into(), endpoint: None, + env: None, } } diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index 11ad9ad..de766a5 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -57,7 +57,7 @@ pub use envs::{ Attachment, EnvLayer, EnvMeta, EnvRegistry, EnvVarInfo, EnvVarSource, MergedEnv, ProjectEntry, SyncConfig, }; -pub use keys::{KeyEntry, KeyExpiryState, KeyPatch, KeyRegistry, NewKey}; +pub use keys::{filter_keys, validate_env_name, KeyEntry, KeyExpiryState, KeyFilter, KeyPatch, KeyRegistry, NewKey}; pub use keys_verify::{verify_key, KeyVerifyOutcome, KeyVerifyStatus}; pub use keystore::Keystore; pub use mcp_clients::{ diff --git a/crates/patchbay-core/src/migrate/export.rs b/crates/patchbay-core/src/migrate/export.rs index 0555cc8..30ac553 100644 --- a/crates/patchbay-core/src/migrate/export.rs +++ b/crates/patchbay-core/src/migrate/export.rs @@ -415,6 +415,7 @@ impl Exporter<'_> { purpose: entry.purpose, scopes: entry.scopes, expires_at: entry.expires_at, + env: entry.env, last4: entry.last4, included, }); diff --git a/crates/patchbay-core/src/migrate/manifest.rs b/crates/patchbay-core/src/migrate/manifest.rs index 1f3cbd2..c173b39 100644 --- a/crates/patchbay-core/src/migrate/manifest.rs +++ b/crates/patchbay-core/src/migrate/manifest.rs @@ -154,6 +154,12 @@ pub struct KeyRecord { pub scopes: Vec, #[serde(default)] pub expires_at: Option>, + /// The variable name the key is exposed as (`CLOUDFLARE_API_TOKEN`), when + /// the vault knows it. A name, not a value — it belongs in the readable + /// half, and it is what lets a new machine's setup say which variable each + /// missing key was. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option, /// Last 4 characters, exactly as the vault stores them. pub last4: String, pub included: bool, @@ -344,6 +350,7 @@ mod tests { purpose: Some("deploy from CI".into()), scopes: vec!["workers:edit".into()], expires_at: None, + env: None, last4: "9876".into(), included: false, }], diff --git a/crates/patchbay-core/src/migrate/setup.rs b/crates/patchbay-core/src/migrate/setup.rs index b624ce7..b1759b6 100644 --- a/crates/patchbay-core/src/migrate/setup.rs +++ b/crates/patchbay-core/src/migrate/setup.rs @@ -257,6 +257,7 @@ mod tests { purpose: None, scopes: vec![], expires_at: None, + env: None, last4: "9876".into(), included: false, }], diff --git a/crates/patchbay-mcp/src/keys.rs b/crates/patchbay-mcp/src/keys.rs index c30ca76..4c4e04d 100644 --- a/crates/patchbay-mcp/src/keys.rs +++ b/crates/patchbay-mcp/src/keys.rs @@ -425,6 +425,7 @@ mod tests { last4: "1234".into(), source: "mcp:test".into(), endpoint: None, + env: None, }; let state = |e: KeyEntry| { describe(&e, now).unwrap()["expiry_state"] @@ -462,6 +463,7 @@ mod tests { last4: "1234".into(), source: "mcp:test".into(), endpoint: None, + env: None, }; assert_eq!( describe(&entry("cloudflare"), now).unwrap()["linked_tool"], @@ -532,6 +534,7 @@ mod tests { last4: "1234".into(), source: "mcp:test".into(), endpoint: None, + env: None, }; let value = describe(&entry, now).unwrap(); let map = value.as_object().unwrap(); From 5ac2b4ddbb62052e93ba68621bc025570a539fa5 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Tue, 8 Sep 2026 00:33:56 +0800 Subject: [PATCH 2/3] [feature] key vault: env names, pb key run/edit, resolve_env_vars, and MCP rules that make an agent look here first --- CHANGELOG.md | 105 ++++ README.md | 5 +- app/src-tauri/src/lib.rs | 7 +- app/src/components/KeysView.tsx | 27 +- app/src/types.ts | 13 + crates/patchbay-cli/src/keys.rs | 651 +++++++++++++++++++- crates/patchbay-core/src/keys.rs | 37 +- crates/patchbay-core/src/lib.rs | 5 +- crates/patchbay-mcp/src/keys.rs | 975 +++++++++++++++++++++++++++++- crates/patchbay-mcp/src/server.rs | 79 ++- docs/env-vault.md | 15 + docs/key-vault.md | 164 ++++- 12 files changed, 2020 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c33db..ad1e857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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 `__` — `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 ... -- ` — 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=` 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 ` — 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 diff --git a/README.md b/README.md index c4f345a..bedfe3b 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 ``` @@ -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 diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index ec585b1..1df93ed 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -187,6 +187,7 @@ async fn key_add( scopes: Vec, expires: Option, endpoint: Option, + env: Option, secret: String, overwrite: bool, ) -> CmdResult { @@ -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)?; diff --git a/app/src/components/KeysView.tsx b/app/src/components/KeysView.tsx index 5332741..bebb65d 100644 --- a/app/src/components/KeysView.tsx +++ b/app/src/components/KeysView.tsx @@ -128,6 +128,7 @@ export function KeysView() { id provider + env label last 4 expiry @@ -141,6 +142,9 @@ export function KeysView() { {k.id} {k.provider} + {/* The second name: what code reads this key as, and what + `pb key run` injects it under. A name, never a value. */} + {k.env ?? —} {k.label} {/* The only thing on this page derived from a secret value. */} ··{k.last4} @@ -168,7 +172,7 @@ export function KeysView() { {confirming === k.id && ( - +
Remove {k.id}? This removes the entry and its keychain value; the @@ -228,6 +232,7 @@ function AddKeyForm({ onAdded: (row: KeyRow) => void | Promise; }>) { const [id, setId] = useState(""); + const [env, setEnv] = useState(""); const [provider, setProvider] = useState(""); const [label, setLabel] = useState(""); const [secret, setSecret] = useState(""); @@ -270,6 +275,7 @@ function AddKeyForm({ .filter(Boolean), expires: expires.trim() || null, endpoint: endpoint.trim() || null, + env: env.trim() || null, overwrite, }, value, @@ -322,6 +328,25 @@ function AddKeyForm({ + {/* Not behind the fold: this is the entry's second name, and the one + anything other than a human looks it up by. */} + +