symbio 0.2.0 → 0.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.3.0]
9
+
10
+ - Remote access: `sym remote` (or Settings → Remote access) opens a temporary Cloudflare link with an
11
+ 8-character access code; remote sessions need the code, 10 wrong codes shut it down, the agents'
12
+ tools and shutdown are never reachable through it, and a banner with a Turn off button shows it's on.
13
+
14
+ ## [0.2.1]
15
+
16
+ - Releases are published from GitHub Actions with npm trusted publishing (no tokens), with
17
+ provenance.
18
+
8
19
  ## [0.2.0]
9
20
 
10
21
  - Installable with `npm install -g symbio`; brings its own OpenCode (pinned to 2.1.x).
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 LinKzy
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LinKzy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -112,6 +112,13 @@ on your computer, including the agents, from using Symbio's API behind your back
112
112
  | `sym logs` | The last lines of the log (`~/.symbio/logs/symbio.log`). |
113
113
  | `sym doctor` | Check Node.js, git, OpenCode, the port and model providers. |
114
114
  | `sym run` | Run in the foreground, with the log in the terminal. |
115
+ | `sym remote` | Open a temporary link and access code to use Symbio from another device (see below). `sym remote off` closes it. |
116
+
117
+ **From another device.** `sym remote` opens a temporary Cloudflare link to your Symbio and prints it
118
+ with an access code. Open the link anywhere (phone, another computer), enter the code, and you're
119
+ in. A banner shows it's on, with a button to turn it off; after 10 wrong codes it turns off by
120
+ itself. Whoever gets in controls your agents, so keep the link and code to yourself. Details in
121
+ [docs/security.md](docs/security.md#remote-access-optional).
115
122
 
116
123
  `symbio` works too, as the long name of `sym`. To run from a clone instead: `npm install`,
117
124
  `npm run build`, then `npm link` once to get the commands.
@@ -172,6 +179,7 @@ All optional, set as environment variables:
172
179
  | `SYMBIO_NO_OPEN` | | Set to `1` to not open the browser on start (use the link Symbio prints) |
173
180
  | `SYMBIO_DEBUG` | | Set to `1` to enable the read-only OpenCode debug routes (for development) |
174
181
  | `SYMBIO_OPENCODE_BIN` | bundled | Path to an `opencode` executable to use instead of the bundled one |
182
+ | `SYMBIO_CLOUDFLARED_BIN` | auto | Path to `cloudflared` for remote access (otherwise the PATH, or a copy downloaded once) |
175
183
  | `SYMBIO_STUCK_MINUTES` | `10` | Silence before the watchdog interrupts a working agent |
176
184
  | `SYMBIO_RECYCLE_MB` | `1500` | Restart OpenCode above this memory use once every agent is idle |
177
185
  | `SYMBIO_RECYCLE_HARD_MB` | `3000` | Restart OpenCode above this memory use regardless (agents resume) |
package/bin/symbio.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // sym logs the last lines of its log
9
9
  // sym doctor check that everything Symbio needs is in place
10
10
  // sym run run in the foreground (for debugging)
11
+ // sym remote open a temporary link to use Symbio from another device (sym remote off to close it)
11
12
 
12
13
  import { execFileSync, exec, spawn } from "node:child_process";
13
14
  import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
@@ -113,12 +114,43 @@ async function status() {
113
114
  const runtime = (await api("GET", "/api/runtime")).body;
114
115
  const projects = (await api("GET", "/api/projects")).body ?? [];
115
116
  console.log(`OpenCode ${runtime?.version ?? "?"} (${runtime?.state ?? "?"}${runtime?.memoryMB ? `, ${runtime.memoryMB} MB` : ""})`);
117
+ const remote = (await api("GET", "/api/remote")).body;
118
+ if (remote?.active) console.log(`Remote access ON: ${remote.url} (${remote.sessions} session(s)) — sym remote off`);
116
119
  for (const p of projects) {
117
120
  const working = p.agents.filter((a) => a.status === "busy").length;
118
121
  console.log(`- ${p.name}: ${p.agents.filter((a) => a.status !== "stopped").length} agents${working ? `, ${working} working` : ""}${p.paused ? " (paused)" : ""}`);
119
122
  }
120
123
  }
121
124
 
125
+ async function remoteOn() {
126
+ if ((await state()) !== "running") {
127
+ process.env.SYMBIO_NO_OPEN = "1";
128
+ await start();
129
+ }
130
+ console.log("Opening a temporary link (the first time, cloudflared is downloaded)…");
131
+ const res = await fetch(BASE + "/api/remote/start", { method: "POST", headers: { "x-symbio-token": token() }, signal: AbortSignal.timeout(180_000) });
132
+ const body = await res.json().catch(() => ({}));
133
+ if (!res.ok) {
134
+ console.error(`Couldn't open remote access: ${body.error ?? res.status}`);
135
+ process.exit(1);
136
+ }
137
+ const code = body.code ? `${body.code.slice(0, 4)}-${body.code.slice(4)}` : "?";
138
+ console.log(`
139
+ Remote access is on.
140
+
141
+ Link: ${body.url}
142
+ Code: ${code}
143
+
144
+ Whoever has both controls your agents, which run commands on this computer.
145
+ Turn it off with "sym remote off" (or the button in Symbio). After 10 wrong codes it turns off by itself.`);
146
+ }
147
+
148
+ async function remoteOff() {
149
+ if ((await state()) !== "running") return console.log("Symbio isn't running.");
150
+ await api("POST", "/api/remote/stop");
151
+ console.log("Remote access is off.");
152
+ }
153
+
122
154
  function logs() {
123
155
  if (!existsSync(LOG)) return console.log(`No log yet (${LOG}).`);
124
156
  const lines = readFileSync(LOG, "utf8").split(/\r?\n/);
@@ -169,6 +201,8 @@ Usage: sym [command]
169
201
  logs show the last lines of the log
170
202
  doctor check that everything Symbio needs is in place
171
203
  run run in the foreground (logs in this terminal)
204
+ remote open a temporary link + access code to use Symbio from another device
205
+ remote off close that link
172
206
  --version print the version
173
207
 
174
208
  Environment: SYMBIO_PORT (default 4310), SYMBIO_HOME (default ~/.symbio), SYMBIO_NO_OPEN=1.`);
@@ -191,6 +225,11 @@ switch (cmd) {
191
225
  openBrowser(link());
192
226
  } else console.log('Symbio isn\'t running. Start it with "sym".');
193
227
  break;
228
+ case "remote":
229
+ case "--remote":
230
+ if (process.argv[3] === "off") await remoteOff();
231
+ else await remoteOn();
232
+ break;
194
233
  case "logs":
195
234
  logs();
196
235
  break;
@@ -6,76 +6,76 @@ import { execFile } from "node:child_process";
6
6
  import { promises as fs } from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
- const WINDOWS_SCRIPT = String.raw `
10
- param([string]$Initial = "", [string]$Title = "Choose the project folder")
11
- $ErrorActionPreference = "Stop"
12
- [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
13
- Add-Type -AssemblyName System.Windows.Forms
14
- Add-Type -ReferencedAssemblies System.Windows.Forms -TypeDefinition @"
15
- using System;
16
- using System.Runtime.InteropServices;
17
- using System.Windows.Forms;
18
-
19
- public static class SymbioFolderPicker {
20
- [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] private class FileOpenDialogCom {}
21
-
22
- [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
23
- private interface IFileOpenDialog {
24
- [PreserveSig] int Show(IntPtr hwndOwner);
25
- void SetFileTypes(); void SetFileTypeIndex(); void GetFileTypeIndex(); void Advise(); void Unadvise();
26
- void SetOptions(uint fos);
27
- void GetOptions(out uint fos);
28
- void SetDefaultFolder(IShellItem psi);
29
- void SetFolder(IShellItem psi);
30
- void GetFolder(); void GetCurrentSelection(); void SetFileName(); void GetFileName();
31
- void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
32
- void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
33
- void SetFileNameLabel();
34
- void GetResult(out IShellItem ppsi);
35
- }
36
-
37
- [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
38
- private interface IShellItem {
39
- void BindToHandler(); void GetParent();
40
- void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
41
- void GetAttributes(); void Compare();
42
- }
43
-
44
- [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
45
- private static extern void SHCreateItemFromParsingName(string pszPath, IntPtr pbc, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, out IShellItem ppv);
46
-
47
- public static string Pick(string initial, string title) {
48
- // An invisible topmost window owns the dialog so it opens in front of the browser.
49
- var owner = new Form { ShowInTaskbar = false, TopMost = true, FormBorderStyle = FormBorderStyle.None, Opacity = 0, Width = 1, Height = 1, StartPosition = FormStartPosition.CenterScreen };
50
- owner.Show();
51
- owner.Activate();
52
- try {
53
- var dialog = (IFileOpenDialog)new FileOpenDialogCom();
54
- dialog.SetOptions(0x20 | 0x40 | 0x800); // PICKFOLDERS | FORCEFILESYSTEM | PATHMUSTEXIST
55
- dialog.SetTitle(title);
56
- dialog.SetOkButtonLabel("Select folder");
57
- if (!String.IsNullOrEmpty(initial)) {
58
- try {
59
- IShellItem folder;
60
- SHCreateItemFromParsingName(initial, IntPtr.Zero, typeof(IShellItem).GUID, out folder);
61
- dialog.SetFolder(folder);
62
- } catch { }
63
- }
64
- int hr = dialog.Show(owner.Handle);
65
- if (hr != 0) return null; // cancelled
66
- IShellItem result;
67
- dialog.GetResult(out result);
68
- string p;
69
- result.GetDisplayName(0x80058000, out p); // SIGDN_FILESYSPATH
70
- return p;
71
- } finally {
72
- owner.Close();
73
- }
74
- }
75
- }
76
- "@
77
- $picked = [SymbioFolderPicker]::Pick($Initial, $Title)
78
- if ($picked) { [Console]::Out.Write($picked) }
9
+ const WINDOWS_SCRIPT = String.raw `
10
+ param([string]$Initial = "", [string]$Title = "Choose the project folder")
11
+ $ErrorActionPreference = "Stop"
12
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
13
+ Add-Type -AssemblyName System.Windows.Forms
14
+ Add-Type -ReferencedAssemblies System.Windows.Forms -TypeDefinition @"
15
+ using System;
16
+ using System.Runtime.InteropServices;
17
+ using System.Windows.Forms;
18
+
19
+ public static class SymbioFolderPicker {
20
+ [ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] private class FileOpenDialogCom {}
21
+
22
+ [ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
23
+ private interface IFileOpenDialog {
24
+ [PreserveSig] int Show(IntPtr hwndOwner);
25
+ void SetFileTypes(); void SetFileTypeIndex(); void GetFileTypeIndex(); void Advise(); void Unadvise();
26
+ void SetOptions(uint fos);
27
+ void GetOptions(out uint fos);
28
+ void SetDefaultFolder(IShellItem psi);
29
+ void SetFolder(IShellItem psi);
30
+ void GetFolder(); void GetCurrentSelection(); void SetFileName(); void GetFileName();
31
+ void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
32
+ void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
33
+ void SetFileNameLabel();
34
+ void GetResult(out IShellItem ppsi);
35
+ }
36
+
37
+ [ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
38
+ private interface IShellItem {
39
+ void BindToHandler(); void GetParent();
40
+ void GetDisplayName(uint sigdnName, [MarshalAs(UnmanagedType.LPWStr)] out string ppszName);
41
+ void GetAttributes(); void Compare();
42
+ }
43
+
44
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
45
+ private static extern void SHCreateItemFromParsingName(string pszPath, IntPtr pbc, [MarshalAs(UnmanagedType.LPStruct)] Guid riid, out IShellItem ppv);
46
+
47
+ public static string Pick(string initial, string title) {
48
+ // An invisible topmost window owns the dialog so it opens in front of the browser.
49
+ var owner = new Form { ShowInTaskbar = false, TopMost = true, FormBorderStyle = FormBorderStyle.None, Opacity = 0, Width = 1, Height = 1, StartPosition = FormStartPosition.CenterScreen };
50
+ owner.Show();
51
+ owner.Activate();
52
+ try {
53
+ var dialog = (IFileOpenDialog)new FileOpenDialogCom();
54
+ dialog.SetOptions(0x20 | 0x40 | 0x800); // PICKFOLDERS | FORCEFILESYSTEM | PATHMUSTEXIST
55
+ dialog.SetTitle(title);
56
+ dialog.SetOkButtonLabel("Select folder");
57
+ if (!String.IsNullOrEmpty(initial)) {
58
+ try {
59
+ IShellItem folder;
60
+ SHCreateItemFromParsingName(initial, IntPtr.Zero, typeof(IShellItem).GUID, out folder);
61
+ dialog.SetFolder(folder);
62
+ } catch { }
63
+ }
64
+ int hr = dialog.Show(owner.Handle);
65
+ if (hr != 0) return null; // cancelled
66
+ IShellItem result;
67
+ dialog.GetResult(out result);
68
+ string p;
69
+ result.GetDisplayName(0x80058000, out p); // SIGDN_FILESYSPATH
70
+ return p;
71
+ } finally {
72
+ owner.Close();
73
+ }
74
+ }
75
+ }
76
+ "@
77
+ $picked = [SymbioFolderPicker]::Pick($Initial, $Title)
78
+ if ($picked) { [Console]::Out.Write($picked) }
79
79
  `;
80
80
  let busy = false;
81
81
  /** Opens the native folder dialog. Resolves to the chosen path, or null if cancelled. */
@@ -10,6 +10,8 @@ import { RemoteOps } from "./remote.js";
10
10
  import { handleMcp } from "./mcp.js";
11
11
  import { isAllowedRequest } from "./origin.js";
12
12
  import { loadUiToken, tokenMatches } from "./ui-token.js";
13
+ import { isTunnelRequest, loginPage, RemoteAccess, remoteDecision, SESSION_COOKIE } from "./remote-access.js";
14
+ import { bus } from "./bus.js";
13
15
  import { OpencodeRuntime } from "./opencode/runtime.js";
14
16
  import { Store, globalDir } from "./store.js";
15
17
  import { Team } from "./team.js";
@@ -66,11 +68,42 @@ async function main() {
66
68
  // are told to continue once OpenCode is up again.
67
69
  team.markInterrupted("Symbio was stopped and has been restarted");
68
70
  const uiToken = loadUiToken(globalDir);
71
+ const remote = new RemoteAccess(path.join(globalDir, "bin"));
72
+ remote.onChange = () => bus.publish({ type: "remote.updated" });
73
+ const remoteRequests = new WeakSet(); // requests that came through the tunnel
69
74
  const app = new Hono();
70
75
  app.use("*", async (c, next) => {
71
- const ok = isAllowedRequest({ method: c.req.method, host: c.req.header("host"), origin: c.req.header("origin") }, PORT);
72
- if (!ok)
73
- return c.json({ error: "Requests must come from Symbio's own page on this computer." }, 403);
76
+ const viaTunnel = isTunnelRequest({ host: c.req.header("host"), cfRay: c.req.header("cf-ray"), cfIp: c.req.header("cf-connecting-ip") }, remote.host);
77
+ if (!viaTunnel) {
78
+ const ok = isAllowedRequest({ method: c.req.method, host: c.req.header("host"), origin: c.req.header("origin") }, PORT);
79
+ if (!ok)
80
+ return c.json({ error: "Requests must come from Symbio's own page on this computer." }, 403);
81
+ return next();
82
+ }
83
+ // Through the tunnel: only Symbio's own remote page may change things.
84
+ const origin = c.req.header("origin");
85
+ if (c.req.method !== "GET" && c.req.method !== "HEAD" && origin && origin !== `https://${remote.host}`) {
86
+ return c.json({ error: "Not allowed." }, 403);
87
+ }
88
+ const decision = remoteDecision({ method: c.req.method, path: c.req.path, hasSession: remote.hasSession(c.req.header("cookie")), active: remote.active });
89
+ if (decision === "deny")
90
+ return c.text("Not available through remote access.", 403);
91
+ if (decision === "login-page")
92
+ return c.html(loginPage());
93
+ if (decision === "need-login")
94
+ return c.json({ error: "Log in with the access code.", code: "remote-login" }, 401);
95
+ if (decision === "login-submit") {
96
+ const body = await c.req.json().catch(() => ({}));
97
+ const result = await remote.login(String(body.code ?? ""));
98
+ if (result.ok) {
99
+ c.header("Set-Cookie", `${SESSION_COOKIE}=${result.session}; Path=/; Max-Age=${result.maxAge}; HttpOnly; Secure; SameSite=Strict`);
100
+ return c.json({ token: uiToken });
101
+ }
102
+ if (!remote.active)
103
+ return c.json({ error: "Too many wrong codes: remote access was turned off." }, 423);
104
+ return c.json({ error: `Wrong code. ${result.left} attempt${result.left === 1 ? "" : "s"} left.` }, 401);
105
+ }
106
+ remoteRequests.add(c.req.raw);
74
107
  await next();
75
108
  });
76
109
  // The UI's key: a header, or a query parameter for the event stream (EventSource can't set headers).
@@ -87,6 +120,20 @@ async function main() {
87
120
  setTimeout(() => void shutdown?.(), 200);
88
121
  return c.json({ ok: true });
89
122
  });
123
+ // Remote access: status for everyone (the code only locally), start locally, stop from anywhere.
124
+ app.get("/api/remote", (c) => c.json(remote.status(!remoteRequests.has(c.req.raw))));
125
+ app.post("/api/remote/start", async (c) => {
126
+ try {
127
+ return c.json(await remote.start(PORT));
128
+ }
129
+ catch (err) {
130
+ return c.json({ error: err.message }, 500);
131
+ }
132
+ });
133
+ app.post("/api/remote/stop", (c) => {
134
+ remote.stop(remoteRequests.has(c.req.raw) ? "turned off from a remote session" : "turned off on this computer");
135
+ return c.json(remote.status(!remoteRequests.has(c.req.raw)));
136
+ });
90
137
  app.route("/api", createApi(store, runtime, team, connections));
91
138
  // Agents' tool server. Only reachable from this machine (we bind to 127.0.0.1) and only with a per-run token.
92
139
  app.all("/mcp/:token", (c) => handleMcp(c, team.mcpResolver));
@@ -115,6 +162,7 @@ async function main() {
115
162
  .catch((err) => console.error("OpenCode failed to start:", err.message));
116
163
  team.startWatchdog();
117
164
  shutdown = async () => {
165
+ remote.stop("Symbio stopped");
118
166
  team.shuttingDown = true;
119
167
  team.stopWatchdog();
120
168
  await runtime.stop();
@@ -0,0 +1,270 @@
1
+ // Remote access: a temporary Cloudflare quick tunnel (https://….trycloudflare.com) to this
2
+ // Symbio, guarded by an access code.
3
+ //
4
+ // Through the tunnel Symbio controls agents that run commands on this computer, so:
5
+ // - remote requests need a session, obtained by entering the access code (8 characters);
6
+ // after 10 wrong codes the tunnel is shut down;
7
+ // - API calls also need the UI key, as locally (a leaked key alone isn't enough from outside);
8
+ // - the agents' tool endpoint, debug routes and shutdown are never reachable remotely;
9
+ // - turning remote access off ends every remote session; the code is shown only locally.
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { randomBytes, randomInt, timingSafeEqual } from "node:crypto";
12
+ import { chmodSync, existsSync, promises as fs } from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I/L
16
+ export const CODE_LENGTH = 8;
17
+ export const MAX_FAILURES = 10;
18
+ const SESSION_MS = 12 * 3600_000;
19
+ export const SESSION_COOKIE = "symbio_remote";
20
+ export function newAccessCode() {
21
+ let code = "";
22
+ for (let i = 0; i < CODE_LENGTH; i++)
23
+ code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
24
+ return code;
25
+ }
26
+ export function codesMatch(expected, given) {
27
+ const a = Buffer.from(expected);
28
+ const b = Buffer.from(given.trim().toUpperCase().replace(/[\s-]/g, ""));
29
+ return a.length === b.length && timingSafeEqual(a, b);
30
+ }
31
+ /** Requests that came through Cloudflare (they carry its headers) or name the tunnel's host. */
32
+ export function isTunnelRequest(headers, tunnelHost) {
33
+ if (headers.cfRay || headers.cfIp)
34
+ return true;
35
+ const host = (headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
36
+ return !!tunnelHost && host === tunnelHost.toLowerCase();
37
+ }
38
+ // Never through the tunnel: the agents' tools, debugging, shutting Symbio down, starting a
39
+ // tunnel, and the native folder dialog (it would open on this computer's screen).
40
+ const NEVER_REMOTE = [/^\/mcp(\/|$)/, /^\/api\/debug(\/|$)/, /^\/api\/shutdown$/, /^\/api\/remote\/start$/, /^\/api\/pick-folder$/];
41
+ /** What to do with a request that came through the tunnel. */
42
+ export function remoteDecision(req) {
43
+ if (!req.active)
44
+ return "deny";
45
+ if (NEVER_REMOTE.some((re) => re.test(req.path)))
46
+ return "deny";
47
+ if (req.path === "/remote-login")
48
+ return req.method === "POST" ? "login-submit" : "login-page";
49
+ if (req.hasSession)
50
+ return "allow";
51
+ return req.path.startsWith("/api/") ? "need-login" : "login-page";
52
+ }
53
+ export function cookieValue(header, name) {
54
+ for (const part of (header ?? "").split(";")) {
55
+ const [k, ...v] = part.trim().split("=");
56
+ if (k === name)
57
+ return v.join("=");
58
+ }
59
+ return null;
60
+ }
61
+ export class RemoteAccess {
62
+ binDir;
63
+ proc = null;
64
+ url = null;
65
+ code = null;
66
+ startedAt = null;
67
+ sessions = new Map(); // id → expiry
68
+ failures = 0;
69
+ lastStop = null;
70
+ onChange = () => { };
71
+ constructor(binDir) {
72
+ this.binDir = binDir;
73
+ }
74
+ get active() {
75
+ return !!this.proc && !!this.url;
76
+ }
77
+ get host() {
78
+ return this.url ? new URL(this.url).host : null;
79
+ }
80
+ status(local) {
81
+ this.pruneSessions();
82
+ return {
83
+ active: this.active,
84
+ url: this.url,
85
+ startedAt: this.startedAt,
86
+ sessions: this.sessions.size,
87
+ ...(local ? { code: this.code } : {}),
88
+ lastStop: this.lastStop,
89
+ };
90
+ }
91
+ /** Opens the tunnel to http://127.0.0.1:<port>. Resolves once Cloudflare gave a URL. */
92
+ async start(port) {
93
+ if (this.active)
94
+ return this.status(true);
95
+ const bin = await this.cloudflared();
96
+ const args = ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`];
97
+ // A .js stand-in (SYMBIO_CLOUDFLARED_BIN) runs with Node: used to test without opening a real tunnel.
98
+ const [cmd, argv] = /\.m?js$/.test(bin) ? [process.execPath, [bin, ...args]] : [bin, args];
99
+ const proc = spawn(cmd, argv, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
100
+ this.proc = proc;
101
+ const url = await new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => reject(new Error("Cloudflare didn't open a tunnel within 60 seconds.")), 60_000);
103
+ const onData = (buf) => {
104
+ const m = buf.toString().match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
105
+ if (m) {
106
+ clearTimeout(timer);
107
+ resolve(m[0]);
108
+ }
109
+ };
110
+ proc.stdout.on("data", onData);
111
+ proc.stderr.on("data", onData);
112
+ proc.once("exit", (code) => {
113
+ clearTimeout(timer);
114
+ reject(new Error(`cloudflared exited (code ${code}) before opening the tunnel.`));
115
+ });
116
+ }).catch((err) => {
117
+ this.kill();
118
+ throw err;
119
+ });
120
+ this.url = url;
121
+ this.code = newAccessCode();
122
+ this.startedAt = Date.now();
123
+ this.failures = 0;
124
+ this.sessions.clear();
125
+ proc.once("exit", () => {
126
+ if (this.proc === proc)
127
+ this.reset("the tunnel closed");
128
+ });
129
+ console.log(`[remote] on: ${url}`);
130
+ this.onChange();
131
+ return this.status(true);
132
+ }
133
+ stop(reason) {
134
+ if (!this.proc && !this.url)
135
+ return;
136
+ this.kill();
137
+ this.reset(reason);
138
+ }
139
+ kill() {
140
+ const proc = this.proc;
141
+ this.proc = null;
142
+ if (proc && proc.exitCode === null)
143
+ proc.kill();
144
+ }
145
+ reset(reason) {
146
+ this.proc = null;
147
+ this.url = null;
148
+ this.code = null;
149
+ this.startedAt = null;
150
+ this.sessions.clear();
151
+ this.lastStop = { at: Date.now(), reason };
152
+ console.log(`[remote] off: ${reason}`);
153
+ this.onChange();
154
+ }
155
+ hasSession(cookieHeader) {
156
+ const id = cookieValue(cookieHeader, SESSION_COOKIE);
157
+ const expiry = id ? this.sessions.get(id) : undefined;
158
+ return !!expiry && expiry > Date.now();
159
+ }
160
+ /** Checks an access code. Too many wrong ones shut the tunnel down. */
161
+ async login(given) {
162
+ if (this.code && codesMatch(this.code, given)) {
163
+ const session = randomBytes(32).toString("base64url");
164
+ this.sessions.set(session, Date.now() + SESSION_MS);
165
+ console.log("[remote] a remote session started");
166
+ this.onChange();
167
+ return { ok: true, session, maxAge: SESSION_MS / 1000 };
168
+ }
169
+ this.failures++;
170
+ console.warn(`[remote] wrong access code (${this.failures}/${MAX_FAILURES})`);
171
+ if (this.failures >= MAX_FAILURES)
172
+ this.stop(`${MAX_FAILURES} wrong access codes; turned off for safety`);
173
+ await new Promise((r) => setTimeout(r, 1500)); // slows guessing down
174
+ return { ok: false, left: Math.max(0, MAX_FAILURES - this.failures) };
175
+ }
176
+ pruneSessions() {
177
+ const now = Date.now();
178
+ for (const [id, exp] of this.sessions)
179
+ if (exp <= now)
180
+ this.sessions.delete(id);
181
+ }
182
+ /** cloudflared from SYMBIO_CLOUDFLARED_BIN, the PATH, or downloaded once into ~/.symbio/bin. */
183
+ async cloudflared() {
184
+ if (process.env.SYMBIO_CLOUDFLARED_BIN)
185
+ return process.env.SYMBIO_CLOUDFLARED_BIN;
186
+ const onPath = spawnSync("cloudflared", ["--version"], { windowsHide: true, encoding: "utf8" });
187
+ if (onPath.status === 0)
188
+ return "cloudflared";
189
+ const exe = path.join(this.binDir, process.platform === "win32" ? "cloudflared.exe" : "cloudflared");
190
+ if (existsSync(exe))
191
+ return exe;
192
+ await downloadCloudflared(exe);
193
+ return exe;
194
+ }
195
+ }
196
+ /** Downloads cloudflared from Cloudflare's official GitHub releases. */
197
+ async function downloadCloudflared(dest) {
198
+ const arch = os.arch() === "arm64" ? "arm64" : "amd64";
199
+ const asset = process.platform === "win32"
200
+ ? `cloudflared-windows-${arch}.exe`
201
+ : process.platform === "darwin"
202
+ ? `cloudflared-darwin-${arch}.tgz`
203
+ : `cloudflared-linux-${arch}`;
204
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
205
+ console.log(`[remote] downloading cloudflared (${asset})…`);
206
+ const res = await fetch(url);
207
+ if (!res.ok)
208
+ throw new Error(`Couldn't download cloudflared (${res.status}). Install it yourself: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/`);
209
+ await fs.mkdir(path.dirname(dest), { recursive: true });
210
+ const tmp = `${dest}.download`;
211
+ await fs.writeFile(tmp, Buffer.from(await res.arrayBuffer()));
212
+ if (asset.endsWith(".tgz")) {
213
+ const r = spawnSync("tar", ["-xzf", tmp, "-C", path.dirname(dest)]);
214
+ await fs.rm(tmp, { force: true });
215
+ if (r.status !== 0)
216
+ throw new Error("Couldn't unpack cloudflared.");
217
+ }
218
+ else {
219
+ await fs.rename(tmp, dest);
220
+ }
221
+ if (process.platform !== "win32")
222
+ chmodSync(dest, 0o755);
223
+ }
224
+ /** The page remote visitors see first: enter the access code. Self-contained (no assets). */
225
+ export function loginPage() {
226
+ return `<!doctype html>
227
+ <html lang="en">
228
+ <head>
229
+ <meta charset="utf-8" />
230
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
231
+ <meta name="robots" content="noindex" />
232
+ <title>Symbio — remote access</title>
233
+ <style>
234
+ :root { color-scheme: dark; }
235
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0b0c0e; color: #e8e9ec; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
236
+ .card { width: min(360px, calc(100vw - 32px)); padding: 28px 24px; border: 1px solid #23252b; border-radius: 12px; background: #111215; }
237
+ h1 { margin: 0 0 6px; font-size: 18px; display: flex; align-items: center; gap: 8px; }
238
+ p { margin: 0 0 18px; color: #9a9ca4; }
239
+ input { box-sizing: border-box; width: 100%; height: 44px; padding: 0 12px; border-radius: 8px; border: 1px solid #2c2f36; background: #0b0c0e; color: #e8e9ec; font: 600 20px/1 ui-monospace, monospace; letter-spacing: 0.25em; text-transform: uppercase; text-align: center; }
240
+ input:focus { outline: none; border-color: #6d6af5; box-shadow: 0 0 0 3px rgba(109,106,245,.25); }
241
+ button { margin-top: 12px; width: 100%; height: 40px; border: 0; border-radius: 8px; background: #6d6af5; color: white; font: 600 14px system-ui, sans-serif; cursor: pointer; }
242
+ button:disabled { opacity: .6; cursor: default; }
243
+ .error { margin-top: 12px; color: #f0726a; min-height: 1.5em; }
244
+ </style>
245
+ </head>
246
+ <body>
247
+ <form class="card" id="f">
248
+ <h1><svg width="22" height="22" viewBox="0 0 24 24"><circle cx="9" cy="12" r="6.5" fill="none" stroke="#6d6af5" stroke-width="2.2"/><circle cx="15" cy="12" r="6.5" fill="none" stroke="#3fb27f" stroke-width="2.2"/></svg>Symbio</h1>
249
+ <p>Enter the access code shown on the computer running Symbio.</p>
250
+ <input id="code" name="code" autocomplete="one-time-code" maxlength="12" autofocus required />
251
+ <button id="go">Open Symbio</button>
252
+ <div class="error" id="err"></div>
253
+ </form>
254
+ <script>
255
+ const f = document.getElementById("f"), go = document.getElementById("go"), err = document.getElementById("err");
256
+ f.addEventListener("submit", async (e) => {
257
+ e.preventDefault();
258
+ go.disabled = true; err.textContent = "";
259
+ try {
260
+ const res = await fetch("/remote-login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: document.getElementById("code").value }) });
261
+ const data = await res.json().catch(() => ({}));
262
+ if (res.ok && data.token) { localStorage.setItem("symbio:token", data.token); location.href = "/"; return; }
263
+ err.textContent = data.error || "That didn't work.";
264
+ } catch { err.textContent = "Couldn't reach Symbio."; }
265
+ go.disabled = false;
266
+ });
267
+ </script>
268
+ </body>
269
+ </html>`;
270
+ }