Add Launcher host gateway protocol support
Advertise and route a single Launcher gateway, expose acknowledged controls, and add the Launcher-only host status UI with regression coverage.
Alessandro committed
Jul 14, 2026 at 17:19 UTC
dafe5a33ba66e699f850527ee70e6a67c8fad43b
10 files changed
+925
-18
plugins/_a0_connector/AGENTS.md
+21
@@ -21,6 +21,25 @@
21
execution metadata enables `code_execution_remote`, and supported enabled
22
Computer Use that does not need re-arming enables `computer_use_remote`.
23
- Do not bypass WebSocket authentication or leak connector session data.
24
+- Advertise Launcher gateways additively through HTTP capability
25
+ `launcher_gateway` and WebSocket feature `launcher_gateway_control`. Older
26
+ ordinary CLI clients retain their existing protocol fields and behavior; do
27
+ not provide a partial tools-only fallback when either feature is absent.
28
+- A Launcher `connector_hello` carries a versioned gateway object with kind,
29
+ stable ID, host label, and bounded status. Store it per authenticated socket,
30
+ remove it on disconnect, and let context-bound CLI sockets retain routing
31
+ priority. One unique Launcher gateway may be the global fallback. A duplicate
32
+ socket with the same ID replaces stale state; distinct simultaneous IDs fail
33
+ closed as Multiple hosts.
34
+- `connector_gateway_control` and `connector_gateway_control_result` cover
35
+ master state, complete scope replacement, and emergency disconnect. Protected
36
+ WebUI mutations require CSRF, await the matching acknowledgement, and return
37
+ refreshed status. Never let the WebUI select a host folder or personal
38
+ browser profile.
39
+- The `chat-top-end` Launcher gateway extension renders only when the user agent
40
+ includes `A0-Launcher/`. It may show status, master/scope controls,
41
+ preparation errors, and Emergency disconnect; standard browser sessions must
42
+ not expose it.
43
- File operation results may arrive as chunked JSON/base64
44
`connector_file_op_result` frames; resolve the pending file operation only
45
after all chunks for the `op_id` are assembled.
@@ -33,6 +52,8 @@
52
## Verification
53
54
- Run connector-specific tests or smoke-test HTTP and `/ws` integration when changing runtime behavior.
55
+- Launcher gateway regression coverage lives in
56
+ `tests/test_a0_connector_launcher_gateway.py`.
57
58
## Child DOX Index
59
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -28,6 +28,7 @@ _BASE_FEATURES = [
28
"connector_browser_op",
29
"remote_file_tree",
30
"token_status",
31
+ "launcher_gateway",
32
]
33
34
_OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
plugins/_a0_connector/api/v1/launcher_gateway_control.py
new
+96
@@ -0,0 +1,96 @@
1
+"""POST /api/plugins/_a0_connector/v1/launcher_gateway_control."""
2
+from __future__ import annotations
3
+
4
+import asyncio
5
+import uuid
6
+
7
+from helpers.api import Request, Response
8
+from helpers.ws_manager import ConnectionNotFoundError, get_shared_ws_manager
9
+
10
+import plugins._a0_connector.api.v1.base as connector_base
11
+from plugins._a0_connector.helpers.ws_runtime import (
12
+ active_launcher_gateway_sid,
13
+ clear_pending_gateway_control,
14
+ launcher_gateway_status,
15
+ store_pending_gateway_control,
16
+)
17
+
18
+
19
+_CONTROL_EVENT = "connector_gateway_control"
20
+_CONTROL_TIMEOUT_SECONDS = 8.0
21
+_SCOPE_KEYS = ("files", "code_execution", "browser", "computer_use")
22
+
23
+
24
+class LauncherGatewayControl(connector_base.ProtectedConnectorApiHandler):
25
+ """Apply an acknowledged control change to the active Launcher gateway."""
26
+
27
+ @classmethod
28
+ def requires_csrf(cls) -> bool:
29
+ return True
30
+
31
+ async def process(self, input: dict, request: Request) -> dict | Response:
32
+ action = str(input.get("action", "") or "").strip().lower()
33
+ payload: dict = {"action": action}
34
+ if action == "set_master":
35
+ if not isinstance(input.get("enabled"), bool):
36
+ return Response("enabled must be a boolean", status=400)
37
+ payload["enabled"] = input["enabled"]
38
+ elif action == "replace_scopes":
39
+ scopes = input.get("scopes")
40
+ if not isinstance(scopes, dict) or any(
41
+ not isinstance(scopes.get(key), bool) for key in _SCOPE_KEYS
42
+ ):
43
+ return Response(
44
+ "scopes must contain boolean files, code_execution, browser, and computer_use values",
45
+ status=400,
46
+ )
47
+ normalized = {key: scopes[key] for key in _SCOPE_KEYS}
48
+ if not normalized["files"]:
49
+ normalized["code_execution"] = False
50
+ payload["scopes"] = normalized
51
+ elif action != "emergency_disconnect":
52
+ return Response("Unknown gateway control action", status=400)
53
+
54
+ status = launcher_gateway_status()
55
+ if status.get("multiple_hosts"):
56
+ return Response("Multiple Launcher hosts are connected", status=409)
57
+ sid = active_launcher_gateway_sid()
58
+ if not sid:
59
+ return Response("No Launcher host gateway is connected", status=409)
60
+
61
+ request_id = str(uuid.uuid4())
62
+ payload["request_id"] = request_id
63
+ loop = asyncio.get_running_loop()
64
+ future: asyncio.Future[dict] = loop.create_future()
65
+ store_pending_gateway_control(
66
+ request_id,
67
+ sid=sid,
68
+ future=future,
69
+ loop=loop,
70
+ )
71
+ try:
72
+ await get_shared_ws_manager().emit_to(
73
+ "/ws",
74
+ sid,
75
+ _CONTROL_EVENT,
76
+ payload,
77
+ handler_id=f"{self.__class__.__module__}.{self.__class__.__name__}",
78
+ )
79
+ result = await asyncio.wait_for(future, timeout=_CONTROL_TIMEOUT_SECONDS)
80
+ except ConnectionNotFoundError:
81
+ return Response("Launcher host gateway disconnected", status=409)
82
+ except asyncio.TimeoutError:
83
+ return Response("Launcher host gateway did not acknowledge the change", status=504)
84
+ finally:
85
+ clear_pending_gateway_control(request_id)
86
+
87
+ if not result.get("ok", False):
88
+ return Response(
89
+ str(result.get("error") or "Launcher host gateway rejected the change"),
90
+ status=409,
91
+ )
92
+ return {
93
+ "ok": True,
94
+ "result": result,
95
+ "status": launcher_gateway_status(),
96
+ }
plugins/_a0_connector/api/v1/launcher_gateway_status.py
new
+14
@@ -0,0 +1,14 @@
1
+"""POST /api/plugins/_a0_connector/v1/launcher_gateway_status."""
2
+from __future__ import annotations
3
+
4
+from helpers.api import Request, Response
5
+
6
+import plugins._a0_connector.api.v1.base as connector_base
7
+from plugins._a0_connector.helpers.ws_runtime import launcher_gateway_status
8
+
9
+
10
+class LauncherGatewayStatus(connector_base.ProtectedConnectorApiHandler):
11
+ """Return the current Launcher-owned host gateway state."""
12
+
13
+ async def process(self, input: dict, request: Request) -> dict | Response:
14
+ return launcher_gateway_status()
plugins/_a0_connector/api/ws_connector.py
+38
@@ -13,6 +13,7 @@ from plugins._a0_connector.helpers.event_bridge import get_context_log_entries
13
from plugins._a0_connector.helpers.version import agent_zero_version
14
from plugins._a0_connector.helpers.ws_runtime import (
15
clear_remote_tree_snapshot,
16
+ clear_sid_launcher_gateway_metadata,
17
clear_sid_host_browser_metadata,
18
clear_sid_computer_use_metadata,
19
clear_sid_remote_exec_metadata,
@@ -22,15 +23,18 @@ from plugins._a0_connector.helpers.ws_runtime import (
23
fail_pending_computer_use_ops_for_sid,
24
fail_pending_exec_ops_for_sid,
25
fail_pending_file_ops_for_sid,
26
+ fail_pending_gateway_controls_for_sid,
27
host_browser_metadata_for_sid,
28
register_sid,
29
remote_exec_metadata_for_sid,
30
remote_file_metadata_for_sid,
31
+ resolve_pending_gateway_control,
32
resolve_pending_browser_op,
33
resolve_pending_computer_use_op,
34
resolve_pending_exec_op,
35
resolve_pending_file_op,
36
store_remote_tree_snapshot,
37
+ store_sid_launcher_gateway_metadata,
38
store_sid_host_browser_metadata,
39
store_sid_computer_use_metadata,
40
store_sid_remote_exec_metadata,
@@ -60,6 +64,7 @@ WS_FEATURES = [
64
"computer_use_remote",
65
"browser_host_remote",
66
"connector_browser_op",
67
+ "launcher_gateway_control",
68
]
69
70
_SNAPSHOT_REPLAY_PAGE_SIZE = 50
@@ -106,10 +111,15 @@ class WsConnector(WsHandler):
111
sid,
112
error="CLI disconnected before completing the requested browser operation",
113
)
114
+ fail_pending_gateway_controls_for_sid(
115
+ sid,
116
+ error="Launcher gateway disconnected before acknowledging the control request",
117
+ )
118
clear_sid_computer_use_metadata(sid)
119
clear_sid_host_browser_metadata(sid)
120
clear_sid_remote_file_metadata(sid)
121
clear_sid_remote_exec_metadata(sid)
122
+ clear_sid_launcher_gateway_metadata(sid)
123
PrintStyle.debug(f"[a0-connector] /ws disconnected: {sid}")
124
125
async def process(
@@ -162,6 +172,9 @@ class WsConnector(WsHandler):
172
if event == "connector_browser_op_result":
173
return self._handle_browser_op_result(data, sid)
174
175
+ if event == "connector_gateway_control_result":
176
+ return self._handle_gateway_control_result(data, sid)
177
+
178
if event.startswith("connector_"):
179
return WsResult.error(
180
code="UNKNOWN_EVENT",
@@ -176,6 +189,7 @@ class WsConnector(WsHandler):
189
host_browser = data.get("host_browser")
190
remote_files = data.get("remote_files")
191
remote_exec = data.get("remote_exec")
192
+ gateway = data.get("gateway")
193
if isinstance(computer_use, dict):
194
store_sid_computer_use_metadata(sid, computer_use)
195
else:
@@ -192,6 +206,10 @@ class WsConnector(WsHandler):
206
store_sid_remote_exec_metadata(sid, remote_exec)
207
else:
208
clear_sid_remote_exec_metadata(sid)
209
+ if isinstance(gateway, dict):
210
+ store_sid_launcher_gateway_metadata(sid, gateway)
211
+ else:
212
+ clear_sid_launcher_gateway_metadata(sid)
213
214
def _associate_declared_context(self, data: dict[str, Any], sid: str) -> str:
215
context_id = str(data.get("context_id", "") or "").strip()
@@ -777,6 +795,26 @@ class WsConnector(WsHandler):
795
796
return {"op_id": op_id, "accepted": True}
797
798
+ def _handle_gateway_control_result(
799
+ self,
800
+ data: dict[str, Any],
801
+ sid: str,
802
+ ) -> dict[str, Any] | WsResult:
803
+ request_id = str(data.get("request_id", "") or "").strip()
804
+ if not request_id:
805
+ return WsResult.error(
806
+ code="MISSING_REQUEST_ID",
807
+ message="request_id is required",
808
+ correlation_id=data.get("correlationId"),
809
+ )
810
+ if not resolve_pending_gateway_control(request_id, sid=sid, payload=data):
811
+ return WsResult.error(
812
+ code="UNKNOWN_REQUEST_ID",
813
+ message=f"No pending gateway control for request_id '{request_id}'",
814
+ correlation_id=data.get("correlationId"),
815
+ )
816
+ return {"request_id": request_id, "accepted": True}
817
+
818
async def _resolve_context(
819
self,
820
*,
plugins/_a0_connector/extensions/webui/chat-top-end/launcher-gateway.html
new
+131
@@ -0,0 +1,131 @@
1
+<script type="module">
2
+ import { store } from "/plugins/_a0_connector/webui/launcher-gateway-store.js";
3
+</script>
4
+
5
+<div
6
+ x-data
7
+ class="launcher-gateway-root"
8
+ x-show="$store.launcherGateway?.visible"
9
+ x-create="$store.launcherGateway?.onMount()"
10
+ x-destroy="$store.launcherGateway?.cleanup()"
11
+ @click.outside="$store.launcherGateway.open = false"
12
+ @keydown.escape.window="$store.launcherGateway.open = false"
13
+ style="display: none;"
14
+>
15
+ <button
16
+ type="button"
17
+ class="launcher-gateway-trigger"
18
+ :class="`is-${$store.launcherGateway.state}`"
19
+ :title="`Host access: ${$store.launcherGateway.stateLabel}`"
20
+ :aria-label="`Host access: ${$store.launcherGateway.stateLabel}`"
21
+ aria-haspopup="dialog"
22
+ :aria-expanded="$store.launcherGateway.open.toString()"
23
+ @click.stop="$store.launcherGateway.open = !$store.launcherGateway.open"
24
+ >
25
+ <span class="launcher-gateway-dot" aria-hidden="true"></span>
26
+ <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.launcherGateway.stateIcon"></span>
27
+ <span class="launcher-gateway-trigger-label" x-text="$store.launcherGateway.stateLabel"></span>
28
+ </button>
29
+
30
+ <section
31
+ class="launcher-gateway-popover"
32
+ role="dialog"
33
+ aria-label="Launcher host access"
34
+ x-show="$store.launcherGateway.open"
35
+ x-transition.opacity.duration.120ms
36
+ @click.stop
37
+ style="display: none;"
38
+ >
39
+ <header class="launcher-gateway-header">
40
+ <div>
41
+ <strong x-text="$store.launcherGateway.hostLabel"></strong>
42
+ <span x-text="$store.launcherGateway.stateLabel"></span>
43
+ </div>
44
+ <button type="button" class="launcher-gateway-close" aria-label="Close host access" @click="$store.launcherGateway.open = false">
45
+ <span class="material-symbols-outlined" aria-hidden="true">close</span>
46
+ </button>
47
+ </header>
48
+
49
+ <template x-if="$store.launcherGateway.gateway">
50
+ <div class="launcher-gateway-controls">
51
+ <label class="launcher-gateway-row is-master">
52
+ <span>Host access</span>
53
+ <input type="checkbox" :checked="$store.launcherGateway.gateway.master_enabled" :disabled="$store.launcherGateway.saving" @change="$store.launcherGateway.setMaster($event.target.checked)">
54
+ </label>
55
+ <label class="launcher-gateway-row">
56
+ <span>Files read/write</span>
57
+ <input type="checkbox" :checked="$store.launcherGateway.gateway.scopes.files" :disabled="$store.launcherGateway.saving" @change="$store.launcherGateway.setScope('files', $event.target.checked)">
58
+ </label>
59
+ <label class="launcher-gateway-row">
60
+ <span>Code execution</span>
61
+ <input type="checkbox" :checked="$store.launcherGateway.gateway.scopes.code_execution" :disabled="$store.launcherGateway.saving || !$store.launcherGateway.gateway.scopes.files" @change="$store.launcherGateway.setScope('code_execution', $event.target.checked)">
62
+ </label>
63
+ <label class="launcher-gateway-row">
64
+ <span>Personal browser</span>
65
+ <input type="checkbox" :checked="$store.launcherGateway.gateway.scopes.browser" :disabled="$store.launcherGateway.saving" @change="$store.launcherGateway.setScope('browser', $event.target.checked)">
66
+ </label>
67
+ <label class="launcher-gateway-row">
68
+ <span>Computer Use</span>
69
+ <input type="checkbox" :checked="$store.launcherGateway.gateway.scopes.computer_use" :disabled="$store.launcherGateway.saving" @change="$store.launcherGateway.setScope('computer_use', $event.target.checked)">
70
+ </label>
71
+ </div>
72
+ </template>
73
+
74
+ <div class="launcher-gateway-notices" x-show="$store.launcherGateway.preparationMessages.length">
75
+ <template x-for="message in $store.launcherGateway.preparationMessages" :key="message">
76
+ <p><span class="material-symbols-outlined" aria-hidden="true">warning</span><span x-text="message"></span></p>
77
+ </template>
78
+ </div>
79
+
80
+ <p class="launcher-gateway-empty" x-show="!$store.launcherGateway.gateway">
81
+ Host access is not connected for this Launcher tab.
82
+ </p>
83
+
84
+ <button
85
+ type="button"
86
+ class="launcher-gateway-disconnect"
87
+ x-show="$store.launcherGateway.gateway"
88
+ :disabled="$store.launcherGateway.saving"
89
+ @click="$store.launcherGateway.emergencyDisconnect()"
90
+ >Emergency disconnect</button>
91
+ </section>
92
+</div>
93
+
94
+<style>
95
+ .launcher-gateway-root { position: relative; }
96
+ .launcher-gateway-trigger {
97
+ display: inline-flex; align-items: center; gap: .35rem; min-height: 2rem;
98
+ padding: .25rem .55rem; border: 1px solid var(--color-border); border-radius: 999px;
99
+ color: var(--color-text); background: color-mix(in srgb, var(--color-panel) 92%, transparent);
100
+ cursor: pointer; font: inherit; font-size: .75rem;
101
+ }
102
+ .launcher-gateway-trigger .material-symbols-outlined { font-size: 1rem; }
103
+ .launcher-gateway-dot { width: .45rem; height: .45rem; border-radius: 50%; background: #7b8491; }
104
+ .launcher-gateway-trigger.is-connected .launcher-gateway-dot { background: #45b86b; }
105
+ .launcher-gateway-trigger.is-connecting .launcher-gateway-dot { background: #5b9cff; }
106
+ .launcher-gateway-trigger.is-paused .launcher-gateway-dot { background: #d6a43d; }
107
+ .launcher-gateway-trigger.is-needs_action .launcher-gateway-dot,
108
+ .launcher-gateway-trigger.is-multiple_hosts .launcher-gateway-dot { background: #e49a35; }
109
+ .launcher-gateway-trigger.is-error .launcher-gateway-dot { background: #db5b61; }
110
+ .launcher-gateway-popover {
111
+ position: absolute; top: calc(100% + .45rem); right: 0; z-index: 1200; width: min(20rem, calc(100vw - 2rem));
112
+ padding: .8rem; border: 1px solid var(--color-border); border-radius: .7rem;
113
+ background: var(--color-panel); box-shadow: 0 16px 42px rgba(0, 0, 0, .24);
114
+ }
115
+ .launcher-gateway-header { display: flex; justify-content: space-between; gap: .75rem; align-items: flex-start; }
116
+ .launcher-gateway-header div { display: grid; gap: .1rem; }
117
+ .launcher-gateway-header span { color: var(--color-text-secondary); font-size: .75rem; }
118
+ .launcher-gateway-close { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0; }
119
+ .launcher-gateway-close .material-symbols-outlined { font-size: 1.1rem; }
120
+ .launcher-gateway-controls { display: grid; gap: .15rem; margin-top: .7rem; }
121
+ .launcher-gateway-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; min-height: 2rem; font-size: .82rem; }
122
+ .launcher-gateway-row.is-master { padding-bottom: .35rem; margin-bottom: .2rem; border-bottom: 1px solid var(--color-border); font-weight: 650; }
123
+ .launcher-gateway-row input { accent-color: var(--color-primary); }
124
+ .launcher-gateway-notices { display: grid; gap: .35rem; margin-top: .55rem; }
125
+ .launcher-gateway-notices p { display: flex; gap: .35rem; margin: 0; color: #d79b35; font-size: .75rem; line-height: 1.3; }
126
+ .launcher-gateway-notices .material-symbols-outlined { font-size: .95rem; }
127
+ .launcher-gateway-empty { margin: .7rem 0 0; color: var(--color-text-secondary); font-size: .78rem; }
128
+ .launcher-gateway-disconnect { width: 100%; margin-top: .75rem; padding: .48rem .65rem; border: 1px solid color-mix(in srgb, #db5b61 55%, var(--color-border)); border-radius: .45rem; background: transparent; color: #db5b61; cursor: pointer; }
129
+ .launcher-gateway-disconnect:disabled { opacity: .55; cursor: default; }
130
+ @media (max-width: 700px) { .launcher-gateway-trigger-label { display: none; } }
131
+</style>
plugins/_a0_connector/helpers/ws_runtime.py
+274
-18
@@ -45,6 +45,13 @@ class PendingBrowserOperation:
45
context_id: str | None = None
46
47
48
+@dataclass
49
+class PendingGatewayControl:
50
+ sid: str
51
+ loop: asyncio.AbstractEventLoop
52
+ future: asyncio.Future[dict[str, Any]]
53
+
54
+
55
@dataclass(frozen=True)
56
class RemoteTreeSnapshot:
57
sid: str
@@ -103,22 +110,37 @@ class RemoteExecMetadata:
110
updated_at: float
111
112
113
+@dataclass(frozen=True)
114
+class LauncherGatewayMetadata:
115
+ gateway_id: str
116
+ host_label: str
117
+ state: str
118
+ master_enabled: bool
119
+ scopes: dict[str, bool]
120
+ status: dict[str, Any]
121
+ updated_at: float
122
+
123
+
124
_context_subscriptions: dict[str, set[str]] = {}
125
_sid_contexts: dict[str, set[str]] = {}
126
_pending_file_ops: dict[str, PendingFileOperation] = {}
127
_pending_exec_ops: dict[str, PendingExecOperation] = {}
128
_pending_computer_use_ops: dict[str, PendingComputerUseOperation] = {}
129
_pending_browser_ops: dict[str, PendingBrowserOperation] = {}
130
+_pending_gateway_controls: dict[str, PendingGatewayControl] = {}
131
_remote_tree_snapshots: dict[str, RemoteTreeSnapshot] = {}
132
_sid_computer_use_metadata: dict[str, ComputerUseMetadata] = {}
133
_sid_host_browser_metadata: dict[str, HostBrowserMetadata] = {}
134
_sid_remote_file_metadata: dict[str, RemoteFileMetadata] = {}
135
_sid_remote_exec_metadata: dict[str, RemoteExecMetadata] = {}
136
+_sid_launcher_gateway_metadata: dict[str, LauncherGatewayMetadata] = {}
137
+_replaced_gateway_sids: set[str] = set()
138
_state_lock = threading.RLock()
139
140
141
def register_sid(sid: str) -> None:
142
with _state_lock:
143
+ _replaced_gateway_sids.discard(sid)
144
_sid_contexts.setdefault(sid, set())
145
146
@@ -130,6 +152,8 @@ def unregister_sid(sid: str) -> set[str]:
152
_sid_host_browser_metadata.pop(sid, None)
153
_sid_remote_file_metadata.pop(sid, None)
154
_sid_remote_exec_metadata.pop(sid, None)
155
+ _sid_launcher_gateway_metadata.pop(sid, None)
156
+ _replaced_gateway_sids.discard(sid)
157
for context_id in contexts:
158
subscribers = _context_subscriptions.get(context_id)
159
if not subscribers:
@@ -176,11 +200,195 @@ def connected_sids() -> set[str]:
200
return set(_sid_contexts.keys())
201
202
203
+_GATEWAY_STATES = {
204
+ "connecting",
205
+ "connected",
206
+ "paused",
207
+ "needs_action",
208
+ "error",
209
+ "disconnected",
210
+}
211
+_GATEWAY_SCOPE_KEYS = ("files", "code_execution", "browser", "computer_use")
212
+
213
+
214
+def _bounded_gateway_status(value: Any, *, depth: int = 0) -> Any:
215
+ if isinstance(value, str):
216
+ return value[:2048]
217
+ if isinstance(value, (bool, int, float)) or value is None:
218
+ return value
219
+ if depth >= 5:
220
+ return None
221
+ if isinstance(value, dict):
222
+ result: dict[str, Any] = {}
223
+ for key, item in list(value.items())[:64]:
224
+ result[str(key)[:80]] = _bounded_gateway_status(item, depth=depth + 1)
225
+ return result
226
+ if isinstance(value, (list, tuple)):
227
+ return [
228
+ _bounded_gateway_status(item, depth=depth + 1)
229
+ for item in list(value)[:64]
230
+ ]
231
+ return str(value)[:2048]
232
+
233
+
234
+def store_sid_launcher_gateway_metadata(
235
+ sid: str,
236
+ payload: dict[str, Any],
237
+) -> LauncherGatewayMetadata | None:
238
+ """Store a validated Launcher gateway declaration for one connector socket."""
239
+ if str(payload.get("kind", "") or "").strip().lower() != "launcher":
240
+ clear_sid_launcher_gateway_metadata(sid)
241
+ return None
242
+ try:
243
+ version = int(payload.get("version") or 0)
244
+ except (TypeError, ValueError):
245
+ version = 0
246
+ gateway_id = str(payload.get("id", "") or "").strip()[:128]
247
+ if version != 1 or not gateway_id:
248
+ clear_sid_launcher_gateway_metadata(sid)
249
+ return None
250
+
251
+ raw_scopes = payload.get("scopes")
252
+ scopes = {
253
+ key: bool(raw_scopes.get(key)) if isinstance(raw_scopes, dict) else False
254
+ for key in _GATEWAY_SCOPE_KEYS
255
+ }
256
+ if not scopes["files"]:
257
+ scopes["code_execution"] = False
258
+ master_enabled = bool(payload.get("master_enabled", True))
259
+ state = str(payload.get("state", "connected") or "").strip().lower()
260
+ if state not in _GATEWAY_STATES:
261
+ state = "connected" if master_enabled else "paused"
262
+ if not master_enabled and state not in {"error", "needs_action", "disconnected"}:
263
+ state = "paused"
264
+ status_value = payload.get("status")
265
+ status = _bounded_gateway_status(status_value) if isinstance(status_value, dict) else {}
266
+ metadata = LauncherGatewayMetadata(
267
+ gateway_id=gateway_id,
268
+ host_label=str(payload.get("host_label", "") or "").strip()[:128],
269
+ state=state,
270
+ master_enabled=master_enabled,
271
+ scopes=scopes,
272
+ status=status,
273
+ updated_at=time.time(),
274
+ )
275
+ with _state_lock:
276
+ if sid in _replaced_gateway_sids:
277
+ return None
278
+ for other_sid, other in list(_sid_launcher_gateway_metadata.items()):
279
+ if other_sid != sid and other.gateway_id == gateway_id:
280
+ _sid_launcher_gateway_metadata.pop(other_sid, None)
281
+ _replaced_gateway_sids.add(other_sid)
282
+ _sid_launcher_gateway_metadata[sid] = metadata
283
+ return metadata
284
+
285
+
286
+def clear_sid_launcher_gateway_metadata(sid: str) -> None:
287
+ with _state_lock:
288
+ _sid_launcher_gateway_metadata.pop(sid, None)
289
+
290
+
291
+def launcher_gateway_metadata_for_sid(sid: str) -> dict[str, Any] | None:
292
+ with _state_lock:
293
+ metadata = _sid_launcher_gateway_metadata.get(sid)
294
+ if metadata is None:
295
+ return None
296
+ return _launcher_gateway_metadata_dict(metadata, sid=sid)
297
+
298
+
299
+def _launcher_gateway_metadata_dict(
300
+ metadata: LauncherGatewayMetadata,
301
+ *,
302
+ sid: str | None = None,
303
+) -> dict[str, Any]:
304
+ result = {
305
+ "version": 1,
306
+ "kind": "launcher",
307
+ "id": metadata.gateway_id,
308
+ "host_label": metadata.host_label,
309
+ "state": metadata.state,
310
+ "master_enabled": metadata.master_enabled,
311
+ "scopes": dict(metadata.scopes),
312
+ "status": copy.deepcopy(metadata.status),
313
+ "updated_at": metadata.updated_at,
314
+ }
315
+ if sid is not None:
316
+ result["sid"] = sid
317
+ return result
318
+
319
+
320
+def _active_launcher_gateways_locked() -> list[tuple[str, LauncherGatewayMetadata]]:
321
+ return sorted(
322
+ (
323
+ (sid, metadata)
324
+ for sid, metadata in _sid_launcher_gateway_metadata.items()
325
+ if sid in _sid_contexts and sid not in _replaced_gateway_sids
326
+ ),
327
+ key=lambda item: item[1].updated_at,
328
+ reverse=True,
329
+ )
330
+
331
+
332
+def _active_launcher_gateway_sid_locked() -> str | None:
333
+ gateways = _active_launcher_gateways_locked()
334
+ if len({metadata.gateway_id for _sid, metadata in gateways}) != 1:
335
+ return None
336
+ return gateways[0][0] if gateways else None
337
+
338
+
339
+def active_launcher_gateway_sid() -> str | None:
340
+ with _state_lock:
341
+ return _active_launcher_gateway_sid_locked()
342
+
343
+
344
+def launcher_gateway_status() -> dict[str, Any]:
345
+ with _state_lock:
346
+ gateways = _active_launcher_gateways_locked()
347
+ distinct_ids = {metadata.gateway_id for _sid, metadata in gateways}
348
+ rows = [
349
+ _launcher_gateway_metadata_dict(metadata)
350
+ for _sid, metadata in gateways
351
+ ]
352
+ if not rows:
353
+ return {
354
+ "state": "disconnected",
355
+ "connected": False,
356
+ "multiple_hosts": False,
357
+ "gateway": None,
358
+ "gateways": [],
359
+ }
360
+ if len(distinct_ids) > 1:
361
+ return {
362
+ "state": "multiple_hosts",
363
+ "connected": False,
364
+ "multiple_hosts": True,
365
+ "gateway": None,
366
+ "gateways": rows,
367
+ "error": "Multiple Launcher hosts are connected; host tools are disabled.",
368
+ }
369
+ gateway = rows[0]
370
+ return {
371
+ "state": gateway["state"],
372
+ "connected": gateway["state"] not in {"disconnected", "error"},
373
+ "multiple_hosts": False,
374
+ "gateway": gateway,
375
+ "gateways": rows,
376
+ }
377
+
378
+
379
def _candidate_sids_for_context_locked(context_id: str) -> list[str]:
380
context_sids = sorted(_context_subscriptions.get(context_id, set()))
381
context_set = set(context_sids)
182
- global_sids = sorted(sid for sid in _sid_contexts if sid not in context_set)
183
- return context_sids + global_sids
382
+ gateway_sid = _active_launcher_gateway_sid_locked()
383
+ gateway_sids = [gateway_sid] if gateway_sid and gateway_sid not in context_set else []
384
+ global_sids = sorted(
385
+ sid
386
+ for sid in _sid_contexts
387
+ if sid not in context_set
388
+ and sid not in _sid_launcher_gateway_metadata
389
+ and sid not in _replaced_gateway_sids
390
+ )
391
+ return context_sids + gateway_sids + global_sids
392
393
394
def remote_tool_sids_for_context(context_id: str) -> list[str]:
@@ -215,20 +423,11 @@ def latest_remote_tree_for_context(
423
) -> dict[str, Any] | None:
424
now = time.time()
425
with _state_lock:
218
- context_sids = sorted(_context_subscriptions.get(context_id, set()))
219
- context_set = set(context_sids)
220
- global_sids = sorted(sid for sid in _sid_contexts if sid not in context_set)
426
+ candidates = _candidate_sids_for_context_locked(context_id)
427
+ context_sids = set(_context_subscriptions.get(context_id, set()))
428
snapshot_groups = [
222
- [
223
- _remote_tree_snapshots[sid]
224
- for sid in context_sids
225
- if sid in _remote_tree_snapshots
226
- ],
227
- [
228
- _remote_tree_snapshots[sid]
229
- for sid in global_sids
230
- if sid in _remote_tree_snapshots
231
- ],
429
+ [_remote_tree_snapshots[sid] for sid in candidates if sid in context_sids and sid in _remote_tree_snapshots],
430
+ [_remote_tree_snapshots[sid] for sid in candidates if sid not in context_sids and sid in _remote_tree_snapshots],
431
]
432
433
for snapshots in snapshot_groups:
@@ -854,8 +1053,51 @@ def fail_pending_browser_ops_for_sid(sid: str, *, error: str) -> None:
1053
_fail_pending_for_sid(_pending_browser_ops, sid=sid, error=error)
1054
1055
1056
+def store_pending_gateway_control(
1057
+ request_id: str,
1058
+ *,
1059
+ sid: str,
1060
+ future: asyncio.Future[dict[str, Any]],
1061
+ loop: asyncio.AbstractEventLoop,
1062
+) -> None:
1063
+ with _state_lock:
1064
+ _pending_gateway_controls[request_id] = PendingGatewayControl(
1065
+ sid=sid,
1066
+ loop=loop,
1067
+ future=future,
1068
+ )
1069
+
1070
+
1071
+def clear_pending_gateway_control(request_id: str) -> None:
1072
+ with _state_lock:
1073
+ _pending_gateway_controls.pop(request_id, None)
1074
+
1075
+
1076
+def resolve_pending_gateway_control(
1077
+ request_id: str,
1078
+ *,
1079
+ sid: str,
1080
+ payload: dict[str, Any],
1081
+) -> bool:
1082
+ gateway = payload.get("gateway")
1083
+ if isinstance(gateway, dict):
1084
+ store_sid_launcher_gateway_metadata(sid, gateway)
1085
+ return _resolve_pending(_pending_gateway_controls, request_id, sid=sid, payload=payload)
1086
+
1087
+
1088
+def fail_pending_gateway_controls_for_sid(sid: str, *, error: str) -> None:
1089
+ _fail_pending_for_sid(_pending_gateway_controls, sid=sid, error=error)
1090
+
1091
+
1092
def _resolve_pending(
858
- registry: dict[str, PendingFileOperation | PendingExecOperation | PendingComputerUseOperation | PendingBrowserOperation],
1093
+ registry: dict[
1094
+ str,
1095
+ PendingFileOperation
1096
+ | PendingExecOperation
1097
+ | PendingComputerUseOperation
1098
+ | PendingBrowserOperation
1099
+ | PendingGatewayControl,
1100
+ ],
1101
op_id: str,
1102
*,
1103
sid: str,
@@ -872,7 +1114,14 @@ def _resolve_pending(
1114
1115
1116
def _fail_pending(
875
- registry: dict[str, PendingFileOperation | PendingExecOperation | PendingComputerUseOperation | PendingBrowserOperation],
1117
+ registry: dict[
1118
+ str,
1119
+ PendingFileOperation
1120
+ | PendingExecOperation
1121
+ | PendingComputerUseOperation
1122
+ | PendingBrowserOperation
1123
+ | PendingGatewayControl,
1124
+ ],
1125
op_id: str,
1126
*,
1127
sid: str | None,
@@ -895,7 +1144,14 @@ def _fail_pending(
1144
1145
1146
def _fail_pending_for_sid(
898
- registry: dict[str, PendingFileOperation | PendingExecOperation | PendingComputerUseOperation | PendingBrowserOperation],
1147
+ registry: dict[
1148
+ str,
1149
+ PendingFileOperation
1150
+ | PendingExecOperation
1151
+ | PendingComputerUseOperation
1152
+ | PendingBrowserOperation
1153
+ | PendingGatewayControl,
1154
+ ],
1155
*,
1156
sid: str,
1157
error: str,
plugins/_a0_connector/webui/launcher-gateway-store.js
new
+130
@@ -0,0 +1,130 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi } from "/js/api.js";
3
+
4
+const STATUS_API = "/plugins/_a0_connector/v1/launcher_gateway_status";
5
+const CONTROL_API = "/plugins/_a0_connector/v1/launcher_gateway_control";
6
+
7
+const model = {
8
+ status: { state: "disconnected", gateway: null },
9
+ loading: false,
10
+ saving: false,
11
+ open: false,
12
+ intervalId: null,
13
+
14
+ get visible() {
15
+ return /(?:^|\s)A0-Launcher\/[^\s]+/.test(navigator.userAgent);
16
+ },
17
+
18
+ get gateway() {
19
+ return this.status?.gateway || null;
20
+ },
21
+
22
+ get state() {
23
+ return this.status?.state || "disconnected";
24
+ },
25
+
26
+ get stateLabel() {
27
+ const labels = {
28
+ connecting: "Connecting",
29
+ connected: "Connected",
30
+ paused: "Paused",
31
+ needs_action: "Needs action",
32
+ error: "Error",
33
+ multiple_hosts: "Multiple hosts",
34
+ disconnected: "Disconnected",
35
+ };
36
+ return labels[this.state] || "Disconnected";
37
+ },
38
+
39
+ get stateIcon() {
40
+ const icons = {
41
+ connecting: "sync",
42
+ connected: "computer",
43
+ paused: "pause_circle",
44
+ needs_action: "warning",
45
+ error: "error",
46
+ multiple_hosts: "devices",
47
+ disconnected: "computer_off",
48
+ };
49
+ return icons[this.state] || "computer_off";
50
+ },
51
+
52
+ get hostLabel() {
53
+ return this.gateway?.host_label || "Launcher host";
54
+ },
55
+
56
+ get preparationMessages() {
57
+ const status = this.gateway?.status || {};
58
+ const messages = [];
59
+ for (const key of ["browser", "computer_use"]) {
60
+ const value = status[key];
61
+ if (typeof value === "string" && value) messages.push(value);
62
+ else if (value?.message) messages.push(value.message);
63
+ else if (value?.error) messages.push(value.error);
64
+ }
65
+ if (this.status?.error) messages.push(this.status.error);
66
+ return [...new Set(messages)];
67
+ },
68
+
69
+ onMount() {
70
+ if (!this.visible || this.intervalId) return;
71
+ void this.refresh();
72
+ this.intervalId = window.setInterval(() => this.refresh(), 2000);
73
+ },
74
+
75
+ cleanup() {
76
+ if (this.intervalId) window.clearInterval(this.intervalId);
77
+ this.intervalId = null;
78
+ },
79
+
80
+ async refresh() {
81
+ if (!this.visible || this.loading) return;
82
+ this.loading = true;
83
+ try {
84
+ this.status = await callJsonApi(STATUS_API, {});
85
+ } catch (error) {
86
+ console.error("Failed to load Launcher host status:", error);
87
+ this.status = { state: "error", gateway: null, error: error?.message || "Status unavailable" };
88
+ } finally {
89
+ this.loading = false;
90
+ }
91
+ },
92
+
93
+ async setMaster(enabled) {
94
+ await this.control({ action: "set_master", enabled: Boolean(enabled) });
95
+ },
96
+
97
+ async setScope(scope, enabled) {
98
+ const current = this.gateway?.scopes || {};
99
+ const scopes = {
100
+ files: Boolean(current.files),
101
+ code_execution: Boolean(current.code_execution),
102
+ browser: Boolean(current.browser),
103
+ computer_use: Boolean(current.computer_use),
104
+ [scope]: Boolean(enabled),
105
+ };
106
+ if (!scopes.files) scopes.code_execution = false;
107
+ await this.control({ action: "replace_scopes", scopes });
108
+ },
109
+
110
+ async emergencyDisconnect() {
111
+ await this.control({ action: "emergency_disconnect" });
112
+ this.open = false;
113
+ },
114
+
115
+ async control(payload) {
116
+ if (this.saving) return;
117
+ this.saving = true;
118
+ try {
119
+ const response = await callJsonApi(CONTROL_API, payload);
120
+ this.status = response?.status || this.status;
121
+ } catch (error) {
122
+ console.error("Failed to control Launcher host:", error);
123
+ await this.refresh();
124
+ } finally {
125
+ this.saving = false;
126
+ }
127
+ },
128
+};
129
+
130
+export const store = createStore("launcherGateway", model);
tests/AGENTS.md
+4
@@ -17,6 +17,10 @@
17
- Keep tests deterministic and isolated from existing chats, uploads, downloads, plugin state, and settings.
18
- Prefer exercising public helper/API contracts over fragile implementation details when practical.
19
- Security regression tests should assert the protected behavior directly.
20
+- Launcher gateway tests must cover feature negotiation, authenticated and
21
+ CSRF-protected control, acknowledgement timeout, identity lifecycle,
22
+ context-bound CLI routing precedence, duplicate/multiple-host behavior,
23
+ scope-driven availability, and emergency disconnect without a live host.
24
25
## Work Guidance
26
tests/test_a0_connector_launcher_gateway.py
new
+216
@@ -0,0 +1,216 @@
1
+from __future__ import annotations
2
+
3
+import asyncio
4
+import uuid
5
+
6
+from plugins._a0_connector.api.v1 import launcher_gateway_control
7
+from plugins._a0_connector.api.v1.capabilities import _feature_list
8
+from plugins._a0_connector.api.v1.launcher_gateway_status import LauncherGatewayStatus
9
+from plugins._a0_connector.api.ws_connector import WS_FEATURES
10
+from plugins._a0_connector.helpers import ws_runtime
11
+
12
+
13
+def _sid(label: str) -> str:
14
+ return f"gateway-{label}-{uuid.uuid4()}"
15
+
16
+
17
+def _gateway(gateway_id: str, *, files: bool = True) -> dict:
18
+ return {
19
+ "version": 1,
20
+ "kind": "launcher",
21
+ "id": gateway_id,
22
+ "host_label": "Test host",
23
+ "state": "connected",
24
+ "master_enabled": True,
25
+ "scopes": {
26
+ "files": files,
27
+ "code_execution": True,
28
+ "browser": True,
29
+ "computer_use": True,
30
+ },
31
+ }
32
+
33
+
34
+def test_launcher_gateway_features_are_negotiated_on_http_and_websocket() -> None:
35
+ assert "launcher_gateway" in _feature_list()
36
+ assert "launcher_gateway_control" in WS_FEATURES
37
+ assert LauncherGatewayStatus.requires_auth() is True
38
+
39
+
40
+def test_launcher_gateway_is_fallback_after_context_bound_cli() -> None:
41
+ context_id = f"ctx-{uuid.uuid4()}"
42
+ cli_sid = _sid("cli")
43
+ gateway_sid = _sid("launcher")
44
+ ws_runtime.register_sid(cli_sid)
45
+ ws_runtime.register_sid(gateway_sid)
46
+ ws_runtime.subscribe_sid_to_context(cli_sid, context_id)
47
+ ws_runtime.store_sid_launcher_gateway_metadata(gateway_sid, _gateway("installation-a"))
48
+ try:
49
+ assert ws_runtime.remote_tool_sids_for_context(context_id)[:2] == [
50
+ cli_sid,
51
+ gateway_sid,
52
+ ]
53
+ finally:
54
+ ws_runtime.unregister_sid(cli_sid)
55
+ ws_runtime.unregister_sid(gateway_sid)
56
+
57
+
58
+def test_distinct_launcher_gateways_fail_closed() -> None:
59
+ first_sid = _sid("first")
60
+ second_sid = _sid("second")
61
+ ws_runtime.register_sid(first_sid)
62
+ ws_runtime.register_sid(second_sid)
63
+ ws_runtime.store_sid_launcher_gateway_metadata(first_sid, _gateway("installation-a"))
64
+ ws_runtime.store_sid_launcher_gateway_metadata(second_sid, _gateway("installation-b"))
65
+ try:
66
+ status = ws_runtime.launcher_gateway_status()
67
+ assert status["state"] == "multiple_hosts"
68
+ assert status["connected"] is False
69
+ assert ws_runtime.active_launcher_gateway_sid() is None
70
+ assert first_sid not in ws_runtime.remote_tool_sids_for_context("unbound")
71
+ assert second_sid not in ws_runtime.remote_tool_sids_for_context("unbound")
72
+ finally:
73
+ ws_runtime.unregister_sid(first_sid)
74
+ ws_runtime.unregister_sid(second_sid)
75
+
76
+
77
+def test_duplicate_gateway_identity_replaces_stale_socket() -> None:
78
+ stale_sid = _sid("stale")
79
+ fresh_sid = _sid("fresh")
80
+ ws_runtime.register_sid(stale_sid)
81
+ ws_runtime.register_sid(fresh_sid)
82
+ ws_runtime.store_sid_launcher_gateway_metadata(stale_sid, _gateway("installation-a"))
83
+ ws_runtime.store_sid_launcher_gateway_metadata(fresh_sid, _gateway("installation-a"))
84
+ try:
85
+ assert ws_runtime.active_launcher_gateway_sid() == fresh_sid
86
+ assert ws_runtime.store_sid_launcher_gateway_metadata(
87
+ stale_sid,
88
+ _gateway("installation-a"),
89
+ ) is None
90
+ finally:
91
+ ws_runtime.unregister_sid(stale_sid)
92
+ ws_runtime.unregister_sid(fresh_sid)
93
+
94
+
95
+def test_gateway_disables_code_execution_when_files_are_off() -> None:
96
+ sid = _sid("scope")
97
+ ws_runtime.register_sid(sid)
98
+ ws_runtime.store_sid_launcher_gateway_metadata(sid, _gateway("installation-a", files=False))
99
+ try:
100
+ gateway = ws_runtime.launcher_gateway_status()["gateway"]
101
+ assert gateway["scopes"]["files"] is False
102
+ assert gateway["scopes"]["code_execution"] is False
103
+ finally:
104
+ ws_runtime.unregister_sid(sid)
105
+
106
+
107
+def test_gateway_status_metadata_is_bounded() -> None:
108
+ sid = _sid("bounded")
109
+ payload = _gateway("installation-a")
110
+ payload["status"] = {
111
+ "browser": {
112
+ "message": "x" * 4000,
113
+ "available_browsers": [{"browser_id": f"browser-{index}"} for index in range(100)],
114
+ },
115
+ "computer_use": {
116
+ "capabilities": {"elements": {"tree_backends": ["ax", "at-spi"]}}
117
+ },
118
+ }
119
+ ws_runtime.register_sid(sid)
120
+ ws_runtime.store_sid_launcher_gateway_metadata(sid, payload)
121
+ try:
122
+ status = ws_runtime.launcher_gateway_status()["gateway"]["status"]
123
+ assert len(status["browser"]["message"]) == 2048
124
+ assert len(status["browser"]["available_browsers"]) == 64
125
+ assert status["computer_use"]["capabilities"]["elements"]["tree_backends"] == [
126
+ "ax",
127
+ "at-spi",
128
+ ]
129
+ finally:
130
+ ws_runtime.unregister_sid(sid)
131
+
132
+
133
+def test_gateway_control_requires_csrf_and_waits_for_ack(monkeypatch) -> None:
134
+ sid = _sid("control")
135
+ ws_runtime.register_sid(sid)
136
+ ws_runtime.store_sid_launcher_gateway_metadata(sid, _gateway("installation-a"))
137
+
138
+ class FakeManager:
139
+ async def emit_to(self, namespace, target_sid, event, data, **kwargs):
140
+ assert namespace == "/ws"
141
+ assert target_sid == sid
142
+ assert event == "connector_gateway_control"
143
+ updated = _gateway("installation-a")
144
+ updated["master_enabled"] = False
145
+ updated["state"] = "paused"
146
+ ws_runtime.resolve_pending_gateway_control(
147
+ data["request_id"],
148
+ sid=sid,
149
+ payload={
150
+ "request_id": data["request_id"],
151
+ "ok": True,
152
+ "gateway": updated,
153
+ },
154
+ )
155
+
156
+ monkeypatch.setattr(launcher_gateway_control, "get_shared_ws_manager", lambda: FakeManager())
157
+ handler = launcher_gateway_control.LauncherGatewayControl(None, None)
158
+ try:
159
+ result = asyncio.run(handler.process({"action": "set_master", "enabled": False}, None))
160
+ assert handler.requires_auth() is True
161
+ assert handler.requires_csrf() is True
162
+ assert result["ok"] is True
163
+ assert result["status"]["gateway"]["master_enabled"] is False
164
+ finally:
165
+ ws_runtime.unregister_sid(sid)
166
+
167
+
168
+def test_gateway_control_acknowledgement_timeout(monkeypatch) -> None:
169
+ sid = _sid("timeout")
170
+ ws_runtime.register_sid(sid)
171
+ ws_runtime.store_sid_launcher_gateway_metadata(sid, _gateway("installation-a"))
172
+
173
+ class SilentManager:
174
+ async def emit_to(self, *_args, **_kwargs):
175
+ return None
176
+
177
+ monkeypatch.setattr(launcher_gateway_control, "get_shared_ws_manager", lambda: SilentManager())
178
+ monkeypatch.setattr(launcher_gateway_control, "_CONTROL_TIMEOUT_SECONDS", 0.01)
179
+ handler = launcher_gateway_control.LauncherGatewayControl(None, None)
180
+ try:
181
+ result = asyncio.run(handler.process({"action": "set_master", "enabled": False}, None))
182
+ assert result.status_code == 504
183
+ finally:
184
+ ws_runtime.unregister_sid(sid)
185
+
186
+
187
+def test_gateway_emergency_disconnect_returns_acknowledged_disconnected_state(monkeypatch) -> None:
188
+ sid = _sid("emergency")
189
+ ws_runtime.register_sid(sid)
190
+ ws_runtime.store_sid_launcher_gateway_metadata(sid, _gateway("installation-a"))
191
+
192
+ class FakeManager:
193
+ async def emit_to(self, _namespace, target_sid, event, data, **_kwargs):
194
+ assert target_sid == sid
195
+ assert event == "connector_gateway_control"
196
+ assert data["action"] == "emergency_disconnect"
197
+ updated = _gateway("installation-a")
198
+ updated["state"] = "disconnected"
199
+ ws_runtime.resolve_pending_gateway_control(
200
+ data["request_id"],
201
+ sid=sid,
202
+ payload={
203
+ "request_id": data["request_id"],
204
+ "ok": True,
205
+ "gateway": updated,
206
+ },
207
+ )
208
+
209
+ monkeypatch.setattr(launcher_gateway_control, "get_shared_ws_manager", lambda: FakeManager())
210
+ handler = launcher_gateway_control.LauncherGatewayControl(None, None)
211
+ try:
212
+ result = asyncio.run(handler.process({"action": "emergency_disconnect"}, None))
213
+ assert result["ok"] is True
214
+ assert result["status"]["state"] == "disconnected"
215
+ finally:
216
+ ws_runtime.unregister_sid(sid)