From 86041e9bfffdffbe6921356a48a23f5c36d627f6 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 28 Aug 2026 19:35:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?[feature]=20pb=20manifest=EF=BC=9A=E4=B8=8D?= =?UTF-8?q?=E5=90=AB=E7=A5=95=E5=AF=86=E7=9A=84=E6=A9=9F=E5=99=A8=E6=B8=85?= =?UTF-8?q?=E5=96=AE=EF=BC=8C=E5=8F=AF=E4=BB=A5=E9=80=B2=E7=89=88=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest.json 本來就是「無祕密、可被 pb plan --manifest 消費」的那一半, 但它只存在於加密 bundle 內部——要拿到安全的那半,得先產出危險的那半再解開。 pb manifest 直接把它單獨寫出來(stdout 或 -o ): - 不開任何憑證檔。這不是效能考量,而是這個指令存在的理由:為了產出 一個不含任何憑證的檔案而去讀遍機器上每一把憑證,正是它要避免的事 - vault 只列不解鎖;MCP 只記變數名稱不記值;carried 一律為空 - 新增 kind 欄位(inventory / bundle,預設 bundle 以相容舊檔)—— 宣稱東西搬過去了卻其實沒有,是這個格式唯一不能說的謊 順手修掉 collect_mcp 的不誠實:只要 spec 讀得到就標 carried=true, 在 bundle 情境下剛好成立,在不搬值的情境下是錯的。 另加 write_manifest MCP tool:機器搬遷裡唯一能讓 agent 自己做的一步, 因為它不碰任何憑證。export / import 仍留在 CLI,跟人與密語在一起。 --- CHANGELOG.md | 41 +++++++ Cargo.lock | 6 +- Cargo.toml | 2 +- README.md | 16 ++- crates/patchbay-cli/src/main.rs | 7 ++ crates/patchbay-cli/src/migrate.rs | 60 ++++++++++ crates/patchbay-core/src/migrate/bundle.rs | 2 + crates/patchbay-core/src/migrate/export.rs | 115 ++++++++++++++++++- crates/patchbay-core/src/migrate/manifest.rs | 33 ++++++ crates/patchbay-core/src/migrate/mod.rs | 4 +- crates/patchbay-core/src/migrate/setup.rs | 2 + crates/patchbay-mcp/src/migrate.rs | 80 ++++++++++++- docs/migration.md | 54 +++++++++ 13 files changed, 405 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35518e0..96c33db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ 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.6.0] - 2026-08-28 + +### Added + +- **`pb manifest` — the record of what this machine uses, with no credential in + it.** `manifest.json` already existed and already had the right shape: no + secret by construction, and the thing `pb plan --manifest` plans against. But + it only ever existed *inside* an encrypted bundle, so getting the readable + half meant producing the dangerous half first and then unpacking it. The one + artifact that was safe to commit was the one you could not get without + encrypting every credential on the machine. + + `pb manifest` writes it on its own, to stdout or `-o `. It opens no + credential file — not an optimisation, but the point: reading every + credential to produce a file that will hold none of them is exactly the + handling this command exists to avoid. The vault is listed and never + unlocked, MCP servers are named with their env/header variable NAMES and + never their values, and `carried` is empty everywhere because nothing was + carried. + + Manifests now say which kind they are — `"kind": "inventory"` here, + `"bundle"` inside an export, defaulting to `bundle` so an older file still + reads. An inventory that claimed things had travelled would be the one lie + this format must never tell. + + The intended shape: keep it in a repo you sync, and a new machine's whole + setup is `pb plan --manifest setup/manifest.json` — install this, log into + that — or the same list over MCP, worked one item at a time by an agent. + +- **`write_manifest` MCP tool.** The one part of a machine move an agent can do + unsupervised, because it touches no credential. `pb export` and `pb import` + stay in the CLI, where the human and the passphrase are. + +### Fixed + +- **MCP records no longer claim to have been carried when they were not.** + `collect_mcp` marked a registration `carried: true` whenever its spec was + readable, which was true for a bundle and wrong for anything that does not + carry values. Found while building the inventory path; it never affected a + real export, where the two happened to coincide. + ## [0.5.0] - 2026-08-28 ### Added diff --git a/Cargo.lock b/Cargo.lock index 86f9910..a957f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,7 +1262,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "patchbay-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anstyle", "anyhow", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "patchbay-core" -version = "0.5.0" +version = "0.6.0" dependencies = [ "age", "anyhow", @@ -1295,7 +1295,7 @@ dependencies = [ [[package]] name = "patchbay-mcp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e76ec64..00bc94d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ exclude = ["app/src-tauri"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2021" license = "MIT" repository = "https://github.com/pathorsAI/patchbay" diff --git a/README.md b/README.md index 510cd0e..c4f345a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ - **[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. - **[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. +- **[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. ## Install @@ -105,6 +105,20 @@ pb import patchbay-*.pbx # --dry-run first; existing files are backed up pb plan # what's left, with the exact command for each ``` +Or carry no credential at all: + +```sh +pb manifest -o setup/manifest.json # the record of what this machine uses +pb plan --manifest setup/manifest.json # …on the new machine: install this, log into that +``` + +`pb manifest` writes the readable half on its own — which CLIs you use, which +accounts are active, what is in the key vault, which MCP servers are registered. +**No secret value is in it and no credential file is even opened**, so it is +meant to be committed and synced. On a new machine it is the input your agent +plans against, which is the difference between "set this laptop up" and "set +this laptop up like the last one". + Files that work anywhere get copied (`gcloud`, `aws`, `kubectl`, `wrangler`, `rclone`, `npm`, `docker`, `ssh` config…). Credentials the OS keychain or the device itself is holding can't, and patchbay says so instead of pretending — diff --git a/crates/patchbay-cli/src/main.rs b/crates/patchbay-cli/src/main.rs index b334821..13cb4b6 100644 --- a/crates/patchbay-cli/src/main.rs +++ b/crates/patchbay-cli/src/main.rs @@ -126,6 +126,12 @@ enum Command { #[arg(long)] json: bool, }, + /// Write the secret-free record of what this machine uses. + Manifest { + /// Where to write it. Defaults to stdout. + #[arg(long, short)] + out: Option, + }, /// Restore a bundle onto this machine. Import { bundle: std::path::PathBuf, @@ -301,6 +307,7 @@ fn run() -> Result { }, &styles(), ), + Command::Manifest { out } => migrate::run(migrate::Command::Manifest { out }, &styles()), Command::Import { bundle, dry_run, diff --git a/crates/patchbay-cli/src/migrate.rs b/crates/patchbay-cli/src/migrate.rs index 020fd8f..62175bf 100644 --- a/crates/patchbay-cli/src/migrate.rs +++ b/crates/patchbay-cli/src/migrate.rs @@ -40,6 +40,12 @@ pub enum Command { #[arg(long)] json: bool, }, + /// Write the secret-free record of what this machine uses. + Manifest { + /// Where to write it. Defaults to stdout. + #[arg(long, short)] + out: Option, + }, /// Restore a bundle onto this machine. Import { bundle: PathBuf, @@ -105,6 +111,32 @@ pub fn run(command: Command, styles: &Styles) -> Result { Ok(0) } + Command::Manifest { out } => { + let manifest = Exporter { + paths: &paths, + registry: ®istry, + vault: &vault, + clients: &clients, + envs: &envs, + } + .manifest(Utc::now())?; + + // No passphrase, no cloud-folder check, no warning about moving the + // file carefully: this one is meant to be committed and synced. + // Those guards exist for bundles, and repeating them here would + // teach people to ignore them where they matter. + let json = manifest.to_json(); + match out { + Some(path) => { + std::fs::write(&path, format!("{json}\n")) + .with_context(|| format!("writing {}", path.display()))?; + print_manifest(&manifest, &path, styles); + } + None => println!("{json}"), + } + Ok(0) + } + Command::Import { bundle, dry_run, @@ -307,6 +339,34 @@ fn print_export(report: &export::ExportReport, styles: &Styles) { ); } +/// Written-to-a-file summary. Deliberately counts rather than lists: the file +/// itself is the listing, and a wall of tool names between the command and the +/// path buries the one line the reader needs. +fn print_manifest(manifest: &Manifest, path: &std::path::Path, styles: &Styles) { + let installed = manifest.tools.iter().filter(|t| t.installed).count(); + println!("wrote {}", path.display()); + println!( + " {installed} CLI(s) installed, {} key(s), {} MCP registration(s), {} env project(s)", + manifest.keys.len(), + manifest.mcp.len(), + manifest.env_projects.len(), + ); + println!( + " {}", + styles.paint( + dim_style(), + "no secret value is in this file — commit it, sync it, hand it to an agent" + ) + ); + println!( + " {}", + styles.paint( + dim_style(), + "on the new machine: pb plan --manifest " + ) + ); +} + fn file_name(path: &std::path::Path) -> String { path.file_name() .map(|n| n.to_string_lossy().into_owned()) diff --git a/crates/patchbay-core/src/migrate/bundle.rs b/crates/patchbay-core/src/migrate/bundle.rs index 48f2f83..06f579d 100644 --- a/crates/patchbay-core/src/migrate/bundle.rs +++ b/crates/patchbay-core/src/migrate/bundle.rs @@ -331,6 +331,7 @@ pub fn read(path: &Path, passphrase: &str) -> anyhow::Result { #[cfg(test)] mod tests { use super::*; + use crate::migrate::manifest::ManifestKind; use crate::migrate::manifest::Source; use chrono::{DateTime, Utc}; @@ -342,6 +343,7 @@ mod tests { version: BUNDLE_VERSION, manifest: Manifest { version: BUNDLE_VERSION, + kind: ManifestKind::default(), created_at: DateTime::parse_from_rfc3339("2026-08-13T00:00:00Z") .unwrap() .with_timezone(&Utc), diff --git a/crates/patchbay-core/src/migrate/export.rs b/crates/patchbay-core/src/migrate/export.rs index 7cc42c0..06f534d 100644 --- a/crates/patchbay-core/src/migrate/export.rs +++ b/crates/patchbay-core/src/migrate/export.rs @@ -32,8 +32,8 @@ use chrono::{DateTime, Utc}; use super::bundle::{self, BundleFile, BundleMcpServer, BundleSecret, Payload}; use super::manifest::{ - EnvEnvironmentRecord, EnvProjectRecord, EnvSyncRecord, KeyRecord, Manifest, McpRecord, - SetupItem, Source, ToolRecord, BUNDLE_VERSION, + EnvEnvironmentRecord, EnvProjectRecord, EnvSyncRecord, KeyRecord, Manifest, ManifestKind, + McpRecord, SetupItem, Source, ToolRecord, BUNDLE_VERSION, }; use super::policy::{policy_for, Portability}; use super::setup; @@ -175,6 +175,33 @@ pub struct Exporter<'a> { impl Exporter<'_> { /// Read the machine and build the payload. Nothing is written here. pub fn payload(&self, keys: &KeySelection, now: DateTime) -> anyhow::Result { + self.build(keys, now, ManifestKind::Bundle) + } + + /// The readable half on its own: no credential file is opened, no vault + /// secret is read, and the result carries nothing that has to be encrypted. + /// + /// This is the artifact you can commit, sync or paste into a chat — the + /// record of *what this machine uses*, which on a new machine is enough for + /// `pb plan --manifest` (or an agent over `plan_setup`) to say what to + /// install and what to log into. + pub fn manifest(&self, now: DateTime) -> anyhow::Result { + Ok(self + .build(&KeySelection::None, now, ManifestKind::Inventory)? + .manifest) + } + + fn build( + &self, + keys: &KeySelection, + now: DateTime, + kind: ManifestKind, + ) -> anyhow::Result { + // An inventory reads no credential file at all. Skipping the walk is + // not an optimisation: opening every credential on the machine to + // produce a file that will hold none of them is exactly the kind of + // unnecessary handling this command exists to avoid. + let carry_files = kind == ManifestKind::Bundle; let mut files = Vec::new(); let mut tools = Vec::new(); let mut gaps = Vec::new(); @@ -201,7 +228,12 @@ impl Exporter<'_> { }; if let Some(policy) = policy { - for location in policy.portability.locations() { + for location in policy + .portability + .locations() + .iter() + .filter(|_| carry_files) + { for found in location.collect(self.paths) { let bytes = match std::fs::read(&found.source) { Ok(bytes) => bytes, @@ -259,12 +291,13 @@ impl Exporter<'_> { let (key_records, secrets, key_gaps) = self.collect_keys(keys)?; gaps.extend(key_gaps); - let (mcp_records, mcp_servers) = self.collect_mcp(); + let (mcp_records, mcp_servers) = self.collect_mcp(carry_files); let (env_records, env_entries, env_gaps) = self.collect_env_projects(); gaps.extend(env_gaps); let manifest = Manifest { version: BUNDLE_VERSION, + kind, created_at: now, source: Source { patchbay_version: env!("CARGO_PKG_VERSION").to_string(), @@ -458,7 +491,11 @@ impl Exporter<'_> { /// Every user-scope MCP registration, by name in the manifest and with /// values in the payload. Project scopes are read but never carried: they /// belong to a repository, not to the machine. - fn collect_mcp(&self) -> (Vec, Vec) { + /// `carry_values` is false for an inventory: the registrations are still + /// worth listing (that is the point of the file), but nothing about them + /// travels, and a record claiming otherwise is the one thing a manifest + /// must never do. + fn collect_mcp(&self, carry_values: bool) -> (Vec, Vec) { let mut records = Vec::new(); let mut servers = Vec::new(); for client in self.clients.clients() { @@ -466,7 +503,9 @@ impl Exporter<'_> { if !entry.is_writable_scope() { continue; } - let spec = self.clients.read_spec(&client.client, &entry.name).ok(); + let spec = carry_values + .then(|| self.clients.read_spec(&client.client, &entry.name).ok()) + .flatten(); if let Some(spec) = &spec { let (transport, command, args, url) = match &spec.transport { TransportSpec::Stdio { command, args } => { @@ -846,6 +885,70 @@ pub(crate) mod tests { assert!(!all.manifest.gaps.iter().any(|g| g.id.starts_with("key:"))); } + #[test] + fn test_an_inventory_manifest_carries_nothing_and_says_so() { + // A machine with a portable credential file, a keychain-bound tool, and + // an MCP registration whose env holds a secret — one of each kind that + // an export would treat differently. + let home = fake_home(&[ + ( + ".config/gh/hosts.yml", + "github.com:\n user: octocat\n users:\n octocat:\n", + ), + ( + ".config/gcloud/configurations/config_default", + "[core]\naccount = a@b.com\n", + ), + ( + ".cursor/mcp.json", + r#"{"mcpServers":{"grafana":{"command":"uvx","args":["mcp-grafana"],"env":{"GRAFANA_TOKEN":"glsa_secret"}}}}"#, + ), + ]); + let (paths, registry, vault, clients, envs) = exporter_parts(home.path()); + let exporter = Exporter { + paths: &paths, + registry: ®istry, + vault: &vault, + clients: &clients, + envs: &envs, + }; + + // The bundle path reads the files; the inventory must not. + let bundle = exporter.payload(&KeySelection::All, Utc::now()).unwrap(); + assert!(!bundle.files.is_empty(), "the bundle should carry files"); + assert_eq!(bundle.manifest.kind, ManifestKind::Bundle); + + let manifest = exporter.manifest(Utc::now()).unwrap(); + assert_eq!(manifest.kind, ManifestKind::Inventory); + + // Nothing may claim to have travelled, because nothing did. + assert!( + manifest.tools.iter().all(|t| t.carried.is_empty()), + "an inventory carries no file, so no tool may list a carried location" + ); + assert!( + manifest.mcp.iter().all(|r| !r.carried), + "an inventory carries no MCP value" + ); + assert!(manifest.keys.iter().all(|k| !k.included)); + + // But it is still a record: the tools and registrations are named. + assert!(manifest.tools.iter().any(|t| t.tool == "gh")); + let grafana = manifest + .mcp + .iter() + .find(|r| r.name == "grafana") + .expect("the registration is the point of the file"); + assert_eq!(grafana.env_keys, vec!["GRAFANA_TOKEN".to_string()]); + + // And the file is safe to commit: the variable name travels, its value + // does not, and neither does anything else secret-shaped. + let json = manifest.to_json(); + assert!(json.contains("GRAFANA_TOKEN"), "{json}"); + assert!(!json.contains("glsa_secret"), "{json}"); + assert!(!json.contains("oauth_token"), "{json}"); + } + #[test] fn test_mcp_registrations_travel_with_their_values_and_are_named_without_them() { let home = fake_home(&[( diff --git a/crates/patchbay-core/src/migrate/manifest.rs b/crates/patchbay-core/src/migrate/manifest.rs index 0c39df6..1f3cbd2 100644 --- a/crates/patchbay-core/src/migrate/manifest.rs +++ b/crates/patchbay-core/src/migrate/manifest.rs @@ -231,10 +231,42 @@ pub struct Source { pub os: String, } +/// How a manifest was produced, and therefore what a reader may assume. +/// +/// The distinction matters to anyone planning against one. A `Bundle` manifest +/// travelled beside the credential files it describes, so `carried` is the list +/// of things that really did move. An `Inventory` manifest travelled alone — +/// it is a record of what this machine uses, nothing more, and every login on +/// the new machine has to be made by hand. `pb plan` re-probes either way, so +/// this changes what the file *claims*, not what the checklist checks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManifestKind { + /// Written inside an encrypted bundle, beside the files it describes. + #[default] + Bundle, + /// Written on its own by `pb manifest`. Carries no credential, and nothing + /// it names has travelled. + Inventory, +} + +impl ManifestKind { + pub fn label(&self) -> &'static str { + match self { + Self::Bundle => "bundle", + Self::Inventory => "inventory", + } + } +} + /// The whole readable manifest. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Manifest { pub version: u32, + /// Bundle or inventory. Defaults to `bundle` so a manifest written by an + /// older patchbay still reads correctly. + #[serde(default)] + pub kind: ManifestKind, pub created_at: DateTime, pub source: Source, pub tools: Vec, @@ -282,6 +314,7 @@ mod tests { fn manifest_with_secret_shaped_everything() -> Manifest { Manifest { version: BUNDLE_VERSION, + kind: ManifestKind::default(), created_at: Utc::now(), source: Source { patchbay_version: "0.1.0".into(), diff --git a/crates/patchbay-core/src/migrate/mod.rs b/crates/patchbay-core/src/migrate/mod.rs index 59db098..f6809ba 100644 --- a/crates/patchbay-core/src/migrate/mod.rs +++ b/crates/patchbay-core/src/migrate/mod.rs @@ -41,8 +41,8 @@ pub use export::{ }; pub use import::{EnvProjectResult, FileOutcome, ImportOptions, ImportReport, Importer}; pub use manifest::{ - EnvEnvironmentRecord, EnvProjectRecord, EnvSyncRecord, Manifest, SetupItem, SetupStatus, - ToolRecord, BUNDLE_VERSION, + EnvEnvironmentRecord, EnvProjectRecord, EnvSyncRecord, Manifest, ManifestKind, SetupItem, + SetupStatus, ToolRecord, BUNDLE_VERSION, }; pub use plan::{plan, recheck}; pub use policy::{policy_for, Location, Portability, PortabilityKind, ToolPolicy, POLICIES}; diff --git a/crates/patchbay-core/src/migrate/setup.rs b/crates/patchbay-core/src/migrate/setup.rs index f0396a0..b624ce7 100644 --- a/crates/patchbay-core/src/migrate/setup.rs +++ b/crates/patchbay-core/src/migrate/setup.rs @@ -204,6 +204,7 @@ pub fn portability_label(kind: PortabilityKind) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::migrate::manifest::ManifestKind; use crate::migrate::manifest::{ EnvEnvironmentRecord, EnvProjectRecord, EnvSyncRecord, KeyRecord, McpRecord, SetupItem, Source, ToolRecord, BUNDLE_VERSION, @@ -215,6 +216,7 @@ mod tests { fn manifest() -> Manifest { Manifest { version: BUNDLE_VERSION, + kind: ManifestKind::default(), created_at: Utc::now(), source: Source { patchbay_version: "0.1.0".into(), diff --git a/crates/patchbay-mcp/src/migrate.rs b/crates/patchbay-mcp/src/migrate.rs index 9e7daf9..7930084 100644 --- a/crates/patchbay-mcp/src/migrate.rs +++ b/crates/patchbay-mcp/src/migrate.rs @@ -11,15 +11,17 @@ //! * `mark_setup_done` — re-probes one item and says whether it actually //! closed. It ignores what the caller claims; the probe decides. //! -//! Neither tool exports, imports or decrypts anything. Those touch every -//! credential on the machine and want a passphrase typed by a human, so they -//! stay in the CLI where the human is. +//! There is a third, of a different kind: `write_manifest` writes the +//! secret-free inventory this machine could hand to a new one. It is safe for +//! an agent because it is the one part of a move that touches no credential — +//! `pb export` and `pb import` do, and want a passphrase typed by a human, so +//! they stay in the CLI where the human is. #[cfg(test)] use std::path::Path; use std::path::PathBuf; -use patchbay_core::migrate::{self, Manifest, SetupItem, SetupStatus}; +use patchbay_core::migrate::{self, Exporter, Manifest, SetupItem, SetupStatus}; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::CallToolResult; use rmcp::{tool, tool_router, ErrorData}; @@ -37,6 +39,13 @@ pub struct PlanParams { pub manifest_path: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WriteManifestParams { + /// Where to write the file. A path the user named, or somewhere they will + /// find it again — a repository they sync, not a temp directory. + pub path: String, +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct MarkDoneParams { /// The `id` of the item, exactly as `plan_setup` returned it — for example @@ -145,6 +154,69 @@ about the key vault.")] Ok(json_ok(plan_json(&items)?)) } + #[tool(description = "\ +TIER 1, CHEAP. Write this machine's inventory — the record of which CLIs it uses, which profiles \ +and accounts are active, which API keys are in the vault and which MCP servers are registered — \ +to a `manifest.json` the user can commit, sync or carry to a new machine. + +NO SECRET VALUE IS IN THIS FILE, by construction and by test. No credential file is even opened. \ +It carries names, accounts, scopes, expiry dates and the NAMES of the environment variables an \ +MCP server sets — never a value. That is what makes it the one part of a machine move an agent \ +can do unsupervised, and what makes the file safe to put in a repository. + +Use it when the user wants a record of what they have set up, wants their setup reproducible on \ +another machine, or is about to move machines and does not want to move credentials. + +It is NOT a backup and it will not log anybody in. On the new machine the file is the INPUT to \ +`plan_setup(manifest_path)`, which turns it into the checklist: install this, log into that. To \ +actually carry credentials the user runs `pb export` themselves — that needs a passphrase and \ +belongs to them, not to you. + +One caveat worth relaying: a key's `purpose` note is free text written by whoever registered it, \ +and it travels verbatim. If someone has pasted a secret into a purpose, it will be in this file. \ +Nothing patchbay stores as a secret is. + +Returns { path, tools, keys, mcp, env_projects, gaps } — counts, not contents.")] + async fn write_manifest( + &self, + Parameters(WriteManifestParams { path }): Parameters, + ) -> Result { + let registry = self.registry.clone(); + let keys = self.keys.clone(); + let clients = self.clients.clone(); + let envs = self.envs.clone(); + let target = PathBuf::from(&path); + let written = offload(move || { + let manifest = Exporter { + paths: registry.paths(), + registry: ®istry, + vault: &keys, + clients: &clients, + envs: &envs, + } + .manifest(chrono::Utc::now())?; + std::fs::write(&target, format!("{}\n", manifest.to_json())) + .map_err(|e| anyhow::anyhow!("could not write {}: {e}", target.display()))?; + Ok::<_, anyhow::Error>(manifest) + }) + .await?; + // The write is the fallible half; a failed write must come back as a + // tool error the agent can read, not as a transport-level failure. + let written = match written { + Ok(manifest) => manifest, + Err(err) => return Ok(tool_error(err)), + }; + + Ok(json_ok(serde_json::json!({ + "path": path, + "tools": written.tools.iter().filter(|t| t.installed).count(), + "keys": written.keys.len(), + "mcp": written.mcp.len(), + "env_projects": written.env_projects.len(), + "gaps": written.gaps.len(), + }))) + } + #[tool(description = "\ TIER 1, CHEAP. Re-check ONE item from `plan_setup` and report whether the gap really closed. \ patchbay re-reads that tool's own state files; it does not take your word for it, and it does not \ diff --git a/docs/migration.md b/docs/migration.md index 7680e0e..45b5ce4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -21,6 +21,55 @@ pb import patchbay-2026-08-13.pbx pb plan # what is left ``` +## The inventory: `pb manifest` + +A bundle answers "move my logins". There is a smaller question underneath it — +*what do I even use?* — and answering that needs no encryption, no passphrase, +and no credential at all. + +```sh +pb manifest # to stdout +pb manifest -o setup/manifest.json # to a file you commit +``` + +It writes `manifest.json` — part 3 of a bundle — on its own. Same schema, same +`pb plan --manifest` on the other end, with `"kind": "inventory"` so a reader +knows nothing beside it travelled. + +**No credential file is opened.** That is not an optimisation: reading every +credential on the machine to produce a file that will hold none of them is +exactly the handling this command exists to avoid. The vault is listed but never +unlocked, and `carried` is empty everywhere because nothing was carried. + +So it is safe where a bundle is not — a repository, a synced folder, a paste +into a chat with your agent: + +| | `pb export` | `pb manifest` | +|---|---|---| +| credential files | copied into the bundle | never opened | +| vault secret values | only with `--keys` | never | +| MCP env/header values | carried | names only | +| encrypted | yes, passphrase required | no — there is nothing to encrypt | +| safe to commit | **no** | yes | +| refuses a cloud-sync folder | yes | no; that is where it belongs | + +One thing does travel verbatim that is worth knowing about: a key's `purpose` +note is free text written by whoever registered it. patchbay never puts a secret +there, but a human can. It is the one field to look at before committing the +file the first time. + +The intended shape is: keep the inventory in a repo you sync, and on a new +machine the whole setup is + +```sh +pb plan --manifest setup/manifest.json +``` + +or the same list over MCP (`plan_setup`), which an agent works one item at a +time — installing what it can, handing you every browser login, re-probing after +each. Your agent can also write the file: `write_manifest` is the one part of a +move it can do unsupervised, precisely because it touches no credential. + ## What is in a bundle One encrypted file, four parts: @@ -166,6 +215,11 @@ reason next to it. A probe added without a policy fails the build's - **`mark_setup_done(item_id)`** — re-probes that one tool and reports whether the gap actually closed. It does not believe the agent, and it does not believe the user. +- **`write_manifest(path)`** — writes the inventory above. The only one of the + three that changes anything on disk, and it is still safe to hand an agent: + the file it writes contains no secret and no credential was read to make it. + Exporting and importing stay in the CLI, where the human and the passphrase + are. The rule the tool descriptions give an agent: work the list one item at a time, run only what `auto` allows, hand every `needs_browser` item to the human with From 9432b446adaa47c34b7f2927d4169681e187bf98 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Fri, 28 Aug 2026 19:45:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?[fix]=20=E4=BF=AE=20CI=EF=BC=9A=E4=B8=8D?= =?UTF-8?q?=E5=9C=A8=E5=8A=9F=E8=83=BD=20PR=20=E5=8B=95=E7=89=88=E8=99=9F?= =?UTF-8?q?=EF=BC=8C=E4=B8=A6=E6=8B=86=E6=8E=89=20build=20=E7=9A=84?= =?UTF-8?q?=E8=A4=87=E9=9B=9C=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 版號回到 0.5.0、CHANGELOG 改回 [Unreleased]。這個 repo 的慣例是功能 PR 只寫 Unreleased,版號由獨立的 [chore] release 一次改六處;我在功能 PR 裡 bump 反而讓 app/src-tauri/Cargo.lock 對不上,panel 的 --locked clippy 掛掉 - Sonar: build 的 cognitive complexity 39 > 15。把每個工具那一圈抽成 tool_record(),檔案複製再抽成 carry_tool_files()。改名 payload→build 讓 整段被當成新程式碼是觸發點,但這函式本來就太長了 --- CHANGELOG.md | 2 +- Cargo.lock | 6 +- Cargo.toml | 2 +- crates/patchbay-core/src/migrate/export.rs | 184 ++++++++++++--------- 4 files changed, 110 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96c33db..c6dc44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.6.0] - 2026-08-28 +## [Unreleased] ### Added diff --git a/Cargo.lock b/Cargo.lock index a957f6a..86f9910 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,7 +1262,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "patchbay-cli" -version = "0.6.0" +version = "0.5.0" dependencies = [ "anstyle", "anyhow", @@ -1276,7 +1276,7 @@ dependencies = [ [[package]] name = "patchbay-core" -version = "0.6.0" +version = "0.5.0" dependencies = [ "age", "anyhow", @@ -1295,7 +1295,7 @@ dependencies = [ [[package]] name = "patchbay-mcp" -version = "0.6.0" +version = "0.5.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 00bc94d..e76ec64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ exclude = ["app/src-tauri"] [workspace.package] -version = "0.6.0" +version = "0.5.0" edition = "2021" license = "MIT" repository = "https://github.com/pathorsAI/patchbay" diff --git a/crates/patchbay-core/src/migrate/export.rs b/crates/patchbay-core/src/migrate/export.rs index 06f534d..0555cc8 100644 --- a/crates/patchbay-core/src/migrate/export.rs +++ b/crates/patchbay-core/src/migrate/export.rs @@ -42,6 +42,7 @@ use crate::keys::KeyRegistry; use crate::mcp_clients::{McpClientRegistry, TransportSpec}; use crate::paths::Paths; use crate::registry::Registry; +use crate::types::ToolStatus; /// Which vault secrets travel inside the encrypted payload. #[derive(Debug, Clone, PartialEq, Eq)] @@ -207,85 +208,8 @@ impl Exporter<'_> { let mut gaps = Vec::new(); for status in self.registry.status_all() { - let policy = policy_for(&status.tool); - let mut record = ToolRecord { - tool: status.tool.clone(), - category: status.category, - installed: status.installed, - portability: policy - .map(|p| p.portability.kind()) - .unwrap_or(super::policy::PortabilityKind::PointerOnly), - reason: policy.map(|p| p.portability.reason()).unwrap_or("").into(), - profiles: status.profiles.clone(), - active: status.active.clone(), - carried: Vec::new(), - subject: None, - scopes: Vec::new(), - // The bundle is a portable record, not a live board: its notes - // are prose for whoever reads the manifest on the new machine, - // so the severity a probe attached here does not travel. - notes: status.notes.iter().map(|n| n.text.clone()).collect(), - }; - - if let Some(policy) = policy { - for location in policy - .portability - .locations() - .iter() - .filter(|_| carry_files) - { - for found in location.collect(self.paths) { - let bytes = match std::fs::read(&found.source) { - Ok(bytes) => bytes, - // A file that vanished between the walk and the - // read is a note, not a failed export. - Err(e) => { - record.notes.push(format!( - "could not read {}: {e}; it is not in the bundle", - found.source.display() - )); - continue; - } - }; - if !record.carried.contains(location) { - record.carried.push(*location); - } - files.push(BundleFile::encode( - &status.tool, - *location, - found.rel.clone(), - found.mode, - &bytes, - )); - } - } - - // What the active credential may do, for the tools where - // re-creating it by hand is easy to get wrong. - if policy.record_permissions && status.installed && self.paths.may_exec() { - if let Ok(report) = self.registry.permissions(&status.tool) { - if report.supported { - record.subject = report.subject; - record.scopes = report.scopes; - } - } - } - - if let Some(gap) = tool_gap(policy, &status) { - gaps.push(gap); - } - } - - // Docker's file names the credential helper; the helper's secrets - // stay in the keychain. Say so where the user will see it. - if status.tool == "docker" && !record.carried.is_empty() { - record.notes.push( - "the registry list travelled; any secret held by a credential helper \ - (`credsStore`) stayed in this machine's keychain — `docker login` again on \ - the new machine if a pull is refused" - .to_string(), - ); - } + let (record, gap) = self.tool_record(&status, carry_files, &mut files); + gaps.extend(gap); tools.push(record); } @@ -322,6 +246,108 @@ impl Exporter<'_> { }) } + /// One tool's manifest row, and the gap it leaves if it cannot travel. + /// + /// Appends to `files` rather than returning them: a tool contributes zero + /// or many files, and threading a second vector back out of here only to + /// splice it into the same place would obscure that the caller's list is + /// the one being built. + fn tool_record( + &self, + status: &ToolStatus, + carry_files: bool, + files: &mut Vec, + ) -> (ToolRecord, Option) { + let policy = policy_for(&status.tool); + let mut record = ToolRecord { + tool: status.tool.clone(), + category: status.category, + installed: status.installed, + portability: policy + .map(|p| p.portability.kind()) + .unwrap_or(super::policy::PortabilityKind::PointerOnly), + reason: policy.map(|p| p.portability.reason()).unwrap_or("").into(), + profiles: status.profiles.clone(), + active: status.active.clone(), + carried: Vec::new(), + subject: None, + scopes: Vec::new(), + // The bundle is a portable record, not a live board: its notes + // are prose for whoever reads the manifest on the new machine, + // so the severity a probe attached here does not travel. + notes: status.notes.iter().map(|n| n.text.clone()).collect(), + }; + + let Some(policy) = policy else { + return (record, None); + }; + + if carry_files { + self.carry_tool_files(status, policy, &mut record, files); + } + + // What the active credential may do, for the tools where re-creating + // it by hand is easy to get wrong. + if policy.record_permissions && status.installed && self.paths.may_exec() { + if let Ok(report) = self.registry.permissions(&status.tool) { + if report.supported { + record.subject = report.subject; + record.scopes = report.scopes; + } + } + } + + // Docker's file names the credential helper; the helper's secrets + // stay in the keychain. Say so where the user will see it. + if status.tool == "docker" && !record.carried.is_empty() { + record.notes.push( + "the registry list travelled; any secret held by a credential helper \ + (`credsStore`) stayed in this machine's keychain — `docker login` again on \ + the new machine if a pull is refused" + .to_string(), + ); + } + + (record, tool_gap(policy, status)) + } + + /// Copy every file this tool's policy points at into the bundle, recording + /// which locations actually yielded one. + fn carry_tool_files( + &self, + status: &ToolStatus, + policy: &super::policy::ToolPolicy, + record: &mut ToolRecord, + files: &mut Vec, + ) { + for location in policy.portability.locations() { + for found in location.collect(self.paths) { + let bytes = match std::fs::read(&found.source) { + Ok(bytes) => bytes, + // A file that vanished between the walk and the read is a + // note, not a failed export. + Err(e) => { + record.notes.push(format!( + "could not read {}: {e}; it is not in the bundle", + found.source.display() + )); + continue; + } + }; + if !record.carried.contains(location) { + record.carried.push(*location); + } + files.push(BundleFile::encode( + &status.tool, + *location, + found.rel.clone(), + found.mode, + &bytes, + )); + } + } + } + /// Vault metadata always; values only for the selected ids. fn collect_keys( &self,