Merge remote-tracking branch 'origin/main' into feat/removed-flag-env-migration
CI / Lint (ruff) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 17s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 14s
CI / Catalog signature (pull_request) Successful in 9s
CI / Lint (ruff) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 23s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 14s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 17s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 14s
CI / Catalog signature (pull_request) Successful in 9s
# Conflicts: # tests/test_core.py
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
# PR #87 — "Move to environment variable" (#83): manual test checklist
|
||||||
|
|
||||||
|
The logic is covered by 15 unit tests in CI; what CI **can't** exercise is the GUI (no PySide6). This checklist is only the parts a human needs to click. Should take ~10 minutes.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/Claude/Projects/BetterClaudeConfig/better-claude-config
|
||||||
|
git fetch origin
|
||||||
|
git checkout feat/83-move-to-env-var
|
||||||
|
git pull # ensure you're on 8fdbe90 or later
|
||||||
|
source .venv/bin/activate # or recreate: python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt
|
||||||
|
python bcc.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a **Claude Code** profile (e.g. `~/.claude.json`) that has, or add, a server with an env value that looks like a secret — e.g. `env: { "API_KEY": "ghp_test123" }`. (You can use a throwaway value; nothing is sent anywhere.)
|
||||||
|
|
||||||
|
## The checklist
|
||||||
|
|
||||||
|
### Gating — where the action appears
|
||||||
|
- [ ] Right-click the **value cell** of a secret env row (`API_KEY`) on a **Claude Code** profile → a **"Move to environment variable…"** item appears.
|
||||||
|
- [ ] Right-click a **non-secret** row (e.g. `REGION` = `us-east-1`) → the item does **not** appear.
|
||||||
|
- [ ] Right-click a row whose value is already a reference (`${API_KEY}`) → the item does **not** appear.
|
||||||
|
- [ ] Switch to a **Claude Desktop** profile (a `claude_desktop_config.json`), right-click the same kind of secret row → the item does **not** appear. (Desktop doesn't expand `${VAR}`, so offering it would break the config — this is the important gate.)
|
||||||
|
|
||||||
|
### The dialog
|
||||||
|
- [ ] Trigger the action → dialog opens with **Variable** pre-filled from the key, sanitized to a legal shell name (e.g. `api-key` → `API_KEY`).
|
||||||
|
- [ ] Edit the variable name → the shown **shell line updates live** and matches your platform (`export VAR='…'` on macOS/Linux, `setx VAR "…"` on Windows), with the other platform shown in parentheses.
|
||||||
|
- [ ] If you type a variable name that **is already set** in your shell environment, the green "already looks set" note appears; if not, it's hidden.
|
||||||
|
- [ ] **Cancel** → nothing changes (value still the raw secret, no dirty state).
|
||||||
|
|
||||||
|
### The conversion
|
||||||
|
- [ ] **Move && copy secret** → the cell now shows the reference `${VAR}` (visible, **not** masked to dots), and the window goes dirty (Save enabled).
|
||||||
|
- [ ] Paste from your clipboard somewhere → it's the **original secret value** (handed back before removal).
|
||||||
|
- [ ] The reference value is **not** flagged as a secret warning anymore (it's the recommended state).
|
||||||
|
|
||||||
|
### Headers + persistence
|
||||||
|
- [ ] Repeat on a **remote server's Headers** table (e.g. an `Authorization` header) → same behavior.
|
||||||
|
- [ ] **Save**, then open the config file on disk in a text editor → the servers block holds `${VAR}`, and the **plaintext secret is gone** from the file.
|
||||||
|
- [ ] Re-open the profile in BCC → the row still shows `${VAR}` (round-trips).
|
||||||
|
|
||||||
|
### Undo (nice-to-have)
|
||||||
|
- [ ] After a conversion, **Ctrl+Z / Cmd+Z** restores the previous value.
|
||||||
|
|
||||||
|
## Known scope (not bugs)
|
||||||
|
- **Args rows** are out of scope for this PR — the core supports them, but the args editor is a free-text widget, so wiring that UI is a deliberate follow-up. Right-clicking args won't offer the action yet.
|
||||||
|
- The "already set" check reads **BCC's** environment, which may differ from the client's — it's advisory, worded that way.
|
||||||
|
|
||||||
|
## If anything's off
|
||||||
|
Tell me which checkbox failed and what you saw; I'll fix on the branch and re-push. If everything passes, approve/merge #87 (or tell me to merge it).
|
||||||
@@ -272,6 +272,226 @@ class _SecretMaskDelegate(QStyledItemDelegate):
|
|||||||
option.text = core.MASK
|
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(
|
||||||
|
"This replaces the value in place with a ${VAR} reference. The secret "
|
||||||
|
"moves to your shell/OS environment — not this config file, and not the "
|
||||||
|
"Environment variables table below. Run the line below to set it there, "
|
||||||
|
"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 MoveArgToEnvDialog(QDialog):
|
||||||
|
"""Confirm relocating a secret arg into the env block (#83, kept in file).
|
||||||
|
|
||||||
|
Unlike the reference move, this keeps the value in the config -- it just
|
||||||
|
moves it out of the argument list (visible in process listings) and into
|
||||||
|
the Environment variables table, where the user can see and edit it. It
|
||||||
|
changes how the server is launched, so it says so plainly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent, key: str, value: str):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Move into environment variables")
|
||||||
|
self.setMinimumWidth(460)
|
||||||
|
v = QVBoxLayout(self)
|
||||||
|
v.setSpacing(10)
|
||||||
|
|
||||||
|
intro = QLabel(
|
||||||
|
"This moves the secret out of the arguments and into the Environment "
|
||||||
|
"variables table below, where you can see and edit its value. The value "
|
||||||
|
"stays in this config file."
|
||||||
|
)
|
||||||
|
intro.setWordWrap(True)
|
||||||
|
v.addWidget(intro)
|
||||||
|
|
||||||
|
warn = QLabel(
|
||||||
|
"⚠ This changes how the server is launched: the flag is dropped and the "
|
||||||
|
"value is set as an environment variable instead. It only works if the "
|
||||||
|
"server reads this secret from that variable."
|
||||||
|
)
|
||||||
|
warn.setWordWrap(True)
|
||||||
|
warn.setStyleSheet(f"color: {WARN};")
|
||||||
|
v.addWidget(warn)
|
||||||
|
|
||||||
|
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))
|
||||||
|
grid.addWidget(self._name_edit, 0, 1)
|
||||||
|
v.addLayout(grid)
|
||||||
|
|
||||||
|
btns = QDialogButtonBox(
|
||||||
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
|
)
|
||||||
|
ok = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
|
ok.setText("Move into env")
|
||||||
|
ok.setObjectName("primary")
|
||||||
|
btns.accepted.connect(self.accept)
|
||||||
|
btns.rejected.connect(self.reject)
|
||||||
|
v.addWidget(btns)
|
||||||
|
|
||||||
|
def var_name(self) -> str:
|
||||||
|
return core.sanitize_env_var_name(self._name_edit.text())
|
||||||
|
|
||||||
|
|
||||||
|
class ReferencedVarsDialog(QDialog):
|
||||||
|
"""Show every ${VAR} the loaded server references and whether it's set (#83).
|
||||||
|
|
||||||
|
After a secret becomes a reference, the variable lives in the user's
|
||||||
|
environment, not the config -- so this is where they confirm it exists and
|
||||||
|
get the command to set it. Read-only; BCC can't (and shouldn't) store the
|
||||||
|
value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent, data: dict):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Referenced variables")
|
||||||
|
self.setMinimumWidth(560)
|
||||||
|
v = QVBoxLayout(self)
|
||||||
|
v.setSpacing(10)
|
||||||
|
|
||||||
|
self._usages = core.referenced_env_vars(data)
|
||||||
|
if not self._usages:
|
||||||
|
v.addWidget(QLabel("This server references no ${VAR} variables."))
|
||||||
|
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
|
btns.rejected.connect(self.reject)
|
||||||
|
btns.accepted.connect(self.accept)
|
||||||
|
v.addWidget(btns)
|
||||||
|
return
|
||||||
|
|
||||||
|
intro = QLabel(
|
||||||
|
"These references are read from your shell/OS environment when the client "
|
||||||
|
"runs. ✓ means it's set in BCC's environment (which may differ from the "
|
||||||
|
"client's) or has a default; ✗ means nothing would fill it."
|
||||||
|
)
|
||||||
|
intro.setWordWrap(True)
|
||||||
|
v.addWidget(intro)
|
||||||
|
|
||||||
|
self._table = QTableWidget(len(self._usages), 3)
|
||||||
|
self._table.setHorizontalHeaderLabels(["Variable", "Status", "Used in"])
|
||||||
|
self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self._table.verticalHeader().setVisible(False)
|
||||||
|
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
for r, u in enumerate(self._usages):
|
||||||
|
is_set = core.is_env_var_set(u.name)
|
||||||
|
status = "✓ set" if is_set else ("✓ default" if u.has_default else "✗ not set")
|
||||||
|
self._table.setItem(r, 0, QTableWidgetItem(u.name))
|
||||||
|
self._table.setItem(r, 1, QTableWidgetItem(status))
|
||||||
|
self._table.setItem(r, 2, QTableWidgetItem(", ".join(u.fields)))
|
||||||
|
self._table.selectionModel().selectionChanged.connect(self._refresh_cmd)
|
||||||
|
v.addWidget(self._table, 1)
|
||||||
|
|
||||||
|
set_lbl = QLabel("Set the selected variable 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)
|
||||||
|
|
||||||
|
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||||||
|
btns.rejected.connect(self.reject)
|
||||||
|
btns.accepted.connect(self.accept)
|
||||||
|
v.addWidget(btns)
|
||||||
|
|
||||||
|
self._table.selectRow(0)
|
||||||
|
|
||||||
|
def _refresh_cmd(self, *_):
|
||||||
|
rows = self._table.selectionModel().selectedRows()
|
||||||
|
if not rows:
|
||||||
|
self._cmd.setText("")
|
||||||
|
return
|
||||||
|
name = self._usages[rows[0].row()].name
|
||||||
|
# A placeholder value -- BCC doesn't hold the secret, this shows the shape.
|
||||||
|
lines = core.shell_export_lines(name, "<value>")
|
||||||
|
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']})")
|
||||||
|
|
||||||
|
|
||||||
class KeyValueTable(QWidget):
|
class KeyValueTable(QWidget):
|
||||||
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
|
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -292,6 +512,11 @@ class KeyValueTable(QWidget):
|
|||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self.table.setMinimumHeight(90)
|
self.table.setMinimumHeight(90)
|
||||||
self.table.itemChanged.connect(self._changed)
|
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.
|
# Secret-looking values (API_KEY, TOKEN, ...) render masked by default.
|
||||||
self._mask_delegate = _SecretMaskDelegate(self.table)
|
self._mask_delegate = _SecretMaskDelegate(self.table)
|
||||||
self.table.setItemDelegateForColumn(1, self._mask_delegate)
|
self.table.setItemDelegateForColumn(1, self._mask_delegate)
|
||||||
@@ -315,6 +540,58 @@ class KeyValueTable(QWidget):
|
|||||||
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
||||||
self.table.viewport().update()
|
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)
|
||||||
|
# Only a real stored secret is worth moving; nothing to offer otherwise.
|
||||||
|
if not core.should_mask_value(key, value):
|
||||||
|
return
|
||||||
|
profile = self.profile_provider() if self.profile_provider else None
|
||||||
|
menu = QMenu(self)
|
||||||
|
act = QAction("Replace with a ${VAR} reference (out of file)…", self)
|
||||||
|
if core.can_move_value_to_env_ref(key, value, profile):
|
||||||
|
act.triggered.connect(lambda: self._move_row_to_env(row))
|
||||||
|
else:
|
||||||
|
# Show it disabled with the reason rather than an empty menu, so the
|
||||||
|
# feature is discoverable and Claude Desktop's gating is explained.
|
||||||
|
act.setEnabled(False)
|
||||||
|
act.setText("Replace with ${VAR} reference — unavailable for Claude Desktop")
|
||||||
|
act.setToolTip(
|
||||||
|
"Claude Desktop doesn't expand ${VAR}, so a reference would reach "
|
||||||
|
"the server as literal text."
|
||||||
|
)
|
||||||
|
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, *_):
|
def _changed(self, item=None, *_):
|
||||||
if item is not None and item.column() == 0:
|
if item is not None and item.column() == 0:
|
||||||
new_key = item.text().strip()
|
new_key = item.text().strip()
|
||||||
@@ -520,6 +797,12 @@ class ServerEditor(QFrame):
|
|||||||
self.logs_btn = QPushButton("View logs")
|
self.logs_btn = QPushButton("View logs")
|
||||||
self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer")
|
self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer")
|
||||||
self.logs_btn.clicked.connect(self._view_logs)
|
self.logs_btn.clicked.connect(self._view_logs)
|
||||||
|
self.vars_btn = QPushButton("Variables…")
|
||||||
|
self.vars_btn.setToolTip(
|
||||||
|
"Show the ${VAR} references this server uses and whether each is set in "
|
||||||
|
"your environment"
|
||||||
|
)
|
||||||
|
self.vars_btn.clicked.connect(self._show_referenced_vars)
|
||||||
self.details_btn = QPushButton("Details ▸")
|
self.details_btn = QPushButton("Details ▸")
|
||||||
self.details_btn.setCheckable(True)
|
self.details_btn.setCheckable(True)
|
||||||
self.details_btn.toggled.connect(self._toggle_diag)
|
self.details_btn.toggled.connect(self._toggle_diag)
|
||||||
@@ -531,6 +814,7 @@ class ServerEditor(QFrame):
|
|||||||
dep.addWidget(self.test_btn)
|
dep.addWidget(self.test_btn)
|
||||||
dep.addWidget(self.spawn_btn)
|
dep.addWidget(self.spawn_btn)
|
||||||
dep.addWidget(self.logs_btn)
|
dep.addWidget(self.logs_btn)
|
||||||
|
dep.addWidget(self.vars_btn)
|
||||||
dep.addWidget(self.details_btn)
|
dep.addWidget(self.details_btn)
|
||||||
dep.addWidget(recheck)
|
dep.addWidget(recheck)
|
||||||
outer.addLayout(dep)
|
outer.addLayout(dep)
|
||||||
@@ -644,6 +928,58 @@ class ServerEditor(QFrame):
|
|||||||
v.addWidget(self.headers, 1)
|
v.addWidget(self.headers, 1)
|
||||||
return w
|
return w
|
||||||
|
|
||||||
|
def set_profile_provider(self, provider):
|
||||||
|
"""Let the env/headers tables and the args editor gate the secret-move
|
||||||
|
actions on which client the loaded profile targets (#83), and wire the
|
||||||
|
args editor's two move actions back to this editor (which owns the whole
|
||||||
|
form, since moving an arg into env touches both fields)."""
|
||||||
|
self.env.profile_provider = provider
|
||||||
|
self.headers.profile_provider = provider
|
||||||
|
self.args.profile_provider = provider
|
||||||
|
self.args.on_move_to_ref = self.move_arg_to_reference
|
||||||
|
self.args.on_move_to_env = self.move_arg_into_env
|
||||||
|
|
||||||
|
# --- secret moves from args (#83) ------------------------------------ #
|
||||||
|
def _reload_from_data(self, new_data: dict):
|
||||||
|
"""Repopulate the form from a transformed data dict and mark dirty."""
|
||||||
|
self.load_entry(core.ServerEntry(self.current_name(), new_data, True))
|
||||||
|
self._emit()
|
||||||
|
|
||||||
|
def move_arg_to_reference(self, index: int):
|
||||||
|
"""Args secret -> ${VAR} reference in place (secret leaves the file)."""
|
||||||
|
data = self.dump_data()
|
||||||
|
args = data.get("args") or []
|
||||||
|
if not (0 <= index < len(args)):
|
||||||
|
return
|
||||||
|
dlg = MoveToEnvDialog(
|
||||||
|
self.window(), core.suggested_env_var_for_arg(args, index), args[index]
|
||||||
|
)
|
||||||
|
if not dlg.exec():
|
||||||
|
return
|
||||||
|
conv = core.move_value_to_env_ref(data, field="args", index=index, var_name=dlg.var_name())
|
||||||
|
if conv is None:
|
||||||
|
return
|
||||||
|
QGuiApplication.clipboard().setText(conv.secret)
|
||||||
|
self._reload_from_data(conv.data)
|
||||||
|
|
||||||
|
def move_arg_into_env(self, index: int):
|
||||||
|
"""Args secret -> env block, kept in this config (visible/editable)."""
|
||||||
|
data = self.dump_data()
|
||||||
|
args = data.get("args") or []
|
||||||
|
if not (0 <= index < len(args)):
|
||||||
|
return
|
||||||
|
default_name = core.suggested_env_var_for_arg(args, index)
|
||||||
|
dlg = MoveArgToEnvDialog(self.window(), default_name, args[index])
|
||||||
|
if not dlg.exec():
|
||||||
|
return
|
||||||
|
new = core.move_arg_to_env_block(data, index, var_name=dlg.var_name())
|
||||||
|
if new is None:
|
||||||
|
return
|
||||||
|
self._reload_from_data(new)
|
||||||
|
|
||||||
|
def _show_referenced_vars(self):
|
||||||
|
ReferencedVarsDialog(self.window(), self.dump_data()).exec()
|
||||||
|
|
||||||
# --- model <-> form -------------------------------------------------- #
|
# --- model <-> form -------------------------------------------------- #
|
||||||
def load_entry(self, entry: core.ServerEntry | None):
|
def load_entry(self, entry: core.ServerEntry | None):
|
||||||
self._loading = True
|
self._loading = True
|
||||||
@@ -955,6 +1291,52 @@ class ArgsEdit(QPlainTextEdit):
|
|||||||
self.blockCountChanged.connect(self._update_gutter_width)
|
self.blockCountChanged.connect(self._update_gutter_width)
|
||||||
self.updateRequest.connect(self._on_update_request)
|
self.updateRequest.connect(self._on_update_request)
|
||||||
self._update_gutter_width()
|
self._update_gutter_width()
|
||||||
|
# Wired by ServerEditor: gate on the loaded client, and the two move
|
||||||
|
# actions (which the editor performs, since moving an arg into env
|
||||||
|
# touches both the args and the env table). Indices are into the
|
||||||
|
# non-blank arg list, matching dump_data()'s args.
|
||||||
|
self.profile_provider = None
|
||||||
|
self.on_move_to_ref = None
|
||||||
|
self.on_move_to_env = None
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event):
|
||||||
|
menu = self.createStandardContextMenu() # keep cut/copy/paste
|
||||||
|
lines = self.toPlainText().splitlines()
|
||||||
|
block = self.cursorForPosition(event.pos()).blockNumber()
|
||||||
|
if 0 <= block < len(lines) and lines[block].strip():
|
||||||
|
# This editor is one arg per line; map the clicked block to its
|
||||||
|
# index among the non-blank args the model actually sees.
|
||||||
|
cleaned = [ln for ln in lines if ln.strip() != ""]
|
||||||
|
idx = sum(1 for ln in lines[:block] if ln.strip() != "")
|
||||||
|
if idx in set(core.secret_arg_indices(cleaned)):
|
||||||
|
profile = self.profile_provider() if self.profile_provider else None
|
||||||
|
expands = profile is None or core.client_expands_env_refs(profile)
|
||||||
|
first = menu.actions()[0] if menu.actions() else None
|
||||||
|
|
||||||
|
# Reference (secret leaves the file) -- needs an expanding client.
|
||||||
|
ref_act = QAction("Replace with a ${VAR} reference (out of file)…", self)
|
||||||
|
if expands and self.on_move_to_ref:
|
||||||
|
ref_act.triggered.connect(lambda: self.on_move_to_ref(idx))
|
||||||
|
else:
|
||||||
|
ref_act.setEnabled(False)
|
||||||
|
ref_act.setText(
|
||||||
|
"Replace with ${VAR} reference — unavailable for Claude Desktop"
|
||||||
|
)
|
||||||
|
ref_act.setToolTip(
|
||||||
|
"Claude Desktop doesn't expand ${VAR}, so a reference would "
|
||||||
|
"reach the server as literal text."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Move into the env block (kept in file) -- works on any client.
|
||||||
|
env_act = QAction("Move into Environment variables (kept in this config)…", self)
|
||||||
|
if self.on_move_to_env:
|
||||||
|
env_act.triggered.connect(lambda: self.on_move_to_env(idx))
|
||||||
|
|
||||||
|
menu.insertAction(first, ref_act)
|
||||||
|
menu.insertAction(first, env_act)
|
||||||
|
if first is not None:
|
||||||
|
menu.insertSeparator(first)
|
||||||
|
menu.exec(event.globalPos())
|
||||||
|
|
||||||
def gutter_width(self) -> int:
|
def gutter_width(self) -> int:
|
||||||
digits = max(1, len(str(self.blockCount())))
|
digits = max(1, len(str(self.blockCount())))
|
||||||
@@ -1696,6 +2078,7 @@ class MainWindow(QMainWindow):
|
|||||||
split.setHandleWidth(10)
|
split.setHandleWidth(10)
|
||||||
split.addWidget(self._build_left())
|
split.addWidget(self._build_left())
|
||||||
self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
|
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.addWidget(self.editor)
|
||||||
split.setStretchFactor(0, 3)
|
split.setStretchFactor(0, 3)
|
||||||
split.setStretchFactor(1, 4)
|
split.setStretchFactor(1, 4)
|
||||||
|
|||||||
+253
@@ -1837,6 +1837,211 @@ 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))
|
||||||
|
|
||||||
|
|
||||||
|
class EnvVarUsage(NamedTuple):
|
||||||
|
"""One ${VAR} referenced by a server definition, and whether it resolves.
|
||||||
|
|
||||||
|
`resolved` is best-effort: a variable is considered resolvable if it's set
|
||||||
|
in the checked environment OR any occurrence carries a `:-default`. Since
|
||||||
|
BCC's environment isn't necessarily the client's, this is advisory (the UI
|
||||||
|
says so).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
fields: tuple[str, ...] # which config fields it appears in (sorted)
|
||||||
|
has_default: bool
|
||||||
|
resolved: bool
|
||||||
|
|
||||||
|
|
||||||
|
def referenced_env_vars(data: dict, environ: dict | None = None) -> list[EnvVarUsage]:
|
||||||
|
"""Every distinct ${VAR} a server definition references, with its status.
|
||||||
|
|
||||||
|
This is the "where did my secret go / is it wired up?" readout for #83:
|
||||||
|
after a value becomes `${VAR}`, the variable lives in the user's
|
||||||
|
environment, not the config, so BCC surfaces the list and whether each one
|
||||||
|
currently resolves. Sorted by name; deduped across fields.
|
||||||
|
"""
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
by_name: dict[str, dict] = {}
|
||||||
|
for ref in server_env_refs(data):
|
||||||
|
slot = by_name.setdefault(ref.name, {"fields": set(), "has_default": False})
|
||||||
|
slot["fields"].add(ref.field)
|
||||||
|
slot["has_default"] = slot["has_default"] or ref.has_default
|
||||||
|
out: list[EnvVarUsage] = []
|
||||||
|
for name in sorted(by_name):
|
||||||
|
slot = by_name[name]
|
||||||
|
has_default = slot["has_default"]
|
||||||
|
out.append(
|
||||||
|
EnvVarUsage(
|
||||||
|
name=name,
|
||||||
|
fields=tuple(sorted(slot["fields"])),
|
||||||
|
has_default=has_default,
|
||||||
|
resolved=has_default or name in env,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def move_arg_to_env_block(data: dict, index: int, var_name=None) -> dict | None:
|
||||||
|
"""Relocate one secret arg into the `env` block, keeping the value in-file.
|
||||||
|
|
||||||
|
The "managed in-file" alternative to a ${VAR} reference: the secret leaves
|
||||||
|
the args (where it's visible in process listings) and becomes an env entry
|
||||||
|
the user can see and edit in BCC's table. Returns a NEW data dict, or None
|
||||||
|
if the target isn't a movable string.
|
||||||
|
|
||||||
|
When the arg follows a `--flag`, the flag is removed too, since a server
|
||||||
|
that reads the secret from an env var no longer needs the switch. This
|
||||||
|
changes how the server is launched -- the GUI warns before doing it.
|
||||||
|
"""
|
||||||
|
new = copy.deepcopy(data)
|
||||||
|
args = new.get("args")
|
||||||
|
if not isinstance(args, list) or not (0 <= index < len(args)):
|
||||||
|
return None
|
||||||
|
value = args[index]
|
||||||
|
if not isinstance(value, str) or is_env_ref(value):
|
||||||
|
return None
|
||||||
|
|
||||||
|
vn = sanitize_env_var_name(
|
||||||
|
var_name if var_name is not None else suggested_env_var_for_arg(args, index)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop the value, and the preceding flag if there is one (--api-key SECRET).
|
||||||
|
remove_from = index
|
||||||
|
if index > 0 and isinstance(args[index - 1], str) and args[index - 1].startswith("-"):
|
||||||
|
remove_from = index - 1
|
||||||
|
del args[remove_from : index + 1]
|
||||||
|
|
||||||
|
env = new.get("env")
|
||||||
|
if not isinstance(env, dict):
|
||||||
|
env = {}
|
||||||
|
new["env"] = env
|
||||||
|
env[vn] = value
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
def is_secret_key(name: str) -> bool:
|
def is_secret_key(name: str) -> bool:
|
||||||
"""Does this env-var / header / flag name look like it holds a secret?"""
|
"""Does this env-var / header / flag name look like it holds a secret?"""
|
||||||
return bool(_SECRET_KEY_RE.search(name or ""))
|
return bool(_SECRET_KEY_RE.search(name or ""))
|
||||||
@@ -1928,6 +2133,54 @@ def args_secret_warning(data: dict) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def secret_arg_indices(args: list) -> list[int]:
|
||||||
|
"""Indices of args that look like a raw credential.
|
||||||
|
|
||||||
|
Same detection as `args_secret_warning`, but per-arg so the "move to
|
||||||
|
environment variable" action (#83) knows exactly which arg to offer on.
|
||||||
|
Flags a token-prefixed positional (ghp_..., sk-...), an embedded-credential
|
||||||
|
URL, or the value following a secret-named flag (`--token abc`). A `${VAR}`
|
||||||
|
reference is never flagged -- it's the fix, not the problem.
|
||||||
|
"""
|
||||||
|
out: list[int] = []
|
||||||
|
mask_next = False
|
||||||
|
for i, a in enumerate(str(x) for x in args):
|
||||||
|
if is_env_ref(a):
|
||||||
|
mask_next = False
|
||||||
|
continue
|
||||||
|
if mask_next:
|
||||||
|
mask_next = False
|
||||||
|
if not a.startswith("-"):
|
||||||
|
out.append(i)
|
||||||
|
continue
|
||||||
|
if a.startswith("-") and "=" in a:
|
||||||
|
continue
|
||||||
|
if a.startswith("-") and is_secret_key(a):
|
||||||
|
mask_next = True
|
||||||
|
continue
|
||||||
|
if a.startswith("-"):
|
||||||
|
continue
|
||||||
|
if _is_secret_value(a) or _EMBEDDED_CRED_RE.search(a):
|
||||||
|
out.append(i)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def suggested_env_var_for_arg(args: list, index: int) -> str:
|
||||||
|
"""A default variable name for moving `args[index]` to a reference.
|
||||||
|
|
||||||
|
Uses the preceding flag when there is one (`--api-key <secret>` ->
|
||||||
|
API_KEY), since that names what the value is; otherwise falls back to a
|
||||||
|
generic SECRET. Always a legal shell name.
|
||||||
|
"""
|
||||||
|
if 0 < index <= len(args):
|
||||||
|
prev = str(args[index - 1]) if index - 1 < len(args) else ""
|
||||||
|
if prev.startswith("-"):
|
||||||
|
base = prev.lstrip("-").split("=", 1)[0]
|
||||||
|
if base:
|
||||||
|
return sanitize_env_var_name(base)
|
||||||
|
return "SECRET"
|
||||||
|
|
||||||
|
|
||||||
def split_suspicious_args(args: list[str]) -> tuple[list[str], list[str]]:
|
def split_suspicious_args(args: list[str]) -> tuple[list[str], list[str]]:
|
||||||
"""
|
"""
|
||||||
Detect the classic argument-entry mistake: several argv tokens typed on one
|
Detect the classic argument-entry mistake: several argv tokens typed on one
|
||||||
|
|||||||
@@ -3206,3 +3206,175 @@ def test_ssh_membermatters_end_to_end():
|
|||||||
]
|
]
|
||||||
assert new["env"] == {"SSH_MCP_PASSWORD": "topsecret"}
|
assert new["env"] == {"SSH_MCP_PASSWORD": "topsecret"}
|
||||||
assert notes
|
assert notes
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Args secret indices + suggested var name (issue #83, args surface)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_secret_arg_indices_flags_token_and_flag_value():
|
||||||
|
args = ["--port", "8080", "ghp_deadbeef", "--token", "sk-abc", "--flag=val"]
|
||||||
|
idxs = c.secret_arg_indices(args)
|
||||||
|
assert 2 in idxs # ghp_ token prefix
|
||||||
|
assert 4 in idxs # value following --token
|
||||||
|
assert 1 not in idxs # 8080
|
||||||
|
assert 5 not in idxs # --flag=val inline pair
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_arg_indices_flags_embedded_url_credentials():
|
||||||
|
args = ["postgres://user:pass@host/db"]
|
||||||
|
assert c.secret_arg_indices(args) == [0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_arg_indices_excludes_existing_references():
|
||||||
|
args = ["--token", "${GH_TOKEN}"]
|
||||||
|
assert c.secret_arg_indices(args) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggested_env_var_for_arg_uses_preceding_flag():
|
||||||
|
args = ["--api-key", "sk-secret"]
|
||||||
|
assert c.suggested_env_var_for_arg(args, 1) == "API_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggested_env_var_for_arg_falls_back_when_no_flag():
|
||||||
|
args = ["ghp_secret"]
|
||||||
|
assert c.suggested_env_var_for_arg(args, 0) == "SECRET"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Referenced-variables readout + args->env relocation (issue #83, "both")
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_referenced_env_vars_dedupes_and_reports_status():
|
||||||
|
data = {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["--token", "${GH_TOKEN}", "${GH_TOKEN}"],
|
||||||
|
"env": {"API_KEY": "${API_KEY}", "REGION": "${REGION:-us-east-1}"},
|
||||||
|
}
|
||||||
|
usages = c.referenced_env_vars(data, environ={"GH_TOKEN": "x"})
|
||||||
|
by = {u.name: u for u in usages}
|
||||||
|
assert set(by) == {"GH_TOKEN", "API_KEY", "REGION"}
|
||||||
|
# GH_TOKEN appears only in args, deduped to one entry, set in env -> resolved
|
||||||
|
assert by["GH_TOKEN"].fields == ("args",)
|
||||||
|
assert by["GH_TOKEN"].resolved is True
|
||||||
|
# API_KEY not set, no default -> unresolved
|
||||||
|
assert by["API_KEY"].resolved is False
|
||||||
|
# REGION has a default -> resolved regardless of environment
|
||||||
|
assert by["REGION"].has_default is True
|
||||||
|
assert by["REGION"].resolved is True
|
||||||
|
# sorted by name
|
||||||
|
assert [u.name for u in usages] == sorted(u.name for u in usages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_referenced_env_vars_empty_when_no_refs():
|
||||||
|
assert c.referenced_env_vars({"command": "npx", "args": ["-y", "pkg"]}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_arg_to_env_block_removes_flag_and_value():
|
||||||
|
data = {"command": "x", "args": ["--api-key", "sk-secret", "run"], "env": {"KEEP": "1"}}
|
||||||
|
out = c.move_arg_to_env_block(data, 1)
|
||||||
|
assert out["args"] == ["run"] # flag + value both gone
|
||||||
|
assert out["env"]["API_KEY"] == "sk-secret"
|
||||||
|
assert out["env"]["KEEP"] == "1" # existing env preserved
|
||||||
|
# input not mutated
|
||||||
|
assert data["args"] == ["--api-key", "sk-secret", "run"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_arg_to_env_block_bare_positional_keeps_no_flag():
|
||||||
|
data = {"command": "x", "args": ["ghp_secret", "serve"]}
|
||||||
|
out = c.move_arg_to_env_block(data, 0, var_name="GITHUB_TOKEN")
|
||||||
|
assert out["args"] == ["serve"]
|
||||||
|
assert out["env"] == {"GITHUB_TOKEN": "ghp_secret"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_arg_to_env_block_none_on_bad_target():
|
||||||
|
assert c.move_arg_to_env_block({"args": ["a"]}, 5) is None
|
||||||
|
assert c.move_arg_to_env_block({"args": ["${REF}"]}, 0) is None # already a ref
|
||||||
|
assert c.move_arg_to_env_block({}, 0) is None
|
||||||
|
|||||||
Reference in New Issue
Block a user