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)