feat: "Move to environment variable" — convert a plaintext secret to ${VAR} (#83)
Follow-up to #76/#82: BCC warns when a config holds a raw credential and
points at ${VAR}, but gave no way to make the change. This adds the
one-click conversion, right-click a secret row in the env or headers table.
The value is about to leave the file, so the action's real job is handing
the secret back before it does:
- Core (pure, tested): sanitize_env_var_name (key -> legal upper-case shell
name; 'api-key' -> API_KEY, '2fa' -> _2FA, non-ASCII/empty handled),
shell_export_lines (the exact export/setx line, POSIX single-quoted
safely), move_value_to_env_ref (data in -> new data out, replaces one
env/header/args value with ${VAR}, returns the removed secret; never
mutates the input; None if the target is missing, non-string, or already a
reference), can_move_value_to_env_ref (offer only a real stored secret, not
already a ref, AND only on a client that expands references -- offering it
on Claude Desktop would author a config that reaches the server as literal
${VAR}, the exact failure #76 exists to prevent), and is_env_var_set (skip
the ceremony when the variable already looks set).
- GUI: KeyValueTable gains a context menu gated on can_move_value_to_env_ref
(so it never appears on a non-secret row or a Claude Desktop profile).
MoveToEnvDialog lets the user name the variable (defaulting to the
sanitised key), shows the platform-appropriate shell line live, notes when
the variable already looks set, and on accept copies the secret to the
clipboard before the cell is replaced with the reference. Wired through
ServerEditor.set_profile_provider so the tables know which client is loaded.
Scope note: env and headers rows for now. The core already handles args by
index; wiring the args editor (a free-text widget, not a table) is a small
follow-up, deliberately not bundled here.
Tests: +15 core (name sanitisation incl. non-ASCII/leading-digit/empty,
POSIX quote safety, the gate across secret/non-secret/already-ref/
non-expanding-client, env+headers+args rewrite, input-not-mutated,
missing/non-string/already-ref -> None, is_env_var_set). 478 passed, ruff
clean. GUI is untestable in CI (no PySide6); the decision logic all lives in
bcc_core and is tested there.
Closes #83
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
This commit is contained in:
@@ -272,6 +272,90 @@ class _SecretMaskDelegate(QStyledItemDelegate):
|
||||
option.text = core.MASK
|
||||
|
||||
|
||||
class MoveToEnvDialog(QDialog):
|
||||
"""Confirm moving a plaintext secret out to a ${VAR} reference (#83).
|
||||
|
||||
The secret is about to leave the config file, so this dialog's whole job is
|
||||
to hand it back first: it lets the user name the variable, shows the exact
|
||||
shell line to set it, and (on accept) the caller copies the secret to the
|
||||
clipboard. If the variable already looks set in this environment, it says so
|
||||
and drops the urgency.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, key: str, secret: str):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Move to environment variable")
|
||||
self.setMinimumWidth(460)
|
||||
self._secret = secret
|
||||
v = QVBoxLayout(self)
|
||||
v.setSpacing(10)
|
||||
|
||||
intro = QLabel(
|
||||
"The value will be removed from the config and replaced with a "
|
||||
"reference. Set the variable in your environment first, or the "
|
||||
"server won't authenticate."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
v.addWidget(intro)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(8)
|
||||
lbl = QLabel("Variable:")
|
||||
lbl.setObjectName("muted")
|
||||
grid.addWidget(lbl, 0, 0)
|
||||
self._name_edit = QLineEdit(core.sanitize_env_var_name(key))
|
||||
self._name_edit.textChanged.connect(self._refresh)
|
||||
grid.addWidget(self._name_edit, 0, 1)
|
||||
v.addLayout(grid)
|
||||
|
||||
self._already = QLabel("")
|
||||
self._already.setWordWrap(True)
|
||||
self._already.setStyleSheet(f"color: {GOOD};")
|
||||
v.addWidget(self._already)
|
||||
|
||||
set_lbl = QLabel("Set it with:")
|
||||
set_lbl.setObjectName("muted")
|
||||
v.addWidget(set_lbl)
|
||||
self._cmd = QLabel("")
|
||||
self._cmd.setWordWrap(True)
|
||||
self._cmd.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self._cmd.setStyleSheet("font-family: monospace;")
|
||||
v.addWidget(self._cmd)
|
||||
|
||||
note = QLabel("The secret will be copied to your clipboard when you continue.")
|
||||
note.setObjectName("muted")
|
||||
note.setWordWrap(True)
|
||||
v.addWidget(note)
|
||||
|
||||
btns = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
ok = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||
ok.setText("Move && copy secret")
|
||||
ok.setObjectName("primary")
|
||||
btns.accepted.connect(self.accept)
|
||||
btns.rejected.connect(self.reject)
|
||||
v.addWidget(btns)
|
||||
|
||||
self._refresh()
|
||||
|
||||
def var_name(self) -> str:
|
||||
return core.sanitize_env_var_name(self._name_edit.text())
|
||||
|
||||
def _refresh(self, *_):
|
||||
name = self.var_name()
|
||||
lines = core.shell_export_lines(name, self._secret)
|
||||
if sys.platform == "win32":
|
||||
self._cmd.setText(f"{lines['windows']}\n\n(macOS/Linux: {lines['posix']})")
|
||||
else:
|
||||
self._cmd.setText(f"{lines['posix']}\n\n(Windows: {lines['windows']})")
|
||||
if core.is_env_var_set(name):
|
||||
self._already.setText(f"{name} already looks set in this environment.")
|
||||
self._already.show()
|
||||
else:
|
||||
self._already.hide()
|
||||
|
||||
|
||||
class KeyValueTable(QWidget):
|
||||
def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None):
|
||||
super().__init__()
|
||||
@@ -292,6 +376,11 @@ class KeyValueTable(QWidget):
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.setMinimumHeight(90)
|
||||
self.table.itemChanged.connect(self._changed)
|
||||
# Right-click a secret row to move it out to a ${VAR} reference (#83).
|
||||
# Set by the owner (ServerEditor) so the action can gate on the client.
|
||||
self.profile_provider = None
|
||||
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.table.customContextMenuRequested.connect(self._context_menu)
|
||||
# Secret-looking values (API_KEY, TOKEN, ...) render masked by default.
|
||||
self._mask_delegate = _SecretMaskDelegate(self.table)
|
||||
self.table.setItemDelegateForColumn(1, self._mask_delegate)
|
||||
@@ -315,6 +404,47 @@ class KeyValueTable(QWidget):
|
||||
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
||||
self.table.viewport().update()
|
||||
|
||||
def _row_key_value(self, row: int):
|
||||
key_item = self.table.item(row, 0)
|
||||
val_item = self.table.item(row, 1)
|
||||
key = key_item.text().strip() if key_item else ""
|
||||
# The mask is display-only (a delegate); the model text is the real value.
|
||||
value = val_item.text() if val_item else ""
|
||||
return key, value
|
||||
|
||||
def _context_menu(self, pos):
|
||||
item = self.table.itemAt(pos)
|
||||
if item is None:
|
||||
return
|
||||
row = item.row()
|
||||
key, value = self._row_key_value(row)
|
||||
profile = self.profile_provider() if self.profile_provider else None
|
||||
if not core.can_move_value_to_env_ref(key, value, profile):
|
||||
return
|
||||
menu = QMenu(self)
|
||||
act = QAction("Move to environment variable…", self)
|
||||
act.triggered.connect(lambda: self._move_row_to_env(row))
|
||||
menu.addAction(act)
|
||||
menu.exec(self.table.viewport().mapToGlobal(pos))
|
||||
|
||||
def _move_row_to_env(self, row: int):
|
||||
key, secret = self._row_key_value(row)
|
||||
if not secret:
|
||||
return
|
||||
dlg = MoveToEnvDialog(self.window(), key, secret)
|
||||
if not dlg.exec():
|
||||
return
|
||||
var_name = dlg.var_name()
|
||||
# Hand the secret back before it leaves the file: clipboard now holds it,
|
||||
# and the dialog showed the exact shell line to set it.
|
||||
QGuiApplication.clipboard().setText(secret)
|
||||
if self._before_change:
|
||||
self._before_change()
|
||||
val_item = self.table.item(row, 1)
|
||||
if val_item is not None:
|
||||
# setText fires itemChanged -> _changed -> on_change (dirty + revalidate).
|
||||
val_item.setText(f"${{{var_name}}}")
|
||||
|
||||
def _changed(self, item=None, *_):
|
||||
if item is not None and item.column() == 0:
|
||||
new_key = item.text().strip()
|
||||
@@ -631,6 +761,12 @@ class ServerEditor(QFrame):
|
||||
v.addWidget(self.headers, 1)
|
||||
return w
|
||||
|
||||
def set_profile_provider(self, provider):
|
||||
"""Let the env/headers tables gate "move to environment variable" on
|
||||
which client the loaded profile targets (#83)."""
|
||||
self.env.profile_provider = provider
|
||||
self.headers.profile_provider = provider
|
||||
|
||||
# --- model <-> form -------------------------------------------------- #
|
||||
def load_entry(self, entry: core.ServerEntry | None):
|
||||
self._loading = True
|
||||
@@ -1649,6 +1785,7 @@ class MainWindow(QMainWindow):
|
||||
split.setHandleWidth(10)
|
||||
split.addWidget(self._build_left())
|
||||
self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo)
|
||||
self.editor.set_profile_provider(lambda: self.current_profile)
|
||||
split.addWidget(self.editor)
|
||||
split.setStretchFactor(0, 3)
|
||||
split.setStretchFactor(1, 4)
|
||||
|
||||
Reference in New Issue
Block a user