Split Launcher host file permissions

Advertise the file-write gateway capability while preserving legacy Files scopes as read/write access. Render five Host access switches in Core, enforce read/write/exec dependencies, and cover negotiation and control behavior with focused tests.

Alessandro committed Jul 15, 2026 at 14:11 UTC b11a57424e30f7878c471a0b678bc6bb1ac3f316
7 files changed +117 -25
plugins/_a0_connector/AGENTS.md
+3
@@ -36,6 +36,9 @@
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 +- Launcher gateway scopes expose file reading and writing separately. File
40 + writing depends on reading, and Code execution depends on file writing. Keep
41 + older gateway declarations without `file_write` read/write compatible.
42 - The `chat-top-end` Launcher gateway extension renders only when the user agent
43 includes `A0-Launcher/`. It may show status, master/scope controls,
44 preparation errors, and Emergency disconnect; standard browser sessions must
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -29,6 +29,7 @@ _BASE_FEATURES = [
29 "remote_file_tree",
30 "token_status",
31 "launcher_gateway",
32 + "launcher_gateway_file_write",
33 ]
34
35 _OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
plugins/_a0_connector/api/v1/launcher_gateway_control.py
+4 -2
@@ -18,7 +18,7 @@ from plugins._a0_connector.helpers.ws_runtime import (
18
19 _CONTROL_EVENT = "connector_gateway_control"
20 _CONTROL_TIMEOUT_SECONDS = 8.0
21 -_SCOPE_KEYS = ("files", "code_execution", "browser", "computer_use")
21 +_SCOPE_KEYS = ("files", "file_write", "code_execution", "browser", "computer_use")
22
23
24 class LauncherGatewayControl(connector_base.ProtectedConnectorApiHandler):
@@ -41,11 +41,13 @@ class LauncherGatewayControl(connector_base.ProtectedConnectorApiHandler):
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",
44 + "scopes must contain boolean files, file_write, 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["file_write"] = False
50 + if not normalized["file_write"]:
51 normalized["code_execution"] = False
52 payload["scopes"] = normalized
53 elif action != "emergency_disconnect":
plugins/_a0_connector/extensions/webui/chat-top-end/launcher-gateway.html
+45 -18
@@ -48,26 +48,48 @@
48
49 <template x-if="$store.launcherGateway.gateway">
50 <div class="launcher-gateway-controls">
51 - <label class="launcher-gateway-row is-master">
51 + <div 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">
53 + <label class="toggle launcher-gateway-switch">
54 + <input type="checkbox" aria-label="Host access" :checked="$store.launcherGateway.gateway.master_enabled" :disabled="$store.launcherGateway.saving" @change="$store.launcherGateway.setMaster($event.target.checked)">
55 + <span class="toggler"></span>
56 + </label>
57 + </div>
58 + <div class="launcher-gateway-row" :class="{ 'is-disabled': !$store.launcherGateway.gateway.master_enabled }">
59 + <span>Files read</span>
60 + <label class="toggle launcher-gateway-switch">
61 + <input type="checkbox" aria-label="Files read" :checked="$store.launcherGateway.gateway.scopes.files" :disabled="$store.launcherGateway.saving || !$store.launcherGateway.gateway.master_enabled" @change="$store.launcherGateway.setScope('files', $event.target.checked)">
62 + <span class="toggler"></span>
63 + </label>
64 + </div>
65 + <div class="launcher-gateway-row" :class="{ 'is-disabled': !$store.launcherGateway.gateway.master_enabled || !$store.launcherGateway.gateway.scopes.files }">
66 + <span>Files write</span>
67 + <label class="toggle launcher-gateway-switch">
68 + <input type="checkbox" aria-label="Files write" :checked="$store.launcherGateway.gateway.scopes.file_write" :disabled="$store.launcherGateway.saving || !$store.launcherGateway.gateway.master_enabled || !$store.launcherGateway.gateway.scopes.files" @change="$store.launcherGateway.setScope('file_write', $event.target.checked)">
69 + <span class="toggler"></span>
70 + </label>
71 + </div>
72 + <div class="launcher-gateway-row" :class="{ 'is-disabled': !$store.launcherGateway.gateway.master_enabled || !$store.launcherGateway.gateway.scopes.file_write }">
73 <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">
74 + <label class="toggle launcher-gateway-switch">
75 + <input type="checkbox" aria-label="Code execution" :checked="$store.launcherGateway.gateway.scopes.code_execution" :disabled="$store.launcherGateway.saving || !$store.launcherGateway.gateway.master_enabled || !$store.launcherGateway.gateway.scopes.file_write" @change="$store.launcherGateway.setScope('code_execution', $event.target.checked)">
76 + <span class="toggler"></span>
77 + </label>
78 + </div>
79 + <div class="launcher-gateway-row" :class="{ 'is-disabled': !$store.launcherGateway.gateway.master_enabled }">
80 + <span>Use my Browser</span>
81 + <label class="toggle launcher-gateway-switch">
82 + <input type="checkbox" aria-label="Use my Browser" :checked="$store.launcherGateway.gateway.scopes.browser" :disabled="$store.launcherGateway.saving || !$store.launcherGateway.gateway.master_enabled" @change="$store.launcherGateway.setScope('browser', $event.target.checked)">
83 + <span class="toggler"></span>
84 + </label>
85 + </div>
86 + <div class="launcher-gateway-row" :class="{ 'is-disabled': !$store.launcherGateway.gateway.master_enabled }">
87 <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>
88 + <label class="toggle launcher-gateway-switch">
89 + <input type="checkbox" aria-label="Computer Use" :checked="$store.launcherGateway.gateway.scopes.computer_use" :disabled="$store.launcherGateway.saving || !$store.launcherGateway.gateway.master_enabled" @change="$store.launcherGateway.setScope('computer_use', $event.target.checked)">
90 + <span class="toggler"></span>
91 + </label>
92 + </div>
93 </div>
94 </template>
95
@@ -132,7 +154,12 @@
154 .launcher-gateway-controls { display: grid; gap: .15rem; margin-top: .7rem; }
155 .launcher-gateway-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; min-height: 2rem; font-size: .82rem; }
156 .launcher-gateway-row.is-master { padding-bottom: .35rem; margin-bottom: .2rem; border-bottom: 1px solid var(--color-border); font-weight: 650; }
135 - .launcher-gateway-row input { accent-color: var(--color-primary); }
157 + .launcher-gateway-row.is-disabled { color: var(--color-text-secondary); opacity: .55; }
158 + .launcher-gateway-switch { flex: 0 0 auto; width: 2.2rem; height: 1.25rem; }
159 + .launcher-gateway-switch .toggler:before { width: .9rem; height: .9rem; left: .18rem; bottom: .18rem; }
160 + .launcher-gateway-switch input:checked + .toggler:before { transform: translateX(.94rem); }
161 + .launcher-gateway-switch input:focus-visible + .toggler { outline: 2px solid var(--color-primary); outline-offset: 2px; }
162 + .launcher-gateway-switch input:disabled + .toggler { cursor: default; }
163 .launcher-gateway-notices { display: grid; gap: .35rem; margin-top: .55rem; }
164 .launcher-gateway-notices p { display: flex; gap: .35rem; margin: 0; color: #d79b35; font-size: .75rem; line-height: 1.3; }
165 .launcher-gateway-notices .material-symbols-outlined { font-size: .95rem; }
plugins/_a0_connector/helpers/ws_runtime.py
+6 -2
@@ -208,7 +208,7 @@ _GATEWAY_STATES = {
208 "error",
209 "disconnected",
210 }
211 -_GATEWAY_SCOPE_KEYS = ("files", "code_execution", "browser", "computer_use")
211 +_GATEWAY_SCOPE_KEYS = ("files", "file_write", "code_execution", "browser", "computer_use")
212
213
214 def _bounded_gateway_status(value: Any, *, depth: int = 0) -> Any:
@@ -250,10 +250,14 @@ def store_sid_launcher_gateway_metadata(
250
251 raw_scopes = payload.get("scopes")
252 scopes = {
253 - key: bool(raw_scopes.get(key)) if isinstance(raw_scopes, dict) else False
253 + key: bool(
254 + raw_scopes.get(key, raw_scopes.get("files") if key == "file_write" else False)
255 + ) if isinstance(raw_scopes, dict) else False
256 for key in _GATEWAY_SCOPE_KEYS
257 }
258 if not scopes["files"]:
259 + scopes["file_write"] = False
260 + if not scopes["file_write"]:
261 scopes["code_execution"] = False
262 master_enabled = bool(payload.get("master_enabled", True))
263 state = str(payload.get("state", "connected") or "").strip().lower()
plugins/_a0_connector/webui/launcher-gateway-store.js
+3 -1
@@ -99,12 +99,14 @@ const model = {
99 const current = this.gateway?.scopes || {};
100 const scopes = {
101 files: Boolean(current.files),
102 + file_write: Boolean(current.file_write ?? current.files),
103 code_execution: Boolean(current.code_execution),
104 browser: Boolean(current.browser),
105 computer_use: Boolean(current.computer_use),
106 [scope]: Boolean(enabled),
107 };
107 - if (!scopes.files) scopes.code_execution = false;
108 + if (!scopes.files) scopes.file_write = false;
109 + if (!scopes.file_write) scopes.code_execution = false;
110 await this.control({ action: "replace_scopes", scopes });
111 },
112
tests/test_a0_connector_launcher_gateway.py
+55 -2
@@ -15,7 +15,12 @@ def _sid(label: str) -> str:
15 return f"gateway-{label}-{uuid.uuid4()}"
16
17
18 -def _gateway(gateway_id: str, *, files: bool = True) -> dict:
18 +def _gateway(
19 + gateway_id: str,
20 + *,
21 + files: bool = True,
22 + file_write: bool | None = None,
23 +) -> dict:
24 return {
25 "version": 1,
26 "kind": "launcher",
@@ -25,6 +30,7 @@ def _gateway(gateway_id: str, *, files: bool = True) -> dict:
30 "master_enabled": True,
31 "scopes": {
32 "files": files,
33 + "file_write": files if file_write is None else file_write,
34 "code_execution": True,
35 "browser": True,
36 "computer_use": True,
@@ -34,6 +40,7 @@ def _gateway(gateway_id: str, *, files: bool = True) -> dict:
40
41 def test_launcher_gateway_features_are_negotiated_on_http_and_websocket() -> None:
42 assert "launcher_gateway" in _feature_list()
43 + assert "launcher_gateway_file_write" in _feature_list()
44 assert "launcher_gateway_control" in WS_FEATURES
45 assert LauncherGatewayStatus.requires_auth() is True
46
@@ -105,18 +112,45 @@ def test_duplicate_gateway_identity_replaces_stale_socket() -> None:
112 ws_runtime.unregister_sid(fresh_sid)
113
114
108 -def test_gateway_disables_code_execution_when_files_are_off() -> None:
115 +def test_gateway_scope_dependencies_keep_reads_separate_from_writes() -> None:
116 sid = _sid("scope")
117 ws_runtime.register_sid(sid)
118 ws_runtime.store_sid_launcher_gateway_metadata(sid, _gateway("installation-a", files=False))
119 try:
120 gateway = ws_runtime.launcher_gateway_status()["gateway"]
121 assert gateway["scopes"]["files"] is False
122 + assert gateway["scopes"]["file_write"] is False
123 + assert gateway["scopes"]["code_execution"] is False
124 + finally:
125 + ws_runtime.unregister_sid(sid)
126 +
127 + sid = _sid("read-only")
128 + ws_runtime.register_sid(sid)
129 + ws_runtime.store_sid_launcher_gateway_metadata(
130 + sid,
131 + _gateway("installation-a", file_write=False),
132 + )
133 + try:
134 + gateway = ws_runtime.launcher_gateway_status()["gateway"]
135 + assert gateway["scopes"]["files"] is True
136 + assert gateway["scopes"]["file_write"] is False
137 assert gateway["scopes"]["code_execution"] is False
138 finally:
139 ws_runtime.unregister_sid(sid)
140
141
142 +def test_legacy_gateway_files_scope_keeps_read_write_behavior() -> None:
143 + sid = _sid("legacy")
144 + payload = _gateway("installation-a")
145 + payload["scopes"].pop("file_write")
146 + ws_runtime.register_sid(sid)
147 + ws_runtime.store_sid_launcher_gateway_metadata(sid, payload)
148 + try:
149 + assert ws_runtime.launcher_gateway_status()["gateway"]["scopes"]["file_write"] is True
150 + finally:
151 + ws_runtime.unregister_sid(sid)
152 +
153 +
154 def test_gateway_status_metadata_is_bounded() -> None:
155 sid = _sid("bounded")
156 payload = _gateway("installation-a")
@@ -178,6 +212,25 @@ def test_gateway_control_requires_csrf_and_waits_for_ack(monkeypatch) -> None:
212 ws_runtime.unregister_sid(sid)
213
214
215 +def test_gateway_scope_control_requires_explicit_file_write() -> None:
216 + handler = launcher_gateway_control.LauncherGatewayControl(None, None)
217 + result = asyncio.run(
218 + handler.process(
219 + {
220 + "action": "replace_scopes",
221 + "scopes": {
222 + "files": True,
223 + "code_execution": True,
224 + "browser": False,
225 + "computer_use": False,
226 + },
227 + },
228 + None,
229 + )
230 + )
231 + assert result.status_code == 400
232 +
233 +
234 def test_gateway_control_acknowledgement_timeout(monkeypatch) -> None:
235 sid = _sid("timeout")
236 ws_runtime.register_sid(sid)