Skip to content

[fix] the migration defects one real machine move turned up, and the panel refresh button - #26

Open
YJack0000 wants to merge 6 commits into
mainfrom
fix/refresh-and-migrate-ux
Open

[fix] the migration defects one real machine move turned up, and the panel refresh button#26
YJack0000 wants to merge 6 commits into
mainfrom
fix/refresh-and-migrate-ux

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

Everything here came out of one real task: moving 64 keys and 53 credential files from this laptop to a second Mac. Every defect below was hit in that hour, in this order.

What was wrong

The import produced a half-migrated machine and called it success. All 53 credential files landed, all 64 keychain writes were refused (the session had no desktop login), and it exited 0. The per-key reason existed in the report and the printer dropped it, so the output was a column of bare skip with nothing explaining it.

Fixed in three places, because the failure had three halves:

  • pb import probes the keystore before copying a file and refuses the import if a write will not be taken. 53 files followed by 64 dropped secrets was the worst reachable outcome; it is no longer reachable.
  • A refused key prints its reason the way a file always did, plus one summary line saying the bundle still holds the values.
  • An import that dropped secrets exits 1. pb import && ./something was the one caller that could not tell.
  • pb import <bundle> --keys-only finishes the vault half once the session is fixed.

The install instructions were encrypted with the tool they install. A bundle's SETUP.md says how to install patchbay on a machine that has none, from inside the encrypted payload. On this move the receiving Mac had neither, and the command was looked up on the releases page by hand. pb export now writes a cleartext <bundle>-SETUP.md beside the bundle: install step and pb import line, nothing about the machine. It takes no Manifest argument at all, which is the structural reason no inventory can leak into it.

The TTY gate on the passphrase caused the leak it exists to prevent. pb export refused a passphrase from anything but a terminal. The reason behind it (argv is visible to ps and lands in shell history) is right and is kept — but a TTY test is the wrong instrument for it. It stops no attacker, and here it pushed the work into a pty wrapper whose echo wrote the passphrase into a log. --passphrase-file and --passphrase-fd now exist, the way gpg, restic, borg and age do it. A passphrase file the group or the world can read is refused: with that flag, the file is the secret. Still no passphrase as an argument.

Two working keys were reported dead. Cloudflare's /user/tokens/verify only answers for user-owned tokens, so an account-owned or scoped token gets the same "Invalid API Token" rejection whether it is live or revoked, and patchbay believed it — nine keys in this vault were red for no reason. A rejection now falls back to listing accounts with the token; if that also fails, the answer is the new inconclusive verdict rather than a death sentence, since at that point a revoked token and a narrowly scoped one are the same HTTP response. A GitHub App PEM had the same false alarm from GET /user, which can never authenticate a PEM: PEM-shaped values are now unsupported without making the request.

pb key verify took exactly one id, which in a vault of 64 means nobody checks. It now takes a list or --all, sweeps bounded-concurrent, and exits 1 for bad news / 2 for unreachable / 0 otherwise, with inconclusive and unsupported in that 0 because neither is a fact about the key.

The panel's refresh button did nothing on two of its three pages. It only re-fetched the tool board, so on the key vault and the MCP matrix it spun, moved the timestamp and left the table loaded on mount. A failed re-read of the MCP matrix also replaced a working board with a banner; it now keeps the board and puts the banner above it, which matters more now that a re-read happens on a timer.

pb plan threw away the one fix the import knew — the KUBECONFIG line for kubeconfigs that landed unmerged. The item now re-derives it from the files, so nothing has to be persisted to stay true.

Plus one wart found on the way: pb key verify offered Grafana's --endpoint advice to every unsupported provider, including the 52 here with no endpoint to set.

Verification

  • cargo test --release: 100 (cli) + 663 (core) + 57 (mcp) + 1 doc-test, 0 failed. cargo fmt --all --check and cargo clippy --workspace --all-targets clean.
  • The panel had no frontend tests; this adds vitest + Testing Library, mocked at invoke, with four regressions that were confirmed to fail when either panel fix is reverted.
  • Smoke-tested end to end against throwaway $HOMEs: both passphrase sources, dry and real imports, a mode-644 passphrase file refused, and a fake home with two loose kubeconfigs producing the real export KUBECONFIG=… line.
  • pb key verify --all against the live vault: 64 keys swept in seconds.

Follow-ups, not in this PR

  1. Multi-line secrets come out of the keychain hex-encodedSecurityCliKeystore::get uses find-generic-password -w, and security prints hex whenever the password contains a newline. Confirmed on 4 of 64 keys (three Apple ASC .p8 files and a GitHub App PEM): what pb key run and pb key copy hand out is 3350 hex characters, not the 1675-byte PEM. This is the real reason that key verifies as dead, and it is worse than a verify problem — anything spending those keys gets a corrupted value. Not fixed here on purpose: blind hex-decoding on read is unsafe, since a genuine 64-character hex API key is indistinguishable from security's output (six keys in this vault are legitimately hex-shaped). The fix is the security-framework rewrite the TODO in keystore.rs already calls for, which also removes the secret from the add-generic-password -w <secret> argv it currently passes.
  2. pb key run / get over a session with no desktop login fails with keychain lookup … failed: no output, which is the same condition the import now names properly.

The header button only ever re-fetched the tool board, so on the key vault
and the MCP matrix it spun, moved the timestamp and left the table that was
loaded on mount. A header that already names the view you are in has to
refresh that view, so the button and the 30s poll both bump a counter every
view reads.

A failed re-read of the MCP matrix also used to replace a working board with
an error banner; it now keeps the matrix and puts the banner above it, which
matters more once a re-read happens on a timer.

The panel had no tests at all. vitest + Testing Library, mocked at `invoke`
because that is the one thing genuinely absent from a test process, with four
regressions that fail if either fix is reverted.
A real move of 64 keys onto a second Mac wrote all 53 credential files, had
every one of the 64 keychain writes refused because the session had no
desktop login, and exited 0. The reason was recorded per key and dropped by
the printer, so the output was a column of bare `skip` with nothing that
explained it.

`pb import` now probes the keystore before it copies a file and refuses the
whole import if a write will not be taken; a refused key prints its reason
like a file always did, plus a summary saying the bundle still holds the
values; and an import that dropped secrets exits 1. `--keys-only` finishes
the vault half once the session is fixed.

Two more things the same move turned up. The bundle`s SETUP.md explains how
to install patchbay on a machine that has none, from inside the encrypted
payload — so `pb export` now writes a cleartext `<bundle>-SETUP.md` beside
it, carrying the install step and nothing about the machine; it takes no
manifest, which is why no inventory can leak into it. And `pb plan` now
re-derives the `KUBECONFIG` line for kubeconfigs an import left unmerged,
which the import knew and the plan had been throwing away.

`--passphrase-file` and `--passphrase-fd` replace the refusal to read a
passphrase from anything but a terminal. The reason behind that refusal —
argv is visible to `ps` and lands in shell history — is kept, but a TTY test
is the wrong instrument for it: it stops no attacker, and it pushed a real
move into a pty wrapper whose echo wrote the passphrase into a log, which is
the leak the rule exists to prevent. A passphrase file the group or the world
can read is refused, because with that flag the file is the secret.
…not a verdict

Cloudflare`s /user/tokens/verify only answers for user-owned tokens. An
account-owned or scoped token gets the same "Invalid API Token" rejection
whether it is live or revoked, and patchbay believed it — telling the user to
replace nine working keys in one real vault, which is the mistake the verdict
enum`s own doc comment warns about.

A rejection now falls back to listing accounts with the token: if that works,
the token demonstrably does. If it does not, the answer is the new
`inconclusive` verdict, because at that point a revoked token and a narrowly
scoped one are the same HTTP response and asserting either would be a guess.
A GitHub App private key had the same false alarm from `GET /user`, which can
never authenticate a PEM; a PEM-shaped value is now `unsupported` before any
request is made.

`pb key verify` also took exactly one id, which in a vault of 64 means
nobody checks. It now takes a list or `--all`, sweeps bounded-concurrent, and
exits 1 for bad news, 2 for a provider it could not reach, 0 otherwise —
`inconclusive` and `unsupported` are in that 0, because neither is a fact
about the key.
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_patchbay

0 open issues on this PR.

…lexity ceiling

Sonar failed the gate on five new violations. Two were the panel props not
being marked read-only. The other three were functions this PR grew past a
cognitive complexity of 15, each along a seam that was already visible:

`verify` served two jobs that print differently — one named key and an --all
sweep — with resolution, the bounded-concurrent run, the write-back and the
exit code tangled between them; those are five helpers now and it is a
dispatcher (21 -> 5).

`print_import` was a run of independent sections, so it is a run of section
printers (17 -> 0). The refused-key count is returned by the only section
that can count it rather than declared at the top and threaded through.

`tool_items` asked four separate questions about one tool — is it installed,
is it logged in, what did the old machine have, is it logged in as somebody
else — and each is a builder now (23 -> 2), following the precedent the
kubeconfig helpers already set.

No behaviour changed anywhere: same output bytes, same item ids and order,
same exit codes. Nothing was suppressed with #[allow] and
sonar-project.properties is untouched.
It went red on the Linux runner, and it turns out to fail 15 times in 40
runs locally. Nothing in this PR touches it: the assertion was always racy.

`lookup_latest` reads the rate-limit gate when a lookup starts, and the run
dispatches up to four at once, so three GitHub tools can all pass the gate
before the first 403 arms it. "Total calls < 3" is therefore not a property
the code has — the number depends on what was already in flight. The old
test just usually got lucky.

So it now asserts what is actually guaranteed and is deterministic: no tool
asks twice, every entry explains the rate limit, and — in a new test at the
`lookup_latest` level — a lookup that starts after the limit was seen makes
no request at all and says it was skipped. That last one is the half that
matters anyway, since it is what stops the next run from spending a budget
that is already gone. Renamed the old test, whose name claimed the guarantee
that moved to the new one.

Confirmed 60/60 green after the change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant