@saadnvd1/agent-os 0.3.499 → 0.3.501

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.
Files changed (47) hide show
  1. package/README.md +70 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Devices/ConnectCard.tsx +10 -12
  4. package/components/Tasks/TaskRow.tsx +2 -0
  5. package/components/Usage/RatchetView.tsx +249 -0
  6. package/components/Usage/UsageDialog.tsx +7 -1
  7. package/components/Usage/WindowCard.tsx +2 -1
  8. package/data/usage/index.ts +28 -0
  9. package/lib/chat/drivers/claude-mapper.ts +1 -0
  10. package/lib/chat/drivers/claude.ts +46 -0
  11. package/lib/chat/events.ts +3 -0
  12. package/lib/chat/worker/host.ts +21 -0
  13. package/lib/chat/worker/protocol.ts +8 -1
  14. package/lib/connect/cli.ts +51 -72
  15. package/lib/connect/cloudflared.ts +172 -0
  16. package/lib/connect/config.ts +75 -26
  17. package/lib/connect/control-api.ts +107 -48
  18. package/lib/connect/enrol.ts +33 -107
  19. package/lib/connect/serve.ts +177 -94
  20. package/lib/connect/supervise.ts +281 -0
  21. package/lib/db/migrations.ts +72 -0
  22. package/lib/security/auth.ts +20 -4
  23. package/lib/security/gate.ts +8 -5
  24. package/lib/security/reach.ts +1 -1
  25. package/lib/tasks/index.ts +8 -0
  26. package/lib/usage/calls.ts +284 -0
  27. package/lib/usage/check-runs.ts +40 -19
  28. package/lib/usage/config.ts +56 -0
  29. package/lib/usage/nightly.ts +157 -0
  30. package/lib/usage/quality.ts +239 -0
  31. package/lib/usage/ratchet.ts +275 -0
  32. package/lib/usage/rate-limits.ts +192 -0
  33. package/lib/usage/rollup.ts +90 -0
  34. package/lib/usage/span.ts +152 -0
  35. package/lib/usage/transcript-import.ts +226 -0
  36. package/lib/usage/weights.ts +64 -0
  37. package/lib/usage/windows.ts +27 -2
  38. package/package.json +2 -4
  39. package/server.ts +7 -4
  40. package/stores/usageUi.ts +3 -3
  41. package/lib/connect/cert-check.ts +0 -32
  42. package/lib/connect/cloudflare-dns.ts +0 -56
  43. package/lib/connect/frames.ts +0 -72
  44. package/lib/connect/identity.ts +0 -98
  45. package/lib/connect/mux.ts +0 -110
  46. package/lib/connect/stream.ts +0 -106
  47. package/lib/connect/tunnel.ts +0 -98
@@ -1,23 +1,36 @@
1
1
  /**
2
- * Serve AgentOS through Connect: streams from the tunnel carry TLS for
3
- * <id>.<machine domain>, which only this process can open. Requests then go
4
- * through the same handlers and device check as any other listener; they
5
- * are never "this machine" (a TunnelStream has no address).
2
+ * Serve AgentOS through Connect. Cloudflare's edge ends TLS for
3
+ * m-<16 hex>.runagentos.com and cloudflared delivers each request to a
4
+ * listener of its own on 127.0.0.1:<tunnel port>. That listener:
5
+ *
6
+ * - marks every socket it accepts, so the gate never treats it as this
7
+ * machine, the tailnet or AGENTOS_AUTH=off: a paired device or nothing;
8
+ * - takes only the machine's own Host (cloudflared pins it) and strips
9
+ * X-Forwarded-Host, Forwarded and X-Original-Host before any handler.
6
10
  *
7
11
  * Connect follows connect.json while running: `agent-os disconnect` or the
8
- * switch in Devices closes the tunnel and every open tunnel connection at
12
+ * switch in Devices stops cloudflared and closes every tunnel connection at
9
13
  * once. Nothing here may stop AgentOS from starting.
10
14
  */
11
15
 
12
16
  import fs from "fs";
13
- import https from "https";
17
+ import http from "http";
14
18
  import path from "path";
15
19
  import type { IncomingMessage, ServerResponse } from "http";
20
+ import type { Socket } from "net";
16
21
  import type { Duplex } from "stream";
17
- import { connectDir, readConnect } from "./config";
22
+ import { markTunnelSocket } from "@/lib/security/auth";
23
+ import {
24
+ connectDir,
25
+ connectTunnelPort,
26
+ readConnect,
27
+ saveConnect,
28
+ type ConnectConfig,
29
+ } from "./config";
18
30
  import { shouldStartConnect, takeConnectLock } from "./guard";
19
- import { startTunnel } from "./tunnel";
20
- import { certStatus, type CertStatus } from "./cert-check";
31
+ import { checkApiUrl, requestTunnel, type Tunnel } from "./control-api";
32
+ import { ensureCloudflared } from "./cloudflared";
33
+ import { superviseTunnel, type Spawn, type TunnelState } from "./supervise";
21
34
 
22
35
  export interface Handlers {
23
36
  onRequest: (req: IncomingMessage, res: ServerResponse) => void;
@@ -26,63 +39,108 @@ export interface Handlers {
26
39
 
27
40
  export interface ConnectStatus {
28
41
  state: "off" | "refused" | "error" | "connecting" | "up" | "denied";
42
+ /** Linked to the Connect service (it may not have a name yet). */
43
+ enrolled?: boolean;
29
44
  hostname?: string;
30
45
  reason?: string;
31
- cert?: CertStatus;
32
- }
33
-
34
- interface Running {
35
- server: https.Server;
36
- tunnel: ReturnType<typeof startTunnel>;
37
- sockets: Set<Duplex>;
38
- hostname: string;
39
- release: () => void;
40
46
  }
41
47
 
42
48
  const g = globalThis as unknown as { __agentosConnect?: () => ConnectStatus };
43
49
  export const connectStatus = (): ConnectStatus =>
44
50
  g.__agentosConnect?.() ?? { state: "off" };
45
51
 
46
- const hostOf = (h?: string) => (h ?? "").toLowerCase().replace(/:\d+$/, "");
52
+ // Headers that would let a caller name a different host to Next or to us.
53
+ const HOST_OVERRIDES = ["x-forwarded-host", "forwarded", "x-original-host"];
54
+
55
+ /**
56
+ * The listener cloudflared talks to. `hostname()` is the only Host it
57
+ * serves; until the service has named the machine, nothing is served.
58
+ */
59
+ export function tunnelListener(
60
+ hostname: () => string,
61
+ handlers: Handlers
62
+ ): http.Server {
63
+ const pinned = (req: IncomingMessage) => {
64
+ for (const h of HOST_OVERRIDES) delete req.headers[h];
65
+ const want = hostname();
66
+ return !!want && (req.headers.host ?? "").toLowerCase() === want;
67
+ };
68
+ const server = http.createServer((req, res) => {
69
+ if (!pinned(req)) {
70
+ res.statusCode = 421;
71
+ return res.end();
72
+ }
73
+ handlers.onRequest(req, res);
74
+ });
75
+ server.on("connection", (s: Socket) => markTunnelSocket(s));
76
+ server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
77
+ if (!pinned(req)) {
78
+ socket.write("HTTP/1.1 421 Misdirected Request\r\n\r\n");
79
+ return socket.destroy();
80
+ }
81
+ handlers.onUpgrade(req, socket, head);
82
+ });
83
+ server.on("clientError", (_err, socket) => socket.destroy());
84
+ return server;
85
+ }
86
+
87
+ export interface ConnectDeps {
88
+ ensureBinary?: (dir: string) => Promise<string>;
89
+ requestTunnel?: (
90
+ config: ConnectConfig,
91
+ privateKey: string,
92
+ port: number
93
+ ) => Promise<Tunnel>;
94
+ spawn?: Spawn;
95
+ delays?: Parameters<typeof superviseTunnel>[0]["delays"];
96
+ }
97
+
98
+ interface Running {
99
+ server: http.Server;
100
+ sockets: Set<Socket>;
101
+ supervisor: ReturnType<typeof superviseTunnel>;
102
+ release: () => void;
103
+ }
47
104
 
48
105
  export function startConnect(
49
106
  handlers: Handlers,
50
107
  port: number,
51
- log = console.log
108
+ log = console.log,
109
+ deps: ConnectDeps = {},
110
+ env: NodeJS.ProcessEnv = process.env
52
111
  ) {
53
112
  const dir = connectDir();
54
- const decision = shouldStartConnect(process.env, port);
113
+ const decision = shouldStartConnect(env, port);
114
+ const tunnelPort = connectTunnelPort(port, env);
55
115
  let base: ConnectStatus = { state: "off" };
116
+ let hostname = "";
56
117
  let running: Running | null = null;
57
- g.__agentosConnect = () =>
58
- running
59
- ? { ...base, state: running.tunnel.state() as ConnectStatus["state"] }
60
- : base;
118
+ // Set while the tunnel listener can't bind; it keeps trying.
119
+ let bindError: string | undefined;
120
+ g.__agentosConnect = () => {
121
+ if (!running) return base;
122
+ const s: TunnelState = running.supervisor.state();
123
+ return {
124
+ ...base,
125
+ hostname: hostname || base.hostname,
126
+ state: bindError ? "error" : s === "stopped" ? "off" : s,
127
+ reason: bindError ?? running.supervisor.reason(),
128
+ };
129
+ };
61
130
 
62
- if (!decision.start) {
63
- if (readConnect(dir).ok) log(`connect: not starting: ${decision.reason}`);
64
- base = { state: "refused", reason: decision.reason };
65
- return { hostname: () => null as string | null };
131
+ if (!decision.start || !tunnelPort) {
132
+ const reason = decision.start
133
+ ? "AGENTOS_CONNECT_PORT isn't a usable port"
134
+ : decision.reason;
135
+ if (readConnect(dir).ok) log(`connect: not starting: ${reason}`);
136
+ base = { state: "refused", reason };
137
+ return { hostname: () => null as string | null, close: () => {} };
66
138
  }
67
139
 
68
- // Only the machine's own name: anything else through the tunnel is refused.
69
- const pinned = (hostname: string): Handlers => ({
70
- onRequest: (req, res) => {
71
- if (hostOf(req.headers.host) !== hostname) {
72
- res.statusCode = 421;
73
- return res.end();
74
- }
75
- handlers.onRequest(req, res);
76
- },
77
- onUpgrade: (req, socket, head) => {
78
- if (hostOf(req.headers.host) !== hostname) return socket.destroy();
79
- handlers.onUpgrade(req, socket, head);
80
- },
81
- });
82
-
83
140
  const down = (why: string) => {
84
141
  if (!running) return;
85
- running.tunnel.stop();
142
+ bindError = undefined;
143
+ running.supervisor.stop();
86
144
  running.sockets.forEach((s) => s.destroy());
87
145
  running.server.close();
88
146
  running.release();
@@ -100,13 +158,17 @@ export function startConnect(
100
158
  if (read.reason !== "not enrolled") log(`connect: ${read.reason}`);
101
159
  return;
102
160
  }
103
- const { files } = read;
104
- base = {
105
- state: "off",
106
- hostname: files.config.hostname,
107
- cert: certStatus(files.tls.cert),
108
- };
161
+ const { config, machineKey } = read.files;
162
+ hostname = config.hostname;
163
+ base = { state: "off", enrolled: true, hostname: hostname || undefined };
109
164
  if (!read.enabled) return;
165
+ try {
166
+ checkApiUrl(config.apiUrl, env.AGENTOS_CONNECT_OPERATOR === "1");
167
+ } catch (err) {
168
+ base = { ...base, state: "error", reason: (err as Error).message };
169
+ log(`connect: ${base.reason}`);
170
+ return;
171
+ }
110
172
  const lock = takeConnectLock(dir);
111
173
  if ("heldBy" in lock) {
112
174
  base = {
@@ -117,62 +179,83 @@ export function startConnect(
117
179
  log(`connect: ${base.reason}`);
118
180
  return;
119
181
  }
120
- try {
121
- const h = pinned(files.config.hostname);
122
- const server = https.createServer(files.tls, h.onRequest);
123
- server.on("upgrade", h.onUpgrade);
124
- const sockets = new Set<Duplex>();
125
- const tunnel = startTunnel({
126
- config: files.config,
127
- machineKey: files.machineKey,
128
- onStream: (stream) => {
129
- sockets.add(stream);
130
- stream.once("close", () => sockets.delete(stream));
131
- server.emit("connection", stream);
132
- },
133
- log,
134
- });
135
- running = {
136
- server,
137
- tunnel,
138
- sockets,
139
- hostname: files.config.hostname,
140
- release: lock.release,
141
- };
142
- } catch (err) {
143
- lock.release();
144
- base = { ...base, state: "error", reason: (err as Error).message };
145
- log(`connect: could not start: ${(err as Error).message}`);
146
- }
182
+ const server = tunnelListener(() => hostname, handlers);
183
+ const sockets = new Set<Socket>();
184
+ server.on("connection", (s: Socket) => {
185
+ sockets.add(s);
186
+ s.once("close", () => sockets.delete(s));
187
+ });
188
+ // Something else may hold the port for a while (an AgentOS shutting
189
+ // down, say): keep trying with backoff rather than giving up.
190
+ const bindDelays = deps.delays ?? { min: 1000, max: 60_000 };
191
+ let bindDelay = bindDelays.min;
192
+ server.on("error", (err) => {
193
+ if (running?.server !== server) return;
194
+ bindError = `can't listen on 127.0.0.1:${tunnelPort}: ${err.message}`;
195
+ log(`connect: ${bindError}; trying again`);
196
+ const t = setTimeout(() => {
197
+ if (running?.server !== server) return;
198
+ server.close();
199
+ server.listen(tunnelPort, "127.0.0.1");
200
+ }, bindDelay);
201
+ t.unref?.();
202
+ bindDelay = Math.min(bindDelay * 2, bindDelays.max);
203
+ });
204
+ server.on("listening", () => {
205
+ bindError = undefined;
206
+ bindDelay = bindDelays.min;
207
+ });
208
+ const ensure =
209
+ deps.ensureBinary ?? ((d: string) => ensureCloudflared({ dir: d }));
210
+ const ask = deps.requestTunnel ?? requestTunnel;
211
+ const supervisor = superviseTunnel({
212
+ dir,
213
+ log,
214
+ spawn: deps.spawn,
215
+ delays: deps.delays,
216
+ prepare: async () => {
217
+ const bin = await ensure(dir);
218
+ // Fresh every start; the token lives only in this call and the child's env.
219
+ const tunnel = await ask(config, machineKey, tunnelPort);
220
+ if (tunnel.hostname !== hostname) {
221
+ hostname = tunnel.hostname;
222
+ saveConnect({ ...config, hostname }, dir);
223
+ log(`connect: reachable at https://${hostname}`);
224
+ }
225
+ return { bin, token: tunnel.token };
226
+ },
227
+ });
228
+ running = { server, sockets, supervisor, release: lock.release };
229
+ server.listen(tunnelPort, "127.0.0.1");
147
230
  };
148
231
 
149
- // Runs from file watchers and a timer in the live server: never throws.
232
+ // Runs from a file watcher and a timer in the live server: never throws.
150
233
  const sync = () => {
151
234
  try {
152
- syncNow();
235
+ const read = readConnect(dir);
236
+ const want = read.ok && read.enabled;
237
+ if (running && !want) down(read.ok ? "turned off" : read.reason);
238
+ else if (!running && want) up();
239
+ else if (!running) up(); // refreshes what the status shows
153
240
  } catch (err) {
154
241
  log(`connect: ${(err as Error).message}`);
155
242
  }
156
243
  };
157
- const syncNow = () => {
158
- const read = readConnect(dir);
159
- const want = read.ok && read.enabled;
160
- if (running && !want) down(read.ok ? "turned off" : read.reason);
161
- else if (!running && want) up();
162
- else if (running && read.ok) {
163
- base.cert = certStatus(read.files.tls.cert);
164
- running.server.setSecureContext(read.files.tls); // a renewed certificate
165
- } else if (!running) up();
166
- };
167
244
 
168
245
  sync();
169
246
  try {
170
247
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
171
248
  } catch {}
172
- for (const f of ["connect.json", "tls.crt"])
173
- fs.watchFile(path.join(dir, f), { interval: 2000 }, sync);
174
- setInterval(sync, 24 * 60 * 60 * 1000).unref();
249
+ fs.watchFile(path.join(dir, "connect.json"), { interval: 2000 }, sync);
175
250
  const stop = () => down("shutting down");
176
251
  process.once("exit", stop);
177
- return { hostname: () => base.hostname ?? running?.hostname ?? null };
252
+ return {
253
+ hostname: () => (running ? hostname || null : (base.hostname ?? null)),
254
+ /** Tests: stop watching and close everything. */
255
+ close() {
256
+ fs.unwatchFile(path.join(dir, "connect.json"), sync);
257
+ process.removeListener("exit", stop);
258
+ down("closed");
259
+ },
260
+ };
178
261
  }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Keeps `cloudflared tunnel run` going while Connect is on. Each start asks
3
+ * the Connect service for a fresh connector token, which goes to the child
4
+ * in TUNNEL_TOKEN only: never argv, never a file, never a log line. The
5
+ * child exits → it's started again with backoff; stop() ends it.
6
+ */
7
+
8
+ import { execFile, spawn as nodeSpawn } from "child_process";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import readline from "readline";
12
+ import type { Readable } from "stream";
13
+ import { ApiError, clean } from "./control-api";
14
+
15
+ export type TunnelState = "connecting" | "up" | "denied" | "error" | "stopped";
16
+
17
+ export interface Prepared {
18
+ bin: string;
19
+ token: string;
20
+ }
21
+
22
+ interface Child {
23
+ pid?: number;
24
+ stdout: Readable | null;
25
+ stderr: Readable | null;
26
+ kill(signal?: NodeJS.Signals): boolean;
27
+ on(event: "exit", fn: (code: number | null) => void): unknown;
28
+ on(event: "error", fn: (err: Error) => void): unknown;
29
+ }
30
+
31
+ export type Spawn = (
32
+ bin: string,
33
+ args: string[],
34
+ opts: { env: Record<string, string>; stdio: ["ignore", "pipe", "pipe"] }
35
+ ) => Child;
36
+
37
+ export interface SuperviseOptions {
38
+ /** Finds the binary and fetches a fresh token; throws to retry later. */
39
+ prepare: () => Promise<Prepared>;
40
+ /** The Connect folder: cloudflared's config, home and pid file live here. */
41
+ dir: string;
42
+ log?: (line: string) => void;
43
+ spawn?: Spawn;
44
+ delays?: { min: number; max: number; setupMax: number; denied: number };
45
+ }
46
+
47
+ const DELAYS = {
48
+ min: 1000,
49
+ max: 60_000,
50
+ setupMax: 5 * 60_000,
51
+ denied: 5 * 60_000,
52
+ };
53
+ // cloudflared can be chatty while the network flaps.
54
+ const LOG_LINES_PER_MINUTE = 20;
55
+
56
+ /** A cloudflared left running by an AgentOS that died without stopping it. */
57
+ const commandOf = (pid: number) =>
58
+ new Promise<string>((resolve) =>
59
+ execFile(
60
+ "ps",
61
+ ["-o", "command=", "-p", String(pid)],
62
+ { encoding: "utf8", timeout: 5000 },
63
+ (err, out) => resolve(err ? "" : out)
64
+ )
65
+ );
66
+
67
+ async function stopStale(pidFile: string, binDir: string) {
68
+ let pid = 0;
69
+ try {
70
+ pid = Number(fs.readFileSync(pidFile, "utf8")) || 0;
71
+ } catch {
72
+ return;
73
+ }
74
+ if (pid > 1) {
75
+ const command = await commandOf(pid);
76
+ try {
77
+ if (command.startsWith(binDir + path.sep)) process.kill(pid, "SIGTERM");
78
+ } catch {}
79
+ }
80
+ fs.rmSync(pidFile, { force: true });
81
+ }
82
+
83
+ export function superviseTunnel(opts: SuperviseOptions) {
84
+ const log = opts.log ?? (() => {});
85
+ const spawn = opts.spawn ?? (nodeSpawn as unknown as Spawn);
86
+ const delays = opts.delays ?? DELAYS;
87
+ const pidFile = path.join(opts.dir, "cloudflared.pid");
88
+ const home = path.join(opts.dir, "cloudflared-home");
89
+ const configFile = path.join(opts.dir, "cloudflared.yml");
90
+
91
+ let state: TunnelState = "connecting";
92
+ let reason: string | undefined;
93
+ let stopped = false;
94
+ let child: Child | null = null;
95
+ let timer: NodeJS.Timeout | null = null;
96
+ let delay = delays.min;
97
+ const connections = new Set<string>();
98
+ let logged = { at: 0, n: 0 };
99
+
100
+ const say = (line: string) => {
101
+ const now = Date.now();
102
+ if (now - logged.at > 60_000) logged = { at: now, n: 0 };
103
+ if (++logged.n <= LOG_LINES_PER_MINUTE) log(line);
104
+ };
105
+
106
+ const retry = (wait: number) => {
107
+ if (stopped) return;
108
+ timer = setTimeout(() => {
109
+ timer = null;
110
+ tick();
111
+ }, wait);
112
+ timer.unref?.();
113
+ };
114
+
115
+ // Nothing here may reject: an unhandled rejection takes AgentOS down.
116
+ const tick = () =>
117
+ void attempt().catch(() => {
118
+ state = "error";
119
+ retry(delays.max);
120
+ });
121
+
122
+ const attempt = async () => {
123
+ if (stopped) return;
124
+ state = "connecting";
125
+ try {
126
+ const prepared = await opts.prepare();
127
+ if (stopped) return;
128
+ await run(prepared);
129
+ } catch (err) {
130
+ if (stopped) return;
131
+ const status = err instanceof ApiError ? err.status : 0;
132
+ reason = clean((err as Error)?.message ?? String(err));
133
+ log(`connect: ${reason}`);
134
+ if (status === 401 || status === 403) {
135
+ // Revoked or disabled: it won't fix itself in seconds.
136
+ state = "denied";
137
+ retry(delays.denied);
138
+ } else {
139
+ state = "error";
140
+ retry(delay);
141
+ delay = Math.min(delay * 2, delays.setupMax);
142
+ }
143
+ }
144
+ };
145
+
146
+ const run = async ({ bin, token }: Prepared) => {
147
+ const redact = (line: string) => clean(line.split(token).join("[token]"));
148
+ await stopStale(pidFile, path.dirname(bin));
149
+ if (stopped) return;
150
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
151
+ // Its own config and home, so no config.yml elsewhere on the machine
152
+ // changes what it does.
153
+ fs.writeFileSync(configFile, "no-autoupdate: true\n", { mode: 0o600 });
154
+ let proc: Child;
155
+ try {
156
+ proc = spawn(
157
+ bin,
158
+ [
159
+ "tunnel",
160
+ "--config",
161
+ configFile,
162
+ "--no-autoupdate",
163
+ // Never debug: it logs every request's headers, device tokens included.
164
+ "--loglevel",
165
+ "info",
166
+ "--output",
167
+ "json",
168
+ "run",
169
+ ],
170
+ {
171
+ env: {
172
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
173
+ HOME: home,
174
+ TUNNEL_TOKEN: token,
175
+ },
176
+ stdio: ["ignore", "pipe", "pipe"],
177
+ }
178
+ );
179
+ } catch (err) {
180
+ state = "error";
181
+ reason = `cloudflared could not start: ${redact((err as Error).message)}`;
182
+ log(`connect: ${reason}`);
183
+ retry(delay);
184
+ delay = Math.min(delay * 2, delays.max);
185
+ return;
186
+ }
187
+ child = proc;
188
+ if (proc.pid) {
189
+ try {
190
+ fs.writeFileSync(pidFile, String(proc.pid), { mode: 0o600 });
191
+ } catch {}
192
+ }
193
+
194
+ const onLine = (line: string) => {
195
+ if (!line.trim()) return;
196
+ let entry: {
197
+ level?: string;
198
+ message?: string;
199
+ error?: string;
200
+ connIndex?: number;
201
+ } | null = null;
202
+ if (line.startsWith("{")) {
203
+ try {
204
+ entry = JSON.parse(line);
205
+ } catch {}
206
+ }
207
+ if (!entry) return say(`connect: cloudflared: ${redact(line)}`);
208
+ const message = String(entry.message ?? "");
209
+ const conn = String(entry.connIndex ?? "0");
210
+ if (message.startsWith("Registered tunnel connection")) {
211
+ connections.add(conn);
212
+ if (state !== "up") log("connect: tunnel up");
213
+ state = "up";
214
+ reason = undefined;
215
+ delay = delays.min;
216
+ } else if (
217
+ message.startsWith("Unregistered tunnel connection") ||
218
+ ((entry.level === "error" || entry.level === "fatal") &&
219
+ entry.connIndex !== undefined)
220
+ ) {
221
+ connections.delete(conn);
222
+ if (state === "up" && connections.size === 0) state = "connecting";
223
+ }
224
+ if (["warn", "error", "fatal"].includes(entry.level ?? "")) {
225
+ const text = entry.error ? `${message}: ${entry.error}` : message;
226
+ reason = redact(text);
227
+ say(`connect: cloudflared: ${reason}`);
228
+ }
229
+ };
230
+ for (const stream of [proc.stdout, proc.stderr]) {
231
+ if (!stream) continue;
232
+ stream.on("error", () => {});
233
+ readline.createInterface({ input: stream }).on("line", onLine);
234
+ }
235
+
236
+ let ended = false;
237
+ const end = (why: string) => {
238
+ if (ended) return;
239
+ ended = true;
240
+ if (child === proc) child = null;
241
+ connections.clear();
242
+ try {
243
+ if (fs.readFileSync(pidFile, "utf8") === String(proc.pid))
244
+ fs.rmSync(pidFile, { force: true });
245
+ } catch {}
246
+ if (stopped) return;
247
+ state = "connecting";
248
+ reason = why;
249
+ log(`connect: ${why}; starting it again`);
250
+ retry(delay);
251
+ delay = Math.min(delay * 2, delays.max);
252
+ };
253
+ proc.on("error", (err) =>
254
+ end(`cloudflared failed: ${redact(err.message)}`)
255
+ );
256
+ proc.on("exit", (code) => end(`cloudflared exited (${code ?? "signal"})`));
257
+ };
258
+
259
+ const killNow = () => child?.kill("SIGTERM");
260
+ process.once("exit", killNow);
261
+ tick();
262
+
263
+ return {
264
+ state: () => state,
265
+ reason: () => reason,
266
+ stop() {
267
+ if (stopped) return;
268
+ stopped = true;
269
+ state = "stopped";
270
+ process.removeListener("exit", killNow);
271
+ if (timer) clearTimeout(timer);
272
+ const proc = child;
273
+ if (!proc) return;
274
+ proc.kill("SIGTERM");
275
+ // It drains connections for a while on SIGTERM; don't wait forever.
276
+ const hard = setTimeout(() => proc.kill("SIGKILL"), 5000);
277
+ hard.unref?.();
278
+ proc.on("exit", () => clearTimeout(hard));
279
+ },
280
+ };
281
+ }