@saadnvd1/agent-os 0.3.499 → 0.3.502

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 (64) hide show
  1. package/README.md +77 -0
  2. package/app/api/usage/rollups/route.ts +36 -0
  3. package/components/Chat/Activity.test.ts +22 -0
  4. package/components/Chat/Activity.tsx +17 -5
  5. package/components/Chat/ChatPanel.tsx +7 -1
  6. package/components/Devices/ConnectCard.tsx +10 -12
  7. package/components/SessionList/RowParts.tsx +13 -4
  8. package/components/SessionList/SessionList.types.ts +1 -0
  9. package/components/Tasks/TaskRow.tsx +2 -0
  10. package/components/Usage/RatchetView.tsx +249 -0
  11. package/components/Usage/UsageDialog.tsx +7 -1
  12. package/components/Usage/WindowCard.tsx +2 -1
  13. package/components/views/types.ts +2 -0
  14. package/data/chat/useChat.ts +7 -0
  15. package/data/usage/index.ts +28 -0
  16. package/lib/chat/activity.ts +24 -2
  17. package/lib/chat/drivers/claude-mapper.ts +8 -0
  18. package/lib/chat/drivers/claude.ts +46 -0
  19. package/lib/chat/drivers/codex-mapper.ts +5 -1
  20. package/lib/chat/drivers/pi-mapper.ts +4 -0
  21. package/lib/chat/events.ts +15 -1
  22. package/lib/chat/registry.ts +8 -1
  23. package/lib/chat/runner.ts +27 -0
  24. package/lib/chat/worker/host.ts +47 -0
  25. package/lib/chat/worker/main.ts +1 -0
  26. package/lib/chat/worker/protocol.ts +14 -1
  27. package/lib/connect/cli.ts +51 -72
  28. package/lib/connect/cloudflared.ts +172 -0
  29. package/lib/connect/config.ts +75 -26
  30. package/lib/connect/control-api.ts +107 -48
  31. package/lib/connect/enrol.ts +33 -107
  32. package/lib/connect/serve.ts +177 -94
  33. package/lib/connect/supervise.ts +281 -0
  34. package/lib/db/migrations.ts +72 -0
  35. package/lib/orchestrator/describe.ts +1 -1
  36. package/lib/orchestrator/facts.ts +5 -1
  37. package/lib/security/auth.ts +20 -4
  38. package/lib/security/gate.ts +8 -5
  39. package/lib/security/reach.ts +1 -1
  40. package/lib/sidebar/shelves.ts +7 -0
  41. package/lib/status/collect.ts +7 -1
  42. package/lib/tasks/index.ts +8 -0
  43. package/lib/usage/calls.ts +284 -0
  44. package/lib/usage/check-runs.ts +40 -19
  45. package/lib/usage/config.ts +56 -0
  46. package/lib/usage/nightly.ts +157 -0
  47. package/lib/usage/quality.ts +239 -0
  48. package/lib/usage/ratchet.ts +275 -0
  49. package/lib/usage/rate-limits.ts +192 -0
  50. package/lib/usage/rollup.ts +90 -0
  51. package/lib/usage/span.ts +152 -0
  52. package/lib/usage/transcript-import.ts +226 -0
  53. package/lib/usage/weights.ts +64 -0
  54. package/lib/usage/windows.ts +27 -2
  55. package/package.json +2 -4
  56. package/server.ts +7 -4
  57. package/stores/usageUi.ts +3 -3
  58. package/lib/connect/cert-check.ts +0 -32
  59. package/lib/connect/cloudflare-dns.ts +0 -56
  60. package/lib/connect/frames.ts +0 -72
  61. package/lib/connect/identity.ts +0 -98
  62. package/lib/connect/mux.ts +0 -110
  63. package/lib/connect/stream.ts +0 -106
  64. package/lib/connect/tunnel.ts +0 -98
@@ -1,25 +1,13 @@
1
1
  /**
2
- * Turning Connect on for this machine. Everything secret is generated here
3
- * and written only to the Connect folder (owner-only): the Ed25519 key that
4
- * proves the machine to the relay, and the TLS key for <id>.<domain> that
5
- * phones complete TLS with. Neither is ever sent anywhere; the certificate
6
- * authority only sees a signing request.
2
+ * Turning Connect on for this machine. The Ed25519 key that proves the
3
+ * machine to the Connect service is generated here and written only to the
4
+ * Connect folder (owner-only); only its public half is ever sent.
7
5
  */
8
6
 
9
7
  import crypto from "crypto";
10
8
  import fs from "fs";
11
9
  import path from "path";
12
- import acme from "acme-client";
13
- import { connectDir, type ConnectConfig } from "./config";
14
- import { generateMachineKey } from "./identity";
15
-
16
- const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
17
-
18
- export function newMachineId(): string {
19
- return Array.from(crypto.randomBytes(8), (b) => ID_ALPHABET[b % 36]).join("");
20
- }
21
-
22
- const SECRETS = ["machine.key", "tls.key", "acme-account.key"];
10
+ import { connectDir, saveConnect, type ConnectConfig } from "./config";
23
11
 
24
12
  const write = (dir: string, file: string, data: string) => {
25
13
  fs.writeFileSync(path.join(dir, file), data, { mode: 0o600 });
@@ -30,110 +18,48 @@ const write = (dir: string, file: string, data: string) => {
30
18
  function lockDown(dir: string) {
31
19
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
32
20
  fs.chmodSync(dir, 0o700);
33
- for (const f of SECRETS) {
34
- if (fs.existsSync(path.join(dir, f)))
35
- fs.chmodSync(path.join(dir, f), 0o600);
36
- }
21
+ if (fs.existsSync(path.join(dir, "machine.key")))
22
+ fs.chmodSync(path.join(dir, "machine.key"), 0o600);
37
23
  }
38
24
 
39
- export interface Enrolment {
40
- config: ConnectConfig;
41
- machinePublicKey: string;
42
- csr: Buffer;
25
+ export interface MachineKeys {
26
+ publicKey: string;
27
+ privateKey: string;
43
28
  }
44
29
 
45
- /** Who names this machine: the Connect service (from a link code), or local (operators). */
46
- export type Register = (publicKey: string) => Promise<ConnectConfig>;
30
+ /** Registers the public key with the Connect service; answers id and name. */
31
+ export type Register = (keys: MachineKeys) => Promise<ConnectConfig>;
47
32
 
48
- /**
49
- * Creates the machine's identity and TLS key, or reuses them if present.
50
- * The machine key comes first: the Connect service registers its public
51
- * half and answers with the machine's id and address.
52
- */
53
- export async function enrol(opts: {
54
- domain: string;
55
- relayUrl: string;
56
- dir?: string;
57
- register?: Register;
58
- }): Promise<Enrolment> {
59
- const dir = opts.dir ?? connectDir();
33
+ /** The machine key, made on first use and reused after. */
34
+ export function machineKeys(dir = connectDir()): MachineKeys {
60
35
  lockDown(dir);
61
36
  if (!fs.existsSync(path.join(dir, "machine.key"))) {
62
- write(dir, "machine.key", generateMachineKey().privateKey);
63
- }
64
- const publicKey = crypto
65
- .createPublicKey(fs.readFileSync(path.join(dir, "machine.key"), "utf8"))
66
- .export({ type: "spki", format: "pem" })
67
- .toString();
68
- const configPath = path.join(dir, "connect.json");
69
- let config: ConnectConfig;
70
- if (fs.existsSync(configPath)) {
71
- config = JSON.parse(fs.readFileSync(configPath, "utf8")) as ConnectConfig;
72
- } else {
73
- if (opts.register) {
74
- config = await opts.register(publicKey);
75
- } else {
76
- const machineId = newMachineId();
77
- config = {
78
- machineId,
79
- hostname: `${machineId}.${opts.domain}`,
80
- relayUrl: opts.relayUrl,
81
- };
82
- }
83
- // Saved before anything else can fail: the link code is single-use, so
84
- // a rerun must resume with this id rather than ask for a new code.
85
- write(dir, "connect.json", JSON.stringify(config, null, 2));
86
- }
87
- if (!fs.existsSync(path.join(dir, "tls.key"))) {
37
+ const { privateKey } = crypto.generateKeyPairSync("ed25519");
88
38
  write(
89
39
  dir,
90
- "tls.key",
91
- (await acme.crypto.createPrivateEcdsaKey()).toString()
40
+ "machine.key",
41
+ privateKey.export({ type: "pkcs8", format: "pem" }).toString()
92
42
  );
93
43
  }
94
- const machinePublicKey = publicKey;
95
- const [, csr] = await acme.crypto.createCsr(
96
- { commonName: config.hostname, altNames: [config.hostname] },
97
- fs.readFileSync(path.join(dir, "tls.key"))
98
- );
99
- return { config, machinePublicKey, csr };
100
- }
101
-
102
- /** Where DNS-01 TXT records get written: the control plane's broker, or (dev) a DNS API. */
103
- export interface DnsChallenge {
104
- set(name: string, value: string): Promise<void>;
105
- clear(name: string, value: string): Promise<void>;
44
+ const privateKey = fs.readFileSync(path.join(dir, "machine.key"), "utf8");
45
+ const publicKey = crypto
46
+ .createPublicKey(privateKey)
47
+ .export({ type: "spki", format: "pem" })
48
+ .toString();
49
+ return { publicKey, privateKey };
106
50
  }
107
51
 
108
- /** Gets a certificate for the CSR over ACME DNS-01 and stores it as tls.crt. */
109
- export async function certify(opts: {
110
- csr: Buffer;
111
- dns: DnsChallenge;
52
+ /**
53
+ * Creates the machine's identity (or reuses it) and registers it. The
54
+ * service's answer is saved before anything else can fail: the link code
55
+ * is single-use, so a rerun must resume with this id.
56
+ */
57
+ export async function enrol(opts: {
58
+ register: Register;
112
59
  dir?: string;
113
- directoryUrl?: string;
114
- }): Promise<void> {
60
+ }): Promise<ConnectConfig> {
115
61
  const dir = opts.dir ?? connectDir();
116
- lockDown(dir);
117
- const accountKeyPath = path.join(dir, "acme-account.key");
118
- if (!fs.existsSync(accountKeyPath)) {
119
- write(
120
- dir,
121
- "acme-account.key",
122
- (await acme.crypto.createPrivateKey()).toString()
123
- );
124
- }
125
- const client = new acme.Client({
126
- directoryUrl: opts.directoryUrl ?? acme.directory.letsencrypt.production,
127
- accountKey: fs.readFileSync(accountKeyPath),
128
- });
129
- const cert = await client.auto({
130
- csr: opts.csr,
131
- termsOfServiceAgreed: true,
132
- challengePriority: ["dns-01"],
133
- challengeCreateFn: async (authz, _c, keyAuth) =>
134
- opts.dns.set(`_acme-challenge.${authz.identifier.value}`, keyAuth),
135
- challengeRemoveFn: async (authz, _c, keyAuth) =>
136
- opts.dns.clear(`_acme-challenge.${authz.identifier.value}`, keyAuth),
137
- });
138
- write(dir, "tls.crt", cert);
62
+ const config = await opts.register(machineKeys(dir));
63
+ saveConnect(config, dir);
64
+ return config;
139
65
  }
@@ -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
  }