"Move to environment variable" — convert a plaintext secret to ${VAR} (#83) #87

Merged
the_og merged 3 commits from feat/83-move-to-env-var into main 2026-08-12 02:10:56 -04:00
3 changed files with 354 additions and 31 deletions
Showing only changes of commit 694439b6f3 - Show all commits
+221 -31
View File
@@ -291,9 +291,10 @@ class MoveToEnvDialog(QDialog):
v.setSpacing(10) v.setSpacing(10)
intro = QLabel( intro = QLabel(
"The value will be removed from the config and replaced with a " "This replaces the value in place with a ${VAR} reference. The secret "
"reference. Set the variable in your environment first, or the " "moves to your shell/OS environment — not this config file, and not the "
"server won't authenticate." "Environment variables table below. Run the line below to set it there, "
"or the server won't authenticate."
) )
intro.setWordWrap(True) intro.setWordWrap(True)
v.addWidget(intro) v.addWidget(intro)
@@ -356,6 +357,141 @@ class MoveToEnvDialog(QDialog):
self._already.hide() 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__()
@@ -423,14 +559,14 @@ class KeyValueTable(QWidget):
return return
profile = self.profile_provider() if self.profile_provider else None profile = self.profile_provider() if self.profile_provider else None
menu = QMenu(self) menu = QMenu(self)
act = QAction("Move to environment variable…", self) act = QAction("Replace with a ${VAR} reference (out of file)…", self)
if core.can_move_value_to_env_ref(key, value, profile): if core.can_move_value_to_env_ref(key, value, profile):
act.triggered.connect(lambda: self._move_row_to_env(row)) act.triggered.connect(lambda: self._move_row_to_env(row))
else: else:
# Show it disabled with the reason rather than an empty menu, so the # Show it disabled with the reason rather than an empty menu, so the
# feature is discoverable and Claude Desktop's gating is explained. # feature is discoverable and Claude Desktop's gating is explained.
act.setEnabled(False) act.setEnabled(False)
act.setText("Move to environment variable — unavailable for Claude Desktop") act.setText("Replace with ${VAR} reference — unavailable for Claude Desktop")
act.setToolTip( act.setToolTip(
"Claude Desktop doesn't expand ${VAR}, so a reference would reach " "Claude Desktop doesn't expand ${VAR}, so a reference would reach "
"the server as literal text." "the server as literal text."
@@ -661,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)
@@ -672,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)
@@ -773,11 +916,56 @@ class ServerEditor(QFrame):
return w return w
def set_profile_provider(self, provider): def set_profile_provider(self, provider):
"""Let the env/headers tables and the args editor gate "move to """Let the env/headers tables and the args editor gate the secret-move
environment variable" on which client the loaded profile targets (#83).""" 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.env.profile_provider = provider
self.headers.profile_provider = provider self.headers.profile_provider = provider
self.args.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):
@@ -1056,9 +1244,13 @@ 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()
# Set by ServerEditor so "move to environment variable" (#83) can gate on # Wired by ServerEditor: gate on the loaded client, and the two move
# the loaded client, same as the env/headers tables. # 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.profile_provider = None
self.on_move_to_ref = None
self.on_move_to_env = None
def contextMenuEvent(self, event): def contextMenuEvent(self, event):
menu = self.createStandardContextMenu() # keep cut/copy/paste menu = self.createStandardContextMenu() # keep cut/copy/paste
@@ -1071,36 +1263,34 @@ class ArgsEdit(QPlainTextEdit):
idx = sum(1 for ln in lines[:block] if ln.strip() != "") idx = sum(1 for ln in lines[:block] if ln.strip() != "")
if idx in set(core.secret_arg_indices(cleaned)): if idx in set(core.secret_arg_indices(cleaned)):
profile = self.profile_provider() if self.profile_provider else None profile = self.profile_provider() if self.profile_provider else None
act = QAction("Move to environment variable…", self) expands = profile is None or core.client_expands_env_refs(profile)
if profile is None or core.client_expands_env_refs(profile): first = menu.actions()[0] if menu.actions() else None
act.triggered.connect(lambda: self._move_arg_to_env(block, idx, cleaned))
# 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: else:
act.setEnabled(False) ref_act.setEnabled(False)
act.setText("Move to environment variable — unavailable for Claude Desktop") ref_act.setText(
act.setToolTip( "Replace with ${VAR} reference — unavailable for Claude Desktop"
)
ref_act.setToolTip(
"Claude Desktop doesn't expand ${VAR}, so a reference would " "Claude Desktop doesn't expand ${VAR}, so a reference would "
"reach the server as literal text." "reach the server as literal text."
) )
first = menu.actions()[0] if menu.actions() else None
menu.insertAction(first, act) # 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: if first is not None:
menu.insertSeparator(first) menu.insertSeparator(first)
menu.exec(event.globalPos()) menu.exec(event.globalPos())
def _move_arg_to_env(self, block: int, idx: int, cleaned: list):
value = cleaned[idx]
default_name = core.suggested_env_var_for_arg(cleaned, idx)
dlg = MoveToEnvDialog(self.window(), default_name, value)
if not dlg.exec():
return
var_name = dlg.var_name()
QGuiApplication.clipboard().setText(value)
lines = self.toPlainText().splitlines()
if 0 <= block < len(lines):
lines[block] = f"${{{var_name}}}"
# setPlainText fires textChanged -> _emit (dirty + arg recheck).
self.setPlainText("\n".join(lines))
def gutter_width(self) -> int: def gutter_width(self) -> int:
digits = max(1, len(str(self.blockCount()))) digits = max(1, len(str(self.blockCount())))
return 14 + self.fontMetrics().horizontalAdvance("9") * digits return 14 + self.fontMetrics().horizontalAdvance("9") * digits
+82
View File
@@ -1960,6 +1960,88 @@ def is_env_var_set(var_name: str, environ: dict | None = None) -> bool:
return bool(env.get(var_name)) 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 ""))
+51
View File
@@ -3180,3 +3180,54 @@ def test_suggested_env_var_for_arg_uses_preceding_flag():
def test_suggested_env_var_for_arg_falls_back_when_no_flag(): def test_suggested_env_var_for_arg_falls_back_when_no_flag():
args = ["ghp_secret"] args = ["ghp_secret"]
assert c.suggested_env_var_for_arg(args, 0) == "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