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(