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)