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

# Conflicts:
#	tests/test_core.py
This commit is contained in:
the_og
2026-08-12 06:12:19 +00:00
4 changed files with 858 additions and 0 deletions
+383
View File
@@ -272,6 +272,226 @@ 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(
"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):
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
super().__init__()
@@ -292,6 +512,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 +540,58 @@ 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)
# 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, *_):
if item is not None and item.column() == 0:
new_key = item.text().strip()
@@ -520,6 +797,12 @@ class ServerEditor(QFrame):
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.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.setCheckable(True)
self.details_btn.toggled.connect(self._toggle_diag)
@@ -531,6 +814,7 @@ class ServerEditor(QFrame):
dep.addWidget(self.test_btn)
dep.addWidget(self.spawn_btn)
dep.addWidget(self.logs_btn)
dep.addWidget(self.vars_btn)
dep.addWidget(self.details_btn)
dep.addWidget(recheck)
outer.addLayout(dep)
@@ -644,6 +928,58 @@ class ServerEditor(QFrame):
v.addWidget(self.headers, 1)
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 -------------------------------------------------- #
def load_entry(self, entry: core.ServerEntry | None):
self._loading = True
@@ -955,6 +1291,52 @@ class ArgsEdit(QPlainTextEdit):
self.blockCountChanged.connect(self._update_gutter_width)
self.updateRequest.connect(self._on_update_request)
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:
digits = max(1, len(str(self.blockCount())))
@@ -1696,6 +2078,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)