The CI matrix includes a windows-latest job where str(Path("/Users/…")) renders
with backslashes, so the platform-parameterised sidecar-path assertions failed
there (macOS/Linux passed). Compare .as_posix() instead — separator-normalised
and portable — and note why. Pure test fix; no production-code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ssh-mcp v2 reads a TOML sidecar and only falls back to CLI args when that file
is ABSENT — so BCC's managed --host/--user args can be completely inert while
the real config lives in a file BCC never looks at. This adds read-only
truth-telling for that (no sidecar writing).
Core (pure, fully injectable platform/environ/home/exists/read for testing):
- sidecar_path(spec): VERIFIED per-platform TOML location from the package source,
NOT the README (macOS → ~/Library/Application Support/ssh-mcp, Windows → %APPDATA%,
else → ${XDG_CONFIG_HOME:-~/.config}). sidecar_doc_path() is the README path.
- sidecar_status(): resolves exists / has_managed_args / args_inert / wrong_path.
- sidecar_warnings(): mirrors removed_flag_warnings' shape. Reports:
* precedence — "these arguments are inert; the server reads <real path>"
* wrong-path — a TOML at the README path the server never actually reads
* #11 credential scoping — an unprefixed SSH_MCP_PASSWORD shared across 2+
profiles in a multi-profile sidecar (count_toml_profiles is a documented
3.10-safe heuristic; unscoped_credential_warning gates on it).
GUI: a read-only advisory label in the stdio editor (mirrors the removed-flag
label; no fix button — editing the sidecar is a separate deliberate action).
Uses the real platform/env/filesystem so it reflects this machine.
pytest green, ruff + format clean. Closes#91. Part of epic #94.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce the server-package axis (orthogonal to ClientSpec): a frozen
ServerSpec dataclass + SERVER_SPECS registry that the sidecar (#91),
version-pin (#92) and permission (#93) work all hang off.
- ServerSpec carries env_flags / removed_flags / drift_flags / sidecar_paths
/ sidecar_doc_path / schema. FLAG_ENV_MIGRATIONS is now a derived view of
the registry, so every existing reader and the migration functions keep the
exact shape #89 shipped — the migration LOGIC is unchanged, only the DATA grew.
- resolve_server_spec(data) is the ServerSpec entry point; detect_migratable_package
is a thin name-only wrapper over it (behaviour identical).
- Add the verified #4 follow-ups: --sudoPassword AND --suPassword now auto-migrate
to SSH_MCP_SUDO_PASSWORD (two flags → one var; existing no-clobber handles it).
--disableSudo stays warn-only (sudo is now a role/policy, no env replacement).
- Add drift_warnings(): --maxChars=none changed meaning (v1 silently capped at
5000 chars). Warn-only, no auto-fix; matches inline and separate arg forms.
- Seed ssh-mcp's verified per-platform sidecar TOML paths and zod enums
(auth/approvalMode/role/port) as data for later issues.
Pure core + tests, no GUI. One #89 test (sudoPassword now migratable) updated to
reflect the intended data growth, with a comment citing the verified facts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ssh-mcp v2 removed --password from the command line and reads
SSH_MCP_PASSWORD instead, so an old config crashes on startup. Add a
data-driven FLAG_ENV_MIGRATIONS registry plus detect_migratable_package,
migrate_removed_flags and removed_flag_warnings in bcc_core, and a
'Fix: move to environment variables' one-click action + warning in the
stdio server editor, with a matching main-window lint line. The literal
value lands in env{} (the only form Claude Desktop honours). 14 tests.
Field testing showed the feature was doing the right thing but describing it
wrong, and left the moved variable invisible. Reworked per that feedback:
Naming. "Move to environment variable" read like "move into the Environment
variables table"; it actually creates a ${VAR} reference to the shell/OS
environment. Renamed the action to "Replace with a ${VAR} reference (out of
file)…" everywhere, and the dialog now says plainly the secret goes to your
shell environment -- not this file, not the table below.
Visibility (the real gap). A lone ${SECRET_KEY} with nothing saying whether
it's wired up isn't much better than a mystery. New Variables… button opens
ReferencedVarsDialog: every ${VAR} the loaded server references, each with
✓ set / ✓ default / ✗ not set and the exact export/setx line to set it.
Backed by pure core.referenced_env_vars (dedupes across fields, resolves
against a given environment or a default).
Two actions on an args secret, because the user may want either:
* "Replace with a ${VAR} reference (out of file)…" -- secret leaves the
file (needs a client that expands refs; disabled with reason on Desktop).
* "Move into Environment variables (kept in this config)…" -- relocates the
arg into the env block where it's visible and editable. Works on any
client (no ${VAR} needed). core.move_arg_to_env_block drops the flag+value
and sets env[VAR]; the dialog warns it changes how the server launches.
Both args actions run through ServerEditor (moving into env touches args AND
the env table), which reloads the form from the transformed data.
Tests: +10 core (referenced_env_vars dedupe/status/default; move_arg_to_env_block
flag+value removal, bare positional, None on bad target). 488 passed, ruff
clean. New dialogs/menus are GUI, untestable in CI as before.
Refs #83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Two things surfaced testing the GUI:
1. Args rows showed the secret warning but no move action -- the args
editor is a free-text widget, not a table, and was deliberately left out
of the first cut. Wired it up: ArgsEdit gains a context menu that offers
"Move to environment variable…" on exactly the args that look like a
credential. New pure core: secret_arg_indices (which args are secrets,
mirroring args_secret_warning per-index) and suggested_env_var_for_arg
(default var name from the preceding flag -- `--api-key <secret>` ->
API_KEY, else SECRET). The move replaces that one arg line with ${VAR}
and copies the secret to the clipboard, same contract as the tables.
2. On a Claude Desktop profile (or any non-expanding client) the menu showed
NOTHING, so it read as broken. Now a real stored secret always shows the
item -- enabled on a client that expands references, or disabled with the
reason ("unavailable for Claude Desktop -- it doesn't expand ${VAR}") so
the gate is visible rather than silent. Applies to env, headers and args.
Not a change: after converting, env_ref_warnings still notes a variable that
isn't set in the environment. That's #82's advisory doing its job -- the user
runs the export line the dialog handed them; auto-adding a ':-default' would
bake a fallback back into the config and defeat moving the secret out.
Tests: +5 core (secret_arg_indices for token/flag-value/embedded-URL/
reference-excluded, suggested_env_var_for_arg with and without a flag).
483 passed, ruff clean. GUI wiring (context menus) remains untestable in CI.
Refs #83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Follow-up to #76/#82: BCC warns when a config holds a raw credential and
points at ${VAR}, but gave no way to make the change. This adds the
one-click conversion, right-click a secret row in the env or headers table.
The value is about to leave the file, so the action's real job is handing
the secret back before it does:
- Core (pure, tested): sanitize_env_var_name (key -> legal upper-case shell
name; 'api-key' -> API_KEY, '2fa' -> _2FA, non-ASCII/empty handled),
shell_export_lines (the exact export/setx line, POSIX single-quoted
safely), move_value_to_env_ref (data in -> new data out, replaces one
env/header/args value with ${VAR}, returns the removed secret; never
mutates the input; None if the target is missing, non-string, or already a
reference), can_move_value_to_env_ref (offer only a real stored secret, not
already a ref, AND only on a client that expands references -- offering it
on Claude Desktop would author a config that reaches the server as literal
${VAR}, the exact failure #76 exists to prevent), and is_env_var_set (skip
the ceremony when the variable already looks set).
- GUI: KeyValueTable gains a context menu gated on can_move_value_to_env_ref
(so it never appears on a non-secret row or a Claude Desktop profile).
MoveToEnvDialog lets the user name the variable (defaulting to the
sanitised key), shows the platform-appropriate shell line live, notes when
the variable already looks set, and on accept copies the secret to the
clipboard before the cell is replaced with the reference. Wired through
ServerEditor.set_profile_provider so the tables know which client is loaded.
Scope note: env and headers rows for now. The core already handles args by
index; wiring the args editor (a free-text widget, not a table) is a small
follow-up, deliberately not bundled here.
Tests: +15 core (name sanitisation incl. non-ASCII/leading-digit/empty,
POSIX quote safety, the gate across secret/non-secret/already-ref/
non-expanding-client, env+headers+args rewrite, input-not-mutated,
missing/non-string/already-ref -> None, is_env_var_set). 478 passed, ruff
clean. GUI is untestable in CI (no PySide6); the decision logic all lives in
bcc_core and is tested there.
Closes#83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
discover_project_configs labelled every project by basename alone, so
~/work/app/.mcp.json and ~/personal/app/.mcp.json both read as
"Project: app". Paths dedupe correctly, so both profiles existed -- they
were just indistinguishable in the picker, and picking the wrong one meant
editing, backing up, and writing the wrong repo's config. api/web/app/
server/client as repo names make this common.
- disambiguate_project_labels(dirs): pure, testable. Labels stay
"Project: <name>" until a basename collides, then only the colliding
ones widen toward the root one component at a time
("Project: work/app" vs "Project: personal/app"), widening further if the
parent also collides. The common no-collision case is unchanged.
- Full path goes in the combo item's ToolTipRole, so a profile is always
verifiable by hover regardless of label.
- Tightened the "is this a project config?" check: the docstring claimed a
top-level object but the code only checked is_file(), so a .mcp.json that
was a JSON array or garbage still became a profile and only failed on
open. It now must parse (strict) to a dict, else it's skipped.
Tests: +6 (no-collision basename, colliding widen, deeper widen, and
discover_project_configs disambiguation + skipping array/garbage/missing).
463 passed, ruff clean.
Closes#74
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
Cross-client support (Cursor / Windsurf / VS Code) was blocked on Claude's
layout being hard-coded throughout the code: servers always under the literal
"mcpServers" key, a fixed set of Claude file locations, and "which client is
this?" answered by sniffing a filename. Adding a client that differs on any of
those axes meant chasing those assumptions through a dozen sites.
This is phase 1 of #5: the keystone refactor, with NO behaviour change. It adds
a ClientSpec adapter that captures the three things that vary across clients --
the top-level servers key, the config discovery paths, and the per-server value
shape -- plus the capability flags that were previously computed inline from a
filename (does the client expand ${VAR}? can we offer Restart?).
- ClientSpec (frozen dataclass): servers_key, disabled_key, config_filename,
expands_env_refs, supports_restart, and entry_to_internal/entry_from_internal
-- the per-server translation seam, identity for any mcpServers-shaped client,
the single point a differently-shaped client (VS Code's type/inputs form)
overrides.
- CLAUDE_DESKTOP and CLAUDE_CODE specs; both use mcpServers + the existing
parking key, so their translation is the identity and nothing changes for
today's users. resolve_client(path) reproduces the old filename rule exactly;
each Profile now carries its resolved .client.
- extract_servers / apply_servers / _server_sections / external_change_summary
take an optional spec and default to Claude's layout, so every existing call
site and test that omits a spec is byte-for-byte unchanged. The cardinal rule
now generalises: apply_servers only ever writes the client's own two keys,
parameterised rather than hard-coded.
- profile_targets_claude_desktop and client_expands_env_refs are now thin reads
off the profile's spec -- one source of truth for client identity instead of
scattered filename checks -- with identical answers.
- GUI: the load, Copy-to, save and stale-merge paths pass the profile's spec
into the core calls. Mechanical; no logic moved into bcc.py (which CI can't
test -- no PySide6).
Tests: +14. Existing suite unchanged and green (behaviour preservation). A
synthetic non-mcpServers spec ("servers" key, a different disabled key, a
per-server `type` field) exercises the whole pipeline -- extract, apply,
masking, external-change diff -- proving the seam actually generalises before
any real client depends on it. 458 passed, 1 skipped; ruff clean.
Refs #5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
The blocker on this issue was whether BCC or the client does the expanding.
Answer, from Anthropic's docs: Claude Code expands ${VAR} and
${VAR:-default} itself, in command, args, env, url and headers, for both
project .mcp.json and user-scope ~/.claude.json. Claude Desktop has no
documented support.
So this is a per-client capability, not a global one, and BCC does NOT
expand on write: resolving a reference into the file would put the secret
back on disk -- the whole thing the user is avoiding -- and would defeat a
feature the client already implements correctly. BCC authors, validates and
warns; expand_env_refs exists to preview what the client will do.
Semantics mirror the documented ones exactly, including the unusual bit:
an unset variable with no default is left as literal ${VAR} text rather
than blanked, because that is what Claude Code passes through.
Gating uses the existing profile_targets_claude_desktop(), so a config that
is correct under Claude Code and broken under Desktop is reported against
whichever profile is actually loaded. The two warnings are worded
differently on purpose -- 'this client will never expand these' is a
different problem from 'this variable looks unset here'.
Two existing behaviours were backwards for this feature and are fixed:
- Secret masking hid placeholders. is_secret_key('API_KEY') is true, so
${API_KEY} rendered as dots -- making a reference indistinguishable from
a stored credential, which is the one distinction that makes the feature
worth adopting. should_mask_value() now skips references, in the table
delegate, _redact_server_data and redact_args alike.
- args_secret_warning fired on placeholders. Moving a token into ${VAR} is
the recommended fix for that warning; continuing to warn punished the
fix. It now skips references while still flagging a real secret that
follows one.
Real secrets are still masked everywhere they were before -- asserted, not
assumed.
Refs #76
Three conflicts, two of them semantic rather than textual:
- bcc.py QSS: this branch added the noticeBanner rules using the old
module-level constants ({MUTED}, {ACCENT}); main had since moved the
stylesheet onto palette slots ({p.muted}). Took main's form and
translated the notice rules into it -- picking either side wholesale
would have either dropped the banner styling or reintroduced globals
that test_stylesheet_builder_has_no_hardcoded_colours now forbids.
- bcc.py methods: both sides appended to MainWindow (update-notice
handlers vs theme handlers). Additive, kept both.
- tests/test_core.py: the usual EOF append. Kept both blocks.
_build_menu_bar auto-merged cleanly (View menu above, Help menu below);
verified both are present with their menu roles intact.
Verified: 272 test functions = 265 (main) + 7 (this branch), no
duplicates; 421 passed, ruff clean.
Union conflict at the end of tests/test_core.py -- both branches appended a
test block. Kept both, main's #72/#73 block first. Verified: 265 test
functions = 238 baseline + 15 (#77) + 12 (theming), no duplicates.
Reported from the field: running v1.2 against a repo with v1.3.0 published
gave no prompt, and there appeared to be no way to check manually. The
checker itself works; it was invisible, for two reasons.
#78 -- the notice was written to the shared status label, which 21 other
call sites rewrite. The check runs off-thread and lands a second or two
after launch, right as the user starts clicking, so the next selection or
refresh wiped it. Exactly the bug fixed for the MSIX warning in #35, which
got a persistent banner; that fix was never carried to the update notice.
Adds NoticeBanner: a persistent, dismissible notice carrying its own action
button. It's a shared widget rather than a second bespoke banner, so the
next thing needing the user's attention doesn't reach for the status bar
again. (The MSIX banner still uses its own QLabel -- migrating it is a
follow-up, deliberately not bundled with a bug fix.)
#79 -- the only 'Check for updates' affordance was a button inside the
About dialog, which is not where anyone looks. Worse, the About action was
created without a menu role, and Qt auto-assigns AboutRole to actions whose
text begins with 'About', relocating it into the macOS application menu --
so the notice's own hint, 'Help > About to view it', pointed at a menu that
on macOS doesn't contain the item.
Help now has its own 'Check for updates...' item with an explicit
ApplicationSpecificRole, and the About action states its AboutRole rather
than inheriting it invisibly. The menu-driven check is never throttled and
always reports back -- the user asked, so silence would read as broken.
The decision and the wording live in core.update_notice() because the test
suite has no PySide6 (CI installs pytest + cryptography only), so anything
in bcc.py is untestable. A test asserts the notice text names no menu path,
which is what went stale here in the first place.
Closes#78Closes#79
BCC has always been dark-only -- BG #1b1d23, hardcoded at import time, with
no light option and no awareness of the desktop's appearance. On a light
desktop it matches nothing else on screen and there was no way to change it.
Adds a Palette value type in bcc_core with DARK (byte-identical to the
colours v1.3.0 shipped) and a new LIGHT, plus resolve_theme(setting,
system_is_dark) so the decision is testable without a Qt app. View > Theme
offers Match system / Light / Dark, persisted in QSettings under ui/theme,
defaulting to following the system.
The light palette's semantic colours are deliberately not the dark ones
lightened: #4ade80 sits near 1.7:1 against white. They are darkened to clear
WCAG AA, and a contrast test enforces >= 4.5:1 for every text colour against
its surface in both palettes so nobody harmonises them back later.
Three near-black literals were baked into the stylesheet (#1a1205 on-accent
text, #202229 disabled table, #16181d diagnostics pane). Fine with one theme,
invisible breakage with two -- each now has a palette slot, and a test
asserts build_stylesheet contains no hex literals at all.
The ~20 inline setStyleSheet(f"color: {MUTED}") call sites are left alone:
apply_palette rebinds the module-level colour names, and an f-string resolves
its names when it runs, so each call site picks up the new colour on its next
render. Switching theme reapplies the global QSS and re-renders the
inline-styled widgets, so nothing is left dark-on-light.
Refs #75
Two silent-failure bugs found auditing the v1.3.0 features.
#72 -- extract_servers called dict() on every server value, so a config
that was valid JSON but held a non-object server ("foo": "oops", a
number, a list, null) raised on load. The strict parse succeeded, so the
repair path never saw it, and the call sat outside the load try/except:
an unhandled traceback with the window half-swapped to the new profile.
It also made the #54 schema lint unreachable for the most likely
hand-edit mistake -- the load died before the linter ran.
Malformed values are now preserved verbatim on ServerEntry.raw (behind a
NO_RAW sentinel, since a literal JSON null is itself a malformed entry
worth keeping) and written back untouched on Save, so nothing is silently
deleted. lint_servers names the offending entry instead.
#73 -- the stale-file "Merge & save" path reloaded the file from disk and
re-applied the user's servers, but apply_servers only writes mcpServers
and _disabledMcpServers. Named server sets live under _bccServerSets in
the same file, so a set saved that session was dropped from disk and then
from memory, with no warning, on the path the user picks because it
sounds like the safe one.
BCC-owned keys are now declared in BCC_OWNED_KEYS and carried across by
carry_owned_keys, which reports genuinely contested keys so the status
line can say so. Deliberately one-directional: a key absent locally is
left alone on disk, because 'user deleted their last set' and 'another
machine just added sets' are indistinguishable and deleting someone
else's data is the worse failure.
Closes#72Closes#73
Fixes findings 2, 3, 4, 6, 7 from the issue #68 adversarial review.
- Finding 2: config.env was type-checked only. Add CATALOG_DENIED_ENV_KEYS
(case-insensitive) for interpreter/loader-override keys (NODE_OPTIONS,
PYTHONPATH, LD_PRELOAD, ...), apply the ASCII check and the existing
secret-value check to env keys/values, and require env values to be
empty or a single <PLACEHOLDER> token.
- Finding 3: version pinning was only checked by catalog_review.py (which
never runs on the signing path per finding 1). Move enforcement into
_validate_catalog_config: npm/uvx specs must carry @version or ==version
(scoped names handled), docker images must have an explicit non-latest
tag. Only the first plausible package-spec token is checked, so flags,
<PLACEHOLDER>s, and docker subcommands/flags don't trip it. All 19 real
catalog entries still validate clean.
- Finding 4: the CI catalog-signature gate imported bcc_core from the PR
branch and trusted whatever CATALOG_PUBKEYS said there, so a PR changing
both catalog.json and CATALOG_PUBKEYS (with a matching signature) went
green. ci.yml now hardcodes the expected base64 pubkey and asserts
bcc_core.CATALOG_PUBKEYS matches it before verifying the signature.
NOTE: the maintainer is planning to rotate this key -- update
EXPECTED_CATALOG_PUBKEY_B64 in ci.yml as its own reviewed change when
that happens, never bundled with a catalog content change.
- Finding 6: resolve_catalog's anti-rollback/anti-freeze guards sat behind
`if best_version >= 0`, so the first verified candidate was accepted
unconditionally and the anti-freeze anchor drifted with each accepted
candidate instead of staying fixed. The cap is now measured against the
bundled catalog's version specifically (the trust anchor baked into the
binary), regardless of evaluation order; bundled wins version ties; and
a new pure `floor` parameter lets a future caller pass a persisted
accepted-version floor.
- Finding 7: catalog id is now constrained to ^[a-z0-9][a-z0-9._-]{0,63}$.
Tests: fixed _minimal_catalog to use a pinned package (was enshrining
finding 3), rewrote the env-passthrough test to prove the validation
boundary instead of asserting env passes through unchecked, and
reordered test_resolve_catalog_rejects_absurd_version_jump so it
actually exercises the first-candidate path. Added positive/negative
tests for every new rule. Manually verified each new check by commenting
it out and confirming the guarding test goes red, then restoring it.
Phase 1 of the MCP server catalog: pure, GUI-free core functions plus the
seed data/catalog.json (20 servers). No GUI wiring in this PR -- bcc.py is
untouched; a follow-up PR adds the picker dialog.
- load_catalog(): strict json.loads ONLY. The lenient repair pipeline
(repair_json_text / parse_pasted_json*) is never used on catalog bytes,
by design and by comment, so a signature always authenticates exactly
what gets parsed.
- validate_catalog(): rejects the whole file (not per-entry) on: bad
schema/version types, missing tier-appropriate fields (basic needs
config.command+args, link-only needs docs_url and no config), a
command allowlist (npx/uvx/docker/node/python/python3 only), -e/--eval/-c
denial for node/python, --privileged and root/$HOME volume-mount denial
for docker, non-empty env_required values (hard rejection -- secrets
never ship in the catalog), secret-looking args (reuses
_TOKEN_PREFIXES/_is_secret_value rather than reimplementing), non-https
URL fields, and non-ASCII code points in id/command/args (homoglyph
defence).
- verify_catalog_signature(): Ed25519 via the cryptography package,
domain-separated message (the literal prefix "bcc-catalog-v1|" + raw
bytes), accepts a match against any key in CATALOG_PUBKEYS
(rotation-ready), never raises.
- resolve_catalog(): picks the highest version among bundled/cached/remote
candidates that EACH independently pass verify + validate -- the bundled
catalog gets no implicit trust, closing the hole where an unsigned
payload merged to main would win on being local. Anti-rollback (never
regress below the best verified candidate already in hand) and
anti-freeze (reject a jump of more than 1000 versions) built in.
- catalog_entry_to_paste_json() / config_has_unfilled_placeholders(): small
pure helpers the future GUI dialog will use to feed a catalog pick into
the existing paste-import path and to gate Save on unfilled placeholder
tokens.
data/catalog.json: the provided 20-server seed, with a signed_at field
added at the top level (lives inside the signed payload once real signing
lands in #62). Wired into bcc.spec's PyInstaller datas so it bundles into
the frozen app.
Security requirements from the issue, and where they landed:
- Catalog bytes never touch the lenient JSON repair path -- enforced by
load_catalog()'s strict json.loads and a comment warning against wiring
it in later.
- env_required values are a hard rejection when non-empty, not a warning.
- Secret-looking args are rejected at validation time, reusing the
existing secret-detection helpers instead of duplicating them.
- Non-ASCII id/command/args rejected (typosquat/homoglyph defence).
- URL fields restricted to https://.
- Ed25519 signature verification is domain-separated and never raises.
- The bundled catalog is verified at runtime exactly like remote/cached --
no implicit trust for being local.
- Anti-rollback and anti-freeze bounds on resolve_catalog's version
comparison.
Tests: 42 new tests added to tests/test_core.py (full suite: 239 passed,
1 pre-existing unrelated skip). ruff check and ruff format --check both
clean.
Sets live in the config under _bccServerSets (bcc-owned, ignored by
Claude, travels with the file). Apply enables exactly the set's members
and parks the rest; vanished members are reported, not fatal. GUI row:
set combo + Apply + Save set… + delete.
Closes#52
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Popen.kill() only terminated the direct child, so runner-style commands
(npx -> node -> server) leaked the real server process on every Windows
spawn test. taskkill /PID <pid> /T /F walks the descendant tree. Also
sets CREATE_NO_WINDOW on the spawned test process so the windowed exe
doesn't flash a console per test.
Closes#13
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
python3 is not a command on a stock Windows install; caught by the new
windows-latest CI job. Probe 'python' there and accept warn (found on
augmented PATH) as proof of resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Linux (and any non-desktop platform): refuse instead of 'pkill claude',
which substring-matched running Claude Code CLI sessions and relaunched
the CLI, not a desktop app. New restart_supported() gates the button.
- macOS: wait (<=5s) for the old instance to exit before 'open -a Claude'
so the relaunch can't re-activate the dying process. Runs off the UI
thread via a RestartWorker.
- Windows: verify the Start-menu shortcut exists BEFORE taskkill, so an
MSIX/Store install is never killed without a relaunch path.
Closes#33
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All network calls are monkeypatched (urllib.request.urlopen) — no live
network in tests. Covers older/newer/equal comparisons, v-prefix,
differing-length tuples, malformed input on both sides, and
fetch_latest_release success/timeout/network-failure/malformed-response
paths.
Mocks subprocess.run/Popen and patches sys.platform per-OS branch (darwin,
win32, linux) -- never actually kills or launches anything. Covers: correct
command sequence per platform, pkill/taskkill exiting non-zero (nothing to
kill) is NOT treated as failure, a failed relaunch IS reported as failure,
and profile_targets_claude_desktop() correctly distinguishes Claude Desktop
configs from Claude Code / legacy settings.json profiles.
Includes the required regression case: rewrite a file with different
content, force the original mtime back via os.utime, and assert the
fingerprint still differs (because size changed).
The crashed/exited/stderr spawn tests used 0.3-0.5s timeouts. On a slow CI
runner, interpreter startup can exceed that, so the process is still starting
when the timeout fires, gets killed, and is misclassified "ok" (still running)
instead of "crashed"/"exited". Bump those three to 2.0s. The two "ok" paths
(sleep-60, stdin-block) stay at 0.3s since timing out there means success.
Adds `args_secret_warning(data)` to bcc_core — returns a warning string
when any arg positional value looks like a raw credential (token prefix,
value following a secret-named flag, or URL with embedded user:pass like
postgres://user:pass@host). `--flag=value` inline forms are intentionally
skipped (the flag name already labels the value).
Adds `secret_warn` QLabel in ServerEditor's stdio page; shown/hidden by
`_check_args()` on every field change, and cleared on deselect or
stdio→remote type switch. Non-blocking — save path is not touched.
Closes#1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records the file mtime at load time; before write_config() fires, re-checks
it. If it changed (e.g. `claude mcp add`, a second BCC window, or Claude
itself writing ~/.claude.json), StaleDialog prompts with the changed top-level
key names and a masked server-section diff. "Merge & save" applies the user's
in-memory server edits on top of the current on-disk file (preserving external
non-server changes); "Overwrite anyway" proceeds as before.
- bcc_core: config_mtime(), external_change_summary(), _server_sections()
helper extracted from backup_diff for reuse
- bcc.py: StaleDialog, MainWindow._loaded_mtime tracked through load/save
- tests: 7 new tests (config_mtime, external_change_summary variants, AC merge test)
Closes#4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The diff preview in RestoreDialog was serializing the full config dict,
exposing secret env values and token args in cleartext on a pasteable surface.
- Add _redact_server_data / _redact_servers_block helpers that apply
redact_args to args and mask env values for is_secret_key() keys
- Rewrite backup_diff to compare only {mcpServers, _disabledMcpServers}
sections (sanitized), not the whole file — also avoids double-serializing
multi-MB ~/.claude.json for a servers-only diff
- Add clarifying comment in _restore_from_backup about why full_config
is the right base after confirm-discard
- Add test: backup_diff with secret args/env → MASK in output, raw values absent
- Add test: restore_backup restores _disabledMcpServers correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add list_backups / backup_label / backup_diff / restore_backup to bcc_core,
RestoreDialog to bcc.py, and a "Restore…" button in the profile top bar.
Restore is selective: only mcpServers and _disabledMcpServers are replaced;
all other keys in the config (history, project state) are preserved verbatim.
Goes through write_config() so a pre-restore backup is always created first.
Closes#3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>