symbio 0.2.1 → 0.3.0-beta.2

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,15 @@ 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
+ - Live updates work through proxies that hold event streams back (such as Cloudflare quick tunnels):
14
+ the page falls back to polling when the stream stays silent.
15
+ - Through remote access, Browse opens a folder browser inside the page instead of the native dialog.
16
+
8
17
  ## [0.2.1]
9
18
 
10
19
  - Releases are published from GitHub Actions with npm trusted publishing (no tokens), with
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;
@@ -2,8 +2,9 @@
2
2
  import { Hono } from "hono";
3
3
  import { streamSSE } from "hono/streaming";
4
4
  import { bus } from "./bus.js";
5
- import { promises as fs } from "node:fs";
5
+ import { existsSync, promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
+ import os from "node:os";
7
8
  import { describeInspection, inspectFolder } from "./inspect.js";
8
9
  import { gitAvailable, initRepository } from "./git.js";
9
10
  import { pickFolder } from "./folder-picker.js";
@@ -211,6 +212,28 @@ export function createApi(store, runtime, team, connections) {
211
212
  const existing = store.list().find((p) => path.resolve(p.directory).toLowerCase() === inspection.directory.toLowerCase());
212
213
  return c.json({ ...inspection, gitAvailable: hasGit, existingProject: existing ? { id: existing.id, name: existing.name } : null });
213
214
  });
215
+ // Folder browser inside the page (works through remote access, where the native dialog
216
+ // would open on this computer's screen). Lists subfolder names only, never files.
217
+ api.get("/fs/dirs", async (c) => {
218
+ const requested = c.req.query("path")?.trim();
219
+ const dir = requested && path.isAbsolute(requested) ? path.resolve(requested) : os.homedir();
220
+ const roots = process.platform === "win32"
221
+ ? "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((d) => `${d}:\\`).filter((d) => existsSync(d))
222
+ : ["/"];
223
+ try {
224
+ const entries = await fs.readdir(dir, { withFileTypes: true });
225
+ const dirs = entries
226
+ .filter((e) => e.isDirectory() && !e.name.startsWith("$") && e.name !== "System Volume Information")
227
+ .map((e) => e.name)
228
+ .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))
229
+ .slice(0, 1000);
230
+ const parent = path.dirname(dir);
231
+ return c.json({ path: dir, parent: parent === dir ? null : parent, dirs, roots, home: os.homedir(), sep: path.sep });
232
+ }
233
+ catch (err) {
234
+ return c.json({ error: `Can't open ${dir}: ${err.code ?? err.message}` }, 400);
235
+ }
236
+ });
214
237
  // Opens the native folder dialog on this machine; path is null when cancelled.
215
238
  api.post("/pick-folder", async (c) => {
216
239
  const body = await c.req.json().catch(() => ({}));
@@ -581,6 +604,39 @@ export function createApi(store, runtime, team, connections) {
581
604
  await stream.writeSSE({ event: "ping", data: "" });
582
605
  }
583
606
  }));
607
+ // The same events by long polling, for connections that can't hold a stream open (e.g. remote
608
+ // access through a Cloudflare quick tunnel). Waits up to 25 s for something new.
609
+ api.get("/events/poll", async (c) => {
610
+ const after = Number(c.req.query("after"));
611
+ if (!c.req.query("after") || !Number.isFinite(after)) {
612
+ return c.json({ seq: bus.lastSeq, events: [{ type: "runtime", ...runtimeInfo() }], gap: false });
613
+ }
614
+ let runtimeChanged = false;
615
+ if (bus.since(after).events.length === 0) {
616
+ await new Promise((resolve) => {
617
+ const done = () => {
618
+ clearTimeout(timer);
619
+ bus.off("ui", onEvent);
620
+ runtime.off("state", onState);
621
+ c.req.raw.signal.removeEventListener("abort", done);
622
+ resolve();
623
+ };
624
+ const onEvent = () => setTimeout(done, 50); // let a burst of events arrive together
625
+ const onState = () => {
626
+ runtimeChanged = true;
627
+ done();
628
+ };
629
+ const timer = setTimeout(done, 25_000);
630
+ bus.on("ui", onEvent);
631
+ runtime.on("state", onState);
632
+ c.req.raw.signal.addEventListener("abort", done);
633
+ });
634
+ }
635
+ const r = bus.since(after);
636
+ if (runtimeChanged)
637
+ r.events.push({ type: "runtime", ...runtimeInfo() });
638
+ return c.json(r);
639
+ });
584
640
  const app = new Hono();
585
641
  app.onError((err, c) => {
586
642
  if (err instanceof HttpError)
@@ -1,9 +1,30 @@
1
1
  // Fan-out of events to connected browser clients.
2
2
  import { EventEmitter } from "node:events";
3
+ // Recent events are also kept, numbered, for clients that can't hold a stream open (some
4
+ // proxies, like Cloudflare quick tunnels, don't pass Server-Sent Events): they poll instead.
5
+ const KEEP = 5000;
3
6
  class Bus extends EventEmitter {
7
+ seq = 0;
8
+ recent = [];
4
9
  publish(event) {
10
+ this.seq++;
11
+ this.recent.push({ seq: this.seq, event });
12
+ if (this.recent.length > KEEP)
13
+ this.recent.splice(0, this.recent.length - KEEP);
5
14
  this.emit("ui", event);
6
15
  }
16
+ get lastSeq() {
17
+ return this.seq;
18
+ }
19
+ /** Events after `after`; `gap` when some were already dropped (the client should refresh). */
20
+ since(after) {
21
+ const first = this.recent[0]?.seq ?? this.seq + 1;
22
+ return {
23
+ seq: this.seq,
24
+ events: this.recent.filter((r) => r.seq > after).map((r) => r.event),
25
+ gap: after < first - 1 && after < this.seq,
26
+ };
27
+ }
7
28
  }
8
29
  export const bus = new Bus();
9
30
  bus.setMaxListeners(100);
@@ -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
+ }