Apply Launcher scope changes before routing
Update remote file and execution metadata from the acknowledged Launcher gateway state before resolving the control request. Add regression coverage for the read/write to read-only transition so writes fail immediately while reads remain routed.
Alessandro committed
Jul 17, 2026 at 15:18 UTC
02e1a98604219cfa098e47754dcf6d946cca75ce
3 files changed
+65
-3
plugins/_a0_connector/AGENTS.md
+4
-2
@@ -35,8 +35,10 @@
35
master state, complete scope replacement, and Disconnect
36
(`emergency_disconnect` on the wire). Protected
37
WebUI mutations require CSRF, await the matching acknowledgement, and return
38
- refreshed status. Never let the WebUI select a host folder or personal
39
- browser profile.
38
+ refreshed status. Apply acknowledged master and scope state to remote file
39
+ and execution routing before resolving the control request; the follow-up
40
+ `connector_hello` only reconciles metadata. Never let the WebUI select a host
41
+ folder or personal browser profile.
42
- Launcher gateway scopes expose file reading and writing separately. File
43
writing depends on reading, and Code execution depends on file writing. Keep
44
older gateway declarations without `file_write` read/write compatible.
plugins/_a0_connector/helpers/ws_runtime.py
+17
-1
@@ -1085,7 +1085,23 @@ def resolve_pending_gateway_control(
1085
) -> bool:
1086
gateway = payload.get("gateway")
1087
if isinstance(gateway, dict):
1088
- store_sid_launcher_gateway_metadata(sid, gateway)
1088
+ stored = store_sid_launcher_gateway_metadata(sid, gateway)
1089
+ if stored is not None:
1090
+ active = stored.master_enabled and stored.state != "disconnected"
1091
+ files_enabled = active and stored.scopes["files"]
1092
+ writes_enabled = files_enabled and stored.scopes["file_write"]
1093
+ store_sid_remote_file_metadata(
1094
+ sid,
1095
+ {
1096
+ "enabled": files_enabled,
1097
+ "write_enabled": writes_enabled,
1098
+ "mode": "read_write" if writes_enabled else "read_only",
1099
+ },
1100
+ )
1101
+ store_sid_remote_exec_metadata(
1102
+ sid,
1103
+ {"enabled": active and stored.scopes["code_execution"]},
1104
+ )
1105
return _resolve_pending(_pending_gateway_controls, request_id, sid=sid, payload=payload)
1106
1107
tests/test_a0_connector_launcher_gateway.py
+44
@@ -274,6 +274,50 @@ def test_gateway_control_requires_csrf_and_waits_for_ack(monkeypatch) -> None:
274
ws_runtime.unregister_sid(sid)
275
276
277
+def test_gateway_scope_ack_updates_file_routing_before_follow_up_hello(monkeypatch) -> None:
278
+ sid = _sid("scope-transition")
279
+ ws_runtime.register_sid(sid)
280
+ ws_runtime.store_sid_launcher_gateway_metadata(sid, _gateway("installation-a"))
281
+ ws_runtime.store_sid_remote_file_metadata(
282
+ sid,
283
+ {"enabled": True, "write_enabled": True, "mode": "read_write"},
284
+ )
285
+ ws_runtime.store_sid_remote_exec_metadata(sid, {"enabled": True})
286
+
287
+ class FakeManager:
288
+ async def emit_to(self, _namespace, target_sid, _event, data, **_kwargs):
289
+ assert target_sid == sid
290
+ updated = _gateway("installation-a", file_write=False)
291
+ ws_runtime.resolve_pending_gateway_control(
292
+ data["request_id"],
293
+ sid=sid,
294
+ payload={
295
+ "request_id": data["request_id"],
296
+ "ok": True,
297
+ "gateway": updated,
298
+ },
299
+ )
300
+
301
+ monkeypatch.setattr(launcher_gateway_control, "get_shared_ws_manager", lambda: FakeManager())
302
+ handler = launcher_gateway_control.LauncherGatewayControl(None, None)
303
+ try:
304
+ result = asyncio.run(
305
+ handler.process(
306
+ {
307
+ "action": "replace_scopes",
308
+ "scopes": _gateway("installation-a", file_write=False)["scopes"],
309
+ },
310
+ None,
311
+ )
312
+ )
313
+ assert result["ok"] is True
314
+ assert ws_runtime.select_remote_file_target_sid("ctx", require_writes=False) == sid
315
+ assert ws_runtime.select_remote_file_target_sid("ctx", require_writes=True) is None
316
+ assert ws_runtime.select_remote_exec_target_sid("ctx") is None
317
+ finally:
318
+ ws_runtime.unregister_sid(sid)
319
+
320
+
321
def test_gateway_scope_control_requires_explicit_file_write() -> None:
322
handler = launcher_gateway_control.LauncherGatewayControl(None, None)
323
result = asyncio.run(