diff --git a/docs/assets/automation-observe-only.png b/docs/assets/automation-observe-only.png new file mode 100644 index 000000000..1428e1bb9 Binary files /dev/null and b/docs/assets/automation-observe-only.png differ diff --git a/docs/automation-bridge.md b/docs/automation-bridge.md index 396dfb6f2..74aac11b5 100644 --- a/docs/automation-bridge.md +++ b/docs/automation-bridge.md @@ -186,6 +186,27 @@ emissions; once confirmed the choice persists. Toggling it drives the same `m_txAllowed` gate live (enabling arms the force-unkey watchdog; disabling force-unkeys immediately). +### Observe-only (read-only) mode + +For a look-but-don't-touch session — handing an assistant visibility +without letting it change anything — check **"Observe only"** in Radio +Setup → Network. The bridge then refuses **every** mutating verb and +answers only pure-introspection reads (`ping`, `verbs`, `whoami`, `get`, +`dumpTree`, `grab`, the read-only `log` actions, `floors`, the inventory-only +`streams` actions, and `hitTest`). In particular, it blocks `log set/reset` +and `streams reset/resync/refresh`; the latter two stream actions clear local +diagnostics or request a fresh radio inventory. It is +enforced in the app, not in the MCP server, so no client can flip it +off; the refusal message points the operator back to the checkbox. The +toggle takes effect immediately on a running bridge — no restart — so +the intended flow works: start the app with the bridge off, check +"Observe only", then start the bridge. `ping` and `whoami` report the +current state as `readOnly`, and the MCP server surfaces it in +`bridge_status` as `bridge_read_only`. Headless/CI runs can pin it with +`AETHER_AUTOMATION_READONLY=1`. + +![Observe only setting in Radio Setup → Network](assets/automation-observe-only.png) + --- ## How it works (the contract) @@ -235,7 +256,7 @@ transmit-gated verbs (refused unless `AETHER_AUTOMATION_ALLOW_TX=1` — see | | [`grab pan [path]`](#grab) | Raw spectrum surface of a specific pan. | | | [`grab pan-visible [path]`](#grab) | Pan applet incl. VFO/flag overlays (alias `pan-composite`). | | | [`floors`](#floors) | Per-pan measured noise + display floor (dBm). | -| | [`whoami`](#whoami) | This bridge instance: pid, socket, label, station, `txAllowed`. | +| | [`whoami`](#whoami) | This bridge instance: pid, socket, label, station, `txAllowed`, `readOnly`. | | **Drive** | [`invoke [v]`](#invoke) | Click/toggle/set/selectRow/submit/trigger a control (TX-guarded). | | | [`close `](#close) | Close the target's top-level window. | | | [`drag " "`](#drag-alias-mouse) | Synthesize press→move→release (alias `mouse`). | @@ -1588,8 +1609,11 @@ The `~500ms` is a **best-effort hint, not a contract** — the re-dump is async; `streams radio` still looks stale, poll again. Returns `not connected — cannot resync display inventory` with no radio. -All `streams` actions are read-only / RX; none keys the transmitter (`resync` -sends only the `sub pan all` subscription command). +None of the `streams` actions keys the transmitter. In **Observe only** mode, +the default Layer-A inventory and `radio`/`inventory` reads remain available; +`reset`, `resync`, and `refresh` are blocked. `reset` changes the local orphan +tally, while `resync`/`refresh` send the `sub pan all` subscription command to +the radio. ### `txwaterfall` Toggle the radio's **show-TX-in-waterfall** display flag (`transmit set @@ -1873,6 +1897,10 @@ push subscription — the observability suite. All diagnostic; nothing keys. oldest`, earlier matching events were evicted and the window is a truncated suffix, not a complete bracket. +In **Observe only** mode, `categories`, `get`, `tail`, `subscribe`, and +`unsubscribe` remain available. `set` and `reset` are blocked because they +change the app's logging state. + ### `record` Drive the client-side **QSO WAV recorder** (the same one behind the manual record button), so a live test can capture audio and verify SSB + CW/CWX is recorded. @@ -2209,7 +2237,7 @@ The complete registry, generated from the `add(...)` table in `AutomationServer. | `scale` | — | scale [pct] — report/persist the UI scale factor | | `panmessage` | — | panmessage [id timeout [tone=…] title\|detail] | | `dss` | — | dss [pan] [args] | -| `streams` | — | streams [radio\|reset] — stream diagnostics | +| `streams` | — | streams [radio\|inventory\|resync\|refresh\|reset] — stream diagnostics | | `tci` | — | tci start\|status\|stop — in-process TCI client simulator (JSON form only) | | `audioCapture` | — | audioCapture [args] | | `txwaterfall` | — | txwaterfall — show keyed TX in the waterfall | diff --git a/src/core/AutomationBridgeSettings.cpp b/src/core/AutomationBridgeSettings.cpp index 7f3ca4a04..f6985d95a 100644 --- a/src/core/AutomationBridgeSettings.cpp +++ b/src/core/AutomationBridgeSettings.cpp @@ -15,7 +15,7 @@ namespace AetherSDR { namespace { // Single nested-JSON key holding the bridge's non-secret config (Principle V). -// Shape: {"enabled":bool,"txAllowed":bool,"txAck":bool}. +// Shape: {"enabled":bool,"txAllowed":bool,"txAck":bool,"readOnly":bool}. const QString kRootKey = QStringLiteral("AutomationBridge"); // Legacy flat keys (pre-nesting). Migrated one-shot into kRootKey on first read. @@ -26,6 +26,7 @@ const QString kLegacyTxAck = QStringLiteral("AutomationBridgeTxAck"); constexpr const char* kFieldEnabled = "enabled"; constexpr const char* kFieldTxAllowed = "txAllowed"; constexpr const char* kFieldTxAck = "txAck"; +constexpr const char* kFieldReadOnly = "readOnly"; // Keychain coordinates for the token (analogous to MqttSettings). constexpr const char* kKeychainService = "AetherSDR"; @@ -91,6 +92,12 @@ bool AutomationBridgeSettings::txAck() } void AutomationBridgeSettings::setTxAck(bool on) { writeBool(kFieldTxAck, on); } +bool AutomationBridgeSettings::readOnly() +{ + return readObj().value(QLatin1String(kFieldReadOnly)).toBool(false); +} +void AutomationBridgeSettings::setReadOnly(bool on) { writeBool(kFieldReadOnly, on); } + QString AutomationBridgeSettings::keychainService() { return QString::fromLatin1(kKeychainService); diff --git a/src/core/AutomationBridgeSettings.h b/src/core/AutomationBridgeSettings.h index 296ab7d96..6303eb1e6 100644 --- a/src/core/AutomationBridgeSettings.h +++ b/src/core/AutomationBridgeSettings.h @@ -19,6 +19,7 @@ namespace AetherSDR { // enabled — the bridge runs at launch (Radio Setup → Network toggle) // txAllowed — an MCP client may key the transmitter (the TX guard) // txAck — the operator has acknowledged the TX warning at least once +// readOnly — observe-only: the bridge refuses every mutating verb (#4188) // // All accessors go through AppSettings and are process-wide. class AutomationBridgeSettings { @@ -29,6 +30,8 @@ class AutomationBridgeSettings { static void setTxAllowed(bool on); static bool txAck(); static void setTxAck(bool on); + static bool readOnly(); + static void setReadOnly(bool on); // Keychain coordinates for the bridge access token (see MqttSettings for // the analogous MQTT-password helpers). diff --git a/src/core/AutomationServer.cpp b/src/core/AutomationServer.cpp index 17b2701eb..20b0b89b1 100644 --- a/src/core/AutomationServer.cpp +++ b/src/core/AutomationServer.cpp @@ -2197,6 +2197,40 @@ struct AutomationServer::VerbSpec { namespace { +// Requests that are pure introspection — allowed even in observe-only mode +// (#4188 area 6). Some diagnostic verbs mix read and write actions, so the +// action must be checked as well as the canonical verb name. Everything else +// (drive/connect/capture/keying) is refused when m_readOnly is set. +bool isReadOnlyRequest(const QString& name, const QString& action) +{ + static const QSet kSafe = { + QStringLiteral("ping"), QStringLiteral("verbs"), + QStringLiteral("whoami"), QStringLiteral("dumpTree"), + QStringLiteral("grab"), QStringLiteral("get"), + QStringLiteral("floors"), QStringLiteral("hitTest"), + }; + if (kSafe.contains(name)) { + return true; + } + + const QString normalizedAction = action.trimmed().toLower(); + if (name == QLatin1String("log")) { + static const QSet kSafeLogActions = { + QString(), QStringLiteral("categories"), QStringLiteral("get"), + QStringLiteral("tail"), QStringLiteral("subscribe"), + QStringLiteral("unsubscribe"), + }; + return kSafeLogActions.contains(normalizedAction); + } + if (name == QLatin1String("streams")) { + static const QSet kSafeStreamActions = { + QString(), QStringLiteral("radio"), QStringLiteral("inventory"), + }; + return kSafeStreamActions.contains(normalizedAction); + } + return false; +} + QString vtok(const QList& p, int i) { return QString::fromUtf8(p.value(i)); @@ -2297,6 +2331,7 @@ const std::vector& AutomationServer::verbRegistry() {QStringLiteral("app"), QStringLiteral("AetherSDR")}, {QStringLiteral("version"), QCoreApplication::applicationVersion()}, {QStringLiteral("authRequired"), !self.m_authToken.isEmpty()}, + {QStringLiteral("readOnly"), self.m_readOnly}, }; }); @@ -2652,7 +2687,7 @@ const std::vector& AutomationServer::verbRegistry() a.value); }); - add("streams", {}, "streams [radio|reset] — stream diagnostics", + add("streams", {}, "streams [radio|inventory|resync|refresh|reset] — stream diagnostics", parseActionOnly, [](AutomationServer& s, A& a, QLocalSocket*) { return s.doStreams(a.action); @@ -2909,6 +2944,20 @@ QJsonObject AutomationServer::handleLine(const QByteArray& line, QLocalSocket* s } } + // Observe-only gate (#4188 area 6). When the operator has enabled + // read-only mode (Radio Setup -> Network -> "Observe only"), refuse any + // verb that isn't pure introspection — no driving, no connect/capture, no + // keying. Enforced HERE in the bridge (not the MCP client) so it can't be + // bypassed by talking to the socket directly. Uses the resolved canonical + // name so aliases are covered. + if (m_readOnly && !isReadOnlyRequest(spec->name, a.action)) { + qCWarning(lcAutomation) << "read-only mode: refused" << spec->name; + return err(QStringLiteral("read-only mode: '") + spec->name + + QStringLiteral("' is blocked. This bridge is observe-only " + "— uncheck \"Observe only\" in Radio Setup " + "-> Network to allow driving.")); + } + return spec->dispatch(*this, a, sock); } @@ -7484,6 +7533,7 @@ QJsonObject AutomationServer::doWhoami() const {QStringLiteral("guiClientIdTransient"), AppSettings::instance().guiClientIdentityIsTransient()}, {QStringLiteral("txAllowed"), m_txAllowed}, + {QStringLiteral("readOnly"), m_readOnly}, {QStringLiteral("version"), QCoreApplication::applicationVersion()}, }; } diff --git a/src/core/AutomationServer.h b/src/core/AutomationServer.h index fb95ec7f3..95ee1fb60 100644 --- a/src/core/AutomationServer.h +++ b/src/core/AutomationServer.h @@ -299,6 +299,14 @@ class AutomationServer : public QObject { void setTxAllowed(bool allowed); bool txAllowed() const { return m_txAllowed; } + // Observe-only gate (#4188 area 6). When true, the bridge refuses every + // verb that isn't pure introspection — no driving, connect, capture, or + // keying. Operator-driven from Radio Setup → Network; enforced in + // handleLine so a client can't bypass it. Safe to toggle live. `ping` and + // `whoami` report the current state. + void setReadOnly(bool readOnly) { m_readOnly = readOnly; } + bool readOnly() const { return m_readOnly; } + private slots: void onNewConnection(); void onReadyRead(); @@ -603,6 +611,7 @@ private slots: int m_txMaxKeyMs{20000}; // max continuous key time before force-unkey int m_txMaxPower{-1}; // power-ceiling clamp for invoke (-1 = off) bool m_txAllowed{false}; // AETHER_AUTOMATION_ALLOW_TX at start() + bool m_readOnly{false}; // observe-only gate (#4188 area 6) QString m_authToken; // shared-secret gate; empty = open (#3646) // Log/event channel (#3646 observability suite). The tap fills m_logRing // from arbitrary logging threads; the main thread reads it for tail/drain. diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index ddb0e7025..77011fd28 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2665,6 +2665,8 @@ void MainWindow::wireRadioSetupDialogSignals(RadioSetupDialog* dlg, const QStrin [this](const QString& tok) { setAutomationBridgeToken(tok); }); connect(dlg, &RadioSetupDialog::automationBridgeTxAllowedChanged, this, [this](bool allowed) { setAutomationTxAllowed(allowed); }); + connect(dlg, &RadioSetupDialog::automationBridgeReadOnlyChanged, this, + [this](bool readOnly) { setAutomationReadOnly(readOnly); }); // serialSettingsChanged is the "external-device settings changed" signal in // practice — the dialog emits it for serial-port, FlexControl, Ulanzi-dial, // and HID-encoder edits. The Ulanzi/HID branches below run regardless of diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index ab9726bb9..a6561b950 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -230,6 +230,9 @@ class MainWindow : public QMainWindow { // Persist the TX-via-MCP opt-in and push it live (Radio Setup → Network). // Enabling arms the force-unkey watchdog; disabling force-unkeys the radio. void setAutomationTxAllowed(bool allowed); + // Persist the observe-only opt-in and push it live (Radio Setup → Network). + // When set, the bridge refuses every mutating verb (#4188 area 6). + void setAutomationReadOnly(bool readOnly); protected: void showEvent(QShowEvent* event) override; diff --git a/src/gui/MainWindow_Session.cpp b/src/gui/MainWindow_Session.cpp index 7bf3333c1..0e5591a70 100644 --- a/src/gui/MainWindow_Session.cpp +++ b/src/gui/MainWindow_Session.cpp @@ -1663,6 +1663,12 @@ bool MainWindow::startAutomationBridge(const QString& sockName) guard->setTxAllowed( qEnvironmentVariableIsSet("AETHER_AUTOMATION_ALLOW_TX") || AutomationBridgeSettings::txAllowed()); + // Observe-only gate (#4188) — apply the persisted operator opt-in after + // start() so the bridge comes up read-only if the box is checked. An env + // override lets headless/CI pin the bridge observe-only regardless. + guard->setReadOnly( + qEnvironmentVariableIsSet("AETHER_AUTOMATION_READONLY") + || AutomationBridgeSettings::readOnly()); }); return true; // start initiated; the socket begins listening once the token resolves } @@ -1696,4 +1702,14 @@ void MainWindow::setAutomationTxAllowed(bool allowed) m_automation->setTxAllowed(allowed); } +void MainWindow::setAutomationReadOnly(bool readOnly) +{ + // Persist the operator opt-in (nested config) so it survives restart. + AutomationBridgeSettings::setReadOnly(readOnly); + // Push live so toggling observe-only takes effect on a running bridge + // immediately — no restart needed to arm or lift the gate. + if (m_automation) + m_automation->setReadOnly(readOnly); +} + } // namespace AetherSDR diff --git a/src/gui/RadioSetupDialog.cpp b/src/gui/RadioSetupDialog.cpp index 86ef08855..6fcf15f64 100644 --- a/src/gui/RadioSetupDialog.cpp +++ b/src/gui/RadioSetupDialog.cpp @@ -1570,7 +1570,42 @@ QWidget* RadioSetupDialog::buildNetworkTab() grid->addWidget(txCheck, 3, 1); } - grid->addWidget(new QLabel("Network MTU:"), 4, 0); + // Observe only — the read-only gate (#4188 area 6). When checked, the + // bridge refuses every mutating verb (set/invoke/connect/tune/capture…) + // and answers only pure-introspection reads. Enforced in the bridge, so + // no MCP client can flip it off. Lets the operator start the app, arm + // observe-only, then start the MCP server for a look-but-don't-touch + // session. An env override (AETHER_AUTOMATION_READONLY) pins it for + // headless/CI runs. + { + grid->addWidget(new QLabel("Observe only:"), 4, 0); + auto* roCheck = new QCheckBox("Read-only (block all driving)"); + roCheck->setObjectName(QStringLiteral("automationReadOnlyCheck")); + const bool envForcesRo = qEnvironmentVariableIsSet("AETHER_AUTOMATION_READONLY"); + roCheck->setChecked(AutomationBridgeSettings::readOnly() || envForcesRo); + roCheck->setToolTip( + "Make the bridge observe-only: MCP clients can read state\n" + "(ping/whoami/get/dumpTree/grab, read-only log and streams\n" + "actions, floors, and hitTest)\n" + "but every mutating verb is refused. Enforced in the app, so a\n" + "client cannot bypass it. Toggle takes effect immediately on the\n" + "running bridge. See docs/automation-bridge.md."); + AetherSDR::ThemeManager::instance().applyStyleSheet(roCheck, + "QCheckBox { color: {{color.text.primary}}; font-size: 11px; }" + "QCheckBox::indicator { width: 14px; height: 14px; }"); + if (envForcesRo) { + roCheck->setEnabled(false); + roCheck->setToolTip(roCheck->toolTip() + + "\n\nForced on by the AETHER_AUTOMATION_READONLY launch variable."); + } + connect(roCheck, &QCheckBox::toggled, this, [this](bool on) { + AutomationBridgeSettings::setReadOnly(on); + emit automationBridgeReadOnlyChanged(on); + }); + grid->addWidget(roCheck, 4, 1); + } + + grid->addWidget(new QLabel("Network MTU:"), 5, 0); auto* mtuSpin = new QSpinBox; mtuSpin->setRange(576, 9000); mtuSpin->setValue(AppSettings::instance().value("NetworkMtu", "1450").toInt()); @@ -1582,7 +1617,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() AppSettings::instance().setValue("NetworkMtu", QString::number(val)); AppSettings::instance().save(); }); - grid->addWidget(mtuSpin, 4, 1); + grid->addWidget(mtuSpin, 5, 1); // VITA-49 UDP receive buffer (SO_RCVBUF). Snap-to-preset slider; the // kernel clamps the grant at net.core.rmem_max, so we show the granted @@ -1599,7 +1634,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() return QStringLiteral("%1 KB").arg(b / 1024); }; - grid->addWidget(new QLabel("VITA-49 RX buffer:"), 5, 0); + grid->addWidget(new QLabel("VITA-49 RX buffer:"), 6, 0); auto* bufRow = new QWidget; auto* bufLay = new QHBoxLayout(bufRow); bufLay->setContentsMargins(0, 0, 0, 0); @@ -1627,7 +1662,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() bufValLabel->setMinimumWidth(48); bufLay->addWidget(bufSlider, 1); bufLay->addWidget(bufValLabel); - grid->addWidget(bufRow, 5, 1); + grid->addWidget(bufRow, 6, 1); auto* bufGrantedLabel = new QLabel; if (m_model && m_model->panStream()) { @@ -1635,7 +1670,7 @@ QWidget* RadioSetupDialog::buildNetworkTab() bufGrantedLabel->setText(g > 0 ? QString("granted: %1").arg(fmtBytes(g)) : QStringLiteral("granted: — (applies on connect)")); } - grid->addWidget(bufGrantedLabel, 6, 1); + grid->addWidget(bufGrantedLabel, 7, 1); connect(bufSlider, &QSlider::valueChanged, this, [this, bufValLabel, fmtBytes](int idx) { diff --git a/src/gui/RadioSetupDialog.h b/src/gui/RadioSetupDialog.h index 2755fb014..5a2539718 100644 --- a/src/gui/RadioSetupDialog.h +++ b/src/gui/RadioSetupDialog.h @@ -69,6 +69,10 @@ class RadioSetupDialog : public PersistentDialog { // one-time confirmation dialog). MainWindow persists it and pushes it to // the running bridge — enabling arms the force-unkey watchdog. void automationBridgeTxAllowedChanged(bool allowed); + // Fired when the user toggles "Observe only" in the Network tab. MainWindow + // persists it and pushes it to the running bridge, which then refuses every + // mutating verb (#4188 area 6) — MCP clients can read but not drive. + void automationBridgeReadOnlyChanged(bool readOnly); protected: void closeEvent(QCloseEvent* event) override; diff --git a/tools/aether_mcp.py b/tools/aether_mcp.py index 2b5da1539..9a9c92285 100644 --- a/tools/aether_mcp.py +++ b/tools/aether_mcp.py @@ -546,6 +546,10 @@ def handle_tool(name, args): try: pong = bridge_request({"cmd": "ping"}, timeout=10) status["bridge_auth_required"] = pong.get("authRequired") + # Observe-only gate (#4188 area 6). The bridge is authoritative; + # this just reflects it so a client knows up front that mutating + # verbs will be refused. Flip it in Radio Setup → Network. + status["bridge_read_only"] = pong.get("readOnly", False) except Exception as e: # noqa: BLE001 status["ping_error"] = str(e) # whoami is auth-gated — its success confirms our token is accepted. @@ -560,6 +564,12 @@ def handle_tool(name, args): "Copy the token from Radio Setup → Network → " "Access Token and set it in this MCP server's " "env config.") + if status.get("bridge_read_only"): + status["read_only_note"] = ( + "This bridge is observe-only. Read verbs work; every " + "mutating verb (set/invoke/connect/tune/capture…) is " + "refused by the app. Uncheck \"Observe only\" in Radio " + "Setup → Network to allow driving.") if not entries and not os.environ.get("AETHER_MCP_SOCKET"): status["hint"] = ("No bridge running. Launch AetherSDR with " "AETHER_AUTOMATION=1 or enable it in Radio Setup " diff --git a/tools/test_aether_mcp.py b/tools/test_aether_mcp.py index 6fe1ca991..0ffc39694 100644 --- a/tools/test_aether_mcp.py +++ b/tools/test_aether_mcp.py @@ -330,6 +330,40 @@ def test_prompts_and_resources(): check("unknown prompt → error", "error" in bad, str(bad)) +def test_read_only_reflected(): + # bridge_status must reflect the bridge's observe-only state (#4188 area 6) + # from the token-free ping — the bridge is authoritative; the server only + # mirrors it. readOnly:true → bridge_read_only + a read_only_note hint. + orig = aether_mcp.bridge_request + os.environ["AETHER_MCP_SOCKET"] = "/tmp/does-not-need-to-exist" + + def ro(obj, timeout=None): + if obj.get("cmd") == "ping": + return {"ok": True, "authRequired": False, "readOnly": True} + if obj.get("cmd") == "whoami": + return {"ok": True, "pid": 1, "readOnly": True} + return {"ok": True} + + aether_mcp.bridge_request = ro + try: + r = json.loads(aether_mcp.handle_tool("bridge_status", {})["content"][0]["text"]) + check("bridge_status reflects readOnly", r.get("bridge_read_only") is True, str(r)) + check("bridge_status adds read_only_note", "read_only_note" in r, str(r)) + + # readOnly absent/false → no note, mirroring an unlocked bridge. + def rw(obj, timeout=None): + if obj.get("cmd") == "ping": + return {"ok": True, "authRequired": False, "readOnly": False} + return {"ok": True} + aether_mcp.bridge_request = rw + r = json.loads(aether_mcp.handle_tool("bridge_status", {})["content"][0]["text"]) + check("bridge_status read-write has no note", + r.get("bridge_read_only") is False and "read_only_note" not in r, str(r)) + finally: + aether_mcp.bridge_request = orig + os.environ.pop("AETHER_MCP_SOCKET", None) + + if __name__ == "__main__": test_field_mapping() test_token_attached() @@ -337,6 +371,7 @@ def test_prompts_and_resources(): test_robustness_tools() test_fuzzy_suggest() test_prompts_and_resources() + test_read_only_reflected() if _failures: print(f"\n{len(_failures)} FAILED: {_failures}") sys.exit(1)