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 +
-
- - - - - - - - - - - - - {rows.map((k) => ( - - - - - {/* The only thing on this page derived from a secret value. */} - - - + {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: +

+ +
+ ) : ( +
+
idproviderlabellast 4expirypurpose
{k.id}{k.provider}{k.label}··{k.last4} - - {KEY_EXPIRY_LABEL[k.expiry_state]} - - {k.purpose ?? }
+ + + + + + + + + - ))} - -
idproviderlabellast 4expirypurpose
-
+ + + {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. + + + +
+ + + )} +
+ ))} + + +
+ )} + + {adding && setAdding(false)} onAdded={added} />}
); } + +/** + * 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 ( ) { return ( ) { return ( {rows.length} {rows.length === 1 ? "key" : "keys"} · metadata only -
@@ -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.

-

@@ -155,6 +155,7 @@ export function KeysView() { {k.purpose ?? } @@ -429,8 +440,8 @@ function AddKeyForm({ onChange={(e) => setOverwrite(e.target.checked)} /> - rotating — replace the key already registered under this id - (its registration date becomes today) + rotating — replace the key already registered under this id{" "} + (its registration date becomes today) @@ -460,7 +471,7 @@ function AddKeyForm({ - + ); } diff --git a/app/src/styles.css b/app/src/styles.css index 60593e9..e437bc2 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -719,10 +719,25 @@ button { /* ---------- tool detail ---------- */ +/* A wash over the board while a drawer is open. The add-key drawer's scrim is + a real