Compare commits
3
Commits
10834a343a
...
8fdbe90b37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fdbe90b37 | ||
|
|
072a5cdc08 | ||
|
|
0191a93eb9 |
@@ -272,6 +272,90 @@ class _SecretMaskDelegate(QStyledItemDelegate):
|
||||
option.text = core.MASK
|
||||
|
||||
|
||||
class MoveToEnvDialog(QDialog):
|
||||
"""Confirm moving a plaintext secret out to a ${VAR} reference (#83).
|
||||
|
||||
The secret is about to leave the config file, so this dialog's whole job is
|
||||
to hand it back first: it lets the user name the variable, shows the exact
|
||||
shell line to set it, and (on accept) the caller copies the secret to the
|
||||
clipboard. If the variable already looks set in this environment, it says so
|
||||
and drops the urgency.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, key: str, secret: str):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Move to environment variable")
|
||||
self.setMinimumWidth(460)
|
||||
self._secret = secret
|
||||
v = QVBoxLayout(self)
|
||||
v.setSpacing(10)
|
||||
|
||||
intro = QLabel(
|
||||
"The value will be removed from the config and replaced with a "
|
||||
"reference. Set the variable in your environment first, or the "
|
||||
"server won't authenticate."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
v.addWidget(intro)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(8)
|
||||
lbl = QLabel("Variable:")
|
||||
lbl.setObjectName("muted")
|
||||
grid.addWidget(lbl, 0, 0)
|
||||
self._name_edit = QLineEdit(core.sanitize_env_var_name(key))
|
||||
self._name_edit.textChanged.connect(self._refresh)
|
||||
grid.addWidget(self._name_edit, 0, 1)
|
||||
v.addLayout(grid)
|
||||
|
||||
self._already = QLabel("")
|
||||
self._already.setWordWrap(True)
|
||||
self._already.setStyleSheet(f"color: {GOOD};")
|
||||
v.addWidget(self._already)
|
||||
|
||||
set_lbl = QLabel("Set it with:")
|
||||
set_lbl.setObjectName("muted")
|
||||
v.addWidget(set_lbl)
|
||||
self._cmd = QLabel("")
|
||||
self._cmd.setWordWrap(True)
|
||||
self._cmd.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self._cmd.setStyleSheet("font-family: monospace;")
|
||||
v.addWidget(self._cmd)
|
||||
|
||||
note = QLabel("The secret will be copied to your clipboard when you continue.")
|
||||
note.setObjectName("muted")
|
||||
note.setWordWrap(True)
|
||||
v.addWidget(note)
|
||||
|
||||
btns = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
ok = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||
ok.setText("Move && copy secret")
|
||||
ok.setObjectName("primary")
|
||||
btns.accepted.connect(self.accept)
|
||||
btns.rejected.connect(self.reject)
|
||||
v.addWidget(btns)
|
||||
|
||||
self._refresh()
|
||||
|
||||
def var_name(self) -> str:
|
||||
return core.sanitize_env_var_name(self._name_edit.text())
|
||||
|
||||
def _refresh(self, *_):
|
||||
name = self.var_name()
|
||||
lines = core.shell_export_lines(name, self._secret)
|
||||
if sys.platform == "win32":
|
||||
self._cmd.setText(f"{lines['windows']}\n\n(macOS/Linux: {lines['posix']})")
|
||||
else:
|
||||
self._cmd.setText(f"{lines['posix']}\n\n(Windows: {lines['windows']})")
|
||||
if core.is_env_var_set(name):
|
||||
self._already.setText(f"{name} already looks set in this environment.")
|
||||
self._already.show()
|
||||
else:
|
||||
self._already.hide()
|
||||
|
||||
|
||||
class KeyValueTable(QWidget):
|
||||
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
|
||||
super().__init__()
|
||||
@@ -292,6 +376,11 @@ class KeyValueTable(QWidget):
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.setMinimumHeight(90)
|
||||
self.table.itemChanged.connect(self._changed)
|
||||
# Right-click a secret row to move it out to a ${VAR} reference (#83).
|
||||
# Set by the owner (ServerEditor) so the action can gate on the client.
|
||||
self.profile_provider = None
|
||||
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(self._context_menu)
|
||||
# Secret-looking values (API_KEY, TOKEN, ...) render masked by default.
|
||||
self._mask_delegate = _SecretMaskDelegate(self.table)
|
||||
self.table.setItemDelegateForColumn(1, self._mask_delegate)
|
||||
@@ -315,6 +404,47 @@ class KeyValueTable(QWidget):
|
||||
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
||||
self.table.viewport().update()
|
||||
|
||||
def _row_key_value(self, row: int):
|
||||
key_item = self.table.item(row, 0)
|
||||
val_item = self.table.item(row, 1)
|
||||
key = key_item.text().strip() if key_item else ""
|
||||
# The mask is display-only (a delegate); the model text is the real value.
|
||||
value = val_item.text() if val_item else ""
|
||||
return key, value
|
||||
|
||||
def _context_menu(self, pos):
|
||||
item = self.table.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
row = item.row()
|
||||
key, value = self._row_key_value(row)
|
||||
profile = self.profile_provider() if self.profile_provider else None
|
||||
if not core.can_move_value_to_env_ref(key, value, profile):
|
||||
return
|
||||
menu = QMenu(self)
|
||||
act = QAction("Move to environment variable…", self)
|
||||
act.triggered.connect(lambda: self._move_row_to_env(row))
|
||||
menu.addAction(act)
|
||||
menu.exec(self.table.viewport().mapToGlobal(pos))
|
||||
|
||||
def _move_row_to_env(self, row: int):
|
||||
key, secret = self._row_key_value(row)
|
||||
if not secret:
|
||||
return
|
||||
dlg = MoveToEnvDialog(self.window(), key, secret)
|
||||
if not dlg.exec():
|
||||
return
|
||||
var_name = dlg.var_name()
|
||||
# Hand the secret back before it leaves the file: clipboard now holds it,
|
||||
# and the dialog showed the exact shell line to set it.
|
||||
QGuiApplication.clipboard().setText(secret)
|
||||
if self._before_change:
|
||||
self._before_change()
|
||||
val_item = self.table.item(row, 1)
|
||||
if val_item is not None:
|
||||
# setText fires itemChanged -> _changed -> on_change (dirty + revalidate).
|
||||
val_item.setText(f"${{{var_name}}}")
|
||||
|
||||
def _changed(self, item=None, *_):
|
||||
if item is not None and item.column() == 0:
|
||||
new_key = item.text().strip()
|
||||
@@ -631,6 +761,12 @@ class ServerEditor(QFrame):
|
||||
v.addWidget(self.headers, 1)
|
||||
return w
|
||||
|
||||
def set_profile_provider(self, provider):
|
||||
"""Let the env/headers tables gate "move to environment variable" on
|
||||
which client the loaded profile targets (#83)."""
|
||||
self.env.profile_provider = provider
|
||||
self.headers.profile_provider = provider
|
||||
|
||||
# --- model <-> form -------------------------------------------------- #
|
||||
def load_entry(self, entry: core.ServerEntry | None):
|
||||
self._loading = True
|
||||
@@ -1649,6 +1785,7 @@ class MainWindow(QMainWindow):
|
||||
split.setHandleWidth(10)
|
||||
split.addWidget(self._build_left())
|
||||
self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
|
||||
self.editor.set_profile_provider(lambda: self.current_profile)
|
||||
split.addWidget(self.editor)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 4)
|
||||
@@ -2043,6 +2180,11 @@ class MainWindow(QMainWindow):
|
||||
for p in self.profiles:
|
||||
tag = "" if p.config_exists else " (no config yet)"
|
||||
self.profile_combo.addItem(f"{p.label}{tag}")
|
||||
# Full path in the tooltip so a profile is always verifiable even
|
||||
# when two labels look alike (e.g. two repos both named "app").
|
||||
self.profile_combo.setItemData(
|
||||
self.profile_combo.count() - 1, str(p.path), Qt.ItemDataRole.ToolTipRole
|
||||
)
|
||||
self.profile_combo.blockSignals(False)
|
||||
if self.profiles:
|
||||
self.profile_combo.setCurrentIndex(0)
|
||||
|
||||
+192
-10
@@ -648,16 +648,67 @@ def msix_warning_text(
|
||||
)
|
||||
|
||||
|
||||
def _dir_name_parts(directory: Path) -> list[str]:
|
||||
"""The path components of `directory` with the filesystem anchor dropped.
|
||||
|
||||
`/home/me/work/app` -> ['home', 'me', 'work', 'app']; `C:\\src\\app` ->
|
||||
['src', 'app']. Falls back to the raw name for an anchor-only path so a
|
||||
label is never empty.
|
||||
"""
|
||||
parts = [p for p in directory.parts if p != directory.anchor]
|
||||
return parts or [directory.name or str(directory)]
|
||||
|
||||
|
||||
def disambiguate_project_labels(dirs: list[Path]) -> dict[Path, str]:
|
||||
"""Map each project directory to a unique `Project: <suffix>` label.
|
||||
|
||||
The suffix is the directory's basename, widened toward the root one
|
||||
component at a time ONLY for directories whose current suffix collides with
|
||||
another in the set -- so the common case stays `Project: app`, and two repos
|
||||
both named `app` become `Project: work/app` and `Project: personal/app`
|
||||
(widening further if even that collides). Deterministic; expects `dirs` to
|
||||
already be de-duplicated by path (identical paths can't be told apart).
|
||||
"""
|
||||
depth = {d: 1 for d in dirs}
|
||||
max_depth = {d: len(_dir_name_parts(d)) for d in dirs}
|
||||
|
||||
def suffix(d: Path) -> str:
|
||||
return "/".join(_dir_name_parts(d)[-depth[d] :])
|
||||
|
||||
# Widen colliding groups until every label is unique or no group can widen.
|
||||
while True:
|
||||
groups: dict[str, list[Path]] = {}
|
||||
for d in dirs:
|
||||
groups.setdefault(suffix(d), []).append(d)
|
||||
widened = False
|
||||
for members in groups.values():
|
||||
if len(members) > 1:
|
||||
for d in members:
|
||||
if depth[d] < max_depth[d]:
|
||||
depth[d] += 1
|
||||
widened = True
|
||||
if not widened:
|
||||
break
|
||||
|
||||
return {d: f"Project: {suffix(d)}" for d in dirs}
|
||||
|
||||
|
||||
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
||||
"""
|
||||
Find project-scope `.mcp.json` configs known to Claude Code.
|
||||
|
||||
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
||||
directory path (that's what the CLI writes as it's used in each repo).
|
||||
Any project whose directory has a `.mcp.json` file next to it -- a
|
||||
standalone file with a top-level `mcpServers` object, same shape BCC
|
||||
already edits -- is surfaced here as its own profile so it can be opened
|
||||
via 'Add config...' without hunting for the path by hand.
|
||||
Any project whose directory has a `.mcp.json` file that parses to a JSON
|
||||
object -- the same shape BCC already edits -- is surfaced here as its own
|
||||
profile so it can be opened via 'Add config...' without hunting for the
|
||||
path by hand. A `.mcp.json` that is missing, unreadable, or not a JSON
|
||||
object (an array, a string, garbage) is skipped rather than surfaced as a
|
||||
profile that only fails when opened.
|
||||
|
||||
Labels are disambiguated so two repos with the same basename don't both
|
||||
read as `Project: app` and let the user edit the wrong one (see
|
||||
`disambiguate_project_labels`).
|
||||
|
||||
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
||||
`projects` value that isn't a dict, just yields an empty list rather than
|
||||
@@ -670,14 +721,22 @@ def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profil
|
||||
projects = cfg.get("projects")
|
||||
if not isinstance(projects, dict):
|
||||
return []
|
||||
out: list[Profile] = []
|
||||
|
||||
valid_dirs: list[Path] = []
|
||||
for key in sorted(k for k in projects if isinstance(k, str)):
|
||||
mcp_path = Path(key) / ".mcp.json"
|
||||
if mcp_path.is_file():
|
||||
out.append(
|
||||
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
|
||||
)
|
||||
return out
|
||||
if not mcp_path.is_file():
|
||||
continue
|
||||
try:
|
||||
data = load_config(mcp_path) # strict; raises on bad JSON / non-object
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
valid_dirs.append(Path(key))
|
||||
|
||||
labels = disambiguate_project_labels(valid_dirs)
|
||||
return [Profile(label=labels[d], path=d / ".mcp.json", config_exists=True) for d in valid_dirs]
|
||||
|
||||
|
||||
def discover_profiles() -> list[Profile]:
|
||||
@@ -1778,6 +1837,129 @@ def env_ref_warnings(
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# "Move to environment variable" (issue #83)
|
||||
#
|
||||
# Turn a plaintext secret in a config into a ${VAR} reference, so the secret
|
||||
# stops living in the file. Two things make this more than a string swap:
|
||||
# * the replaced value is the ONLY copy of the secret the user may have, so
|
||||
# the caller must hand it back (clipboard + the exact shell line) before it
|
||||
# leaves the config -- shell_export_lines builds that;
|
||||
# * only clients that expand ${VAR} (Claude Code, not Desktop) should be
|
||||
# offered this, or it walks the user straight into a broken config -- that
|
||||
# gate is can_move_value_to_env_ref, reusing client_expands_env_refs.
|
||||
# The rewrite itself is pure (data in -> data out) and lives here; the GUI does
|
||||
# clipboard, the confirm dialog, and the already-set check.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class EnvRefConversion:
|
||||
"""The result of moving one secret out to a ${VAR} reference."""
|
||||
|
||||
var_name: str # the sanitised shell variable name chosen
|
||||
reference: str # "${VAR_NAME}" -- what now sits in the config
|
||||
secret: str # the plaintext value that was removed (hand this back!)
|
||||
data: dict # a NEW server-definition dict with the value replaced
|
||||
|
||||
|
||||
def sanitize_env_var_name(name: str) -> str:
|
||||
"""Coerce an arbitrary key into a legal, conventional shell variable name.
|
||||
|
||||
POSIX names are `[A-Za-z_][A-Za-z0-9_]*`; env vars are conventionally
|
||||
upper-case. Non-alphanumerics (and any non-ASCII) become `_`, a leading
|
||||
digit gets an `_` prefix, and an empty/degenerate result falls back to VAR.
|
||||
'api-key' -> 'API_KEY'; '2fa' -> '_2FA'; '' -> 'VAR'.
|
||||
"""
|
||||
cleaned = "".join(
|
||||
ch if (ch.isascii() and (ch.isalnum() or ch == "_")) else "_" for ch in (name or "")
|
||||
)
|
||||
if not cleaned.strip("_"):
|
||||
return "VAR"
|
||||
if cleaned[0].isdigit():
|
||||
cleaned = "_" + cleaned
|
||||
return cleaned.upper()
|
||||
|
||||
|
||||
def _posix_single_quote(s: str) -> str:
|
||||
"""Wrap s in single quotes, safely, for a POSIX shell (handles embedded ')."""
|
||||
return "'" + s.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def shell_export_lines(var_name: str, secret: str) -> dict[str, str]:
|
||||
"""The exact command to set `var_name`=`secret` in the user's shell.
|
||||
|
||||
Returned per-platform so the UI can show the one that fits (or both). This
|
||||
is what makes moving the secret out safe: the user gets the setter before
|
||||
the value leaves the file.
|
||||
"""
|
||||
return {
|
||||
"posix": f"export {var_name}={_posix_single_quote(secret)}",
|
||||
"windows": f'setx {var_name} "{secret}"',
|
||||
}
|
||||
|
||||
|
||||
def can_move_value_to_env_ref(key: str, value, profile: Profile | None = None) -> bool:
|
||||
"""Whether to offer "move to environment variable" for one env/header row.
|
||||
|
||||
True only for a real stored secret (`should_mask_value`) that isn't already
|
||||
a reference, on a client that expands references. Offering it on Claude
|
||||
Desktop would produce a config that reaches the server as literal `${VAR}`
|
||||
text -- the exact failure #76 exists to prevent -- so a non-expanding
|
||||
profile refuses outright.
|
||||
"""
|
||||
if profile is not None and not client_expands_env_refs(profile):
|
||||
return False
|
||||
if is_env_ref(value):
|
||||
return False
|
||||
return should_mask_value(key, value)
|
||||
|
||||
|
||||
def move_value_to_env_ref(
|
||||
data: dict, *, field: str, key: str | None = None, index: int | None = None, var_name=None
|
||||
) -> EnvRefConversion | None:
|
||||
"""Replace one secret value in `data` with a `${VAR}` reference.
|
||||
|
||||
`field` is "env" or "headers" (addressed by `key`) or "args" (addressed by
|
||||
`index`). `var_name` defaults to the row's key (sanitised); args have no
|
||||
key, so a var name should be supplied there. Returns an EnvRefConversion
|
||||
carrying a NEW data dict (the input is never mutated) and the removed
|
||||
secret, or None if the target isn't found or isn't a string to move.
|
||||
"""
|
||||
new = copy.deepcopy(data)
|
||||
|
||||
if field in ("env", "headers"):
|
||||
block = new.get(field)
|
||||
if not isinstance(block, dict) or key not in block:
|
||||
return None
|
||||
current = block[key]
|
||||
if not isinstance(current, str) or is_env_ref(current):
|
||||
return None
|
||||
vn = sanitize_env_var_name(var_name if var_name is not None else key)
|
||||
block[key] = f"${{{vn}}}"
|
||||
elif field == "args":
|
||||
args = new.get("args")
|
||||
if not isinstance(args, list) or index is None or not (0 <= index < len(args)):
|
||||
return None
|
||||
current = args[index]
|
||||
if not isinstance(current, str) or is_env_ref(current):
|
||||
return None
|
||||
vn = sanitize_env_var_name(var_name if var_name is not None else f"ARG_{index}")
|
||||
args[index] = f"${{{vn}}}"
|
||||
else:
|
||||
return None
|
||||
|
||||
return EnvRefConversion(var_name=vn, reference=f"${{{vn}}}", secret=current, data=new)
|
||||
|
||||
|
||||
def is_env_var_set(var_name: str, environ: dict | None = None) -> bool:
|
||||
"""Whether `var_name` is already present (non-empty) in the environment.
|
||||
|
||||
Lets the UI skip the copy-the-secret ceremony when the variable is already
|
||||
set. Best-effort: BCC's environment may differ from the client's.
|
||||
"""
|
||||
env = os.environ if environ is None else environ
|
||||
return bool(env.get(var_name))
|
||||
|
||||
|
||||
def is_secret_key(name: str) -> bool:
|
||||
"""Does this env-var / header / flag name look like it holds a secret?"""
|
||||
return bool(_SECRET_KEY_RE.search(name or ""))
|
||||
|
||||
@@ -2991,3 +2991,160 @@ def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path):
|
||||
changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE)
|
||||
assert "servers" in changed_keys
|
||||
assert diff # a server-section change under the custom key is diffed
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Project profile label disambiguation (issue #74)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_disambiguate_labels_no_collision_uses_basename():
|
||||
dirs = [Path("/home/me/work/api"), Path("/home/me/work/web")]
|
||||
labels = c.disambiguate_project_labels(dirs)
|
||||
assert labels[Path("/home/me/work/api")] == "Project: api"
|
||||
assert labels[Path("/home/me/work/web")] == "Project: web"
|
||||
|
||||
|
||||
def test_disambiguate_labels_widens_only_colliding_basenames():
|
||||
dirs = [
|
||||
Path("/home/me/work/app"),
|
||||
Path("/home/me/personal/app"),
|
||||
Path("/home/me/notes"),
|
||||
]
|
||||
labels = c.disambiguate_project_labels(dirs)
|
||||
# the two "app"s widen by one parent; the unique "notes" stays plain
|
||||
assert labels[Path("/home/me/work/app")] == "Project: work/app"
|
||||
assert labels[Path("/home/me/personal/app")] == "Project: personal/app"
|
||||
assert labels[Path("/home/me/notes")] == "Project: notes"
|
||||
|
||||
|
||||
def test_disambiguate_labels_widens_further_when_parent_also_collides():
|
||||
dirs = [Path("/a/x/app"), Path("/b/x/app")]
|
||||
labels = c.disambiguate_project_labels(dirs)
|
||||
assert labels[Path("/a/x/app")] == "Project: a/x/app"
|
||||
assert labels[Path("/b/x/app")] == "Project: b/x/app"
|
||||
|
||||
|
||||
def _write_project(tmp_path, name, mcp_content):
|
||||
d = tmp_path / name
|
||||
d.mkdir(parents=True)
|
||||
if mcp_content is not None:
|
||||
(d / ".mcp.json").write_text(mcp_content, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
def _claude_json_with_projects(tmp_path, dirs):
|
||||
cj = tmp_path / ".claude.json"
|
||||
cj.write_text(json.dumps({"projects": {str(d): {} for d in dirs}}), encoding="utf-8")
|
||||
return cj
|
||||
|
||||
|
||||
def test_discover_project_configs_disambiguates_same_basename(tmp_path):
|
||||
d1 = _write_project(tmp_path / "work", "app", '{"mcpServers": {}}')
|
||||
d2 = _write_project(tmp_path / "personal", "app", '{"mcpServers": {}}')
|
||||
cj = _claude_json_with_projects(tmp_path, [d1, d2])
|
||||
profiles = c.discover_project_configs(cj)
|
||||
labels = sorted(p.label for p in profiles)
|
||||
assert labels == ["Project: personal/app", "Project: work/app"]
|
||||
|
||||
|
||||
def test_discover_project_configs_skips_non_object_and_garbage(tmp_path):
|
||||
good = _write_project(tmp_path, "good", '{"mcpServers": {}}')
|
||||
array = _write_project(tmp_path, "arr", "[1, 2, 3]")
|
||||
garbage = _write_project(tmp_path, "junk", "not json at all")
|
||||
missing = tmp_path / "nofile"
|
||||
missing.mkdir()
|
||||
cj = _claude_json_with_projects(tmp_path, [good, array, garbage, missing])
|
||||
profiles = c.discover_project_configs(cj)
|
||||
paths = {str(p.path) for p in profiles}
|
||||
assert str(good / ".mcp.json") in paths
|
||||
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