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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
119 changes: 117 additions & 2 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -80,8 +82,16 @@ async fn permissions(tool: String) -> CmdResult<PermissionsReport> {
/// 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)]
Expand All @@ -106,6 +116,109 @@ async fn keys_list() -> CmdResult<Vec<KeyRow>> {
.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<DateTime<Utc>> {
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<String>) -> Option<String> {
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<String>,
label: Option<String>,
purpose: Option<String>,
scopes: Vec<String>,
expires: Option<String>,
endpoint: Option<String>,
secret: String,
overwrite: bool,
) -> CmdResult<KeyRow> {
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<RemovedKey> {
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.
Expand Down Expand Up @@ -143,6 +256,8 @@ pub fn run() {
verify_profile,
permissions,
keys_list,
key_add,
key_remove,
mcp_list
])
.run(tauri::generate_context!())
Expand Down
19 changes: 18 additions & 1 deletion app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { invoke } from "@tauri-apps/api/core";
import type {
KeyRow,
McpClient,
NewKeyInput,
PermissionsReport,
RemovedKey,
SwitchOutcome,
ToolStatus,
VerifyOutcome,
Expand All @@ -26,7 +28,22 @@ export const verifyProfile = (tool: string, profile: string) =>

export const permissions = (tool: string) => invoke<PermissionsReport>("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<KeyRow[]>("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 <id>` remains the only way out of the vault.
*/
export const keyAdd = (key: NewKeyInput, secret: string) =>
invoke<KeyRow>("key_add", { ...key, secret });

/** Unregister a key: metadata row and keychain item both. Not a revocation. */
export const keyRemove = (id: string) => invoke<RemovedKey>("key_remove", { id });

export const mcpList = () => invoke<McpClient[]>("mcp_list");
29 changes: 27 additions & 2 deletions app/src/components/Glyphs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

/** Registered vault keys. */
export function KeyGlyph({ size = 11 }: { size?: number }) {
export function KeyGlyph({ size = 11 }: Readonly<{ size?: number }>) {
return (
<svg
className="glyph-svg"
Expand All @@ -33,8 +33,33 @@ 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 }: Readonly<{ size?: number }>) {
return (
<svg
className="glyph-svg"
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M2.5 4.2h11" />
<path d="M6.2 4.2V2.6h3.6v1.6" />
<path d="M4 4.2l.7 9.2h6.6l.7-9.2" />
<path d="M6.6 6.6v4.4M9.4 6.6v4.4" />
</svg>
);
}

/** The MCP servers-against-clients matrix. */
export function MatrixGlyph({ size = 11 }: { size?: number }) {
export function MatrixGlyph({ size = 11 }: Readonly<{ size?: number }>) {
return (
<svg
className="glyph-svg"
Expand Down
Loading
Loading