@saadnvd1/agent-os 0.3.514 → 0.3.516

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/README.md CHANGED
@@ -1585,7 +1585,13 @@ device can use AgentOS but can't add devices or change access; only this
1585
1585
  machine and the tailnet can.
1586
1586
 
1587
1587
  AgentOS also refuses requests addressed to any other host name (DNS rebinding),
1588
- and API calls or terminal connections made by another website's page.
1588
+ and API calls or terminal connections made by another website's page. That
1589
+ Origin check holds whatever `AGENTOS_BIND` says: a page may write to the API
1590
+ or open a terminal, chat or status socket only from this machine's own
1591
+ addresses (loopback, the addresses it listens on, its tailnet name, its own
1592
+ Connect address and `AGENTOS_ALLOWED_HOSTS`). Another Connect machine's page
1593
+ is always refused. With `AGENTOS_BIND=0.0.0.0`, reaching it by a DNS name
1594
+ other than these needs that name in `AGENTOS_ALLOWED_HOSTS`.
1589
1595
 
1590
1596
  | Variable | Effect |
1591
1597
  | -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
@@ -1613,6 +1619,12 @@ While Connect is on, the production server:
1613
1619
  - asks the Connect service for a fresh connector token on every start and hands
1614
1620
  it to `cloudflared tunnel run` in `TUNNEL_TOKEN` only, never in argv, a file
1615
1621
  or a log;
1622
+ - starts cloudflared only once AgentOS holds the tunnel port and has seen a
1623
+ connection to it arrive on its own listener, and stops it if the listener
1624
+ goes, so it never publishes something else on that port;
1625
+ - runs cloudflared in a process group of its own under a small watchdog that
1626
+ kills it the moment AgentOS exits, however it exits, and kills one a crash
1627
+ left behind (from `cloudflared.pid`) before starting another;
1616
1628
  - restarts cloudflared with backoff if it exits, and stops it when Connect is
1617
1629
  turned off. Devices shows its state.
1618
1630
 
@@ -0,0 +1,209 @@
1
+ import { NextRequest } from "next/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DELETE, PATCH, PUT } from "./route";
4
+ import { DELETE as SELF_DELETE } from "../route";
5
+ import {
6
+ deviceForToken,
7
+ getDevicePush,
8
+ mintDevice,
9
+ revokeDevice,
10
+ } from "@/lib/security/devices";
11
+ import { DEVICE_HEADER } from "@/lib/security/auth";
12
+ import { getDb } from "@/lib/db";
13
+ import { defaultCategories } from "@/lib/notify/copy";
14
+
15
+ const call = (
16
+ fn: (r: NextRequest) => Promise<Response>,
17
+ method: string,
18
+ device: string | null,
19
+ body?: unknown
20
+ ) =>
21
+ fn(
22
+ new NextRequest("http://x/api/devices/me/push", {
23
+ method,
24
+ headers: device ? { [DEVICE_HEADER]: device } : {},
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ })
27
+ );
28
+
29
+ const tok = (n: number) => "apns" + String(n).padStart(8, "0") + "ab".repeat(8);
30
+
31
+ describe("/api/devices/me/push", () => {
32
+ it("refuses a request that isn't from a paired device", async () => {
33
+ for (const [fn, m] of [
34
+ [PUT, "PUT"],
35
+ [PATCH, "PATCH"],
36
+ [DELETE, "DELETE"],
37
+ ] as const)
38
+ expect((await call(fn, m, null, { token: tok(1) })).status).toBe(403);
39
+ expect(
40
+ (
41
+ await SELF_DELETE(
42
+ new NextRequest("http://x/api/devices/me", { method: "DELETE" })
43
+ )
44
+ ).status
45
+ ).toBe(403);
46
+ });
47
+
48
+ it("registers, defaults the categories, and is idempotent", async () => {
49
+ const { device } = mintDevice("phone");
50
+ const res = await call(PUT, "PUT", device.id, {
51
+ token: tok(2),
52
+ environment: "sandbox",
53
+ });
54
+ expect(res.status).toBe(200);
55
+ expect(await res.json()).toMatchObject({
56
+ deviceId: device.id,
57
+ enabled: true,
58
+ platform: "ios",
59
+ environment: "sandbox",
60
+ categories: defaultCategories(),
61
+ });
62
+ const again = await call(PUT, "PUT", device.id, {
63
+ token: tok(2),
64
+ environment: "sandbox",
65
+ });
66
+ expect(again.status).toBe(200);
67
+ expect(getDevicePush(device.id)?.token).toBe(tok(2));
68
+ });
69
+
70
+ it("a new token replaces the old and keeps saved categories", async () => {
71
+ const { device } = mintDevice("phone");
72
+ await call(PUT, "PUT", device.id, { token: tok(3) });
73
+ await call(PATCH, "PATCH", device.id, { categories: ["asks", "merged"] });
74
+ await call(PUT, "PUT", device.id, { token: tok(4) });
75
+ expect(getDevicePush(device.id)).toMatchObject({
76
+ token: tok(4),
77
+ categories: ["asks", "merged"],
78
+ });
79
+ });
80
+
81
+ it("moves a token from the device that held it", async () => {
82
+ const a = mintDevice("old pairing").device;
83
+ const b = mintDevice("new pairing").device;
84
+ await call(PUT, "PUT", a.id, { token: tok(5) });
85
+ await call(PUT, "PUT", b.id, { token: tok(5) });
86
+ expect(getDevicePush(a.id)).toBeNull();
87
+ expect(getDevicePush(b.id)?.token).toBe(tok(5));
88
+ });
89
+
90
+ it("rejects bad input", async () => {
91
+ const { device } = mintDevice("phone");
92
+ for (const b of [
93
+ {},
94
+ { token: "short" },
95
+ { token: "bad token with spaces!!" },
96
+ { token: tok(6), platform: "palm" },
97
+ { token: tok(6), environment: "staging" },
98
+ { token: tok(6), categories: ["asks", "nope"] },
99
+ { token: tok(6), categories: "asks" },
100
+ ])
101
+ expect((await call(PUT, "PUT", device.id, b)).status).toBe(400);
102
+ expect(getDevicePush(device.id)).toBeNull();
103
+ });
104
+
105
+ it("PATCH sets categories, needs a registration, and validates", async () => {
106
+ const { device } = mintDevice("phone");
107
+ expect(
108
+ (await call(PATCH, "PATCH", device.id, { categories: ["asks"] })).status
109
+ ).toBe(404);
110
+ await call(PUT, "PUT", device.id, { token: tok(7) });
111
+ expect(
112
+ (await call(PATCH, "PATCH", device.id, { categories: ["x"] })).status
113
+ ).toBe(400);
114
+ expect((await call(PATCH, "PATCH", device.id, {})).status).toBe(400);
115
+ const res = await call(PATCH, "PATCH", device.id, {
116
+ categories: ["ci", "ci"],
117
+ });
118
+ expect((await res.json()).categories).toEqual(["ci"]);
119
+ await call(PATCH, "PATCH", device.id, { categories: [] });
120
+ expect(getDevicePush(device.id)?.categories).toEqual([]);
121
+ });
122
+
123
+ it("DELETE clears push for this device only", async () => {
124
+ const a = mintDevice("a").device;
125
+ const b = mintDevice("b").device;
126
+ await call(PUT, "PUT", a.id, { token: tok(8) });
127
+ await call(PUT, "PUT", b.id, { token: tok(9) });
128
+ const res = await call(DELETE, "DELETE", a.id);
129
+ expect(await res.json()).toMatchObject({ enabled: false });
130
+ expect(getDevicePush(a.id)).toBeNull();
131
+ expect(getDevicePush(b.id)).not.toBeNull();
132
+ });
133
+
134
+ it("refuses an oversized body", async () => {
135
+ const { device } = mintDevice("phone");
136
+ const res = await call(PUT, "PUT", device.id, {
137
+ token: tok(14),
138
+ pad: "x".repeat(5000),
139
+ });
140
+ expect(res.status).toBe(413);
141
+ expect(getDevicePush(device.id)).toBeNull();
142
+ });
143
+
144
+ it("reads a corrupt saved category list as the defaults", async () => {
145
+ const { device } = mintDevice("phone");
146
+ await call(PUT, "PUT", device.id, { token: tok(15) });
147
+ for (const junk of ["{not json", '"asks"', "7"]) {
148
+ getDb()
149
+ .prepare(`UPDATE devices SET push_categories = ? WHERE id = ?`)
150
+ .run(junk, device.id);
151
+ expect(getDevicePush(device.id)?.categories).toEqual(defaultCategories());
152
+ }
153
+ });
154
+
155
+ it("never returns the token", async () => {
156
+ const { device } = mintDevice("phone");
157
+ const res = await call(PUT, "PUT", device.id, { token: tok(10) });
158
+ expect(JSON.stringify(await res.json())).not.toContain(tok(10));
159
+ });
160
+ });
161
+
162
+ describe("device revocation and push", () => {
163
+ it("revoking clears every push column", async () => {
164
+ const { device } = mintDevice("phone");
165
+ await call(PUT, "PUT", device.id, { token: tok(11), categories: ["asks"] });
166
+ expect(revokeDevice(device.id)).toBe(true);
167
+ const row = getDb()
168
+ .prepare(
169
+ `SELECT push_token, push_platform, push_environment, push_categories,
170
+ push_updated_at, push_last_error FROM devices WHERE id = ?`
171
+ )
172
+ .get(device.id);
173
+ expect(Object.values(row as object).every((v) => v === null)).toBe(true);
174
+ expect(getDevicePush(device.id)).toBeNull();
175
+ });
176
+
177
+ it("DELETE /api/devices/me revokes the caller and its push", async () => {
178
+ const { device, token } = mintDevice("phone");
179
+ await call(PUT, "PUT", device.id, { token: tok(12) });
180
+ const res = await SELF_DELETE(
181
+ new NextRequest("http://x/api/devices/me", {
182
+ method: "DELETE",
183
+ headers: { [DEVICE_HEADER]: device.id },
184
+ })
185
+ );
186
+ expect(res.status).toBe(200);
187
+ expect(deviceForToken(token)).toBeNull();
188
+ expect(getDevicePush(device.id)).toBeNull();
189
+ // A second sign-out finds nothing.
190
+ expect(
191
+ (
192
+ await SELF_DELETE(
193
+ new NextRequest("http://x/api/devices/me", {
194
+ method: "DELETE",
195
+ headers: { [DEVICE_HEADER]: device.id },
196
+ })
197
+ )
198
+ ).status
199
+ ).toBe(404);
200
+ });
201
+
202
+ it("a revoked device can't register push", async () => {
203
+ const { device } = mintDevice("phone");
204
+ revokeDevice(device.id);
205
+ expect((await call(PUT, "PUT", device.id, { token: tok(13) })).status).toBe(
206
+ 404
207
+ );
208
+ });
209
+ });
@@ -0,0 +1,115 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ clearDevicePush,
4
+ getDevicePush,
5
+ setDevicePush,
6
+ setDevicePushCategories,
7
+ } from "@/lib/security/devices";
8
+ import { callingDevice, needsDevice } from "@/lib/security/device-me";
9
+ import { readCapped } from "@/lib/security/read-capped";
10
+ import { isPushCategory } from "@/lib/notify/copy";
11
+ import {
12
+ PUSH_ENVIRONMENTS,
13
+ PUSH_PLATFORMS,
14
+ type PushCategory,
15
+ type PushEnvironment,
16
+ type PushPlatform,
17
+ } from "@/lib/notify/push-types";
18
+
19
+ const bad = (error: string) => NextResponse.json({ error }, { status: 400 });
20
+
21
+ const MAX_BODY_BYTES = 4096;
22
+
23
+ /** The JSON body, or null when it's over the cap. Bad JSON reads as empty. */
24
+ async function body(request: NextRequest) {
25
+ const text = await readCapped(request, MAX_BODY_BYTES);
26
+ if (text === null) return null;
27
+ try {
28
+ const v = JSON.parse(text || "{}");
29
+ return (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ const tooBig = () => NextResponse.json({ error: "Too large" }, { status: 413 });
36
+
37
+ /** Undefined if absent, null if present but invalid. */
38
+ function categoriesOf(v: unknown): PushCategory[] | undefined | null {
39
+ if (v === undefined) return undefined;
40
+ if (!Array.isArray(v) || !v.every(isPushCategory)) return null;
41
+ return [...new Set(v)];
42
+ }
43
+
44
+ const view = (id: string) => {
45
+ const p = getDevicePush(id);
46
+ return {
47
+ deviceId: id,
48
+ enabled: !!p,
49
+ categories: p?.categories ?? [],
50
+ environment: p?.environment ?? null,
51
+ platform: p?.platform ?? null,
52
+ lastError: p?.lastError ?? null,
53
+ };
54
+ };
55
+
56
+ // PUT /api/devices/me/push - register or refresh this device's push token
57
+ export async function PUT(request: NextRequest) {
58
+ const id = callingDevice(request);
59
+ if (!id) return needsDevice();
60
+ const b = await body(request);
61
+ if (!b) return tooBig();
62
+ const token = b.token;
63
+ if (
64
+ typeof token !== "string" ||
65
+ token.length < 8 ||
66
+ token.length > 512 ||
67
+ !/^[A-Za-z0-9:_.\-]+$/.test(token)
68
+ )
69
+ return bad("token must be a device push token");
70
+ const platform = b.platform ?? "ios";
71
+ const environment = b.environment ?? "production";
72
+ if (!PUSH_PLATFORMS.includes(platform as PushPlatform))
73
+ return bad("platform must be ios or android");
74
+ if (!PUSH_ENVIRONMENTS.includes(environment as PushEnvironment))
75
+ return bad("environment must be production or sandbox");
76
+ const categories = categoriesOf(b.categories);
77
+ if (categories === null) return bad("categories must be known names");
78
+ // Intended: a phone re-paired to this machine gets a new device row and the
79
+ // same APNs token, which must move to it so the old row stops getting sends.
80
+ // Only a holder of a valid device token can register one.
81
+ if (
82
+ !setDevicePush(id, {
83
+ token,
84
+ platform: platform as PushPlatform,
85
+ environment: environment as PushEnvironment,
86
+ categories,
87
+ })
88
+ )
89
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
90
+ return NextResponse.json(view(id));
91
+ }
92
+
93
+ // PATCH /api/devices/me/push - choose which categories to receive
94
+ export async function PATCH(request: NextRequest) {
95
+ const id = callingDevice(request);
96
+ if (!id) return needsDevice();
97
+ const b = await body(request);
98
+ if (!b) return tooBig();
99
+ const categories = categoriesOf(b.categories);
100
+ if (!categories) return bad("categories must be a list of known names");
101
+ if (!setDevicePushCategories(id, categories))
102
+ return NextResponse.json(
103
+ { error: "Push isn't registered" },
104
+ { status: 404 }
105
+ );
106
+ return NextResponse.json(view(id));
107
+ }
108
+
109
+ // DELETE /api/devices/me/push - stop pushing to this device
110
+ export async function DELETE(request: NextRequest) {
111
+ const id = callingDevice(request);
112
+ if (!id) return needsDevice();
113
+ clearDevicePush(id);
114
+ return NextResponse.json(view(id));
115
+ }
@@ -0,0 +1,12 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { revokeDevice } from "@/lib/security/devices";
3
+ import { callingDevice, needsDevice } from "@/lib/security/device-me";
4
+
5
+ // DELETE /api/devices/me - the device signs itself out ("Forget machine")
6
+ export async function DELETE(request: NextRequest) {
7
+ const id = callingDevice(request);
8
+ if (!id) return needsDevice();
9
+ if (!revokeDevice(id))
10
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
11
+ return NextResponse.json({ ok: true });
12
+ }
@@ -8,6 +8,10 @@
8
8
  * - takes only the machine's own Host (cloudflared pins it) and strips
9
9
  * X-Forwarded-Host, Forwarded and X-Original-Host before any handler.
10
10
  *
11
+ * cloudflared starts only once that listener is bound and a connection to
12
+ * the port is seen to arrive on it, and stops if the listener goes: it must
13
+ * never publish whatever else holds the port.
14
+ *
11
15
  * Connect follows connect.json while running: `agent-os disconnect` or the
12
16
  * switch in Devices stops cloudflared and closes every tunnel connection at
13
17
  * once. Nothing here may stop AgentOS from starting.
@@ -15,6 +19,7 @@
15
19
 
16
20
  import fs from "fs";
17
21
  import http from "http";
22
+ import net from "net";
18
23
  import path from "path";
19
24
  import type { IncomingMessage, ServerResponse } from "http";
20
25
  import type { Socket } from "net";
@@ -36,7 +41,13 @@ import {
36
41
  } from "./control-api";
37
42
  import { appVersion, startHeartbeat } from "./heartbeat";
38
43
  import { ensureCloudflared } from "./cloudflared";
39
- import { superviseTunnel, type Spawn, type TunnelState } from "./supervise";
44
+ import {
45
+ reapTunnel,
46
+ superviseTunnel,
47
+ type ReapDeps,
48
+ type Spawn,
49
+ type TunnelState,
50
+ } from "./supervise";
40
51
 
41
52
  export interface Handlers {
42
53
  onRequest: (req: IncomingMessage, res: ServerResponse) => void;
@@ -101,12 +112,51 @@ export interface ConnectDeps {
101
112
  delays?: Parameters<typeof superviseTunnel>[0]["delays"];
102
113
  heartbeat?: typeof sendHeartbeat;
103
114
  heartbeatMs?: number;
115
+ reap?: ReapDeps;
116
+ }
117
+
118
+ /**
119
+ * Whether connections to 127.0.0.1:<port> reach `server`: one is made and
120
+ * must show up on it. Never rejects.
121
+ */
122
+ export function listenerIsOurs(
123
+ server: http.Server,
124
+ port: number,
125
+ timeoutMs = 3000
126
+ ): Promise<boolean> {
127
+ return new Promise((resolve) => {
128
+ const arrived = new Set<number>();
129
+ let probePort: number | undefined;
130
+ let done = false;
131
+ const finish = (ok: boolean) => {
132
+ if (done) return;
133
+ done = true;
134
+ clearTimeout(timer);
135
+ server.removeListener("connection", onConnection);
136
+ client.destroy();
137
+ resolve(ok);
138
+ };
139
+ const onConnection = (s: Socket) => {
140
+ if (s.remotePort === undefined) return;
141
+ arrived.add(s.remotePort);
142
+ if (s.remotePort === probePort) finish(true);
143
+ };
144
+ server.prependListener("connection", onConnection);
145
+ const timer = setTimeout(() => finish(false), timeoutMs);
146
+ timer.unref?.();
147
+ const client = net.connect(port, "127.0.0.1", () => {
148
+ probePort = client.localPort;
149
+ // The server may have seen it before this callback ran.
150
+ if (probePort !== undefined && arrived.has(probePort)) finish(true);
151
+ });
152
+ client.on("error", () => finish(false));
153
+ });
104
154
  }
105
155
 
106
156
  interface Running {
107
157
  server: http.Server;
108
158
  sockets: Set<Socket>;
109
- supervisor: ReturnType<typeof superviseTunnel>;
159
+ supervisor: ReturnType<typeof superviseTunnel> | null;
110
160
  heartbeat: ReturnType<typeof startHeartbeat>;
111
161
  release: () => void;
112
162
  }
@@ -131,12 +181,12 @@ export function startConnect(
131
181
  let bindError: string | undefined;
132
182
  g.__agentosConnect = () => {
133
183
  if (!running) return base;
134
- const s: TunnelState = running.supervisor.state();
184
+ const s: TunnelState = running.supervisor?.state() ?? "connecting";
135
185
  return {
136
186
  ...base,
137
187
  hostname: hostname || base.hostname,
138
188
  state: bindError ? "error" : s === "stopped" ? "off" : s,
139
- reason: bindError ?? running.supervisor.reason(),
189
+ reason: bindError ?? running.supervisor?.reason(),
140
190
  };
141
191
  };
142
192
 
@@ -153,7 +203,7 @@ export function startConnect(
153
203
  if (!running) return;
154
204
  bindError = undefined;
155
205
  running.heartbeat.stop();
156
- running.supervisor.stop();
206
+ running.supervisor?.stop();
157
207
  running.sockets.forEach((s) => s.destroy());
158
208
  running.server.close();
159
209
  running.release();
@@ -200,19 +250,50 @@ export function startConnect(
200
250
  log(`connect: ${base.reason}`);
201
251
  return;
202
252
  }
253
+ // The lock is ours, so a cloudflared in the pid file belongs to an
254
+ // AgentOS that died: it goes before anything else happens.
255
+ const reaped = reapTunnel(dir, undefined, deps.reap).catch(() => false);
203
256
  const server = tunnelListener(() => hostname, handlers);
204
257
  const sockets = new Set<Socket>();
205
258
  server.on("connection", (s: Socket) => {
206
259
  sockets.add(s);
207
260
  s.once("close", () => sockets.delete(s));
208
261
  });
262
+ const ensure =
263
+ deps.ensureBinary ?? ((d: string) => ensureCloudflared({ dir: d }));
264
+ const ask = deps.requestTunnel ?? requestTunnel;
265
+ const startTunnel = () =>
266
+ superviseTunnel({
267
+ dir,
268
+ log,
269
+ spawn: deps.spawn,
270
+ delays: deps.delays,
271
+ prepare: async () => {
272
+ const bin = await ensure(dir);
273
+ // Fresh every start; the token lives only in this call and the child's env.
274
+ const tunnel = await ask(config, machineKey, tunnelPort);
275
+ if (tunnel.hostname !== hostname) {
276
+ hostname = tunnel.hostname;
277
+ saveConnect({ ...config, hostname }, dir);
278
+ log(`connect: reachable at https://${hostname}`);
279
+ }
280
+ return { bin, token: tunnel.token };
281
+ },
282
+ });
283
+ const stopTunnel = () => {
284
+ if (running?.server !== server || !running.supervisor) return;
285
+ running.supervisor.stop();
286
+ running.supervisor = null;
287
+ };
209
288
  // Something else may hold the port for a while (an AgentOS shutting
210
- // down, say): keep trying with backoff rather than giving up.
289
+ // down, say): keep trying with backoff rather than giving up, with
290
+ // cloudflared stopped meanwhile.
211
291
  const bindDelays = deps.delays ?? { min: 1000, max: 60_000 };
212
292
  let bindDelay = bindDelays.min;
213
- server.on("error", (err) => {
293
+ const rebind = (why: string) => {
214
294
  if (running?.server !== server) return;
215
- bindError = `can't listen on 127.0.0.1:${tunnelPort}: ${err.message}`;
295
+ stopTunnel();
296
+ bindError = `can't listen on 127.0.0.1:${tunnelPort}: ${why}`;
216
297
  log(`connect: ${bindError}; trying again`);
217
298
  const t = setTimeout(() => {
218
299
  if (running?.server !== server) return;
@@ -221,30 +302,19 @@ export function startConnect(
221
302
  }, bindDelay);
222
303
  t.unref?.();
223
304
  bindDelay = Math.min(bindDelay * 2, bindDelays.max);
224
- });
225
- server.on("listening", () => {
305
+ };
306
+ server.on("error", (err) => rebind(err.message));
307
+ server.on("close", () => stopTunnel());
308
+ server.on("listening", async () => {
309
+ const [ours] = await Promise.all([
310
+ listenerIsOurs(server, tunnelPort),
311
+ reaped,
312
+ ]);
313
+ if (running?.server !== server || !server.listening) return;
314
+ if (!ours) return rebind("connections there don't reach AgentOS");
226
315
  bindError = undefined;
227
316
  bindDelay = bindDelays.min;
228
- });
229
- const ensure =
230
- deps.ensureBinary ?? ((d: string) => ensureCloudflared({ dir: d }));
231
- const ask = deps.requestTunnel ?? requestTunnel;
232
- const supervisor = superviseTunnel({
233
- dir,
234
- log,
235
- spawn: deps.spawn,
236
- delays: deps.delays,
237
- prepare: async () => {
238
- const bin = await ensure(dir);
239
- // Fresh every start; the token lives only in this call and the child's env.
240
- const tunnel = await ask(config, machineKey, tunnelPort);
241
- if (tunnel.hostname !== hostname) {
242
- hostname = tunnel.hostname;
243
- saveConnect({ ...config, hostname }, dir);
244
- log(`connect: reachable at https://${hostname}`);
245
- }
246
- return { bin, token: tunnel.token };
247
- },
317
+ if (!running.supervisor) running.supervisor = startTunnel();
248
318
  });
249
319
  const beat = deps.heartbeat ?? sendHeartbeat;
250
320
  const version = appVersion();
@@ -261,7 +331,13 @@ export function startConnect(
261
331
  down("machine no longer linked");
262
332
  },
263
333
  });
264
- running = { server, sockets, supervisor, heartbeat, release: lock.release };
334
+ running = {
335
+ server,
336
+ sockets,
337
+ supervisor: null,
338
+ heartbeat,
339
+ release: lock.release,
340
+ };
265
341
  server.listen(tunnelPort, "127.0.0.1");
266
342
  };
267
343