From 17547c51523aebbcbfa8ad8ece73eca0315100f4 Mon Sep 17 00:00:00 2001
From: YJack0000
Date: Fri, 14 Aug 2026 01:13:40 +0800
Subject: [PATCH 1/3] =?UTF-8?q?[feature]=20panel:=20key=20vault=20add/remo?=
=?UTF-8?q?ve=20=E2=80=94=20masked=20secret=20entry,=20delete=20with=20con?=
=?UTF-8?q?firm?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 19 ++
app/src-tauri/src/lib.rs | 119 +++++++-
app/src/api.ts | 19 +-
app/src/components/Glyphs.tsx | 25 ++
app/src/components/KeysView.tsx | 503 +++++++++++++++++++++++++++-----
app/src/styles.css | 113 +++++++
app/src/types.ts | 27 ++
docs/key-vault.md | 34 ++-
8 files changed, 780 insertions(+), 79 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ad65028..8bfb556 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **The panel writes to the key vault** — `add key` opens a form (id, provider,
+ label, a masked secret field, with purpose, scopes, expiry, endpoint and the
+ rotation checkbox folded away), and every row gets a trash affordance behind
+ an inline confirm that says what removing does *not* do: the entry and its
+ keychain value go, the credential keeps working until you revoke it at the
+ provider. Both commands (`key_add`, `key_remove`) are thin wrappers over the
+ same `KeyRegistry` calls the CLI makes, so the registry's rules — duplicate id
+ refused unless you are rotating, empty secret refused, both-or-neither writes —
+ and its error strings reach the panel verbatim. The vault view no longer tells
+ you to go and use the command line. The secret exists in the field, the invoke
+ payload and `KeyRegistry::add`, and nowhere else: it is cleared on submit,
+ never logged, never echoed back. The panel still cannot *read* a value —
+ `get_secret` is not wired up, there is no reveal and no copy, and `pb key copy`
+ remains the only way one leaves the vault. The old CLI-only rule was about
+ argv (`ps`, shell history), and a password field in a native window has
+ neither.
+
## [0.2.0] - 2026-08-13
### Added
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index dbddc83..41c1b69 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -4,6 +4,8 @@
//! and (tier 2) spawn the tools' own CLIs, so each call runs on the blocking
//! pool — the webview never waits on the async runtime's worker threads.
+use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
+use patchbay_core::keys::NewKey;
use patchbay_core::{
KeyEntry, KeyExpiryState, KeyRegistry, McpClient, McpClientRegistry, PermissionsReport,
Registry, SwitchOutcome, ToolStatus, VerifyOutcome,
@@ -80,8 +82,16 @@ async fn permissions(tool: String) -> CmdResult {
/// markers on the board can never disagree about what "expiring soon" means.
///
/// Metadata only, by construction: [`KeyEntry`] has never carried the secret
-/// value — only its `last4`. [`KeyRegistry::get_secret`] is deliberately *not*
-/// wired up as a command; the panel displays keys, it never needs to hold one.
+/// value — only its `last4`.
+///
+/// The panel takes a secret exactly once — [`key_add`], from a masked field,
+/// held in memory for the length of one call and handed straight to the
+/// keychain. It never reads one back: [`KeyRegistry::get_secret`] is
+/// deliberately *not* wired up as a command, there is no reveal and no copy in
+/// this window, and `pb key copy` stays the only way a value gets out of the
+/// vault. Writing is easy, reading is not — that asymmetry is the design, and
+/// the CLI-only rule it replaced was about argv (`ps`, shell history), which a
+/// masked field in a native app does not touch.
#[derive(serde::Serialize)]
struct KeyRow {
#[serde(flatten)]
@@ -106,6 +116,109 @@ async fn keys_list() -> CmdResult> {
.await
}
+/// What [`key_remove`] reports back: enough to name the key that is gone in a
+/// confirmation, and nothing else. `last4` is the same four characters the
+/// table was already showing.
+#[derive(serde::Serialize)]
+struct RemovedKey {
+ id: String,
+ last4: String,
+}
+
+/// A date the panel's form accepts: `YYYY-MM-DD` (UTC midnight) or a full
+/// timestamp. Reimplemented rather than shared with the CLI's `--expires`
+/// parser — the panel does not depend on `patchbay-cli` and is not about to
+/// start for six lines. Both go through `patchbay_core::util::parse_timestamp`,
+/// which is where the timestamp knowledge actually lives.
+fn parse_expiry(raw: &str) -> anyhow::Result> {
+ let raw = raw.trim();
+ if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
+ return Ok(Utc.from_utc_datetime(&date.and_time(NaiveTime::MIN)));
+ }
+ patchbay_core::util::parse_timestamp(raw)
+ .ok_or_else(|| anyhow::anyhow!("could not read `{raw}` as a date; try `2027-01-01`"))
+}
+
+/// Trimmed, or `None` when the field was left blank — an empty optional field
+/// is an absent one, not an empty string in the registry.
+fn some_text(raw: Option) -> Option {
+ raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
+}
+
+/// Register a key: metadata to `keys.json`, value to the keychain, both or
+/// neither — [`KeyRegistry::add`] owns that guarantee and its rules (duplicate
+/// id refused unless `overwrite`, empty secret refused) are surfaced to the
+/// panel verbatim rather than re-litigated here.
+///
+/// `secret` is the one piece of credential material that crosses this boundary.
+/// It is moved into the blocking closure, borrowed once by `add`, and dropped
+/// there. It is never logged, never stored, and never put in an error: every
+/// message the panel can show comes from the registry, which only ever knew the
+/// metadata.
+#[tauri::command]
+#[allow(clippy::too_many_arguments)]
+async fn key_add(
+ id: String,
+ provider: Option,
+ label: Option,
+ purpose: Option,
+ scopes: Vec,
+ expires: Option,
+ endpoint: Option,
+ secret: String,
+ overwrite: bool,
+) -> CmdResult {
+ off_thread(move || {
+ let id = id.trim().to_string();
+ let expires_at = some_text(expires)
+ .as_deref()
+ .map(parse_expiry)
+ .transpose()?;
+ let new = NewKey::new(
+ id.as_str(),
+ // Where the entry came from, for the `source` column. The vault has
+ // said `"gui"` since it was written; this is the first thing to use it.
+ "gui",
+ )
+ .provider(some_text(provider).unwrap_or_else(|| "unknown".to_string()))
+ .label(some_text(label).unwrap_or_else(|| id.clone()))
+ .purpose(some_text(purpose))
+ .scopes(
+ scopes
+ .into_iter()
+ .filter_map(|s| some_text(Some(s)))
+ .collect(),
+ )
+ .expires_at(expires_at)
+ .endpoint(some_text(endpoint));
+
+ let registry = KeyRegistry::detect()?;
+ let entry = registry.add(new, &secret, overwrite)?;
+ drop(secret);
+
+ Ok(KeyRow {
+ expiry_state: entry.expiry_state(Utc::now()),
+ entry,
+ })
+ })
+ .await
+}
+
+/// Unregister a key: the metadata row and the keychain item both go. Removing
+/// is not revoking — the credential itself keeps working, which is why the
+/// panel says so before it asks.
+#[tauri::command]
+async fn key_remove(id: String) -> CmdResult {
+ off_thread(move || {
+ let entry = KeyRegistry::detect()?.remove(id.trim())?;
+ Ok(RemovedKey {
+ id: entry.id,
+ last4: entry.last4,
+ })
+ })
+ .await
+}
+
/// Every MCP client patchbay knows about, present or not — the absent ones are
/// the point of the matrix as much as the present ones. Server entries carry
/// env var *names* and header *names* only; core never reads the values.
@@ -143,6 +256,8 @@ pub fn run() {
verify_profile,
permissions,
keys_list,
+ key_add,
+ key_remove,
mcp_list
])
.run(tauri::generate_context!())
diff --git a/app/src/api.ts b/app/src/api.ts
index 67a7fca..fca376f 100644
--- a/app/src/api.ts
+++ b/app/src/api.ts
@@ -2,7 +2,9 @@ import { invoke } from "@tauri-apps/api/core";
import type {
KeyRow,
McpClient,
+ NewKeyInput,
PermissionsReport,
+ RemovedKey,
SwitchOutcome,
ToolStatus,
VerifyOutcome,
@@ -26,7 +28,22 @@ export const verifyProfile = (tool: string, profile: string) =>
export const permissions = (tool: string) => invoke("permissions", { tool });
-/** Vault metadata. Read-only, and there is no command that returns a value. */
+/** Vault metadata. There is no command that returns a value — see `keyAdd`. */
export const keysList = () => invoke("keys_list");
+/**
+ * Register a key. The secret is a separate argument on purpose: it belongs to
+ * no object, is never held in state alongside the metadata, and exists only
+ * between the form field and this call. The backend hands it to the same
+ * `KeyRegistry::add` the CLI uses and drops it.
+ *
+ * Values only ever travel in this direction. Nothing in the panel reads one
+ * back — `pb key copy ` remains the only way out of the vault.
+ */
+export const keyAdd = (key: NewKeyInput, secret: string) =>
+ invoke("key_add", { ...key, secret });
+
+/** Unregister a key: metadata row and keychain item both. Not a revocation. */
+export const keyRemove = (id: string) => invoke("key_remove", { id });
+
export const mcpList = () => invoke("mcp_list");
diff --git a/app/src/components/Glyphs.tsx b/app/src/components/Glyphs.tsx
index a8b79ef..3394b3d 100644
--- a/app/src/components/Glyphs.tsx
+++ b/app/src/components/Glyphs.tsx
@@ -33,6 +33,31 @@ export function KeyGlyph({ size = 11 }: { size?: number }) {
);
}
+/** Remove a vault entry. Drawn for the same reason as the others: 🗑 is an
+ * emoji on some machines and a tofu box on others, and this one sits in a
+ * table row where a colour surprise would read as an error state. */
+export function TrashGlyph({ size = 11 }: { size?: number }) {
+ return (
+
+ );
+}
+
/** The MCP servers-against-clients matrix. */
export function MatrixGlyph({ size = 11 }: { size?: number }) {
return (
diff --git a/app/src/components/KeysView.tsx b/app/src/components/KeysView.tsx
index 5bed408..71b1cef 100644
--- a/app/src/components/KeysView.tsx
+++ b/app/src/components/KeysView.tsx
@@ -1,32 +1,74 @@
-import { useEffect, useState } from "react";
-import { keysList } from "../api";
+import { Fragment, useCallback, useEffect, useState, type FormEvent } from "react";
+import { keyAdd, keyRemove, keysList } from "../api";
import { Copyable } from "./Copyable";
+import { TrashGlyph } from "./Glyphs";
import { KEY_EXPIRY_LABEL, KEY_EXPIRY_LEVEL, type KeyRow } from "../types";
/**
* The key vault: the standalone API keys no CLI has ever heard of, which the
- * user registered with patchbay on purpose. Read-only in the panel — adding a
- * key means handing over a secret, and that belongs on the command line where
- * the value never crosses a process boundary it did not have to.
+ * user registered with patchbay on purpose.
*
- * Metadata only, and there is no code path here that could show otherwise: the
- * `keys_list` command returns `last4` and nothing else derived from the value.
+ * The panel registers and removes them, and does neither of those things with a
+ * value it can show you. A secret enters through one masked field, rides one
+ * `invoke` call, and lands in the OS keychain; nothing here stores it, echoes
+ * it, or can ask for it back. `pb key copy ` is still the only way a value
+ * leaves the vault — the asymmetry the CLI was built around survives intact.
+ *
+ * The old rule was "adding happens on the command line", and its reason was
+ * argv: a secret passed as an argument is visible in `ps` and written verbatim
+ * into shell history. Neither is true of a password field in a native window,
+ * so the rule was protecting nothing here and cost the panel the one action a
+ * key vault is for.
*/
export function KeysView() {
const [rows, setRows] = useState(null);
const [error, setError] = useState(null);
+ const [adding, setAdding] = useState(false);
+ /** The row whose delete is armed. One at a time, and never pre-armed. */
+ const [confirming, setConfirming] = useState(null);
+ const [removing, setRemoving] = useState(null);
+ /** The outcome of the last write, in the panel's usual note voice. */
+ const [note, setNote] = useState<{ text: string; bad: boolean } | null>(null);
- useEffect(() => {
- let live = true;
- keysList()
- .then((r) => live && setRows(r))
- .catch((e) => live && setError(String(e)));
- return () => {
- live = false;
- };
+ const load = useCallback(async () => {
+ try {
+ setRows(await keysList());
+ setError(null);
+ } catch (e) {
+ setError(String(e));
+ }
}, []);
- if (error) {
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const remove = async (id: string) => {
+ setRemoving(id);
+ setNote(null);
+ try {
+ const gone = await keyRemove(id);
+ setNote({
+ text: `removed ${gone.id} (··${gone.last4}) — patchbay has forgotten it; the credential itself keeps working until you revoke it at the provider`,
+ bad: false,
+ });
+ setConfirming(null);
+ await load();
+ } catch (e) {
+ // Core's errors say what half-state the vault is in; do not paraphrase.
+ setNote({ text: String(e), bad: true });
+ } finally {
+ setRemoving(null);
+ }
+ };
+
+ const added = async (row: KeyRow) => {
+ setAdding(false);
+ setNote({ text: `registered ${row.id} (··${row.last4}) — value in the OS keychain`, bad: false });
+ await load();
+ };
+
+ if (error && !rows) {
return (
△
@@ -37,30 +79,6 @@ export function KeysView() {
if (!rows) return
reading the vault…
;
- if (rows.length === 0) {
- return (
-
-
no keys registered
-
- The vault holds API keys that no CLI tracks — a Cloudflare token used from a script, a
- provider key an agent was handed. Metadata lives in a JSON file; the value goes straight
- to the OS keychain.
-
- {/* patchbay runs its own actions rather than handing out commands, and
- this is the deliberate exception: registering a key means typing the
- secret somewhere, and the panel is not that somewhere. Nothing here
- ever accepts or displays a value, which is the whole point. */}
-
-
- Adding a key has to happen on the command line: it means typing the secret itself, and
- the panel deliberately never takes one.
-
-
-
-
- );
- }
-
return (
@@ -68,42 +86,381 @@ export function KeysView() {
{rows.length} {rows.length === 1 ? "key" : "keys"} · metadata only
+
-
-
-
-
-
id
-
provider
-
label
-
last 4
-
expiry
-
purpose
-
-
-
- {rows.map((k) => (
-
-
{k.id}
-
{k.provider}
-
{k.label}
- {/* The only thing on this page derived from a secret value. */}
-
··{k.last4}
-
-
- {KEY_EXPIRY_LABEL[k.expiry_state]}
-
-
-
{k.purpose ?? —}
+ {error && (
+
+ △
+ {error}
+
+ )}
+
+ {note && (
+
+ {note.text}
+
+ )}
+
+ {rows.length === 0 ? (
+
+
no keys registered
+
+ The vault holds API keys that no CLI tracks — a Cloudflare token used from a script, a
+ provider key an agent was handed. Metadata lives in a JSON file; the value goes straight
+ to the OS keychain, and nothing in this window can read it back.
+
+
+
+ Already in a terminal? The same registration, with the secret on stdin so it never
+ reaches argv or your shell history:
+
+
+
+ ) : (
+
+
+
+
+
id
+
provider
+
label
+
last 4
+
expiry
+
purpose
+
- ))}
-
-
-
+
+
+ {rows.map((k) => (
+
+
+
{k.id}
+
{k.provider}
+
{k.label}
+ {/* The only thing on this page derived from a secret value. */}
+
··{k.last4}
+
+
+ {KEY_EXPIRY_LABEL[k.expiry_state]}
+
+
+
{k.purpose ?? —}
+
+
+
+
+ {confirming === k.id && (
+
+
+
+
+ Remove {k.id}? This removes the entry and its keychain value; the
+ credential itself keeps working — revoke it at the provider.
+
+
+
+
);
}
+
+/**
+ * The one place in patchbay's UI that takes a secret.
+ *
+ * It lives in component state for as long as the drawer is open and is cleared
+ * on every submit; closing the drawer unmounts the component and takes it with
+ * it. It is never logged, never put in the success note, and never written
+ * anywhere but the `key_add` payload.
+ *
+ * Validation is core's job — the registry's messages about slugs, duplicate ids
+ * and empty secrets are written to be read, so they are shown verbatim rather
+ * than pre-empted. The only client-side rules are the two that decide whether
+ * pressing the button could possibly work.
+ */
+function AddKeyForm({
+ onClose,
+ onAdded,
+}: {
+ onClose: () => void;
+ onAdded: (row: KeyRow) => void | Promise;
+}) {
+ const [id, setId] = useState("");
+ const [provider, setProvider] = useState("");
+ const [label, setLabel] = useState("");
+ const [secret, setSecret] = useState("");
+ const [more, setMore] = useState(false);
+ const [purpose, setPurpose] = useState("");
+ const [scopes, setScopes] = useState("");
+ const [expires, setExpires] = useState("");
+ const [endpoint, setEndpoint] = useState("");
+ const [overwrite, setOverwrite] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const onKey = (e: globalThis.KeyboardEvent) => e.key === "Escape" && onClose();
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const ready = id.trim().length > 0 && secret.length > 0 && !busy;
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!ready) return;
+ setBusy(true);
+ setError(null);
+ // Taken out of state before the await, so the value the request carries is
+ // a local that dies with this function whatever the backend answers.
+ const value = secret;
+ setSecret("");
+ try {
+ const row = await keyAdd(
+ {
+ id: id.trim(),
+ provider: provider.trim(),
+ label: label.trim(),
+ purpose: purpose.trim() || null,
+ scopes: scopes
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ expires: expires.trim() || null,
+ endpoint: endpoint.trim() || null,
+ overwrite,
+ },
+ value,
+ );
+ await onAdded(row);
+ } catch (err) {
+ setError(String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/src/styles.css b/app/src/styles.css
index 5e4f374..60593e9 100644
--- a/app/src/styles.css
+++ b/app/src/styles.css
@@ -1188,6 +1188,119 @@ button {
line-height: 1.55;
}
+/* The view's one action sits at the far end of its own head, the way the
+ detail drawer's close button does. */
+.view-head > .action {
+ margin-left: auto;
+}
+
+/* ---------- add key ---------- */
+
+/* Reuses the detail drawer wholesale — same scrim, same panel, same sections.
+ Only the fields are new. */
+.detail-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-width: 0;
+}
+
+.field-input {
+ width: 100%;
+ padding: 5px 8px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--panel-2);
+ color: var(--ink);
+ font-family: var(--mono);
+ font-size: 12px;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.field-input::placeholder {
+ color: var(--muted);
+}
+
+.field-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.check {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--ink-2);
+}
+
+.check input {
+ margin: 2px 0 0;
+ accent-color: var(--accent);
+}
+
+/* ---------- removing a vault entry ---------- */
+
+/* Narrow, right-aligned, and last: the trash is an affordance on the row, not
+ a column of data. */
+.cell-actions {
+ width: 1%;
+ text-align: right;
+ white-space: nowrap;
+}
+
+/* The confirm is a row of the same table rather than a dialog: it says what
+ removing does and does not do, in place, under the key it is about. */
+.confirm-row > td {
+ background: var(--panel-2);
+}
+
+.confirm {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.confirm-why {
+ flex: 1 1 260px;
+ min-width: 0;
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--ink-2);
+}
+
+/* .row-action's shape, the risk palette: the one destructive button in the
+ panel, and it is never the resting state of a row. */
+.row-danger {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 1px 7px;
+ border: 1px solid var(--risk);
+ border-radius: 4px;
+ background: var(--risk-wash);
+ color: var(--risk);
+ font-family: var(--mono);
+ font-size: 10px;
+ cursor: pointer;
+ transition: border-color 90ms linear, color 90ms linear;
+}
+
+.row-danger:hover:not(:disabled) {
+ color: var(--ink);
+}
+
+.row-danger:disabled {
+ cursor: default;
+ color: var(--muted);
+ border-color: var(--line);
+ background: transparent;
+}
+
/* Wide tables scroll inside their own frame; the pane never scrolls sideways. */
.scroller {
overflow-x: auto;
diff --git a/app/src/types.ts b/app/src/types.ts
index 6739106..433fc23 100644
--- a/app/src/types.ts
+++ b/app/src/types.ts
@@ -172,6 +172,33 @@ export interface KeyRow {
expiry_state: KeyExpiryState;
}
+/**
+ * The metadata half of a registration, as `key_add` takes it. The secret is
+ * deliberately *not* a field here: it is passed separately, so no object in the
+ * panel ever carries a value, not even in flight.
+ *
+ * Blank optional fields are absent fields — the backend trims and drops them.
+ * `expires` is `YYYY-MM-DD` or a full timestamp; the backend parses it and
+ * refuses anything it cannot read.
+ */
+export interface NewKeyInput {
+ id: string;
+ provider: string;
+ label: string;
+ purpose: string | null;
+ scopes: string[];
+ expires: string | null;
+ endpoint: string | null;
+ /** Replace an existing entry under this id — a rotation, not an accident. */
+ overwrite: boolean;
+}
+
+/** What `key_remove` reports: enough to name what is gone, nothing more. */
+export interface RemovedKey {
+ id: string;
+ last4: string;
+}
+
/** Mirrors `patchbay_core::McpTransport` — an internally tagged enum. */
export type McpTransport =
| { transport: "stdio"; command: string; args_len: number }
diff --git a/docs/key-vault.md b/docs/key-vault.md
index c587a88..8aa03cd 100644
--- a/docs/key-vault.md
+++ b/docs/key-vault.md
@@ -24,6 +24,19 @@ pb key verify cf-gh-actions-deploy # ask Cloudflare whether it still works
pb key rm cf-gh-actions-deploy # metadata and Keychain item, both
```
+### In the panel
+
+The vault view browses the same registry, and writes to it: **add key** opens a
+form — id, provider, label, a masked secret field, and the optional purpose,
+scopes, expiry and endpoint behind a fold — and each row has a trash affordance
+with an inline confirm. Both go through the same `KeyRegistry` calls as `pb key
+add` and `pb key rm`, so the rules and the error messages are identical.
+
+The panel takes a secret; it never gives one back. There is no reveal, no copy,
+and no command behind the window that returns a value — `pb key copy ` is
+still the only way out. See the security model below for why the asymmetry
+survives a GUI intact.
+
### Verification
`pb key list` can only repeat what you told it. `pb key verify` asks the issuer:
@@ -111,7 +124,21 @@ it was. The registry never advertises a key whose value was never stored.
**Writing is easy, reading is not.** There is no `pb key show`. `pb key copy`
pipes the value into `pbcopy` — it never passes through stdout, a log or your
-shell history. Secrets never arrive as arguments either, in either direction.
+shell history.
+
+**Why the CLI reads stdin.** A secret passed as an argument is not private:
+argv is world-readable through `ps` for the length of the process, and your
+shell writes the line verbatim into `~/.zsh_history`. So `pb key add` takes the
+value from a pipe or a hidden prompt, never from a flag — in either direction.
+
+**The panel takes a secret too, and that is not a hole in the rule.** The add
+form's field is a password input; the value lives in memory for one call,
+crosses the Tauri boundary once, and is handed to the same `KeyRegistry::add`
+the CLI uses. No argv, no history file, no log — the two hazards the CLI rule
+exists to avoid are properties of command lines, and a native window has
+neither. What does not change is the other half: the panel never displays a
+value, never copies one, and has no command wired up that could return one.
+`KeyRegistry::get_secret` is deliberately not exposed to the webview.
**AI agents can register keys, not read them.** Over MCP:
@@ -140,6 +167,7 @@ has no way to take a password on stdin. Moving to the Security framework API,
where the value never becomes a command line, is tracked in
`crates/patchbay-core/src/keystore.rs`.
-**Removing is not revoking.** `pb key rm` makes patchbay forget a key. The
-credential keeps working until you revoke it at the provider.
+**Removing is not revoking.** `pb key rm`, and the panel's trash affordance,
+make patchbay forget a key. The credential keeps working until you revoke it at
+the provider — which is what the panel's confirm says before it asks.
From a76a0db959bc0a91642cdeaf1d1f5f598f4e45f9 Mon Sep 17 00:00:00 2001
From: YJack0000
Date: Fri, 14 Aug 2026 01:14:35 +0800
Subject: [PATCH 2/3] [fix] panel: pin src-tauri as its own workspace so nested
worktree builds work
---
app/src-tauri/Cargo.toml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index 25b0def..2dd15f1 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -23,3 +23,7 @@ tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread"] }
+
+# Standalone: keeps cargo from adopting an outer checkout's workspace when this
+# app is built from a nested git worktree (.claude/worktrees/...).
+[workspace]
From 848631fe40d44884ce66fa4ad9eae332fe3815cc Mon Sep 17 00:00:00 2001
From: YJack0000
Date: Fri, 14 Aug 2026 01:21:45 +0800
Subject: [PATCH 3/3] =?UTF-8?q?[fix]=20panel:=20sonar=20findings=20?=
=?UTF-8?q?=E2=80=94=20button=20types,=20read-only=20props,=20dialog=20a11?=
=?UTF-8?q?y?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/src/components/Glyphs.tsx | 6 +++---
app/src/components/KeysView.tsx | 31 +++++++++++++++++++----------
app/src/styles.css | 35 +++++++++++++++++++++++++++++++++
3 files changed, 59 insertions(+), 13 deletions(-)
diff --git a/app/src/components/Glyphs.tsx b/app/src/components/Glyphs.tsx
index 3394b3d..cc4c17f 100644
--- a/app/src/components/Glyphs.tsx
+++ b/app/src/components/Glyphs.tsx
@@ -12,7 +12,7 @@
*/
/** Registered vault keys. */
-export function KeyGlyph({ size = 11 }: { size?: number }) {
+export function KeyGlyph({ size = 11 }: Readonly<{ size?: number }>) {
return (
@@ -112,7 +112,7 @@ export function KeysView() {
provider key an agent was handed. Metadata lives in a JSON file; the value goes straight
to the OS keychain, and nothing in this window can read it back.