Half of a developer's logins are plain files that would work anywhere. The other half are in the OS keychain, in a device-registered session, or in a key that is the machine's identity — and copying those either does nothing or does something worse than nothing.
So patchbay does not pretend either half is the whole story. pb export moves
what can move, refuses to fake the rest, and hands you (or your AI) a checklist
with the exact command for every gap.
# old machine
pb export # -> patchbay-2026-08-13.pbx, encrypted
# copy it across by AirDrop / USB / LAN — not by cloud sync
# new machine
pb import patchbay-2026-08-13.pbx --dry-run
pb import patchbay-2026-08-13.pbx
pb plan # what is leftOne encrypted file, four parts:
- Portable credential files, copied verbatim.
- Key vault secrets — only with
--keys. Off by default. manifest.json— no secrets: profiles, active identities,ghscopes, key metadata, MCP registrations by name, and the gap list.SETUP.md— generated at export time, including how to install patchbay on a machine that does not have it yet.- The env vault's project manifest — names and sync pins, never values. See below.
Parts 3 and 4 live inside the encrypted payload and are written out on import.
pb env rides along, as metadata. The bundle carries the
portable project manifest — ids, environments, synced_at, and each
project's sync pin — so the new machine knows your projects exist and where
their variables come from.
Three things deliberately do not travel:
| not carried | why |
|---|---|
| every variable value, synced or local | the synced layer is rebuilt from the remote, which is more current than a bundle from last Tuesday; the local layer is .env.local semantics, and a DATABASE_URL pointing at a container on the old laptop is exactly what must not follow you |
| the local layer's variable names | names without values would make pb env list on the new machine promise variables pb env run could not produce |
attachments.json |
which directories belong to a project is a list of paths on this machine, and meaningless on the next one |
So on the new machine:
pb env projects # the projects arrived, with no values
pb env pull --project <id> # rebuild each linked project's synced layer
pb env attach <id> # bind a directory — a committed .patchbay.toml
# does this on its own for a fresh clonepb plan lists one pb env pull --project <id> per linked project, marked
auto: false on purpose: a pull only works under the account the project is
pinned to, and the infisical CLI's active login is machine-global, so the item
names that account and lets you (or pb use infisical <email>) sort it out
first. A project the old machine had unlinked but with a synced layer becomes
a gap instead — nothing here can rebuild it.
Importing never overwrites a project this machine already has, even if the bundle's copy is different. It is skipped with a note naming it: the machine in front of you may be the newer one, and a stale sync pin written over a live one is not recoverable the way a backed-up file is.
Every tool on the board declares its own policy in
migrate/policy.rs, with the
reason next to it. A probe added without a policy fails the build's
test_every_registered_tool_has_a_policy.
| tool | what moves |
|---|---|
gcloud |
credentials.db, access_tokens.db, ADC, active_config, configurations/, legacy_credentials/ |
aws |
config, credentials, and the SSO token cache |
wrangler |
the OAuth config TOML |
rclone |
rclone.conf |
kubectl |
every file KUBECONFIG names |
vercel |
auth.json + config.json |
firebase |
the configstore login |
neon |
credentials.json |
doctl |
config.yaml |
flyctl |
config.yml |
npm |
~/.npmrc |
ssh |
~/.ssh/config only — never a private key |
docker |
the registry list; helper-held secrets stay in the keychain |
ngrok |
ngrok.yml (the authtoken) |
cloudflared |
cert.pem + the per-tunnel credential JSONs — account credentials, so encrypted payload only, never named in the manifest |
| tool | why | fix |
|---|---|---|
gh |
OAuth token in the OS keychain | gh auth login |
az |
MSAL cache is keychain-backed | az login |
infisical |
JWT lives in the vault backend | infisical login |
op |
1Password registers the device; biometric unlock | op account add |
supabase |
token in the OS keyring | supabase login |
stripe |
live key is redacted on disk, real one in the keychain | stripe login |
tailscale |
the node key is this device on the tailnet | tailscale up |
claude |
OAuth token in the Keychain | claude, then /login |
ollama |
the credential is a private ed25519 key | ollama signin |
| tool | why | fix |
|---|---|---|
huggingface |
the token is a bare secret in a cache dir, and which named token is active cannot be read from disk | hf auth login |
- Encryption is
agewith a passphrase (scrypt recipient) — no key files to manage or lose. Prompted twice on export, hidden, never taken as an argument: argv is visible topsand lands in your shell history. - The bundle is
0600and starts with a 19-byte cleartext header (patchbay-bundle/1) so a version skew is refused before you type a passphrase. Nothing else about it is readable. - Cloud-sync folders are refused. Writing into iCloud Drive, Dropbox, Google
Drive, OneDrive, Box or pCloud needs
--force, because copying credential files is not a metaphor for how sessions get hijacked — it is the technique, and a bundle in a sync folder is that technique performed on yourself with a delivery mechanism attached. Move it by AirDrop, USB or a direct LAN copy, and delete it from both machines afterwards. - No secret leaves the encrypted payload. Not into
manifest.json, not intoSETUP.md, not into a log line, an error, a--jsonblob or an MCP response. The types that hold credential material have hand-writtenDebugimpls that print counts, so a stray{:?}cannot leak one. - Decryption is in memory only. No staging directory: each file goes from the decrypted payload to its destination through a temp file in the destination directory, renamed into place.
- Private keys never travel.
~/.ssh/id_*is not collected, by policy and by test.
- Existing files are copied to
<path>.patchbay-bakbefore being replaced. --dry-runprints the whole plan and writes nothing at all, backups included.- Idempotent: a destination whose bytes already match is left alone — no
write, no backup, reported as
unchanged. Running the import twice produces the same machine. - Every path is resolved through
Pathson the destination, so anAWS_SHARED_CREDENTIALS_FILEor a[paths]entry on the new machine decides where a file lands. - Several kubeconfigs land in one directory, with a note telling you the
KUBECONFIGline to set — kubectl only merges what the variable names.
pb plan is the same list your agent gets over MCP:
plan_setup(manifest_path?)— re-probes every tool and returns{ open, done, blocked, complete, items }. Each item carriesauto(can patchbay close it itself?), the exactcommand, andneeds_browser.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.
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
the exact command, re-check after each one, stop when complete is true.
Without a manifest, plan_setup and pb plan still work — the list becomes
"what on this machine is not logged in".
pb status --diff <manifest.json> is the same comparison in board form.
- MCP registrations travel with their values. A server nobody can
authenticate to is not a server — the same trade
pb mcp copyalready makes. The manifest lists only the variable names, and the export prints them, so nothing travels unannounced. Project-scoped Claude Code servers are read but never carried: they belong to a repository, not to the machine. --keysis opt-in per key.--keysalone takes every vault secret,--keys=a,btakes those two. Without it, metadata still travels and each key becomes a checklist item with its provider and last four characters.pb planexits 1 while anything is open, sopb plan && ./deploydoes the obvious thing.