feat: "Move to environment variable" — convert a plaintext secret to ${VAR} (#83)
CI / Lint (ruff) (pull_request) Successful in 14s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 23s
CI / Catalog signature (pull_request) Successful in 17s
CI / Lint (ruff) (pull_request) Successful in 14s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 23s
CI / Catalog signature (pull_request) Successful in 17s
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
This commit is contained in:
@@ -3059,3 +3059,92 @@ def test_discover_project_configs_skips_non_object_and_garbage(tmp_path):
|
||||
assert str(array / ".mcp.json") not in paths
|
||||
assert str(garbage / ".mcp.json") not in paths
|
||||
assert str(missing / ".mcp.json") not in paths
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Move to environment variable (issue #83)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("API_KEY", "API_KEY"),
|
||||
("api-key", "API_KEY"),
|
||||
("x.y z", "X_Y_Z"),
|
||||
("2fa", "_2FA"),
|
||||
("", "VAR"),
|
||||
("***", "VAR"),
|
||||
("clé", "CL_"), # non-ASCII becomes _
|
||||
],
|
||||
)
|
||||
def test_sanitize_env_var_name(raw, expected):
|
||||
assert c.sanitize_env_var_name(raw) == expected
|
||||
|
||||
|
||||
def test_shell_export_lines_quote_safely():
|
||||
lines = c.shell_export_lines("TOKEN", "ab'cd")
|
||||
assert lines["posix"] == "export TOKEN='ab'\\''cd'"
|
||||
assert lines["windows"] == 'setx TOKEN "ab\'cd"'
|
||||
|
||||
|
||||
def test_can_move_gate_requires_secret_and_expanding_client():
|
||||
desktop = c.Profile(label="d", path="/x/Claude/claude_desktop_config.json", config_exists=True)
|
||||
code = c.Profile(label="c", path=Path.home() / ".claude.json", config_exists=True)
|
||||
# real secret on an expanding client -> offer
|
||||
assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", code) is True
|
||||
assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", None) is True
|
||||
# non-secret key -> no
|
||||
assert c.can_move_value_to_env_ref("REGION", "us-east-1", code) is False
|
||||
# already a reference -> no
|
||||
assert c.can_move_value_to_env_ref("API_KEY", "${API_KEY}", code) is False
|
||||
# non-expanding client (Claude Desktop) -> refuse even a real secret
|
||||
assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", desktop) is False
|
||||
|
||||
|
||||
def test_move_env_value_replaces_with_reference_and_returns_secret():
|
||||
data = {"command": "x", "env": {"API_KEY": "ghp_secret", "REGION": "us"}}
|
||||
conv = c.move_value_to_env_ref(data, field="env", key="API_KEY")
|
||||
assert conv is not None
|
||||
assert conv.var_name == "API_KEY"
|
||||
assert conv.reference == "${API_KEY}"
|
||||
assert conv.secret == "ghp_secret"
|
||||
assert conv.data["env"]["API_KEY"] == "${API_KEY}"
|
||||
# non-secret row untouched
|
||||
assert conv.data["env"]["REGION"] == "us"
|
||||
# input never mutated
|
||||
assert data["env"]["API_KEY"] == "ghp_secret"
|
||||
|
||||
|
||||
def test_move_derives_and_sanitises_var_name_from_key():
|
||||
data = {"headers": {"x-api-key": "sekret"}}
|
||||
conv = c.move_value_to_env_ref(data, field="headers", key="x-api-key")
|
||||
assert conv.var_name == "X_API_KEY"
|
||||
assert conv.data["headers"]["x-api-key"] == "${X_API_KEY}"
|
||||
|
||||
|
||||
def test_move_honours_explicit_var_name():
|
||||
data = {"env": {"tok": "sekret"}}
|
||||
conv = c.move_value_to_env_ref(data, field="env", key="tok", var_name="GITHUB_TOKEN")
|
||||
assert conv.reference == "${GITHUB_TOKEN}"
|
||||
assert conv.data["env"]["tok"] == "${GITHUB_TOKEN}"
|
||||
|
||||
|
||||
def test_move_args_by_index():
|
||||
data = {"command": "x", "args": ["--token", "ghp_secret"]}
|
||||
conv = c.move_value_to_env_ref(data, field="args", index=1, var_name="GH_TOKEN")
|
||||
assert conv.secret == "ghp_secret"
|
||||
assert conv.data["args"] == ["--token", "${GH_TOKEN}"]
|
||||
|
||||
|
||||
def test_move_returns_none_on_missing_or_nonstring_or_already_ref():
|
||||
data = {"env": {"API_KEY": "${API_KEY}", "N": 5}}
|
||||
assert c.move_value_to_env_ref(data, field="env", key="ABSENT") is None
|
||||
assert c.move_value_to_env_ref(data, field="env", key="N") is None # not a string
|
||||
assert c.move_value_to_env_ref(data, field="env", key="API_KEY") is None # already a ref
|
||||
assert c.move_value_to_env_ref({}, field="bogus") is None
|
||||
assert c.move_value_to_env_ref({"args": ["a"]}, field="args", index=9) is None
|
||||
|
||||
|
||||
def test_is_env_var_set():
|
||||
assert c.is_env_var_set("FOO", {"FOO": "x"}) is True
|
||||
assert c.is_env_var_set("FOO", {"FOO": ""}) is False
|
||||
assert c.is_env_var_set("FOO", {}) is False
|
||||
|
||||
Reference in New Issue
Block a user