Add Remote Control tunnel providers
Rename the Remote Link UI and user-facing messages to Remote Control. Refactor tunnel startup into provider helpers, remove ngrok support, and keep Cloudflare Tunnel, Microsoft Dev Tunnels, Serveo, and Tailscale wired through the Remote Control selector. Add provider-time binary preparation, Tailscale userspace tailscaled startup, Tailscale login URL display, Microsoft Dev Tunnel progress feedback, and focused regression coverage for the Remote Control provider flows.
Alessandro committed
May 28, 2026 at 22:14 UTC
67224672e89dbd330aab0745230c49b0f6d6fa3a
17 files changed
+1818
-88
extensions/webui/right_canvas_register_surfaces/register-remote-link.js
+1
-1
@@ -1,3 +1,3 @@
1
export default async function registerRemoteLinkAction() {
2
- // Remote Link is opened from the sidebar dropdown, not the right canvas rail.
2
+ // Remote Control is opened from the sidebar dropdown, not the right canvas rail.
3
}
helpers/cli_tunnel.py
new
+262
@@ -0,0 +1,262 @@
1
+import os
2
+import platform
3
+import queue
4
+import shutil
5
+import subprocess
6
+import tarfile
7
+import tempfile
8
+import threading
9
+import time
10
+import urllib.request
11
+import zipfile
12
+from collections import deque
13
+from pathlib import Path
14
+
15
+from flaredantic import NotifyEvent
16
+
17
+from helpers import files
18
+from helpers.tunnel_common import TunnelHelper
19
+
20
+
21
+RUNTIME_BIN_DIR = Path(files.get_abs_path("tmp", "bin"))
22
+
23
+
24
+def executable_name(name):
25
+ return f"{name}.exe" if platform.system().lower() == "windows" else name
26
+
27
+
28
+def chmod_executable(path):
29
+ if platform.system().lower() != "windows":
30
+ path.chmod(0o755)
31
+
32
+
33
+def notify_download(notify, message, data=None):
34
+ if callable(notify):
35
+ notify(NotifyEvent.DOWNLOADING, message, data)
36
+
37
+
38
+def notify_download_complete(notify, message, data=None):
39
+ if callable(notify):
40
+ notify(NotifyEvent.DOWNLOAD_COMPLETE, message, data)
41
+
42
+
43
+def download_file(url, destination, notify=None):
44
+ destination.parent.mkdir(parents=True, exist_ok=True)
45
+ notify_download(notify, f"Downloading {url}")
46
+ temp_fd, temp_name = tempfile.mkstemp(
47
+ prefix=f"{destination.name}.",
48
+ suffix=".download",
49
+ dir=str(destination.parent),
50
+ )
51
+ os.close(temp_fd)
52
+ temp_path = Path(temp_name)
53
+ try:
54
+ with urllib.request.urlopen(url, timeout=60) as response:
55
+ with temp_path.open("wb") as handle:
56
+ shutil.copyfileobj(response, handle)
57
+ temp_path.replace(destination)
58
+ notify_download_complete(notify, f"Downloaded {destination.name}")
59
+ return destination
60
+ except Exception:
61
+ temp_path.unlink(missing_ok=True)
62
+ raise
63
+
64
+
65
+def platform_parts():
66
+ system = platform.system().lower()
67
+ machine = platform.machine().lower()
68
+ if machine in {"x86_64", "amd64"}:
69
+ arch = "amd64"
70
+ elif machine in {"aarch64", "arm64"}:
71
+ arch = "arm64"
72
+ elif machine in {"i386", "i686", "x86"}:
73
+ arch = "386"
74
+ elif machine.startswith("arm"):
75
+ arch = "arm"
76
+ else:
77
+ raise RuntimeError(f"Unsupported CPU architecture for tunnel binary: {machine}")
78
+
79
+ if system not in {"linux", "darwin", "windows"}:
80
+ raise RuntimeError(f"Unsupported operating system for tunnel binary: {system}")
81
+ return system, arch
82
+
83
+
84
+def extract_named_members_from_tar(archive_path, destination_dir, member_names):
85
+ destination_dir.mkdir(parents=True, exist_ok=True)
86
+ extracted = {}
87
+ wanted = set(member_names)
88
+ with tarfile.open(archive_path, "r:*") as archive:
89
+ for member in archive.getmembers():
90
+ member_name = Path(member.name).name
91
+ if member_name not in wanted or not member.isfile():
92
+ continue
93
+ target = destination_dir / executable_name(member_name)
94
+ source = archive.extractfile(member)
95
+ if source is None:
96
+ continue
97
+ with source, target.open("wb") as handle:
98
+ shutil.copyfileobj(source, handle)
99
+ chmod_executable(target)
100
+ extracted[member_name] = target
101
+ missing = wanted - set(extracted)
102
+ if missing:
103
+ raise RuntimeError(
104
+ f"Archive {archive_path.name} did not contain expected binaries: "
105
+ f"{', '.join(sorted(missing))}."
106
+ )
107
+ return extracted
108
+
109
+
110
+def extract_named_members_from_zip(archive_path, destination_dir, member_names):
111
+ destination_dir.mkdir(parents=True, exist_ok=True)
112
+ extracted = {}
113
+ wanted = set(member_names)
114
+ with zipfile.ZipFile(archive_path) as archive:
115
+ for member in archive.infolist():
116
+ member_name = Path(member.filename).name
117
+ normalized_name = member_name.removesuffix(".exe")
118
+ if normalized_name not in wanted or member.is_dir():
119
+ continue
120
+ target = destination_dir / executable_name(normalized_name)
121
+ with archive.open(member) as source, target.open("wb") as handle:
122
+ shutil.copyfileobj(source, handle)
123
+ chmod_executable(target)
124
+ extracted[normalized_name] = target
125
+ missing = wanted - set(extracted)
126
+ if missing:
127
+ raise RuntimeError(
128
+ f"Archive {archive_path.name} did not contain expected binaries: "
129
+ f"{', '.join(sorted(missing))}."
130
+ )
131
+ return extracted
132
+
133
+
134
+class CliTunnelHelper(TunnelHelper):
135
+ label = "CLI tunnel"
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ label,
141
+ binary,
142
+ port,
143
+ command,
144
+ url_pattern,
145
+ missing_binary_message,
146
+ shutdown_command=None,
147
+ timeout=30,
148
+ notify=None,
149
+ binary_resolver=None,
150
+ preflight=None,
151
+ ):
152
+ super().__init__(port, notify=notify)
153
+ self.label = label
154
+ self.binary = binary
155
+ self.command = command
156
+ self.url_pattern = url_pattern
157
+ self.missing_binary_message = missing_binary_message
158
+ self.shutdown_command = shutdown_command
159
+ self.timeout = timeout
160
+ self.tunnel_process = None
161
+ self.binary_path = None
162
+ self.binary_resolver = binary_resolver
163
+ self.preflight = preflight
164
+ self.command_prefix = []
165
+
166
+ def _extract_url(self, line):
167
+ match = self.url_pattern.search(line)
168
+ if not match:
169
+ return None
170
+ return match.group(1 if match.lastindex else 0).rstrip(".,)")
171
+
172
+ def start(self):
173
+ binary_path = (
174
+ self.binary_resolver(self._notify)
175
+ if callable(self.binary_resolver)
176
+ else shutil.which(self.binary)
177
+ )
178
+ if not binary_path:
179
+ raise RuntimeError(self.missing_binary_message)
180
+ self.binary_path = binary_path
181
+ if callable(self.preflight):
182
+ preflight_result = self.preflight(binary_path, notify=self._notify)
183
+ if isinstance(preflight_result, dict):
184
+ self.command_prefix = list(preflight_result.get("command_prefix") or [])
185
+
186
+ command = [binary_path, *self.command_prefix, *self.command[1:]]
187
+ self.notify_starting(self.label)
188
+ self.tunnel_process = subprocess.Popen(
189
+ command,
190
+ stdout=subprocess.PIPE,
191
+ stderr=subprocess.STDOUT,
192
+ text=True,
193
+ bufsize=1,
194
+ )
195
+
196
+ output_queue = queue.Queue()
197
+
198
+ def read_output():
199
+ if self.tunnel_process is None or self.tunnel_process.stdout is None:
200
+ return
201
+ for line in self.tunnel_process.stdout:
202
+ output_queue.put(line)
203
+
204
+ threading.Thread(target=read_output, daemon=True).start()
205
+
206
+ deadline = time.monotonic() + self.timeout
207
+ recent_output = deque(maxlen=8)
208
+ while time.monotonic() < deadline:
209
+ try:
210
+ line = output_queue.get(timeout=0.1)
211
+ except queue.Empty:
212
+ if self.tunnel_process.poll() is not None:
213
+ break
214
+ continue
215
+
216
+ cleaned_line = line.strip()
217
+ if cleaned_line:
218
+ recent_output.append(cleaned_line)
219
+ url = self._extract_url(cleaned_line)
220
+ if url:
221
+ self.tunnel_url = url
222
+ self.notify_url_ready(self.label, url)
223
+ return self.tunnel_url
224
+
225
+ details = " ".join(recent_output)
226
+ if self.tunnel_process.poll() is not None:
227
+ raise RuntimeError(
228
+ f"{self.label} exited before it reported a remote URL."
229
+ + (f" Output: {details}" if details else "")
230
+ )
231
+ self._terminate_process()
232
+ raise RuntimeError(
233
+ f"{self.label} did not report a remote URL within {self.timeout} seconds."
234
+ + (f" Output: {details}" if details else "")
235
+ )
236
+
237
+ def _terminate_process(self):
238
+ if not self.tunnel_process or self.tunnel_process.poll() is not None:
239
+ return
240
+ self.tunnel_process.terminate()
241
+ try:
242
+ self.tunnel_process.wait(timeout=8)
243
+ except subprocess.TimeoutExpired:
244
+ self.tunnel_process.kill()
245
+ self.tunnel_process.wait(timeout=3)
246
+
247
+ def stop(self):
248
+ self._terminate_process()
249
+
250
+ if self.shutdown_command:
251
+ binary_path = self.binary_path or shutil.which(self.binary)
252
+ if binary_path:
253
+ subprocess.run(
254
+ [binary_path, *self.command_prefix, *self.shutdown_command[1:]],
255
+ check=False,
256
+ stdout=subprocess.DEVNULL,
257
+ stderr=subprocess.DEVNULL,
258
+ timeout=10,
259
+ )
260
+ self.tunnel_url = None
261
+ self.notify_stopped(self.label)
262
+ return True
helpers/cloudflare_tunnel._py
+5
-5
@@ -120,11 +120,11 @@ class CloudflareTunnel:
120
return
121
122
def start(self):
123
- """Starts the cloudflare tunnel"""
123
+ """Starts Cloudflare Tunnel"""
124
if not self.cloudflared_path:
125
self.download_cloudflared()
126
127
- PrintStyle().print("\nStarting Cloudflare tunnel...")
127
+ PrintStyle().print("\nStarting Cloudflare Tunnel...")
128
# Start tunnel process
129
self.tunnel_process = subprocess.Popen(
130
[
@@ -147,11 +147,11 @@ class CloudflareTunnel:
147
).start()
148
149
def stop(self):
150
- """Stops the cloudflare tunnel"""
150
+ """Stops Cloudflare Tunnel"""
151
self._stop_event.set()
152
if self.tunnel_process:
153
- PrintStyle().print("\nStopping Cloudflare tunnel...")
153
+ PrintStyle().print("\nStopping Cloudflare Tunnel...")
154
self.tunnel_process.terminate()
155
self.tunnel_process.wait()
156
self.tunnel_process = None
157
- self.tunnel_url = None
\ No newline at end of file
157
+ self.tunnel_url = None
helpers/cloudflare_tunnel.py
new
+11
@@ -0,0 +1,11 @@
1
+from flaredantic import FlareConfig, FlareTunnel
2
+
3
+from helpers.tunnel_common import FlaredanticTunnelHelper
4
+
5
+
6
+class CloudflareTunnel(FlaredanticTunnelHelper):
7
+ label = "Cloudflare Tunnel"
8
+
9
+ def build_tunnel(self):
10
+ config = FlareConfig(port=self.port, verbose=True)
11
+ return FlareTunnel(config)
helpers/microsoft_tunnel.py
new
+144
@@ -0,0 +1,144 @@
1
+import getpass
2
+import hashlib
3
+import os
4
+import socket
5
+
6
+from flaredantic import MicrosoftConfig, MicrosoftTunnel, NotifyEvent
7
+
8
+try:
9
+ from flaredantic.core.exceptions import MicrosoftTunnelError
10
+except Exception: # pragma: no cover - keeps tests independent from package internals
11
+ MicrosoftTunnelError = RuntimeError
12
+
13
+from helpers import files
14
+from helpers.tunnel_common import FlaredanticTunnelHelper
15
+
16
+
17
+MICROSOFT_TUNNEL_ID_ENV_KEYS = (
18
+ "A0_MICROSOFT_DEV_TUNNEL_ID",
19
+ "MICROSOFT_DEV_TUNNEL_ID",
20
+)
21
+MICROSOFT_TUNNEL_TIMEOUT = 120
22
+
23
+
24
+def default_microsoft_tunnel_id():
25
+ for env_key in MICROSOFT_TUNNEL_ID_ENV_KEYS:
26
+ configured = (os.environ.get(env_key) or "").strip()
27
+ if configured:
28
+ return configured
29
+
30
+ seed = "|".join([
31
+ getpass.getuser(),
32
+ socket.gethostname(),
33
+ files.get_abs_path("usr"),
34
+ ])
35
+ digest = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:10]
36
+ return f"agent-zero-{digest}"
37
+
38
+
39
+class AgentZeroMicrosoftTunnel(MicrosoftTunnel):
40
+ def notify(self, event, message, data=None):
41
+ try:
42
+ return super().notify(event, message, data)
43
+ except AttributeError:
44
+ self.agent_zero_notifications.append({
45
+ "event": event.value if hasattr(event, "value") else event,
46
+ "message": message,
47
+ "data": data,
48
+ })
49
+ return None
50
+
51
+ @property
52
+ def agent_zero_notifications(self):
53
+ if not hasattr(self, "_agent_zero_notifications"):
54
+ self._agent_zero_notifications = []
55
+ return self._agent_zero_notifications
56
+
57
+ def _notify_progress(self, message, data=None):
58
+ self.notify(NotifyEvent.INFO, message, data)
59
+
60
+ def _ensure_logged_in(self):
61
+ parent = getattr(super(), "_ensure_logged_in", None)
62
+ if callable(parent):
63
+ parent()
64
+ self._notify_progress(
65
+ "Microsoft Dev Tunnels login confirmed. Preparing your tunnel..."
66
+ )
67
+
68
+ def _ensure_tunnel(self):
69
+ tunnel_id = self.config.tunnel_id
70
+ port = str(self.config.port)
71
+
72
+ self._notify_progress(
73
+ f"Checking Microsoft Dev Tunnel `{tunnel_id}`...",
74
+ {"tunnel_id": tunnel_id},
75
+ )
76
+ show = self._run_cmd(["show", tunnel_id])
77
+ if show.returncode != 0:
78
+ self._notify_progress(
79
+ f"Creating Microsoft Dev Tunnel `{tunnel_id}`...",
80
+ {"tunnel_id": tunnel_id},
81
+ )
82
+ create = self._run_cmd(["create", tunnel_id])
83
+ if create.returncode != 0:
84
+ raise MicrosoftTunnelError(f"Failed to create tunnel: {create.stdout}")
85
+ else:
86
+ self._notify_progress(
87
+ f"Microsoft Dev Tunnel `{tunnel_id}` already exists. Checking port {port}...",
88
+ {"tunnel_id": tunnel_id, "port": port},
89
+ )
90
+
91
+ self._notify_progress(
92
+ f"Checking Microsoft Dev Tunnel port {port}...",
93
+ {"tunnel_id": tunnel_id, "port": port},
94
+ )
95
+ port_show = self._run_cmd(["port", "show", tunnel_id, "-p", port])
96
+ if port_show.returncode != 0:
97
+ self._notify_progress(
98
+ f"Creating Microsoft Dev Tunnel port {port}...",
99
+ {"tunnel_id": tunnel_id, "port": port},
100
+ )
101
+ port_create = self._run_cmd([
102
+ "port",
103
+ "create",
104
+ tunnel_id,
105
+ "-p",
106
+ port,
107
+ "--protocol",
108
+ "http",
109
+ ])
110
+ if port_create.returncode != 0:
111
+ raise MicrosoftTunnelError(
112
+ f"Failed to create port: {port_create.stdout}"
113
+ )
114
+
115
+ self._notify_progress(
116
+ "Microsoft Dev Tunnel setup is ready. Starting the secure host..."
117
+ )
118
+
119
+
120
+class MicrosoftDevTunnel(FlaredanticTunnelHelper):
121
+ label = "Microsoft Dev Tunnels"
122
+
123
+ def build_tunnel(self):
124
+ config = MicrosoftConfig(
125
+ port=self.port,
126
+ verbose=True,
127
+ timeout=MICROSOFT_TUNNEL_TIMEOUT,
128
+ tunnel_id=default_microsoft_tunnel_id(),
129
+ )
130
+ return AgentZeroMicrosoftTunnel(config)
131
+
132
+ def start(self):
133
+ try:
134
+ return super().start()
135
+ except Exception as e:
136
+ if "Timeout waiting for Microsoft Dev Tunnels URL" not in str(e):
137
+ raise
138
+ tunnel_id = default_microsoft_tunnel_id()
139
+ raise RuntimeError(
140
+ "Microsoft Dev Tunnels did not return a URL. Agent Zero uses "
141
+ f"the tunnel id `{tunnel_id}` to avoid flaredantic's global "
142
+ "`flaredantic` tunnel-id collision. If this still fails, set "
143
+ "`A0_MICROSOFT_DEV_TUNNEL_ID` to a fresh unique value and try again."
144
+ ) from e
helpers/runtime.py
+1
-1
@@ -31,7 +31,7 @@ def initialize():
31
"--cloudflare_tunnel",
32
type=bool,
33
default=False,
34
- help="Use cloudflare tunnel for public URL",
34
+ help="Use Cloudflare Tunnel for public URL",
35
)
36
parser.add_argument(
37
"--development", type=bool, default=False, help="Development mode"
helpers/serveo_tunnel.py
new
+11
@@ -0,0 +1,11 @@
1
+from flaredantic import ServeoConfig, ServeoTunnel
2
+
3
+from helpers.tunnel_common import FlaredanticTunnelHelper
4
+
5
+
6
+class ServeoTunnelHelper(FlaredanticTunnelHelper):
7
+ label = "Serveo"
8
+
9
+ def build_tunnel(self):
10
+ config = ServeoConfig(port=self.port)
11
+ return ServeoTunnel(config)
helpers/tailscale_tunnel.py
new
+433
@@ -0,0 +1,433 @@
1
+from collections import deque
2
+import json
3
+import os
4
+import queue
5
+import re
6
+import shutil
7
+import subprocess
8
+import threading
9
+import time
10
+import urllib.parse
11
+import urllib.request
12
+from pathlib import Path
13
+
14
+from flaredantic import NotifyEvent
15
+
16
+from helpers import cli_tunnel, files
17
+from helpers.cli_tunnel import CliTunnelHelper
18
+
19
+
20
+TAILSCALE_URL_RE = re.compile(r"https://[a-zA-Z0-9.-]+\.ts\.net[^\s\"']*")
21
+TAILSCALE_LOGIN_URL_RE = re.compile(r"https://login\.tailscale\.com/[^\s\"']+")
22
+TAILSCALE_STABLE_PACKAGES_URL = "https://pkgs.tailscale.com/stable/?v=latest"
23
+TAILSCALE_UP_TIMEOUT = 180
24
+TAILSCALE_DAEMON_START_TIMEOUT = 12
25
+TAILSCALE_RUNTIME_DIR = Path(files.get_abs_path("tmp", "tailscale"))
26
+TAILSCALE_STATE_DIR = Path(files.get_abs_path("usr", "tailscale"))
27
+TAILSCALE_SOCKET_PATH = TAILSCALE_RUNTIME_DIR / "tailscaled.sock"
28
+TAILSCALE_DAEMON_LOG_PATH = TAILSCALE_RUNTIME_DIR / "tailscaled.log"
29
+TAILSCALE_DAEMON_PID_PATH = TAILSCALE_RUNTIME_DIR / "tailscaled.pid"
30
+
31
+
32
+def tailscale_arch():
33
+ _, arch = cli_tunnel.platform_parts()
34
+ return arch
35
+
36
+
37
+def tailscale_archive_url():
38
+ arch = tailscale_arch()
39
+ with urllib.request.urlopen(TAILSCALE_STABLE_PACKAGES_URL, timeout=30) as response:
40
+ html = response.read().decode("utf-8", errors="replace")
41
+ pattern = re.compile(rf'href="([^"]*tailscale_[^"]+_{re.escape(arch)}\.tgz)"')
42
+ match = pattern.search(html)
43
+ if not match:
44
+ raise RuntimeError(f"Could not find a Tailscale static binary for {arch}.")
45
+ return urllib.parse.urljoin(TAILSCALE_STABLE_PACKAGES_URL, match.group(1))
46
+
47
+
48
+def install_tailscale(notify=None):
49
+ existing = shutil.which("tailscale")
50
+ if existing and resolve_tailscaled_binary(existing):
51
+ return existing
52
+
53
+ install_path = cli_tunnel.RUNTIME_BIN_DIR / cli_tunnel.executable_name("tailscale")
54
+ daemon_path = cli_tunnel.RUNTIME_BIN_DIR / cli_tunnel.executable_name("tailscaled")
55
+ if install_path.exists() and daemon_path.exists():
56
+ return str(install_path)
57
+
58
+ download_url = tailscale_archive_url()
59
+ archive_path = (
60
+ cli_tunnel.RUNTIME_BIN_DIR
61
+ / urllib.parse.urlparse(download_url).path.rsplit("/", 1)[-1]
62
+ )
63
+ cli_tunnel.download_file(download_url, archive_path, notify=notify)
64
+ try:
65
+ extracted = cli_tunnel.extract_named_members_from_tar(
66
+ archive_path,
67
+ cli_tunnel.RUNTIME_BIN_DIR,
68
+ {"tailscale", "tailscaled"},
69
+ )
70
+ return str(extracted["tailscale"])
71
+ finally:
72
+ archive_path.unlink(missing_ok=True)
73
+
74
+
75
+def resolve_tailscaled_binary(binary_path):
76
+ sibling = Path(binary_path).with_name(cli_tunnel.executable_name("tailscaled"))
77
+ if sibling.exists():
78
+ return str(sibling)
79
+ return shutil.which("tailscaled")
80
+
81
+
82
+def notify_info(notify, message, data=None):
83
+ if callable(notify):
84
+ notify(NotifyEvent.INFO, message, data)
85
+
86
+
87
+def tailscale_socket_args(socket_path=None):
88
+ return ["--socket", str(socket_path)] if socket_path else []
89
+
90
+
91
+def tailscale_command(binary_path, args, socket_path=None):
92
+ return [binary_path, *tailscale_socket_args(socket_path), *args]
93
+
94
+
95
+def tailscale_status(binary_path, socket_path=None):
96
+ return subprocess.run(
97
+ tailscale_command(binary_path, ["status", "--json"], socket_path),
98
+ check=False,
99
+ text=True,
100
+ capture_output=True,
101
+ timeout=12,
102
+ )
103
+
104
+
105
+def compact_output(lines):
106
+ return " ".join(line.strip() for line in lines if line and line.strip())
107
+
108
+
109
+def tailscale_daemon_hint(output):
110
+ lowered = output.lower()
111
+ return any(
112
+ marker in lowered
113
+ for marker in (
114
+ "failed to connect to local tailscaled",
115
+ "tailscaled.sock",
116
+ "no such file or directory",
117
+ "connection refused",
118
+ "is tailscaled running",
119
+ )
120
+ )
121
+
122
+
123
+def tailscale_daemon_ready(binary_path, socket_path):
124
+ completed = tailscale_status(binary_path, socket_path=socket_path)
125
+ output = compact_output([completed.stderr, completed.stdout])
126
+ return not tailscale_daemon_hint(output)
127
+
128
+
129
+def read_recent_tailscaled_log():
130
+ if not TAILSCALE_DAEMON_LOG_PATH.exists():
131
+ return ""
132
+ try:
133
+ lines = TAILSCALE_DAEMON_LOG_PATH.read_text(
134
+ encoding="utf-8",
135
+ errors="replace",
136
+ ).splitlines()
137
+ except OSError:
138
+ return ""
139
+ return compact_output(lines[-12:])
140
+
141
+
142
+def start_tailscaled(binary_path, notify=None):
143
+ daemon_path = resolve_tailscaled_binary(binary_path)
144
+ if not daemon_path:
145
+ raise RuntimeError(
146
+ "Tailscale was prepared, but Agent Zero could not find the "
147
+ "`tailscaled` daemon binary. Try Tailscale Remote Control again so "
148
+ "Agent Zero can re-download the static Tailscale package."
149
+ )
150
+
151
+ if tailscale_daemon_ready(binary_path, TAILSCALE_SOCKET_PATH):
152
+ return TAILSCALE_SOCKET_PATH
153
+
154
+ TAILSCALE_RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
155
+ TAILSCALE_STATE_DIR.mkdir(parents=True, exist_ok=True)
156
+ TAILSCALE_SOCKET_PATH.unlink(missing_ok=True)
157
+
158
+ notify_info(
159
+ notify,
160
+ "Starting Tailscale's background service inside this container...",
161
+ )
162
+
163
+ log_handle = TAILSCALE_DAEMON_LOG_PATH.open("ab")
164
+ process = subprocess.Popen(
165
+ [
166
+ daemon_path,
167
+ "--tun=userspace-networking",
168
+ "--socket",
169
+ str(TAILSCALE_SOCKET_PATH),
170
+ "--statedir",
171
+ str(TAILSCALE_STATE_DIR),
172
+ "--state",
173
+ str(TAILSCALE_STATE_DIR / "tailscaled.state"),
174
+ ],
175
+ stdout=log_handle,
176
+ stderr=subprocess.STDOUT,
177
+ stdin=subprocess.DEVNULL,
178
+ start_new_session=True,
179
+ close_fds=True,
180
+ )
181
+ log_handle.close()
182
+ TAILSCALE_DAEMON_PID_PATH.write_text(str(process.pid), encoding="utf-8")
183
+
184
+ deadline = time.monotonic() + TAILSCALE_DAEMON_START_TIMEOUT
185
+ while time.monotonic() < deadline:
186
+ if process.poll() is not None:
187
+ break
188
+ if tailscale_daemon_ready(binary_path, TAILSCALE_SOCKET_PATH):
189
+ notify_info(notify, "Tailscale background service is running.")
190
+ return TAILSCALE_SOCKET_PATH
191
+ time.sleep(0.25)
192
+
193
+ details = read_recent_tailscaled_log()
194
+ if process.poll() is None:
195
+ process.terminate()
196
+ try:
197
+ process.wait(timeout=5)
198
+ except subprocess.TimeoutExpired:
199
+ process.kill()
200
+ process.wait(timeout=3)
201
+ TAILSCALE_DAEMON_PID_PATH.unlink(missing_ok=True)
202
+ message = (
203
+ "Agent Zero downloaded Tailscale, but could not start the `tailscaled` "
204
+ "background service in this container."
205
+ )
206
+ if details:
207
+ message = f"{message} Details: {details}"
208
+ raise RuntimeError(message)
209
+
210
+
211
+def stop_managed_tailscaled():
212
+ if not TAILSCALE_DAEMON_PID_PATH.exists():
213
+ return
214
+ try:
215
+ pid = int(TAILSCALE_DAEMON_PID_PATH.read_text(encoding="utf-8").strip())
216
+ except Exception:
217
+ TAILSCALE_DAEMON_PID_PATH.unlink(missing_ok=True)
218
+ return
219
+ try:
220
+ cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().decode(
221
+ "utf-8",
222
+ errors="replace",
223
+ )
224
+ except Exception:
225
+ cmdline = ""
226
+ if "tailscaled" in cmdline:
227
+ try:
228
+ os.kill(pid, 15)
229
+ deadline = time.monotonic() + 5
230
+ while time.monotonic() < deadline:
231
+ try:
232
+ os.kill(pid, 0)
233
+ except ProcessLookupError:
234
+ break
235
+ time.sleep(0.1)
236
+ else:
237
+ os.kill(pid, 9)
238
+ except ProcessLookupError:
239
+ pass
240
+ except PermissionError:
241
+ pass
242
+ TAILSCALE_DAEMON_PID_PATH.unlink(missing_ok=True)
243
+ TAILSCALE_SOCKET_PATH.unlink(missing_ok=True)
244
+
245
+
246
+def tailscale_up_failure_message(output, *, timed_out=False):
247
+ details = compact_output(output)
248
+ if tailscale_daemon_hint(details):
249
+ message = (
250
+ "Agent Zero started Tailscale and ran `tailscale up`, but the "
251
+ "Tailscale background service stopped responding."
252
+ )
253
+ elif timed_out:
254
+ message = (
255
+ "Agent Zero ran `tailscale up`, but it did not finish before the "
256
+ "setup timeout. If a Tailscale login link was shown, approve this "
257
+ "container in your browser, then start Remote Control again."
258
+ )
259
+ else:
260
+ message = (
261
+ "Agent Zero ran `tailscale up`, but Tailscale did not finish joining "
262
+ "this container to your tailnet. Complete any Tailscale login or "
263
+ "admin approval it requested, then try Tailscale Remote Control again."
264
+ )
265
+ if details:
266
+ message = f"{message} Details: {details}"
267
+ return message
268
+
269
+
270
+def run_tailscale_up(
271
+ binary_path,
272
+ notify=None,
273
+ timeout=TAILSCALE_UP_TIMEOUT,
274
+ socket_path=None,
275
+):
276
+ notify_info(
277
+ notify,
278
+ "Tailscale needs this container to join your tailnet. Running `tailscale up` now...",
279
+ )
280
+ process = subprocess.Popen(
281
+ tailscale_command(binary_path, ["up"], socket_path),
282
+ stdout=subprocess.PIPE,
283
+ stderr=subprocess.STDOUT,
284
+ text=True,
285
+ bufsize=1,
286
+ )
287
+ output_queue = queue.Queue()
288
+
289
+ def read_output():
290
+ if process.stdout is None:
291
+ return
292
+ for line in process.stdout:
293
+ output_queue.put(line)
294
+
295
+ reader = threading.Thread(target=read_output, daemon=True)
296
+ reader.start()
297
+ deadline = time.monotonic() + timeout
298
+ recent_output = deque(maxlen=10)
299
+ login_announced = False
300
+
301
+ def record_line(line):
302
+ nonlocal login_announced
303
+ cleaned = line.strip()
304
+ if not cleaned:
305
+ return
306
+ recent_output.append(cleaned)
307
+ login_match = TAILSCALE_LOGIN_URL_RE.search(cleaned)
308
+ if login_match and not login_announced:
309
+ login_url = login_match.group(0).rstrip(".,)")
310
+ notify_info(
311
+ notify,
312
+ "Open the Tailscale login link and approve this container. "
313
+ "Agent Zero will continue when Tailscale finishes setup.",
314
+ {"provider": "tailscale", "url": login_url},
315
+ )
316
+ login_announced = True
317
+
318
+ while True:
319
+ try:
320
+ record_line(output_queue.get(timeout=0.1))
321
+ except queue.Empty:
322
+ if process.poll() is not None:
323
+ reader.join(timeout=1)
324
+ while True:
325
+ try:
326
+ record_line(output_queue.get_nowait())
327
+ except queue.Empty:
328
+ break
329
+ break
330
+ if time.monotonic() >= deadline:
331
+ process.terminate()
332
+ try:
333
+ process.wait(timeout=8)
334
+ except subprocess.TimeoutExpired:
335
+ process.kill()
336
+ process.wait(timeout=3)
337
+ reader.join(timeout=1)
338
+ while True:
339
+ try:
340
+ record_line(output_queue.get_nowait())
341
+ except queue.Empty:
342
+ break
343
+ raise RuntimeError(
344
+ tailscale_up_failure_message(recent_output, timed_out=True)
345
+ )
346
+ continue
347
+
348
+ returncode = process.wait(timeout=3)
349
+ if returncode != 0:
350
+ raise RuntimeError(tailscale_up_failure_message(recent_output))
351
+
352
+ notify_info(
353
+ notify,
354
+ "Tailscale setup completed. Checking the tailnet connection...",
355
+ )
356
+
357
+
358
+def ensure_tailscale_ready(binary_path, notify=None):
359
+ socket_path = None
360
+ completed = tailscale_status(binary_path)
361
+ if completed.returncode != 0 and tailscale_daemon_hint(
362
+ compact_output([completed.stderr, completed.stdout])
363
+ ):
364
+ socket_path = start_tailscaled(binary_path, notify=notify)
365
+ completed = tailscale_status(binary_path, socket_path=socket_path)
366
+
367
+ if completed.returncode != 0:
368
+ run_tailscale_up(binary_path, notify=notify, socket_path=socket_path)
369
+ completed = tailscale_status(binary_path, socket_path=socket_path)
370
+ if completed.returncode != 0:
371
+ details = compact_output([completed.stderr, completed.stdout])
372
+ message = (
373
+ "Agent Zero ran `tailscale up`, but Tailscale status is still "
374
+ "not available. Tailscale may still need browser approval, admin "
375
+ "approval, or a running `tailscaled` service."
376
+ )
377
+ if details:
378
+ message = f"{message} Details: {details}"
379
+ raise RuntimeError(message)
380
+
381
+ try:
382
+ payload = json.loads(completed.stdout or "{}")
383
+ except json.JSONDecodeError:
384
+ return
385
+
386
+ backend_state = str(payload.get("BackendState") or "").lower()
387
+ if backend_state and backend_state != "running":
388
+ run_tailscale_up(binary_path, notify=notify, socket_path=socket_path)
389
+ completed = tailscale_status(binary_path, socket_path=socket_path)
390
+ try:
391
+ payload = json.loads(completed.stdout or "{}")
392
+ except json.JSONDecodeError:
393
+ return {"command_prefix": tailscale_socket_args(socket_path)}
394
+ backend_state = str(payload.get("BackendState") or "").lower()
395
+ if backend_state and backend_state != "running":
396
+ raise RuntimeError(
397
+ "Agent Zero ran `tailscale up`, but this node is still not ready "
398
+ "for Tailscale Funnel "
399
+ f"(state: {backend_state}). Complete Tailscale sign-in or admin "
400
+ "approval, then try again."
401
+ )
402
+
403
+ return {"command_prefix": tailscale_socket_args(socket_path)}
404
+
405
+
406
+class TailscaleTunnel(CliTunnelHelper):
407
+ def __init__(self, port, notify=None):
408
+ target = f"http://127.0.0.1:{port}"
409
+ super().__init__(
410
+ label="Tailscale Funnel",
411
+ binary="tailscale",
412
+ port=port,
413
+ command=["tailscale", "funnel", "--yes", target],
414
+ url_pattern=TAILSCALE_URL_RE,
415
+ missing_binary_message=(
416
+ "Tailscale could not be prepared in this environment. Install "
417
+ "Tailscale, make sure the `tailscaled` service is available to "
418
+ "this container, enable Funnel for the tailnet, then try again."
419
+ ),
420
+ shutdown_command=["tailscale", "funnel", "--yes", target, "off"],
421
+ binary_resolver=lambda notify_callback: install_tailscale(
422
+ notify=notify_callback
423
+ ),
424
+ preflight=ensure_tailscale_ready,
425
+ notify=notify,
426
+ )
427
+
428
+ def stop(self):
429
+ try:
430
+ return super().stop()
431
+ finally:
432
+ if self.command_prefix:
433
+ stop_managed_tailscaled()
helpers/tunnel_common.py
new
+56
@@ -0,0 +1,56 @@
1
+from flaredantic import NotifyEvent
2
+
3
+
4
+def event_value(event):
5
+ return event.value if hasattr(event, "value") else event
6
+
7
+
8
+class TunnelHelper:
9
+ def __init__(self, port, notify=None):
10
+ self.port = port
11
+ self.notify_callback = notify
12
+ self.tunnel = None
13
+ self.tunnel_url = None
14
+
15
+ def _notify(self, event, message, data=None):
16
+ if callable(self.notify_callback):
17
+ self.notify_callback(event, message, data)
18
+
19
+ def notify_starting(self, label):
20
+ self._notify(
21
+ NotifyEvent.CREATING_TUNNEL,
22
+ f"Starting {label} on port {self.port}...",
23
+ )
24
+
25
+ def notify_url_ready(self, label, url):
26
+ self._notify(
27
+ NotifyEvent.TUNNEL_URL,
28
+ f"{label} URL is ready",
29
+ {"url": url},
30
+ )
31
+
32
+ def notify_stopped(self, label):
33
+ self._notify(NotifyEvent.TUNNEL_STOPPED, f"{label} stopped")
34
+
35
+
36
+class FlaredanticTunnelHelper(TunnelHelper):
37
+ label = "Remote Control"
38
+
39
+ def build_tunnel(self):
40
+ raise NotImplementedError
41
+
42
+ def start(self):
43
+ self.notify_starting(self.label)
44
+ self.tunnel = self.build_tunnel()
45
+ self.tunnel.start()
46
+ self.tunnel_url = getattr(self.tunnel, "tunnel_url", None)
47
+ if self.tunnel_url:
48
+ self.notify_url_ready(self.label, self.tunnel_url)
49
+ return self.tunnel_url
50
+
51
+ def stop(self):
52
+ if self.tunnel:
53
+ self.tunnel.stop()
54
+ self.tunnel_url = None
55
+ self.notify_stopped(self.label)
56
+ return True
helpers/tunnel_manager.py
+74
-39
@@ -1,13 +1,42 @@
1
-from flaredantic import (
2
- FlareTunnel, FlareConfig,
3
- ServeoConfig, ServeoTunnel,
4
- MicrosoftTunnel, MicrosoftConfig,
5
- notifier, NotifyData, NotifyEvent
6
-)
1
import threading
2
+import time
3
from collections import deque
4
5
+from flaredantic import NotifyData, NotifyEvent, notifier
6
+
7
+from helpers.cloudflare_tunnel import CloudflareTunnel
8
+from helpers.microsoft_tunnel import MicrosoftDevTunnel
9
from helpers.print_style import PrintStyle
10
+from helpers.serveo_tunnel import ServeoTunnelHelper
11
+from helpers.tailscale_tunnel import TailscaleTunnel
12
+from helpers.tunnel_common import event_value
13
+
14
+
15
+SUPPORTED_TUNNEL_PROVIDERS = {
16
+ "cloudflared",
17
+ "microsoft",
18
+ "serveo",
19
+ "tailscale",
20
+}
21
+TUNNEL_PROVIDER_ALIASES = {
22
+ "cloudflare": "cloudflared",
23
+ "cloudflare_tunnel": "cloudflared",
24
+ "cloudflare-tunnel": "cloudflared",
25
+ "tailscale_funnel": "tailscale",
26
+ "tailscale-funnel": "tailscale",
27
+}
28
+
29
+
30
+def normalize_provider(provider):
31
+ normalized = (provider or "serveo").strip().lower()
32
+ normalized = TUNNEL_PROVIDER_ALIASES.get(normalized, normalized)
33
+ if normalized not in SUPPORTED_TUNNEL_PROVIDERS:
34
+ supported = ", ".join(sorted(SUPPORTED_TUNNEL_PROVIDERS))
35
+ raise ValueError(
36
+ f"Unsupported remote control provider '{provider}'. Choose one of: {supported}."
37
+ )
38
+ return normalized
39
+
40
41
# Singleton to manage the tunnel instance
42
class TunnelManager:
@@ -30,80 +59,86 @@ class TunnelManager:
59
self._subscribed = False
60
61
def _on_notify(self, data: NotifyData):
33
- """Handle notifications from flaredantic"""
62
+ """Handle notifications from flaredantic."""
63
self.notifications.append({
64
"event": data.event.value,
65
"message": data.message,
37
- "data": data.data
66
+ "data": data.data,
67
})
68
69
def _ensure_subscribed(self):
41
- """Subscribe to flaredantic notifications if not already"""
70
+ """Subscribe to flaredantic notifications if not already."""
71
if not self._subscribed:
72
notifier.subscribe(self._on_notify)
73
self._subscribed = True
74
75
def get_notifications(self):
47
- """Get and clear pending notifications"""
76
+ """Get and clear pending notifications."""
77
notifications = list(self.notifications)
78
self.notifications.clear()
79
return notifications
80
81
def get_last_error(self):
53
- """Check for recent error in notifications without clearing"""
54
- for n in reversed(list(self.notifications)):
55
- if n['event'] == NotifyEvent.ERROR.value:
56
- return n['message']
82
+ """Check for recent error in notifications without clearing."""
83
+ for notification in reversed(list(self.notifications)):
84
+ if notification["event"] == NotifyEvent.ERROR.value:
85
+ return notification["message"]
86
return None
87
88
+ def _append_notification(self, event, message, data=None):
89
+ self.notifications.append({
90
+ "event": event_value(event),
91
+ "message": message,
92
+ "data": data,
93
+ })
94
+
95
+ def _create_tunnel(self, port, provider):
96
+ if provider == "cloudflared":
97
+ return CloudflareTunnel(port, notify=self._append_notification)
98
+ if provider == "microsoft":
99
+ return MicrosoftDevTunnel(port, notify=self._append_notification)
100
+ if provider == "tailscale":
101
+ return TailscaleTunnel(port, notify=self._append_notification)
102
+ return ServeoTunnelHelper(port, notify=self._append_notification)
103
+
104
def start_tunnel(self, port=80, provider="serveo"):
60
- """Start a new tunnel or return the existing one's URL"""
105
+ """Start a new tunnel or return the existing one's URL."""
106
if self.is_running and self.tunnel_url:
107
return self.tunnel_url
108
64
- self.provider = provider
109
self._ensure_subscribed()
110
self.notifications.clear()
111
+ try:
112
+ self.provider = normalize_provider(provider)
113
+ except Exception as e:
114
+ error_msg = str(e)
115
+ PrintStyle.error(f"Error starting tunnel: {error_msg}")
116
+ self._append_notification(NotifyEvent.ERROR, error_msg)
117
+ return None
118
119
try:
69
- # Start tunnel in a separate thread to avoid blocking
120
+ # Start tunnel in a separate thread to avoid blocking.
121
def run_tunnel():
122
try:
72
- if self.provider == "cloudflared":
73
- config = FlareConfig(port=port, verbose=True)
74
- self.tunnel = FlareTunnel(config)
75
- elif self.provider == "microsoft":
76
- config = MicrosoftConfig(port=port, verbose=True) # type: ignore
77
- self.tunnel = MicrosoftTunnel(config)
78
- else: # Default to serveo
79
- config = ServeoConfig(port=port) # type: ignore
80
- self.tunnel = ServeoTunnel(config)
81
-
123
+ self.tunnel = self._create_tunnel(port, self.provider)
124
self.tunnel.start()
125
self.tunnel_url = self.tunnel.tunnel_url
126
self.is_running = True
127
except Exception as e:
128
error_msg = str(e)
129
PrintStyle.error(f"Error in tunnel thread: {error_msg}")
88
- self.notifications.append({
89
- "event": NotifyEvent.ERROR.value,
90
- "message": error_msg,
91
- "data": None
92
- })
130
+ self._append_notification(NotifyEvent.ERROR, error_msg)
131
132
tunnel_thread = threading.Thread(target=run_tunnel)
133
tunnel_thread.daemon = True
134
tunnel_thread.start()
135
98
- # Wait for tunnel to start (no timeout - user may need time for login)
99
- import time
136
+ # No timeout: Microsoft login can legitimately require user interaction.
137
while True:
138
if self.tunnel_url:
139
break
103
- # Check if we have errors
104
- if any(n['event'] == NotifyEvent.ERROR.value for n in self.notifications):
140
+ if any(n["event"] == NotifyEvent.ERROR.value for n in self.notifications):
141
break
106
- # Check if thread died without producing URL
142
if not tunnel_thread.is_alive():
143
break
144
time.sleep(0.1)
@@ -114,7 +149,7 @@ class TunnelManager:
149
return None
150
151
def stop_tunnel(self):
117
- """Stop the running tunnel"""
152
+ """Stop the running tunnel."""
153
if self.tunnel and self.is_running:
154
try:
155
self.tunnel.stop()
@@ -127,5 +162,5 @@ class TunnelManager:
162
return False
163
164
def get_tunnel_url(self):
130
- """Get the current tunnel URL if available"""
165
+ """Get the current tunnel URL if available."""
166
return self.tunnel_url if self.is_running else None
tests/test_tunnel_remote_link.py
new
+757
@@ -0,0 +1,757 @@
1
+import io
2
+import enum
3
+import importlib
4
+import os
5
+import sys
6
+import tarfile
7
+import types
8
+import zipfile
9
+from pathlib import Path
10
+
11
+import pytest
12
+
13
+
14
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
15
+if str(PROJECT_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(PROJECT_ROOT))
17
+
18
+
19
+class FakeNotifyEvent(enum.Enum):
20
+ DOWNLOADING = "downloading"
21
+ DOWNLOAD_PROGRESS = "download_progress"
22
+ DOWNLOAD_COMPLETE = "download_complete"
23
+ CREATING_TUNNEL = "creating_tunnel"
24
+ TUNNEL_URL = "tunnel_url"
25
+ TUNNEL_STOPPED = "tunnel_stopped"
26
+ ERROR = "error"
27
+ INFO = "info"
28
+
29
+
30
+class FakeConfig:
31
+ def __init__(self, **kwargs):
32
+ self.kwargs = kwargs
33
+ for key, value in kwargs.items():
34
+ setattr(self, key, value)
35
+
36
+
37
+class FakeTunnel:
38
+ def __init__(self, config):
39
+ self.config = config
40
+ self.tunnel_url = ""
41
+ self.stopped = False
42
+ self.notifications = []
43
+
44
+ def start(self):
45
+ self.tunnel_url = "https://example.test"
46
+ return self.tunnel_url
47
+
48
+ def stop(self):
49
+ self.stopped = True
50
+ return True
51
+
52
+ def notify(self, event, message, data=None):
53
+ self.notifications.append({
54
+ "event": event.value if hasattr(event, "value") else event,
55
+ "message": message,
56
+ "data": data,
57
+ })
58
+
59
+
60
+class FakeNotifier:
61
+ def subscribe(self, callback):
62
+ self.callback = callback
63
+
64
+
65
+def write_tar_archive(path, members):
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ with tarfile.open(path, "w:gz") as archive:
68
+ for name, content in members.items():
69
+ payload = content.encode("utf-8")
70
+ info = tarfile.TarInfo(name)
71
+ info.size = len(payload)
72
+ info.mode = 0o755
73
+ archive.addfile(info, io.BytesIO(payload))
74
+
75
+
76
+def write_zip_archive(path, members):
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ with zipfile.ZipFile(path, "w") as archive:
79
+ for name, content in members.items():
80
+ archive.writestr(name, content)
81
+
82
+
83
+HELPER_MODULES = [
84
+ "helpers.cli_tunnel",
85
+ "helpers.cloudflare_tunnel",
86
+ "helpers.microsoft_tunnel",
87
+ "helpers.serveo_tunnel",
88
+ "helpers.tailscale_tunnel",
89
+ "helpers.tunnel_common",
90
+ "helpers.tunnel_manager",
91
+]
92
+
93
+
94
+def remote_link_modules():
95
+ return types.SimpleNamespace(
96
+ cli=importlib.import_module("helpers.cli_tunnel"),
97
+ microsoft=importlib.import_module("helpers.microsoft_tunnel"),
98
+ tailscale=importlib.import_module("helpers.tailscale_tunnel"),
99
+ )
100
+
101
+
102
+@pytest.fixture()
103
+def tunnel_manager_module(monkeypatch):
104
+ fake_flaredantic = types.SimpleNamespace(
105
+ FlareConfig=FakeConfig,
106
+ FlareTunnel=FakeTunnel,
107
+ MicrosoftConfig=FakeConfig,
108
+ MicrosoftTunnel=FakeTunnel,
109
+ NotifyData=object,
110
+ NotifyEvent=FakeNotifyEvent,
111
+ ServeoConfig=FakeConfig,
112
+ ServeoTunnel=FakeTunnel,
113
+ notifier=FakeNotifier(),
114
+ )
115
+ monkeypatch.setitem(sys.modules, "flaredantic", fake_flaredantic)
116
+ helpers_package = sys.modules.get("helpers")
117
+ for module_name in HELPER_MODULES:
118
+ sys.modules.pop(module_name, None)
119
+ if helpers_package and hasattr(helpers_package, module_name.rsplit(".", 1)[-1]):
120
+ delattr(helpers_package, module_name.rsplit(".", 1)[-1])
121
+ module = importlib.import_module("helpers.tunnel_manager")
122
+ yield module
123
+ for module_name in HELPER_MODULES:
124
+ sys.modules.pop(module_name, None)
125
+ if helpers_package and hasattr(helpers_package, module_name.rsplit(".", 1)[-1]):
126
+ delattr(helpers_package, module_name.rsplit(".", 1)[-1])
127
+
128
+
129
+def test_remote_link_provider_options_match_supported_remote_link_providers():
130
+ html = (
131
+ PROJECT_ROOT / "webui/components/settings/tunnel/tunnel-section.html"
132
+ ).read_text(encoding="utf-8")
133
+
134
+ assert html.count("<option value=") == 4
135
+ assert "Remote Control" in html
136
+ assert "Remote " + "Link" not in html
137
+ assert "loginActionVisible" in html
138
+ assert "loginActionTitle" in html
139
+ assert (
140
+ 'class="microsoft-login-box" x-show="$store.tunnelStore.loginActionVisible"'
141
+ in html
142
+ )
143
+ assert '<option value="cloudflared">Cloudflare Tunnel</option>' in html
144
+ assert '<option value="tailscale">Tailscale</option>' in html
145
+ assert '<option value="microsoft">Microsoft Dev Tunnels</option>' in html
146
+ assert '<option value="serveo">Serveo</option>' in html
147
+ assert '<option value="cloudflared">Cloudflare</option>' not in html
148
+ assert "Cloudflare Tunnel is the quickest shareable URL" in html
149
+ assert "Agent Zero will start its sign-in flow" in html
150
+
151
+
152
+def test_tunnel_provider_normalization_preserves_aliases(tunnel_manager_module):
153
+ manager = tunnel_manager_module
154
+
155
+ assert manager.normalize_provider("cloudflare") == "cloudflared"
156
+ assert manager.normalize_provider("Cloudflare-Tunnel") == "cloudflared"
157
+ assert manager.normalize_provider("tailscale-funnel") == "tailscale"
158
+
159
+ with pytest.raises(ValueError, match="Unsupported remote control provider"):
160
+ manager.normalize_provider("lantern")
161
+
162
+
163
+def test_tailscale_cli_commands_are_wired(tunnel_manager_module):
164
+ manager = tunnel_manager_module.TunnelManager()
165
+
166
+ tailscale = manager._create_tunnel(50001, "tailscale")
167
+
168
+ assert tailscale.command == [
169
+ "tailscale",
170
+ "funnel",
171
+ "--yes",
172
+ "http://127.0.0.1:50001",
173
+ ]
174
+ assert tailscale.shutdown_command == [
175
+ "tailscale",
176
+ "funnel",
177
+ "--yes",
178
+ "http://127.0.0.1:50001",
179
+ "off",
180
+ ]
181
+
182
+
183
+def test_remote_link_providers_have_dedicated_helper_modules():
184
+ helper_files = {
185
+ "cloudflared": PROJECT_ROOT / "helpers/cloudflare_tunnel.py",
186
+ "microsoft": PROJECT_ROOT / "helpers/microsoft_tunnel.py",
187
+ "serveo": PROJECT_ROOT / "helpers/serveo_tunnel.py",
188
+ "tailscale": PROJECT_ROOT / "helpers/tailscale_tunnel.py",
189
+ }
190
+
191
+ assert all(path.exists() for path in helper_files.values())
192
+
193
+
194
+def test_microsoft_dev_tunnel_uses_unique_a0_tunnel_id(tunnel_manager_module):
195
+ manager = tunnel_manager_module.TunnelManager()
196
+ tunnel = manager._create_tunnel(50001, "microsoft")
197
+
198
+ assert tunnel.start() == "https://example.test"
199
+
200
+ config = tunnel.tunnel.config.kwargs
201
+ assert config["tunnel_id"].startswith("agent-zero-")
202
+ assert config["tunnel_id"] != "flaredantic"
203
+ assert config["timeout"] == 120
204
+
205
+
206
+def test_microsoft_dev_tunnel_id_can_be_overridden(
207
+ tunnel_manager_module,
208
+ monkeypatch,
209
+):
210
+ modules = remote_link_modules()
211
+ monkeypatch.setenv("A0_MICROSOFT_DEV_TUNNEL_ID", "agent-zero-custom")
212
+
213
+ assert modules.microsoft.default_microsoft_tunnel_id() == "agent-zero-custom"
214
+
215
+
216
+def test_microsoft_dev_tunnel_timeout_error_is_enriched(
217
+ tunnel_manager_module,
218
+):
219
+ modules = remote_link_modules()
220
+
221
+ class FailingMicrosoftTunnel:
222
+ def __init__(self, config):
223
+ self.config = config
224
+
225
+ def start(self):
226
+ raise RuntimeError("Timeout waiting for Microsoft Dev Tunnels URL")
227
+
228
+ def stop(self):
229
+ return None
230
+
231
+ modules.microsoft.AgentZeroMicrosoftTunnel = FailingMicrosoftTunnel
232
+ tunnel = modules.microsoft.MicrosoftDevTunnel(50001)
233
+
234
+ with pytest.raises(RuntimeError, match="global `flaredantic` tunnel-id collision"):
235
+ tunnel.start()
236
+
237
+
238
+def test_microsoft_dev_tunnel_emits_setup_progress_notifications(
239
+ tunnel_manager_module,
240
+):
241
+ modules = remote_link_modules()
242
+ config = FakeConfig(port=80, tunnel_id="agent-zero-test")
243
+ tunnel = modules.microsoft.AgentZeroMicrosoftTunnel(config)
244
+ commands = []
245
+
246
+ def fake_run_cmd(args):
247
+ commands.append(args)
248
+ if args[0] == "show" or args[:2] == ["port", "show"]:
249
+ return types.SimpleNamespace(returncode=1, stdout="missing")
250
+ return types.SimpleNamespace(returncode=0, stdout="ok")
251
+
252
+ tunnel._run_cmd = fake_run_cmd
253
+
254
+ tunnel._ensure_tunnel()
255
+
256
+ messages = [notification["message"] for notification in tunnel.notifications]
257
+ assert messages == [
258
+ "Checking Microsoft Dev Tunnel `agent-zero-test`...",
259
+ "Creating Microsoft Dev Tunnel `agent-zero-test`...",
260
+ "Checking Microsoft Dev Tunnel port 80...",
261
+ "Creating Microsoft Dev Tunnel port 80...",
262
+ "Microsoft Dev Tunnel setup is ready. Starting the secure host...",
263
+ ]
264
+ assert commands == [
265
+ ["show", "agent-zero-test"],
266
+ ["create", "agent-zero-test"],
267
+ ["port", "show", "agent-zero-test", "-p", "80"],
268
+ ["port", "create", "agent-zero-test", "-p", "80", "--protocol", "http"],
269
+ ]
270
+
271
+
272
+@pytest.mark.parametrize(
273
+ ("provider", "expected_label"),
274
+ [
275
+ ("cloudflared", "Cloudflare Tunnel"),
276
+ ("microsoft", "Microsoft Dev Tunnels"),
277
+ ("serveo", "Serveo"),
278
+ ],
279
+)
280
+def test_flaredantic_provider_helpers_emit_manager_notifications(
281
+ tunnel_manager_module,
282
+ provider,
283
+ expected_label,
284
+):
285
+ manager = tunnel_manager_module.TunnelManager()
286
+ tunnel = manager._create_tunnel(50001, provider)
287
+
288
+ assert tunnel.start() == "https://example.test"
289
+
290
+ assert manager.notifications[0] == {
291
+ "event": "creating_tunnel",
292
+ "message": f"Starting {expected_label} on port 50001...",
293
+ "data": None,
294
+ }
295
+ assert manager.notifications[-1] == {
296
+ "event": "tunnel_url",
297
+ "message": f"{expected_label} URL is ready",
298
+ "data": {"url": "https://example.test"},
299
+ }
300
+
301
+
302
+def test_zip_extraction_accepts_windows_exe_members(
303
+ tunnel_manager_module,
304
+ monkeypatch,
305
+ tmp_path,
306
+):
307
+ modules = remote_link_modules()
308
+ archive_path = tmp_path / "tailscale.zip"
309
+ destination = tmp_path / "bin"
310
+ write_zip_archive(archive_path, {"tailscale.exe": "binary"})
311
+ monkeypatch.setattr(modules.cli.platform, "system", lambda: "Windows")
312
+
313
+ extracted = modules.cli.extract_named_members_from_zip(
314
+ archive_path,
315
+ destination,
316
+ {"tailscale"},
317
+ )
318
+
319
+ assert extracted == {"tailscale": destination / "tailscale.exe"}
320
+ assert (destination / "tailscale.exe").read_text(encoding="utf-8") == "binary"
321
+
322
+
323
+def test_tailscale_installs_runtime_binaries_from_static_archive(
324
+ tunnel_manager_module,
325
+ monkeypatch,
326
+ tmp_path,
327
+):
328
+ modules = remote_link_modules()
329
+ monkeypatch.setattr(modules.tailscale.shutil, "which", lambda binary: None)
330
+ monkeypatch.setattr(modules.cli, "RUNTIME_BIN_DIR", tmp_path / "bin")
331
+ monkeypatch.setattr(
332
+ modules.tailscale,
333
+ "tailscale_archive_url",
334
+ lambda: "https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz",
335
+ )
336
+
337
+ def fake_download(url, destination, notify=None):
338
+ write_tar_archive(
339
+ destination,
340
+ {
341
+ "tailscale_1.84.0_amd64/tailscale": "#!/bin/sh\n",
342
+ "tailscale_1.84.0_amd64/tailscaled": "#!/bin/sh\n",
343
+ },
344
+ )
345
+ return destination
346
+
347
+ monkeypatch.setattr(modules.cli, "download_file", fake_download)
348
+
349
+ installed = Path(modules.tailscale.install_tailscale())
350
+
351
+ assert installed == tmp_path / "bin" / "tailscale"
352
+ assert (tmp_path / "bin" / "tailscaled").exists()
353
+ assert os.access(tmp_path / "bin" / "tailscale", os.X_OK)
354
+ assert os.access(tmp_path / "bin" / "tailscaled", os.X_OK)
355
+ assert not (tmp_path / "bin" / "tailscale_1.84.0_amd64.tgz").exists()
356
+
357
+
358
+def test_tailscale_installer_downloads_static_pair_when_system_daemon_is_missing(
359
+ tunnel_manager_module,
360
+ monkeypatch,
361
+ tmp_path,
362
+):
363
+ modules = remote_link_modules()
364
+ monkeypatch.setattr(
365
+ modules.tailscale.shutil,
366
+ "which",
367
+ lambda binary: "/usr/bin/tailscale" if binary == "tailscale" else None,
368
+ )
369
+ monkeypatch.setattr(modules.cli, "RUNTIME_BIN_DIR", tmp_path / "bin")
370
+ monkeypatch.setattr(
371
+ modules.tailscale,
372
+ "tailscale_archive_url",
373
+ lambda: "https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz",
374
+ )
375
+
376
+ def fake_download(url, destination, notify=None):
377
+ write_tar_archive(
378
+ destination,
379
+ {
380
+ "tailscale_1.84.0_amd64/tailscale": "#!/bin/sh\n",
381
+ "tailscale_1.84.0_amd64/tailscaled": "#!/bin/sh\n",
382
+ },
383
+ )
384
+ return destination
385
+
386
+ monkeypatch.setattr(modules.cli, "download_file", fake_download)
387
+
388
+ assert modules.tailscale.install_tailscale() == str(tmp_path / "bin" / "tailscale")
389
+ assert (tmp_path / "bin" / "tailscaled").exists()
390
+
391
+
392
+def test_tailscale_static_package_url_is_discovered_from_official_listing(
393
+ tunnel_manager_module,
394
+ monkeypatch,
395
+):
396
+ modules = remote_link_modules()
397
+ html = (
398
+ '<a href="tailscale_1.84.0_arm64.tgz">arm</a>'
399
+ '<a href="tailscale_1.84.0_amd64.tgz">amd64</a>'
400
+ )
401
+
402
+ class FakeResponse:
403
+ def __enter__(self):
404
+ return self
405
+
406
+ def __exit__(self, exc_type, exc, traceback):
407
+ return None
408
+
409
+ def read(self):
410
+ return html.encode("utf-8")
411
+
412
+ monkeypatch.setattr(modules.tailscale, "tailscale_arch", lambda: "amd64")
413
+ monkeypatch.setattr(
414
+ modules.tailscale.urllib.request,
415
+ "urlopen",
416
+ lambda url, timeout=30: FakeResponse(),
417
+ )
418
+
419
+ assert (
420
+ modules.tailscale.tailscale_archive_url()
421
+ == "https://pkgs.tailscale.com/stable/tailscale_1.84.0_amd64.tgz"
422
+ )
423
+
424
+
425
+def test_tar_extraction_sanitizes_member_paths(
426
+ tunnel_manager_module,
427
+ tmp_path,
428
+):
429
+ modules = remote_link_modules()
430
+ archive_path = tmp_path / "tailscale.tgz"
431
+ destination = tmp_path / "safe"
432
+ write_tar_archive(archive_path, {"../tailscale": "binary"})
433
+
434
+ extracted = modules.cli.extract_named_members_from_tar(
435
+ archive_path,
436
+ destination,
437
+ {"tailscale"},
438
+ )
439
+
440
+ assert extracted == {"tailscale": destination / "tailscale"}
441
+ assert (destination / "tailscale").read_text(encoding="utf-8") == "binary"
442
+ assert not (tmp_path / "tailscale").exists()
443
+
444
+
445
+@pytest.mark.parametrize(
446
+ ("provider", "module_name", "installer_name", "expected_message"),
447
+ [
448
+ ("tailscale", "tailscale", "install_tailscale", "tailscale download failed"),
449
+ ],
450
+)
451
+def test_cli_provider_installer_failures_return_actionable_error(
452
+ tunnel_manager_module,
453
+ monkeypatch,
454
+ provider,
455
+ module_name,
456
+ installer_name,
457
+ expected_message,
458
+):
459
+ manager_module = tunnel_manager_module
460
+ modules = remote_link_modules()
461
+ manager = manager_module.TunnelManager()
462
+
463
+ def fail_install(notify=None):
464
+ raise RuntimeError(expected_message)
465
+
466
+ monkeypatch.setattr(getattr(modules, module_name), installer_name, fail_install)
467
+
468
+ assert manager.start_tunnel(port=50001, provider=provider) is None
469
+ assert expected_message in manager.get_last_error()
470
+
471
+
472
+def test_tailscale_preflight_starts_managed_daemon_then_runs_up_with_socket(
473
+ tunnel_manager_module,
474
+ monkeypatch,
475
+ tmp_path,
476
+):
477
+ modules = remote_link_modules()
478
+ runtime_dir = tmp_path / "runtime"
479
+ state_dir = tmp_path / "state"
480
+ socket_path = runtime_dir / "tailscaled.sock"
481
+ bin_dir = tmp_path / "bin"
482
+ bin_dir.mkdir()
483
+ tailscale = bin_dir / "tailscale"
484
+ tailscaled = bin_dir / "tailscaled"
485
+ tailscale.write_text("#!/bin/sh\n", encoding="utf-8")
486
+ tailscaled.write_text("#!/bin/sh\n", encoding="utf-8")
487
+ monkeypatch.setattr(modules.tailscale, "TAILSCALE_RUNTIME_DIR", runtime_dir)
488
+ monkeypatch.setattr(modules.tailscale, "TAILSCALE_STATE_DIR", state_dir)
489
+ monkeypatch.setattr(modules.tailscale, "TAILSCALE_SOCKET_PATH", socket_path)
490
+ monkeypatch.setattr(
491
+ modules.tailscale,
492
+ "TAILSCALE_DAEMON_LOG_PATH",
493
+ runtime_dir / "tailscaled.log",
494
+ )
495
+ monkeypatch.setattr(
496
+ modules.tailscale,
497
+ "TAILSCALE_DAEMON_PID_PATH",
498
+ runtime_dir / "tailscaled.pid",
499
+ )
500
+ status_results = iter([
501
+ types.SimpleNamespace(
502
+ returncode=1,
503
+ stdout="",
504
+ stderr="failed to connect to local tailscaled",
505
+ ),
506
+ types.SimpleNamespace(
507
+ returncode=1,
508
+ stdout="",
509
+ stderr="failed to connect to local tailscaled",
510
+ ),
511
+ types.SimpleNamespace(returncode=1, stdout="Logged out.", stderr=""),
512
+ types.SimpleNamespace(returncode=1, stdout="Logged out.", stderr=""),
513
+ types.SimpleNamespace(
514
+ returncode=0,
515
+ stdout='{"BackendState": "Running"}',
516
+ stderr="",
517
+ ),
518
+ ])
519
+ status_calls = []
520
+
521
+ def fake_status(binary_path, socket_path=None):
522
+ status_calls.append((binary_path, socket_path))
523
+ return next(status_results)
524
+
525
+ monkeypatch.setattr(modules.tailscale, "tailscale_status", fake_status)
526
+ popen_commands = []
527
+
528
+ class FakeProcess:
529
+ def __init__(self, command, **kwargs):
530
+ popen_commands.append(command)
531
+ self.pid = 12345
532
+ self.is_tailscale_up = command[0] == str(tailscale)
533
+ self.stdout = iter(["Success.\n"]) if command[0] == str(tailscale) else None
534
+
535
+ def poll(self):
536
+ return 0 if self.is_tailscale_up else None
537
+
538
+ def terminate(self):
539
+ return None
540
+
541
+ def wait(self, timeout=None):
542
+ return 0
543
+
544
+ monkeypatch.setattr(modules.tailscale.subprocess, "Popen", FakeProcess)
545
+
546
+ result = modules.tailscale.ensure_tailscale_ready(str(tailscale))
547
+
548
+ assert result == {"command_prefix": ["--socket", str(socket_path)]}
549
+ assert status_calls == [
550
+ (str(tailscale), None),
551
+ (str(tailscale), socket_path),
552
+ (str(tailscale), socket_path),
553
+ (str(tailscale), socket_path),
554
+ (str(tailscale), socket_path),
555
+ ]
556
+ assert popen_commands == [
557
+ [
558
+ str(tailscaled),
559
+ "--tun=userspace-networking",
560
+ "--socket",
561
+ str(socket_path),
562
+ "--statedir",
563
+ str(state_dir),
564
+ "--state",
565
+ str(state_dir / "tailscaled.state"),
566
+ ],
567
+ [str(tailscale), "--socket", str(socket_path), "up"],
568
+ ]
569
+
570
+
571
+def test_tailscale_preflight_emits_login_url_from_tailscale_up(
572
+ tunnel_manager_module,
573
+ monkeypatch,
574
+):
575
+ modules = remote_link_modules()
576
+ monkeypatch.setattr(
577
+ modules.tailscale,
578
+ "tailscale_status",
579
+ lambda binary_path, socket_path=None: types.SimpleNamespace(
580
+ returncode=1,
581
+ stdout="",
582
+ stderr="",
583
+ ),
584
+ )
585
+
586
+ class FakeProcess:
587
+ stdout = iter(
588
+ [
589
+ "To authenticate, visit:\n",
590
+ "https://login.tailscale.com/a/abcdef\n",
591
+ ]
592
+ )
593
+
594
+ def poll(self):
595
+ return 1
596
+
597
+ def terminate(self):
598
+ return None
599
+
600
+ def wait(self, timeout=None):
601
+ return 1
602
+
603
+ notifications = []
604
+ monkeypatch.setattr(
605
+ modules.tailscale.subprocess,
606
+ "Popen",
607
+ lambda *args, **kwargs: FakeProcess(),
608
+ )
609
+
610
+ with pytest.raises(RuntimeError, match="joining this container to your tailnet"):
611
+ modules.tailscale.ensure_tailscale_ready(
612
+ "/tmp/tailscale",
613
+ notify=lambda event, message, data=None: notifications.append(
614
+ {"event": event.value, "message": message, "data": data}
615
+ ),
616
+ )
617
+
618
+ assert notifications[0]["message"] == (
619
+ "Tailscale needs this container to join your tailnet. Running `tailscale up` now..."
620
+ )
621
+ assert notifications[1] == {
622
+ "event": "info",
623
+ "message": (
624
+ "Open the Tailscale login link and approve this container. "
625
+ "Agent Zero will continue when Tailscale finishes setup."
626
+ ),
627
+ "data": {
628
+ "provider": "tailscale",
629
+ "url": "https://login.tailscale.com/a/abcdef",
630
+ },
631
+ }
632
+
633
+
634
+def test_tailscale_preflight_runs_up_then_accepts_running_status(
635
+ tunnel_manager_module,
636
+ monkeypatch,
637
+):
638
+ modules = remote_link_modules()
639
+ status_results = iter([
640
+ types.SimpleNamespace(returncode=1, stdout="", stderr="not logged in"),
641
+ types.SimpleNamespace(
642
+ returncode=0,
643
+ stdout='{"BackendState": "Running"}',
644
+ stderr="",
645
+ ),
646
+ ])
647
+ notifications = []
648
+
649
+ monkeypatch.setattr(
650
+ modules.tailscale,
651
+ "tailscale_status",
652
+ lambda binary_path, socket_path=None: next(status_results),
653
+ )
654
+
655
+ class FakeProcess:
656
+ stdout = iter(["Success.\n"])
657
+
658
+ def poll(self):
659
+ return 0
660
+
661
+ def wait(self, timeout=None):
662
+ return 0
663
+
664
+ monkeypatch.setattr(
665
+ modules.tailscale.subprocess,
666
+ "Popen",
667
+ lambda *args, **kwargs: FakeProcess(),
668
+ )
669
+
670
+ modules.tailscale.ensure_tailscale_ready(
671
+ "/tmp/tailscale",
672
+ notify=lambda event, message, data=None: notifications.append(
673
+ {"event": event.value, "message": message, "data": data}
674
+ ),
675
+ )
676
+
677
+ assert notifications[-1] == {
678
+ "event": "info",
679
+ "message": "Tailscale setup completed. Checking the tailnet connection...",
680
+ "data": None,
681
+ }
682
+
683
+
684
+def test_cli_tunnel_preflight_prefix_is_used_for_start_and_shutdown(
685
+ tunnel_manager_module,
686
+ monkeypatch,
687
+):
688
+ modules = remote_link_modules()
689
+ popen_commands = []
690
+ run_commands = []
691
+
692
+ class FakeProcess:
693
+ def __init__(self, command, **kwargs):
694
+ popen_commands.append(command)
695
+ self.stdout = iter(["https://agent-zero.ts.net\n"])
696
+
697
+ def poll(self):
698
+ return None
699
+
700
+ def terminate(self):
701
+ return None
702
+
703
+ def wait(self, timeout=None):
704
+ return 0
705
+
706
+ monkeypatch.setattr(modules.cli.subprocess, "Popen", FakeProcess)
707
+ monkeypatch.setattr(
708
+ modules.cli.subprocess,
709
+ "run",
710
+ lambda command, **kwargs: run_commands.append(command)
711
+ or types.SimpleNamespace(returncode=0),
712
+ )
713
+
714
+ tunnel = modules.cli.CliTunnelHelper(
715
+ label="Tailscale Funnel",
716
+ binary="tailscale",
717
+ port=50001,
718
+ command=["tailscale", "funnel", "--yes", "http://127.0.0.1:50001"],
719
+ shutdown_command=[
720
+ "tailscale",
721
+ "funnel",
722
+ "--yes",
723
+ "http://127.0.0.1:50001",
724
+ "off",
725
+ ],
726
+ url_pattern=modules.tailscale.TAILSCALE_URL_RE,
727
+ missing_binary_message="missing",
728
+ binary_resolver=lambda notify=None: "/tmp/tailscale",
729
+ preflight=lambda binary_path, notify=None: {
730
+ "command_prefix": ["--socket", "/tmp/tailscaled.sock"]
731
+ },
732
+ )
733
+
734
+ assert tunnel.start() == "https://agent-zero.ts.net"
735
+ tunnel.stop()
736
+
737
+ assert popen_commands == [
738
+ [
739
+ "/tmp/tailscale",
740
+ "--socket",
741
+ "/tmp/tailscaled.sock",
742
+ "funnel",
743
+ "--yes",
744
+ "http://127.0.0.1:50001",
745
+ ]
746
+ ]
747
+ assert run_commands == [
748
+ [
749
+ "/tmp/tailscale",
750
+ "--socket",
751
+ "/tmp/tailscaled.sock",
752
+ "funnel",
753
+ "--yes",
754
+ "http://127.0.0.1:50001",
755
+ "off",
756
+ ]
757
+ ]
webui/components/settings/external/external-settings.html
+2
-2
@@ -41,8 +41,8 @@
41
</li>
42
<li>
43
<a href="#section-tunnel">
44
- <img src="/public/tunnel.svg" alt="Remote Link" />
45
- <span>Remote Link</span>
44
+ <img src="/public/tunnel.svg" alt="Remote Control" />
45
+ <span>Remote Control</span>
46
</a>
47
</li>
48
</ul>
webui/components/settings/settings-store.js
+1
-1
@@ -47,7 +47,7 @@ const TAB_ITEMS = Object.freeze([
47
{ id: "section-secrets", label: "Secrets", icon: "lock" },
48
{ id: "section-auth", label: "Authentication", icon: "passkey" },
49
{ id: "section-external-api", label: "External API", icon: "api" },
50
- { id: "section-tunnel", label: "Remote Link", icon: "share" },
50
+ { id: "section-tunnel", label: "Remote Control", icon: "share" },
51
],
52
},
53
{
webui/components/settings/tunnel/remote-link.html
+1
-1
@@ -1,6 +1,6 @@
1
<html class="remote-link-modal">
2
<head>
3
- <title>Remote Link</title>
3
+ <title>Remote Control</title>
4
</head>
5
6
<body>
webui/components/settings/tunnel/tunnel-section.html
+19
-18
@@ -1,6 +1,6 @@
1
<html>
2
<head>
3
- <title>Remote Link</title>
3
+ <title>Remote Control</title>
4
<script type="module">
5
import { store } from "/components/settings/tunnel/tunnel-store.js";
6
</script>
@@ -20,23 +20,23 @@
20
<span class="material-symbols-outlined">share</span>
21
</div>
22
<div>
23
- <div class="section-title">Remote Link</div>
23
+ <div class="section-title">Remote Control</div>
24
<div class="section-description">
25
- Create a temporary HTTPS link for this Agent Zero instance, then open it from another browser or scan it from your phone.
25
+ Create temporary HTTPS access for this Agent Zero instance, then open it from another browser or scan it from your phone.
26
</div>
27
</div>
28
</header>
29
30
<div class="remote-link-safety-note">
31
<span class="material-symbols-outlined" aria-hidden="true">lock</span>
32
- <span>Use sign-in before sharing a remote link. The URL can reach your instance while the link is active.</span>
32
+ <span>Use sign-in before sharing remote control access. The URL can reach your instance while access is active.</span>
33
</div>
34
35
<div class="field remote-link-provider-field">
36
<div class="field-label">
37
<div class="field-title">Link provider</div>
38
<div class="field-description">
39
- Cloudflare is the quickest path for a shareable URL. Microsoft Dev Tunnels may ask you to sign in.
39
+ Cloudflare Tunnel is the quickest shareable URL. Tailscale is private to your tailnet and Agent Zero will start its sign-in flow when needed. Microsoft Dev Tunnels may ask you to approve a GitHub device login.
40
</div>
41
</div>
42
<div class="field-control">
@@ -45,7 +45,8 @@
45
x-model="$store.tunnelStore.provider"
46
:disabled="$store.tunnelStore.isLoading"
47
>
48
- <option value="cloudflared">Cloudflare</option>
48
+ <option value="cloudflared">Cloudflare Tunnel</option>
49
+ <option value="tailscale">Tailscale</option>
50
<option value="microsoft">Microsoft Dev Tunnels</option>
51
<option value="serveo">Serveo</option>
52
</select>
@@ -54,22 +55,22 @@
55
56
<div class="remote-link-loading" x-show="$store.tunnelStore.isLoading" style="display: none;">
57
<span class="material-symbols-outlined spin" aria-hidden="true">progress_activity</span>
57
- <span x-text="$store.tunnelStore.loadingText || 'Preparing remote link...'"></span>
58
+ <span x-text="$store.tunnelStore.loadingText || 'Preparing remote control...'"></span>
59
</div>
60
60
- <div class="microsoft-login-box" x-show="$store.tunnelStore.microsoftLoginCode" x-transition style="display: none;">
61
+ <div class="microsoft-login-box" x-show="$store.tunnelStore.loginActionVisible" x-transition style="display: none;">
62
<div class="microsoft-login-header">
63
<span class="material-symbols-outlined" aria-hidden="true">key</span>
64
<div>
64
- <div class="microsoft-login-title">Microsoft sign-in</div>
65
- <div class="microsoft-login-copy">Approve the tunnel request, then Agent Zero will finish creating the link.</div>
65
+ <div class="microsoft-login-title" x-text="$store.tunnelStore.loginActionTitle"></div>
66
+ <div class="microsoft-login-copy" x-text="$store.tunnelStore.loginActionCopy"></div>
67
</div>
68
</div>
68
- <a class="microsoft-login-link" :href="$store.tunnelStore.microsoftLoginUrl" target="_blank" rel="noopener noreferrer">
69
+ <a class="microsoft-login-link" x-show="$store.tunnelStore.microsoftLoginUrl" :href="$store.tunnelStore.microsoftLoginUrl" target="_blank" rel="noopener noreferrer">
70
<span class="material-symbols-outlined" aria-hidden="true">open_in_new</span>
71
<span x-text="$store.tunnelStore.microsoftLoginUrl"></span>
72
</a>
72
- <div class="microsoft-login-code-row">
73
+ <div class="microsoft-login-code-row" x-show="$store.tunnelStore.microsoftLoginCode">
74
<div class="microsoft-login-code" x-text="$store.tunnelStore.microsoftLoginCode"></div>
75
<button
76
type="button"
@@ -91,7 +92,7 @@
92
class="remote-link-input"
93
:value="$store.tunnelStore.tunnelLink"
94
readonly
94
- aria-label="Remote link URL"
95
+ aria-label="Remote Control URL"
96
>
97
<button
98
type="button"
@@ -112,7 +113,7 @@
113
:disabled="$store.tunnelStore.isLoading"
114
>
115
<span class="material-symbols-outlined" aria-hidden="true">refresh</span>
115
- <span>New link</span>
116
+ <span>New URL</span>
117
</button>
118
</div>
119
@@ -127,7 +128,7 @@
128
<div class="remote-link-status-panel">
129
<span class="material-symbols-outlined" aria-hidden="true">verified</span>
130
<div>
130
- <div class="remote-link-status-title">Link is active</div>
131
+ <div class="remote-link-status-title">Remote Control is active</div>
132
<div class="remote-link-status-copy">
133
It stays available until you stop it or the container restarts.
134
</div>
@@ -143,14 +144,14 @@
144
:disabled="$store.tunnelStore.isLoading"
145
>
146
<span class="material-symbols-outlined" aria-hidden="true">stop_circle</span>
146
- <span>Stop link</span>
147
+ <span>Stop Remote Control</span>
148
</button>
149
</div>
150
</div>
151
152
<div class="remote-link-empty" x-show="!$store.tunnelStore.linkGenerated">
153
<span class="material-symbols-outlined" aria-hidden="true">link</span>
153
- <strong>No remote link yet</strong>
154
+ <strong>No Remote Control yet</strong>
155
<span>Create one when you want to reach Agent Zero from another device or share access temporarily.</span>
156
<button
157
type="button"
@@ -159,7 +160,7 @@
160
:disabled="$store.tunnelStore.isLoading"
161
>
162
<span class="material-symbols-outlined" aria-hidden="true">share</span>
162
- <span>Create remote link</span>
163
+ <span>Start Remote Control</span>
164
</button>
165
</div>
166
</div>
webui/components/settings/tunnel/tunnel-store.js
+39
-19
@@ -9,6 +9,7 @@ const model = {
9
loadingText: "",
10
qrCodeInstance: null,
11
provider: "cloudflared",
12
+ loginProvider: "",
13
microsoftLoginCode: "",
14
microsoftLoginUrl: "",
15
codeCopied: false,
@@ -36,7 +37,23 @@ const model = {
37
return "Copy link";
38
},
39
40
+ get loginActionVisible() {
41
+ return Boolean(this.microsoftLoginUrl || this.microsoftLoginCode);
42
+ },
43
+
44
+ get loginActionTitle() {
45
+ return this.loginProvider === "tailscale" ? "Tailscale sign-in" : "Microsoft sign-in";
46
+ },
47
+
48
+ get loginActionCopy() {
49
+ if (this.loginProvider === "tailscale") {
50
+ return "Open the Tailscale login link and approve this container. Agent Zero will continue when Tailscale finishes setup.";
51
+ }
52
+ return "Approve the tunnel request, then Agent Zero will finish enabling Remote Control.";
53
+ },
54
+
55
clearMicrosoftLogin() {
56
+ this.loginProvider = "";
57
this.microsoftLoginCode = "";
58
this.microsoftLoginUrl = "";
59
this.codeCopied = false;
@@ -80,18 +97,21 @@ const model = {
97
this.loadingText = n.message;
98
break;
99
case "info":
83
- // Check for Microsoft login code
84
- if (n.data && n.data.code) {
85
- this.microsoftLoginCode = n.data.code;
100
+ // Sign-in providers can provide a device code, a login URL, or both.
101
+ if (n.data && n.data.url) {
102
+ this.loginProvider = n.data.provider || (n.data.code ? "microsoft" : "tailscale");
103
+ this.microsoftLoginCode = n.data.code || "";
104
this.microsoftLoginUrl = n.data.url || "";
87
- this.loadingText = "Waiting for Microsoft login...";
105
+ this.loadingText = this.loginProvider === "tailscale"
106
+ ? "Waiting for Tailscale login..."
107
+ : "Waiting for Microsoft login...";
108
} else {
109
this.loadingText = n.message;
110
}
111
break;
112
case "error":
113
this.hasError = true;
94
- window.toastFrontendError(n.message, "Remote Link");
114
+ window.toastFrontendError(n.message, "Remote Control");
115
this.stopNotificationPolling();
116
break;
117
case "tunnel_url":
@@ -233,7 +253,7 @@ const model = {
253
// Call generate but with a confirmation first
254
if (
255
confirm(
236
- "Create a new remote link? The current URL will stop working."
256
+ "Create new Remote Control access? The current URL will stop working."
257
)
258
) {
259
@@ -263,7 +283,7 @@ const model = {
283
await this.generateLink();
284
} catch (error) {
285
console.error("Error refreshing tunnel:", error);
266
- window.toastFrontendError("Error refreshing remote link", "Remote Link");
286
+ window.toastFrontendError("Error refreshing Remote Control", "Remote Control");
287
this.isLoading = false;
288
this.loadingText = "";
289
}
@@ -287,7 +307,7 @@ const model = {
307
// If no authentication is set, warn the user
308
if (!hasAuth) {
309
const proceed = confirm(
290
- "Remote Link works best with sign-in enabled.\n\n" +
310
+ "Remote Control works best with sign-in enabled.\n\n" +
311
"Without a login, anyone with the URL can reach this Agent Zero instance.\n\n" +
312
"Turn on authentication in Settings before sharing this link. Continue anyway?"
313
);
@@ -332,7 +352,7 @@ const model = {
352
// Check for error
353
if (!data.success && data.message) {
354
this.hasError = true;
335
- window.toastFrontendError(data.message, "Remote Link");
355
+ window.toastFrontendError(data.message, "Remote Control");
356
console.error("Tunnel creation failed:", data);
357
this.stopNotificationPolling();
358
return;
@@ -351,12 +371,12 @@ const model = {
371
372
// Show success message to confirm creation
373
window.toastFrontendInfo(
354
- "Remote link is ready",
355
- "Remote Link"
374
+ "Remote Control is ready",
375
+ "Remote Control"
376
);
377
}
378
} catch (error) {
359
- window.toastFrontendError("Error creating remote link", "Remote Link");
379
+ window.toastFrontendError("Error creating Remote Control", "Remote Control");
380
console.error("Error creating tunnel:", error);
381
} finally {
382
this.isLoading = false;
@@ -370,7 +390,7 @@ const model = {
390
async stopTunnel() {
391
if (
392
confirm(
373
- "Stop this remote link? The current URL will no longer be accessible."
393
+ "Stop Remote Control? The current URL will no longer be accessible."
394
)
395
) {
396
this.isLoading = true;
@@ -404,14 +424,14 @@ const model = {
424
this.linkGenerated = false;
425
426
window.toastFrontendInfo(
407
- "Remote link stopped",
408
- "Remote Link"
427
+ "Remote Control stopped",
428
+ "Remote Control"
429
);
430
} else {
411
- window.toastFrontendError("Failed to stop remote link", "Remote Link");
431
+ window.toastFrontendError("Failed to stop Remote Control", "Remote Control");
432
}
433
} catch (error) {
414
- window.toastFrontendError("Error stopping remote link", "Remote Link");
434
+ window.toastFrontendError("Error stopping Remote Control", "Remote Control");
435
console.error("Error stopping tunnel:", error);
436
} finally {
437
this.isLoading = false;
@@ -430,7 +450,7 @@ const model = {
450
451
// Show toast notification
452
window.toastFrontendInfo(
433
- "Remote link copied",
453
+ "Remote Control URL copied",
454
"Clipboard"
455
);
456
@@ -443,7 +463,7 @@ const model = {
463
console.error("Failed to copy URL: ", err);
464
this.copyState = "error";
465
window.toastFrontendError(
446
- "Failed to copy remote link",
466
+ "Failed to copy Remote Control URL",
467
"Clipboard Error"
468
);
469
webui/components/sidebar/top-section/header-icons.html
+1
-1
@@ -90,7 +90,7 @@
90
91
<button class="dropdown-item" @click="ensureModalOpen('settings/tunnel/remote-link.html'); $store.sidebar.menuClose()">
92
<span class="material-symbols-outlined">share</span>
93
- <span>Remote Link</span>
93
+ <span>Remote Control</span>
94
</button>
95
96
<button class="dropdown-item" @click="open('https://space-agent.ai/', '_blank', 'noopener,noreferrer'); $store.sidebar.menuClose()">