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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user