@mono-agent/agent-app 0.11.5 → 0.12.0

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 (103) hide show
  1. package/README.md +24 -13
  2. package/dist/background.d.ts +2 -0
  3. package/dist/background.d.ts.map +1 -1
  4. package/dist/background.js +2 -2
  5. package/dist/background.js.map +1 -1
  6. package/dist/channel-drivers/live.d.ts +1 -1
  7. package/dist/channel-drivers/live.js +1 -1
  8. package/dist/channel-drivers/tui.d.ts.map +1 -1
  9. package/dist/channel-drivers/tui.js +24 -0
  10. package/dist/channel-drivers/tui.js.map +1 -1
  11. package/dist/cli-args.d.ts +10 -8
  12. package/dist/cli-args.d.ts.map +1 -1
  13. package/dist/cli-args.js +21 -6
  14. package/dist/cli-args.js.map +1 -1
  15. package/dist/cli-commands.d.ts +2 -0
  16. package/dist/cli-commands.d.ts.map +1 -1
  17. package/dist/cli-commands.js +26 -4
  18. package/dist/cli-commands.js.map +1 -1
  19. package/dist/cli-help.d.ts.map +1 -1
  20. package/dist/cli-help.js +17 -12
  21. package/dist/cli-help.js.map +1 -1
  22. package/dist/config-reference.d.ts +1 -1
  23. package/dist/config-reference.d.ts.map +1 -1
  24. package/dist/config-reference.js +62 -0
  25. package/dist/config-reference.js.map +1 -1
  26. package/dist/configured-agent.js +3 -1
  27. package/dist/configured-agent.js.map +1 -1
  28. package/dist/doctor.js +3 -2
  29. package/dist/doctor.js.map +1 -1
  30. package/dist/launchd.d.ts +16 -0
  31. package/dist/launchd.d.ts.map +1 -1
  32. package/dist/launchd.js +67 -0
  33. package/dist/launchd.js.map +1 -1
  34. package/dist/local-configuration.d.ts +2 -2
  35. package/dist/local-configuration.d.ts.map +1 -1
  36. package/dist/local-configuration.js +10 -10
  37. package/dist/local-configuration.js.map +1 -1
  38. package/dist/modules/known-tools.d.ts +2 -2
  39. package/dist/modules/known-tools.d.ts.map +1 -1
  40. package/dist/modules/known-tools.js +2 -1
  41. package/dist/modules/known-tools.js.map +1 -1
  42. package/dist/notify-runtime.d.ts +26 -0
  43. package/dist/notify-runtime.d.ts.map +1 -0
  44. package/dist/notify-runtime.js +27 -0
  45. package/dist/notify-runtime.js.map +1 -0
  46. package/dist/notify-tool.d.ts +51 -0
  47. package/dist/notify-tool.d.ts.map +1 -0
  48. package/dist/notify-tool.js +182 -0
  49. package/dist/notify-tool.js.map +1 -0
  50. package/dist/project-skills.d.ts +1 -1
  51. package/dist/project-skills.d.ts.map +1 -1
  52. package/dist/project-skills.js +9 -7
  53. package/dist/project-skills.js.map +1 -1
  54. package/dist/recipes/base.d.ts +15 -0
  55. package/dist/recipes/base.d.ts.map +1 -0
  56. package/dist/recipes/base.js +51 -0
  57. package/dist/recipes/base.js.map +1 -0
  58. package/dist/recipes/catalog.d.ts +4 -0
  59. package/dist/recipes/catalog.d.ts.map +1 -0
  60. package/dist/recipes/catalog.js +525 -0
  61. package/dist/recipes/catalog.js.map +1 -0
  62. package/dist/recipes/index.d.ts +11 -0
  63. package/dist/recipes/index.d.ts.map +1 -0
  64. package/dist/recipes/index.js +14 -0
  65. package/dist/recipes/index.js.map +1 -0
  66. package/dist/recipes/types.d.ts +70 -0
  67. package/dist/recipes/types.d.ts.map +1 -0
  68. package/dist/recipes/types.js +15 -0
  69. package/dist/recipes/types.js.map +1 -0
  70. package/dist/request-model-override.d.ts +3 -2
  71. package/dist/request-model-override.d.ts.map +1 -1
  72. package/dist/request-model-override.js +9 -7
  73. package/dist/request-model-override.js.map +1 -1
  74. package/dist/sessions-command.d.ts +53 -0
  75. package/dist/sessions-command.d.ts.map +1 -0
  76. package/dist/sessions-command.js +309 -0
  77. package/dist/sessions-command.js.map +1 -0
  78. package/dist/setup.d.ts +29 -0
  79. package/dist/setup.d.ts.map +1 -0
  80. package/dist/setup.js +97 -0
  81. package/dist/setup.js.map +1 -0
  82. package/dist/tui-command.d.ts +1 -1
  83. package/dist/tui-command.d.ts.map +1 -1
  84. package/dist/tui-command.js +4 -4
  85. package/dist/tui-command.js.map +1 -1
  86. package/dist/web-command.d.ts +109 -43
  87. package/dist/web-command.d.ts.map +1 -1
  88. package/dist/web-command.js +1108 -237
  89. package/dist/web-command.js.map +1 -1
  90. package/dist/wizard/prompts.d.ts.map +1 -1
  91. package/dist/wizard/prompts.js +2 -1
  92. package/dist/wizard/prompts.js.map +1 -1
  93. package/dist/wizard/run.d.ts.map +1 -1
  94. package/dist/wizard/run.js +9 -9
  95. package/dist/wizard/run.js.map +1 -1
  96. package/package.json +16 -15
  97. package/schema/mono-agent.config.schema.json +68 -0
  98. package/skills/mono-agent-composer/SKILL.md +1 -1
  99. package/skills/mono-agent-composer/references/config-blueprint.md +15 -2
  100. package/skills/mono-agent-composer/references/discovery-questions.md +2 -0
  101. package/skills/mono-agent-composer/references/feature-coverage.md +7 -6
  102. package/skills/mono-agent-composer/references/package-map.md +4 -3
  103. package/skills/mono-agent-composer/references/playbooks.md +3 -3
@@ -1,309 +1,1180 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
1
3
  import { networkInterfaces } from "node:os";
2
- import { resolve } from "node:path";
4
+ import { readFile, rm } from "node:fs/promises";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import process from "node:process";
7
+ import { fileURLToPath } from "node:url";
3
8
  import { pruneTraceSources } from "@mono-agent/observability";
4
- import { startSessionWebServer } from "@mono-agent/session-web";
5
- import { generateBearerToken, hostForUrl, isLoopbackHost, isWildcardHost, normalizeHostForBind, normalizeOptionalString, } from "@mono-agent/agent-contracts";
6
- import { resolveAppTraceRegistryDir, resolveGlobalTraceRegistryDir } from "./app-config.js";
7
- export const DEFAULT_WEB_PORT = 4599;
8
- const WEB_AUTH_TOKEN_ENV = "MONO_AGENT_WEB_AUTH_TOKEN";
9
- /**
10
- * `mono-agent web`: discover every running agent on this machine (trace-source
11
- * registry, the same mechanism as `mono-agent tui`) and serve the read-only
12
- * Session Recorder PWA that visualises their runs live. Unlike `tui`, it shows
13
- * ALL instances at once and needs no TTY — it runs until interrupted.
14
- */
15
- export async function runWeb(options, deps = {}) {
9
+ import { isLoopbackHost } from "@mono-agent/agent-contracts";
10
+ import { accountHomeDirectory } from "./account-home.js";
11
+ import { resolveGlobalTraceRegistryDir } from "./app-config.js";
12
+ import { acquireFilesystemLifecycleLock, ensureOwnerPrivateLaunchdDirectory, writeOwnerPrivateLaunchdFile, } from "./background.js";
13
+ import { selectBackgroundOperationalEnvironment } from "./background-environment.js";
14
+ import { ensureManagedBackgroundRuntime } from "./background-runtime.js";
15
+ import { bootout, bootstrap, buildWebPlistXml, defaultPathEnv, launchdServiceInfo, makeLaunchctlRunner, } from "./launchd.js";
16
+ import * as ui from "./ui.js";
17
+ export const DEFAULT_WEB_HOST = "0.0.0.0";
18
+ export const DEFAULT_WEB_PORT = 5050;
19
+ // Deliberately outside `com.mono-agent.*`: fleet discovery reserves that prefix
20
+ // for configured agent instances.
21
+ export const WEB_LAUNCHD_LABEL = "com.mono-agent-web";
22
+ const DEFAULT_LOG_LINES = 200;
23
+ const WEB_SERVICE_SCHEMA = "mono-agent.web-service.v1";
24
+ const TAILSCALE_OWNERSHIP_SCHEMA = "mono-agent.web-tailscale-serve.v1";
25
+ const READY_TIMEOUT_MS = 15_000;
26
+ const READY_POLL_MS = 200;
27
+ const TAILSCALE_FALLBACK_PORT_START = 8443;
28
+ const TAILSCALE_FALLBACK_PORT_END = 8499;
29
+ const TAILSCALE_STATUS_ATTEMPTS = 3;
30
+ const TAILSCALE_STATUS_RETRY_MS = 200;
31
+ const WEB_PACKAGE_NAME = "@mono-agent/web";
32
+ export function webPaths(homeDir = accountHomeDirectory()) {
33
+ const stateDir = resolve(homeDir, ".mono-agent", "web");
34
+ const logDir = join(stateDir, "logs");
35
+ const launchAgentsDir = resolve(homeDir, "Library", "LaunchAgents");
36
+ return {
37
+ stateDir,
38
+ recordPath: join(stateDir, "service.json"),
39
+ tailscalePath: join(stateDir, "tailscale-serve.json"),
40
+ launchd: {
41
+ launchAgentsDir,
42
+ logDir,
43
+ plistPath: join(launchAgentsDir, `${WEB_LAUNCHD_LABEL}.plist`),
44
+ stdoutPath: join(logDir, "web.out.log"),
45
+ stderrPath: join(logDir, "web.err.log"),
46
+ },
47
+ };
48
+ }
49
+ export function renderWebHelp() {
50
+ return [
51
+ "mono-agent web — always-on multi-agent web console",
52
+ "",
53
+ " mono-agent web",
54
+ " mono-agent web start [--host <addr> | --loopback] [--port <n>]",
55
+ " mono-agent web restart [--host <addr> | --loopback] [--port <n>]",
56
+ " mono-agent web stop | status",
57
+ " mono-agent web logs [--follow|-f] [--lines <n>]",
58
+ " mono-agent web run [--host <addr> | --loopback] [--port <n>]",
59
+ " mono-agent web reset --all --yes",
60
+ "",
61
+ `Default bind: ${DEFAULT_WEB_HOST}:${String(DEFAULT_WEB_PORT)} (LAN/Tailnet reachable; no app login).`,
62
+ "--loopback narrows the bind to 127.0.0.1. start/restart claim a free Tailscale Serve HTTPS port without replacing existing handlers.",
63
+ "",
64
+ ].join("\n");
65
+ }
66
+ /** Route the `mono-agent web` service namespace. Bare invocation is read-only. */
67
+ export async function runWebCommand(options, deps = {}) {
16
68
  const stdout = deps.stdout ?? process.stdout;
17
69
  const stderr = deps.stderr ?? process.stderr;
18
- const configuredRegistryDir = await resolveAppTraceRegistryDir({
19
- env: options.env,
20
- cwd: options.cwd,
21
- configPath: options.configPath,
22
- });
23
- const globalRegistryDir = resolveGlobalTraceRegistryDir(options.env);
24
- const registryDirs = dedupePaths([configuredRegistryDir, globalRegistryDir]);
25
- await Promise.all(registryDirs.map((registryDir) => pruneTraceSources({ registryDir })));
26
- const authRequired = requiresServerAuth(options.host);
27
- const interactive = deps.interactive ?? process.stdout.isTTY === true;
28
- const configuredAuthToken = normalizeOptionalString(options.env.MONO_AGENT_WEB_AUTH_TOKEN);
29
- if (authRequired && configuredAuthToken === undefined && !interactive) {
30
- stderr.write(`Non-interactive non-loopback web serving requires ${WEB_AUTH_TOKEN_ENV}; refusing to generate a bearer secret into logs.\n`);
31
- return 1;
70
+ const action = options.positionals[0];
71
+ if (options.positionals.length > 1 || (action !== undefined && !WEB_ACTIONS.has(action))) {
72
+ stderr.write(ui.errorLine(`Unknown mono-agent web action \`${options.positionals.join(" ")}\`.`));
73
+ stdout.write(renderWebHelp());
74
+ return 2;
75
+ }
76
+ const validation = validateWebFlags(action, options);
77
+ if (validation !== undefined) {
78
+ stderr.write(ui.errorLine(validation));
79
+ stdout.write(renderWebHelp());
80
+ return 2;
81
+ }
82
+ if (action === undefined) {
83
+ stdout.write(renderWebHelp());
84
+ await statusWeb(options, deps, false);
85
+ return 0;
86
+ }
87
+ switch (action) {
88
+ case "run":
89
+ return await runWebForeground(options, deps);
90
+ case "start":
91
+ return await startWebBackground(options, deps, false);
92
+ case "restart":
93
+ return await startWebBackground(options, deps, true);
94
+ case "stop":
95
+ return await stopWebBackground(options, deps);
96
+ case "status":
97
+ return await statusWeb(options, deps, true);
98
+ case "logs":
99
+ return await tailWebLogs(options, deps);
100
+ case "reset":
101
+ return await resetWeb(options, deps);
102
+ }
103
+ return 2;
104
+ }
105
+ const WEB_ACTIONS = new Set(["start", "stop", "restart", "status", "logs", "run", "reset"]);
106
+ function validateWebFlags(action, options) {
107
+ if (options.loopback === true && options.host !== undefined) {
108
+ return "Choose either --loopback or --host, not both.";
109
+ }
110
+ if (options.port !== undefined && options.port === 0) {
111
+ return "mono-agent web requires a stable --port between 1 and 65535.";
112
+ }
113
+ if ((options.host !== undefined || options.port !== undefined || options.loopback === true)
114
+ && action !== "start" && action !== "restart" && action !== "run") {
115
+ return "--host, --port, and --loopback are only supported for web start, restart, or run.";
116
+ }
117
+ if ((options.follow === true || options.lines !== undefined) && action !== "logs") {
118
+ return "--follow and --lines are only supported for mono-agent web logs.";
32
119
  }
33
- const authToken = configuredAuthToken ?? (authRequired ? generateBearerToken() : undefined);
34
- const generatedAuthToken = authToken !== undefined && configuredAuthToken === undefined;
120
+ if ((options.all === true || options.yes === true) && action !== "reset") {
121
+ return "--all and --yes are only supported for mono-agent web reset in this command namespace.";
122
+ }
123
+ if (action === "reset" && (options.all !== true || options.yes !== true)) {
124
+ return "Destructive reset requires the exact confirmation: mono-agent web reset --all --yes.";
125
+ }
126
+ return undefined;
127
+ }
128
+ async function runWebForeground(options, deps) {
129
+ const stdout = deps.stdout ?? process.stdout;
130
+ const stderr = deps.stderr ?? process.stderr;
131
+ const host = effectiveHost(options);
35
132
  const port = options.port ?? DEFAULT_WEB_PORT;
36
- const startServer = deps.startServer ?? startSessionWebServer;
133
+ const paths = webPaths(deps.homeDir);
134
+ await (deps.prepareState ?? defaultPrepareWebState)({ stateDir: paths.stateDir, env: options.env });
135
+ const registryDir = resolveGlobalTraceRegistryDir(options.env);
136
+ await pruneTraceSources({ registryDir });
37
137
  let handle;
38
138
  try {
39
- handle = await startServer({
40
- registryDirs,
41
- env: options.env,
42
- ...(options.host === undefined ? {} : { host: options.host }),
43
- port,
44
- ...(options.allowNonLoopback === undefined ? {} : { allowNonLoopback: options.allowNonLoopback }),
45
- ...(authToken === undefined ? {} : { authToken }),
46
- ...(options.includeMemory === undefined ? {} : { includeMemory: options.includeMemory }),
47
- ...(options.maxRunsPerInstance === undefined ? {} : { maxRunsPerInstance: options.maxRunsPerInstance }),
48
- });
139
+ const startServer = deps.startServer ?? defaultStartWebServer;
140
+ handle = await startServer({ host, port, registryDirs: [registryDir], stateDir: paths.stateDir, env: options.env });
49
141
  }
50
142
  catch (error) {
51
- stderr.write(`mono-agent web failed to start: ${error instanceof Error ? error.message : String(error)}\n`);
143
+ stderr.write(ui.errorLine(`mono-agent web failed to start: ${errorMessage(error)}`));
52
144
  return 1;
53
145
  }
54
- const wildcardBind = isWildcardUrl(handle.url)
55
- || (handle.boundAddress !== undefined && isWildcardHost(handle.boundAddress));
56
- const networkAddresses = wildcardBind
57
- ? bestEffortNetworkAddresses(deps.discoverNetworkAddresses)
58
- : [];
59
- const advertisedUrls = resolveAdvertisedUrls(handle.url, networkAddresses, handle.boundAddress);
60
- const primaryUrl = advertisedUrls[0]?.url ?? handle.url;
61
- const mayPrintConfiguredToken = configuredAuthToken !== undefined
62
- && options.showAuthUrl === true
63
- && interactive;
64
- const printableToken = generatedAuthToken || mayPrintConfiguredToken ? authToken : undefined;
65
- printAdvertisedUrls(stdout, advertisedUrls, printableToken);
66
- if (configuredAuthToken !== undefined && printableToken === undefined) {
67
- stdout.write(`Authentication: ${WEB_AUTH_TOKEN_ENV} is configured (token redacted).\n`);
68
- }
69
- if (configuredAuthToken !== undefined && options.showAuthUrl === true && !mayPrintConfiguredToken) {
70
- stderr.write("--show-auth-url was ignored because stdout is not an interactive terminal.\n");
71
- }
72
- stdout.write(`Watching ${registryDirs.length} registr${registryDirs.length === 1 ? "y" : "ies"} for agents. Press Ctrl-C to stop.\n`);
73
- stdout.write(`${reverseProxyHint(primaryUrl)}\n`);
74
- stdout.write(`${reachabilityHint(handle.url, handle.boundAddress)}\n`);
75
- if ((options.open ?? true) && authToken === undefined) {
76
- try {
77
- (deps.openUrl ?? openInBrowser)(primaryUrl);
78
- }
79
- catch {
80
- // Best-effort: a headless host without a browser is fine — the URL is printed.
81
- }
146
+ printWebUrls(stdout, handle.url, handle.port ?? port, host, deps.discoverNetworkAddresses);
147
+ stdout.write("No app authentication is enabled; network reachability is the access boundary. Press Ctrl-C to stop.\n");
148
+ try {
149
+ await (deps.waitForShutdown ?? waitForShutdownSignal)();
82
150
  }
83
- else if ((options.open ?? true) && authToken !== undefined) {
84
- stdout.write("Browser launch skipped in authenticated mode so the bearer token never enters process arguments.\n");
151
+ finally {
152
+ await handle.stop();
85
153
  }
86
- deps.onReady?.(handle);
87
- await (deps.waitForShutdown ?? waitForSignal)();
88
- await handle.stop();
89
154
  stdout.write("mono-agent web stopped.\n");
90
155
  return 0;
91
156
  }
92
- /**
93
- * A one-line hint about who can reach the server. Direct HTTP never depends on
94
- * Tailscale Serve; Serve is only an optional HTTPS/PWA-installability layer.
95
- */
96
- function reachabilityHint(url, boundAddress) {
97
- let port = "";
98
- let host = "";
157
+ async function startWebBackground(options, deps, restart) {
158
+ const guard = requireDarwin(deps, restart ? "restart" : "start");
159
+ if (guard !== undefined)
160
+ return guard;
161
+ const stdout = deps.stdout ?? process.stdout;
162
+ const stderr = deps.stderr ?? process.stderr;
163
+ const launchctl = deps.launchctl ?? makeLaunchctlRunner();
164
+ const getuid = deps.getuid ?? requiredUid;
165
+ const paths = webPaths(deps.homeDir);
99
166
  try {
100
- const parsed = new URL(url);
101
- port = parsed.port;
102
- host = parsed.hostname;
167
+ await (deps.prepareState ?? defaultPrepareWebState)({ stateDir: paths.stateDir, env: options.env });
103
168
  }
104
- catch {
105
- /* leave blank */
169
+ catch (error) {
170
+ stderr.write(ui.errorLine(`Could not prepare the owner-private web state: ${errorMessage(error)}`));
171
+ return 1;
106
172
  }
107
- const effectiveHost = boundAddress ?? host;
108
- if (effectiveHost.length > 0 && !isLoopbackHost(effectiveHost)) {
109
- return "Bound non-loopback: direct HTTP is available over your LAN/Tailnet at the URLs above. Tailscale Serve is optional and only needed for HTTPS + installable/offline PWA behavior.";
173
+ const release = await (deps.acquireLifecycleLock ?? acquireWebLifecycleLock)(paths);
174
+ if (release === undefined) {
175
+ stderr.write(ui.errorLine("Another mono-agent web lifecycle command is active."));
176
+ return 1;
110
177
  }
111
- return `Loopback only. Re-run with --host 0.0.0.0 --allow-non-loopback for direct LAN/Tailnet HTTP. Tailscale Serve is optional for HTTPS + installable/offline PWA behavior (port ${port || "<port>"}).`;
112
- }
113
- function reverseProxyHint(url) {
114
- let target = url;
178
+ let previous;
179
+ let previousStopped = false;
180
+ let replacementLaunchAttempted = false;
181
+ let publicationAttempted = false;
182
+ let publicationSnapshot;
183
+ const fail = async (message, error) => {
184
+ stderr.write(ui.errorLine(`${message}${error === undefined ? "" : `: ${errorMessage(error)}`}`));
185
+ if (previousStopped && previous !== undefined) {
186
+ const recovery = await restorePreviousWebService(previous, paths, launchctl, getuid(), deps);
187
+ if (recovery.ok) {
188
+ stderr.write(ui.style.yellow("⚠ The failed restart was rolled back and the previous web worker is running again.\n"));
189
+ }
190
+ else {
191
+ stderr.write(ui.style.yellow(`⚠ The failed restart could not restore the prior worker: ${recovery.detail}\n`));
192
+ }
193
+ }
194
+ else {
195
+ let safeToRestorePublication = true;
196
+ if (replacementLaunchAttempted) {
197
+ try {
198
+ const replacement = await launchdServiceInfo(launchctl, WEB_LAUNCHD_LABEL, getuid());
199
+ if (replacement.loaded) {
200
+ const stopped = await stopLaunchdOnly(replacement, launchctl, getuid(), deps);
201
+ safeToRestorePublication = stopped;
202
+ stderr.write(stopped
203
+ ? ui.style.yellow("⚠ The failed initial web worker was stopped.\n")
204
+ : ui.style.yellow("⚠ The failed initial web worker could not be proven stopped; inspect `mono-agent web status` and logs.\n"));
205
+ }
206
+ }
207
+ catch (cleanupError) {
208
+ safeToRestorePublication = false;
209
+ stderr.write(ui.style.yellow(`⚠ Could not clean up the failed initial web worker: ${errorMessage(cleanupError)}\n`));
210
+ }
211
+ }
212
+ if (publicationAttempted && publicationSnapshot !== undefined && safeToRestorePublication) {
213
+ try {
214
+ await restoreWebPublication(paths, publicationSnapshot, deps.writePrivateFile ?? writeOwnerPrivateLaunchdFile);
215
+ }
216
+ catch (restoreError) {
217
+ stderr.write(ui.style.yellow(`⚠ Could not restore the pre-start service definition: ${errorMessage(restoreError)}\n`));
218
+ }
219
+ }
220
+ }
221
+ return 1;
222
+ };
115
223
  try {
116
- const parsed = new URL(url);
117
- parsed.username = "";
118
- parsed.password = "";
119
- parsed.search = "";
120
- parsed.hash = "";
121
- target = parsed.toString();
224
+ const uid = getuid();
225
+ const existing = await launchdServiceInfo(launchctl, WEB_LAUNCHD_LABEL, uid);
226
+ if (existing.loaded && !restart) {
227
+ stdout.write(ui.style.dim("mono-agent web is already managed by launchd.\n"));
228
+ return await statusWeb(options, deps, true);
229
+ }
230
+ const recordRead = await readServiceRecord(paths.recordPath);
231
+ if (recordRead.kind === "invalid") {
232
+ return await fail(`Refusing to start because ${recordRead.detail}`);
233
+ }
234
+ const existingPlist = await readOptionalText(paths.launchd.plistPath);
235
+ publicationSnapshot = {
236
+ ...(existingPlist === undefined ? {} : { plist: existingPlist }),
237
+ ...(recordRead.kind === "valid" ? { recordText: recordRead.contents } : {}),
238
+ };
239
+ if (existing.loaded && restart) {
240
+ try {
241
+ if (existingPlist === undefined)
242
+ throw new Error("the loaded LaunchAgent plist is missing");
243
+ if (recordRead.kind !== "valid")
244
+ throw new Error("the loaded web service record is missing");
245
+ previous = {
246
+ plist: existingPlist,
247
+ recordText: recordRead.contents,
248
+ record: recordRead.record,
249
+ };
250
+ }
251
+ catch (error) {
252
+ return await fail("Refusing restart because the current web service could not be snapshotted", error);
253
+ }
254
+ }
255
+ if (existing.loaded) {
256
+ const stopped = await stopLaunchdOnly(existing, launchctl, uid, deps);
257
+ if (!stopped) {
258
+ stderr.write(ui.errorLine("Could not prove the existing mono-agent web worker stopped; its definition was preserved."));
259
+ return 1;
260
+ }
261
+ previousStopped = restart && previous !== undefined;
262
+ }
263
+ const priorRecord = recordRead.kind === "valid" ? recordRead.record : undefined;
264
+ const host = effectiveHost(options, priorRecord?.host);
265
+ const port = options.port ?? priorRecord?.port ?? DEFAULT_WEB_PORT;
266
+ await ensureWebDirectories(paths);
267
+ const currentCliPath = fileURLToPath(new URL("./cli.js", import.meta.url));
268
+ let runtime;
269
+ try {
270
+ runtime = await (deps.ensureManagedRuntime ?? ((input) => ensureManagedBackgroundRuntime(input)))({
271
+ currentCliPath,
272
+ nodePath: process.execPath,
273
+ });
274
+ }
275
+ catch (error) {
276
+ return await fail("Could not install the durable web runtime", error);
277
+ }
278
+ const tailscaleRunner = deps.tailscale ?? makeTailscaleRunner(options.env);
279
+ const priorTailscaleOwnership = await readTailscaleOwnership(paths.tailscalePath);
280
+ const recordedTailscaleDnsName = priorTailscaleOwnership.kind === "valid"
281
+ ? tailscaleWebHostname(priorTailscaleOwnership.ownership.webKey)
282
+ : undefined;
283
+ // A healthy existing Serve route must keep working through a transient
284
+ // LocalAPI outage during restart. The owner-private, exact-route record is
285
+ // re-verified by ensureTailscaleServe after the replacement worker starts.
286
+ const tailscaleDnsName = await readTailscaleDnsName(tailscaleRunner, deps.sleep)
287
+ ?? recordedTailscaleDnsName;
288
+ const allowedHosts = mergeWebAllowedHosts(options.env.MONO_AGENT_WEB_ALLOWED_HOSTS, tailscaleDnsName);
289
+ const environment = {
290
+ ...selectBackgroundOperationalEnvironment(options.env),
291
+ PATH: defaultPathEnv(options.env),
292
+ ...(options.env.MONO_AGENT_GLOBAL_TRACE_REGISTRY_DIR === undefined
293
+ ? {}
294
+ : { MONO_AGENT_GLOBAL_TRACE_REGISTRY_DIR: options.env.MONO_AGENT_GLOBAL_TRACE_REGISTRY_DIR }),
295
+ ...(allowedHosts === undefined ? {} : { MONO_AGENT_WEB_ALLOWED_HOSTS: allowedHosts }),
296
+ };
297
+ const record = {
298
+ schema: WEB_SERVICE_SCHEMA,
299
+ host,
300
+ port,
301
+ updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
302
+ };
303
+ const plist = buildWebPlistXml({
304
+ label: WEB_LAUNCHD_LABEL,
305
+ nodePath: runtime.nodePath,
306
+ cliPath: runtime.cliPath,
307
+ cwd: paths.stateDir,
308
+ host,
309
+ port,
310
+ stdoutPath: paths.launchd.stdoutPath,
311
+ stderrPath: paths.launchd.stderrPath,
312
+ environment,
313
+ });
314
+ const writePrivateFile = deps.writePrivateFile ?? writeOwnerPrivateLaunchdFile;
315
+ publicationAttempted = true;
316
+ try {
317
+ await writePrivateFile(paths.recordPath, `${JSON.stringify(record, undefined, 2)}\n`);
318
+ await writePrivateFile(paths.launchd.plistPath, plist);
319
+ }
320
+ catch (error) {
321
+ return await fail("Could not publish the web LaunchAgent definition", error);
322
+ }
323
+ replacementLaunchAttempted = true;
324
+ const bootstrapped = await bootstrap(launchctl, paths.launchd.plistPath, uid);
325
+ if (bootstrapped.code !== 0) {
326
+ writeCommandDetail(stderr, bootstrapped);
327
+ return await fail(`launchctl could not start mono-agent web (exit ${String(bootstrapped.code)})`);
328
+ }
329
+ const ready = await waitForWebReady(host, port, launchctl, uid, deps);
330
+ if (!ready) {
331
+ stderr.write(ui.hint("Inspect `mono-agent web logs`, then retry `mono-agent web restart`."));
332
+ return await fail("mono-agent web did not become healthy before the startup timeout");
333
+ }
334
+ const tailscale = tailscaleDnsName === undefined
335
+ ? { kind: "unavailable", detail: "the node's exact Tailscale DNS name could not be resolved; no Serve handler was changed" }
336
+ : await ensureTailscaleServe(paths, host, port, options.env, { ...deps, tailscale: tailscaleRunner }, tailscaleDnsName);
337
+ if (tailscale.kind === "unavailable" && tailscale.requiresServiceRollback === true) {
338
+ return await fail(`Tailscale route migration failed and the replacement web worker cannot remain active: ${tailscale.detail}`);
339
+ }
340
+ stdout.write(`${ui.badge("ok")}${ui.style.bold(restart ? "Restarted mono-agent web" : "Started mono-agent web")}\n`);
341
+ printWebUrls(stdout, `http://${urlHost(host)}:${String(port)}/`, port, host, deps.discoverNetworkAddresses);
342
+ stdout.write("No app authentication is enabled; anyone who can reach this port can operate discovered agents.\n");
343
+ if (tailscale.kind === "active") {
344
+ stdout.write(`Tailscale HTTPS → ${tailscale.ownership.url}${tailscale.reused ? " (existing owned handler)" : ""}\n`);
345
+ }
346
+ else {
347
+ stderr.write(ui.style.yellow(`⚠ Tailscale Serve was not configured: ${tailscale.detail}\n`));
348
+ stderr.write(ui.hint(`LAN HTTP remains healthy on port ${String(port)}. Resolve Tailscale, then run mono-agent web restart.`));
349
+ }
350
+ return 0;
122
351
  }
123
- catch {
124
- /* use the original URL */
125
- }
126
- return `Reverse proxies should target ${target} (default port ${DEFAULT_WEB_PORT}; override with --port).`;
127
- }
128
- function requiresServerAuth(host) {
129
- return host !== undefined && !isLoopbackHost(host);
130
- }
131
- function withAuthToken(url, token) {
132
- const parsed = new URL(url);
133
- const fragment = new URLSearchParams(parsed.hash.startsWith("#") ? parsed.hash.slice(1) : parsed.hash);
134
- fragment.set("token", token);
135
- parsed.hash = fragment.toString();
136
- return parsed.toString();
137
- }
138
- function printAdvertisedUrls(stdout, urls, authToken) {
139
- const printable = urls.map((entry) => ({
140
- ...entry,
141
- url: authToken === undefined ? entry.url : withAuthToken(entry.url, authToken),
142
- }));
143
- const primary = printable[0];
144
- if (primary === undefined) {
145
- return;
352
+ catch (error) {
353
+ return await fail("mono-agent web lifecycle failed", error);
146
354
  }
147
- stdout.write(`mono-agent web → ${primary.url}\n`);
148
- for (const entry of printable.slice(1)) {
149
- stdout.write(`${entry.label.padEnd(9)} ${entry.url}\n`);
150
- }
151
- }
152
- function resolveAdvertisedUrls(url, networkAddresses, boundAddress) {
153
- const requestedWildcard = isWildcardUrl(url) ? new URL(url).hostname : undefined;
154
- const actualWildcard = boundAddress !== undefined && isWildcardHost(boundAddress)
155
- ? boundAddress
156
- : undefined;
157
- if (requestedWildcard === undefined && actualWildcard === undefined) {
158
- return [{ label: isLoopbackUrl(url) ? "Loopback" : "Web", url }];
159
- }
160
- const wildcardHost = requestedWildcard ?? actualWildcard ?? new URL(url).hostname;
161
- const loopbackHost = loopbackForWildcardHost(wildcardHost);
162
- const urls = [{ label: "Loopback", url: replaceUrlHost(url, loopbackHost) }];
163
- for (const entry of networkAddresses) {
164
- urls.push({
165
- label: entry.kind === "tailscale" ? "Tailscale" : "LAN",
166
- url: replaceUrlHost(url, entry.address),
355
+ finally {
356
+ await release().catch((error) => {
357
+ stderr.write(ui.style.yellow(`⚠ Could not release the web lifecycle lock: ${errorMessage(error)}\n`));
167
358
  });
168
359
  }
169
- return urls;
170
360
  }
171
- function loopbackForWildcardHost(host) {
172
- const normalized = normalizeHostForBind(host).toLowerCase();
173
- if (normalized === "0.0.0.0") {
174
- return "127.0.0.1";
361
+ async function stopWebBackground(options, deps) {
362
+ const guard = requireDarwin(deps, "stop");
363
+ if (guard !== undefined)
364
+ return guard;
365
+ const stdout = deps.stdout ?? process.stdout;
366
+ const stderr = deps.stderr ?? process.stderr;
367
+ const launchctl = deps.launchctl ?? makeLaunchctlRunner();
368
+ const uid = (deps.getuid ?? requiredUid)();
369
+ const paths = webPaths(deps.homeDir);
370
+ const initialService = await launchdServiceInfo(launchctl, WEB_LAUNCHD_LABEL, uid);
371
+ // A running worker owns the backend state lease. Stop must reach launchd
372
+ // bootout without opening or preparing that state first. On a pristine,
373
+ // already-stopped install, preparation creates a valid marked state root
374
+ // before the filesystem lifecycle lock needs to place its lock directory.
375
+ if (!initialService.loaded) {
376
+ try {
377
+ await (deps.prepareState ?? defaultPrepareWebState)({ stateDir: paths.stateDir, env: options.env });
378
+ }
379
+ catch (error) {
380
+ stderr.write(ui.errorLine(`Could not prepare the owner-private web state: ${errorMessage(error)}`));
381
+ return 1;
382
+ }
383
+ }
384
+ const release = await (deps.acquireLifecycleLock ?? acquireWebLifecycleLock)(paths);
385
+ if (release === undefined) {
386
+ stderr.write(ui.errorLine("Another mono-agent web lifecycle command is active."));
387
+ return 1;
175
388
  }
176
389
  try {
177
- const canonical = new URL(`http://[${normalized}]/`).hostname.slice(1, -1);
178
- if (canonical === "::ffff:0:0") {
179
- return "127.0.0.1";
390
+ const service = await launchdServiceInfo(launchctl, WEB_LAUNCHD_LABEL, uid);
391
+ if (service.loaded && !await stopLaunchdOnly(service, launchctl, uid, deps)) {
392
+ stderr.write(ui.errorLine("Could not prove mono-agent web stopped; its LaunchAgent and Tailscale handler were preserved."));
393
+ return 1;
180
394
  }
395
+ const tailscaleResult = await removeOwnedTailscaleServe(paths, deps);
396
+ await rm(paths.launchd.plistPath, { force: true });
397
+ if (tailscaleResult.kind === "unavailable") {
398
+ stderr.write(ui.style.yellow(`⚠ Web stopped, but the owned Tailscale handler was preserved: ${tailscaleResult.detail}\n`));
399
+ return 1;
400
+ }
401
+ stdout.write(service.loaded
402
+ ? `${ui.badge("ok")}${ui.style.bold("Stopped mono-agent web")} and removed its LaunchAgent.\n`
403
+ : "mono-agent web was already stopped; removed its LaunchAgent if present.\n");
404
+ return 0;
181
405
  }
182
- catch {
183
- // isWildcardHost already validated the caller; fall through defensively.
406
+ finally {
407
+ await release().catch(() => undefined);
408
+ }
409
+ }
410
+ async function statusWeb(options, deps, strictExit) {
411
+ const stdout = deps.stdout ?? process.stdout;
412
+ const paths = webPaths(deps.homeDir);
413
+ const recordRead = await readServiceRecord(paths.recordPath);
414
+ const record = recordRead.kind === "valid" ? recordRead.record : undefined;
415
+ const host = record?.host ?? DEFAULT_WEB_HOST;
416
+ const port = record?.port ?? DEFAULT_WEB_PORT;
417
+ let service = { loaded: false };
418
+ if ((deps.platform ?? process.platform) === "darwin") {
419
+ service = await launchdServiceInfo(deps.launchctl ?? makeLaunchctlRunner(), WEB_LAUNCHD_LABEL, (deps.getuid ?? requiredUid)());
420
+ }
421
+ const healthy = recordRead.kind !== "invalid"
422
+ && service.loaded
423
+ && await (deps.healthcheck ?? webHealthcheck)(healthUrl(host, port));
424
+ stdout.write(ui.rule("Web console status"));
425
+ stdout.write(ui.keyValue([
426
+ ["service", service.loaded ? (healthy ? "running" : "loaded, not healthy") : "stopped"],
427
+ ["bind", recordRead.kind === "invalid" ? "invalid service record" : `${host}:${String(port)}`],
428
+ ["state", paths.stateDir],
429
+ ["pid", service.pid === undefined ? "—" : String(service.pid)],
430
+ ["authentication", "none (network reachability is the boundary)"],
431
+ ]));
432
+ if (recordRead.kind === "invalid")
433
+ stdout.write(ui.errorLine(recordRead.detail));
434
+ else {
435
+ printWebUrls(stdout, `http://${urlHost(host)}:${String(port)}/`, port, host, deps.discoverNetworkAddresses);
184
436
  }
185
- return "::1";
437
+ const owned = await readTailscaleOwnership(paths.tailscalePath);
438
+ if (owned.kind === "absent") {
439
+ stdout.write("Tailscale HTTPS: not owned by mono-agent web.\n");
440
+ }
441
+ else if (owned.kind === "invalid") {
442
+ stdout.write(`Tailscale HTTPS: ${owned.detail}\n`);
443
+ }
444
+ else {
445
+ const inspection = await inspectOwnedTailscaleRoute(owned.ownership, deps);
446
+ stdout.write(inspection.kind === "exact"
447
+ ? `Tailscale HTTPS: ${owned.ownership.url}\n`
448
+ : "Tailscale HTTPS: ownership record is stale or cannot be verified; existing handlers will not be changed.\n");
449
+ }
450
+ if (!service.loaded)
451
+ stdout.write(ui.hint("Start it with: mono-agent web start"));
452
+ else if (!healthy)
453
+ stdout.write(ui.hint("Inspect: mono-agent web logs"));
454
+ return (strictExit && !healthy) || recordRead.kind === "invalid" ? 1 : 0;
186
455
  }
187
- function replaceUrlHost(url, host) {
188
- const parsed = new URL(url);
189
- parsed.hostname = hostForUrl(host);
190
- return parsed.toString();
456
+ async function tailWebLogs(options, deps) {
457
+ const guard = requireDarwin(deps, "logs");
458
+ if (guard !== undefined)
459
+ return guard;
460
+ const paths = webPaths(deps.homeDir);
461
+ const args = [
462
+ "-n",
463
+ String(options.lines ?? DEFAULT_LOG_LINES),
464
+ ...(options.follow === true ? ["-F"] : []),
465
+ paths.launchd.stderrPath,
466
+ paths.launchd.stdoutPath,
467
+ ];
468
+ return await (deps.spawnTail ?? defaultSpawnTail)(args);
191
469
  }
192
- function isWildcardUrl(url) {
470
+ async function resetWeb(options, deps) {
471
+ const stderr = deps.stderr ?? process.stderr;
472
+ const stdout = deps.stdout ?? process.stdout;
473
+ const paths = webPaths(deps.homeDir);
474
+ if ((deps.platform ?? process.platform) === "darwin") {
475
+ const service = await launchdServiceInfo(deps.launchctl ?? makeLaunchctlRunner(), WEB_LAUNCHD_LABEL, (deps.getuid ?? requiredUid)());
476
+ if (service.loaded) {
477
+ stderr.write(ui.errorLine("Refusing to reset while mono-agent web is running."));
478
+ stderr.write(ui.hint("Run `mono-agent web stop`, then repeat `mono-agent web reset --all --yes`."));
479
+ return 1;
480
+ }
481
+ }
482
+ await (deps.prepareState ?? defaultPrepareWebState)({ stateDir: paths.stateDir, env: options.env });
193
483
  try {
194
- const host = new URL(url).hostname;
195
- return isWildcardHost(host);
484
+ const tailscaleOwnershipBefore = await readOptionalText(paths.tailscalePath);
485
+ await (deps.resetState ?? defaultResetWebState)({ stateDir: paths.stateDir, env: options.env });
486
+ if (tailscaleOwnershipBefore !== undefined) {
487
+ const after = await readOptionalText(paths.tailscalePath);
488
+ if (after !== tailscaleOwnershipBefore) {
489
+ await (deps.writePrivateFile ?? writeOwnerPrivateLaunchdFile)(paths.tailscalePath, tailscaleOwnershipBefore);
490
+ stderr.write(ui.errorLine("Reset changed the Tailscale ownership record; it was restored so a live route cannot become unowned."));
491
+ return 1;
492
+ }
493
+ }
196
494
  }
197
- catch {
495
+ catch (error) {
496
+ stderr.write(ui.errorLine(`Could not reset the web console: ${errorMessage(error)}`));
497
+ return 1;
498
+ }
499
+ stdout.write(`${ui.badge("ok")}Reset all mono-agent web conversations, messages, attachments, and settings.\n`);
500
+ return 0;
501
+ }
502
+ async function defaultStartWebServer(options) {
503
+ const web = await import(WEB_PACKAGE_NAME);
504
+ return await web.startWebServer(options);
505
+ }
506
+ async function defaultResetWebState(options) {
507
+ const web = await import(WEB_PACKAGE_NAME);
508
+ await web.resetWebState(options);
509
+ }
510
+ async function defaultPrepareWebState(options) {
511
+ const web = await import(WEB_PACKAGE_NAME);
512
+ await web.prepareWebState(options);
513
+ }
514
+ function effectiveHost(options, priorHost) {
515
+ return options.loopback === true ? "127.0.0.1" : options.host ?? priorHost ?? DEFAULT_WEB_HOST;
516
+ }
517
+ async function ensureWebDirectories(paths) {
518
+ for (const path of [
519
+ dirname(paths.stateDir),
520
+ paths.stateDir,
521
+ paths.launchd.logDir,
522
+ paths.launchd.launchAgentsDir,
523
+ ]) {
524
+ await ensureOwnerPrivateLaunchdDirectory(path);
525
+ }
526
+ }
527
+ async function acquireWebLifecycleLock(paths) {
528
+ await ensureOwnerPrivateLaunchdDirectory(dirname(paths.stateDir));
529
+ await ensureOwnerPrivateLaunchdDirectory(paths.stateDir);
530
+ return await acquireFilesystemLifecycleLock({ label: WEB_LAUNCHD_LABEL, paths: paths.launchd });
531
+ }
532
+ async function stopLaunchdOnly(service, runner, uid, deps) {
533
+ const result = await bootout(runner, WEB_LAUNCHD_LABEL, uid);
534
+ if (result.code !== 0)
198
535
  return false;
536
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)));
537
+ const deadline = (deps.now ?? Date.now)() + READY_TIMEOUT_MS;
538
+ for (;;) {
539
+ const current = await launchdServiceInfo(runner, WEB_LAUNCHD_LABEL, uid);
540
+ const pidAlive = service.pid !== undefined && (deps.isAlive ?? processIsAlive)(service.pid);
541
+ if (!current.loaded && !pidAlive)
542
+ return true;
543
+ if ((deps.now ?? Date.now)() >= deadline)
544
+ return false;
545
+ await sleep(READY_POLL_MS);
199
546
  }
200
547
  }
201
- function isLoopbackUrl(url) {
548
+ async function restorePreviousWebService(snapshot, paths, runner, uid, deps) {
202
549
  try {
203
- return isLoopbackHost(new URL(url).hostname);
550
+ const current = await launchdServiceInfo(runner, WEB_LAUNCHD_LABEL, uid);
551
+ if (current.loaded && !await stopLaunchdOnly(current, runner, uid, deps)) {
552
+ return { ok: false, detail: "the failed replacement worker could not be stopped" };
553
+ }
554
+ const writer = deps.writePrivateFile ?? writeOwnerPrivateLaunchdFile;
555
+ if (snapshot.recordText === undefined)
556
+ await rm(paths.recordPath, { force: true });
557
+ else
558
+ await writer(paths.recordPath, snapshot.recordText);
559
+ await writer(paths.launchd.plistPath, snapshot.plist);
560
+ const booted = await bootstrap(runner, paths.launchd.plistPath, uid);
561
+ if (booted.code !== 0) {
562
+ return { ok: false, detail: commandDetail(booted) || `launchctl bootstrap exited ${String(booted.code)}` };
563
+ }
564
+ const now = deps.now ?? Date.now;
565
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)));
566
+ const deadline = now() + READY_TIMEOUT_MS;
567
+ for (;;) {
568
+ const restored = await launchdServiceInfo(runner, WEB_LAUNCHD_LABEL, uid);
569
+ const alive = restored.pid !== undefined && (deps.isAlive ?? processIsAlive)(restored.pid);
570
+ const healthy = snapshot.record === undefined
571
+ ? true
572
+ : await (deps.healthcheck ?? webHealthcheck)(healthUrl(snapshot.record.host, snapshot.record.port));
573
+ if (restored.loaded && alive && healthy)
574
+ return { ok: true };
575
+ if (now() >= deadline)
576
+ return { ok: false, detail: "the restored LaunchAgent did not become healthy" };
577
+ await sleep(READY_POLL_MS);
578
+ }
579
+ }
580
+ catch (error) {
581
+ return { ok: false, detail: errorMessage(error) };
582
+ }
583
+ }
584
+ async function restoreWebPublication(paths, snapshot, writer) {
585
+ if (snapshot.recordText === undefined)
586
+ await rm(paths.recordPath, { force: true });
587
+ else
588
+ await writer(paths.recordPath, snapshot.recordText);
589
+ if (snapshot.plist === undefined)
590
+ await rm(paths.launchd.plistPath, { force: true });
591
+ else
592
+ await writer(paths.launchd.plistPath, snapshot.plist);
593
+ }
594
+ async function waitForWebReady(host, port, launchctl, uid, deps) {
595
+ const now = deps.now ?? Date.now;
596
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)));
597
+ const healthcheck = deps.healthcheck ?? webHealthcheck;
598
+ const deadline = now() + READY_TIMEOUT_MS;
599
+ for (;;) {
600
+ const service = await launchdServiceInfo(launchctl, WEB_LAUNCHD_LABEL, uid);
601
+ if (service.loaded && service.pid !== undefined && (deps.isAlive ?? processIsAlive)(service.pid)
602
+ && await healthcheck(healthUrl(host, port)))
603
+ return true;
604
+ if (now() >= deadline)
605
+ return false;
606
+ await sleep(READY_POLL_MS);
607
+ }
608
+ }
609
+ function healthUrl(host, port) {
610
+ const checkHost = host === "0.0.0.0" || host === "::" || host === "[::]" ? "127.0.0.1" : urlHost(host);
611
+ return `http://${checkHost}:${String(port)}/healthz`;
612
+ }
613
+ export async function webHealthcheck(url) {
614
+ try {
615
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
616
+ if (!response.ok || response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
617
+ return false;
618
+ }
619
+ const body = await response.json();
620
+ return isRecord(body)
621
+ && hasExactKeys(body, ["status", "version"])
622
+ && body.status === "ok"
623
+ && body.version === 1;
204
624
  }
205
625
  catch {
206
626
  return false;
207
627
  }
208
628
  }
209
- function bestEffortNetworkAddresses(injected) {
629
+ export async function ensureTailscaleServe(paths, bindHost, appPort, env, deps, expectedDnsName) {
630
+ const runner = deps.tailscale ?? makeTailscaleRunner(env);
631
+ const proxyTarget = tailscaleProxyTarget(bindHost, appPort);
632
+ const ownershipRead = await readTailscaleOwnership(paths.tailscalePath);
633
+ if (ownershipRead.kind === "invalid") {
634
+ return { kind: "unavailable", detail: ownershipRead.detail };
635
+ }
636
+ let existing = ownershipRead.kind === "valid" ? ownershipRead.ownership : undefined;
637
+ let priorMigration;
638
+ const failMigration = async (detail) => {
639
+ if (priorMigration === undefined)
640
+ return { kind: "unavailable", detail };
641
+ const restored = await restorePriorTailscaleRoute(paths, priorMigration.ownership, priorMigration.contents, runner, deps);
642
+ return { kind: "unavailable", detail: `${detail}; ${restored}`, requiresServiceRollback: true };
643
+ };
644
+ if (proxyTarget === undefined) {
645
+ return {
646
+ kind: "unavailable",
647
+ detail: `bind host ${bindHost} is not reachable through a loopback proxy; use --host 0.0.0.0, --host ::, or --loopback`,
648
+ };
649
+ }
650
+ if (existing !== undefined) {
651
+ const inspection = await inspectOwnedTailscaleRoute(existing, deps, runner);
652
+ if (inspection.kind === "absent") {
653
+ const cleared = await clearProvablyAbsentTailscaleOwnership(paths, existing, runner);
654
+ if (cleared.kind === "unavailable")
655
+ return { kind: "unavailable", detail: cleared.detail };
656
+ existing = undefined;
657
+ }
658
+ else if (inspection.kind !== "exact") {
659
+ return {
660
+ kind: "unavailable",
661
+ detail: inspection.kind === "unavailable"
662
+ ? inspection.detail
663
+ : "the prior Tailscale ownership record no longer matches its exact handler; refusing to overwrite it",
664
+ };
665
+ }
666
+ }
667
+ if (existing !== undefined) {
668
+ if (expectedDnsName !== undefined && tailscaleWebHostname(existing.webKey) !== expectedDnsName) {
669
+ return {
670
+ kind: "unavailable",
671
+ detail: "the owned Tailscale handler hostname does not match this node's exact DNS name; refusing to reuse or replace it",
672
+ };
673
+ }
674
+ if (proxyTarget !== undefined && existing.proxyTarget === proxyTarget) {
675
+ return { kind: "active", ownership: existing, reused: true };
676
+ }
677
+ if (ownershipRead.kind !== "valid") {
678
+ return { kind: "unavailable", detail: "the prior Tailscale ownership record changed during migration; refusing to remove its handler" };
679
+ }
680
+ priorMigration = { ownership: existing, contents: ownershipRead.contents };
681
+ const removed = await removeOwnedTailscaleServe(paths, { ...deps, tailscale: runner });
682
+ if (removed.kind === "unavailable") {
683
+ return await failMigration(`could not migrate the prior exact Tailscale handler: ${removed.detail}`);
684
+ }
685
+ }
686
+ const initial = await readTailscaleServeStatus(runner);
687
+ if (initial.kind === "error")
688
+ return await failMigration(initial.detail);
689
+ const httpsPort = chooseTailscaleHttpsPort(initial.status);
690
+ if (httpsPort === undefined) {
691
+ return await failMigration("ports 443 and 8443-8499 are already assigned; no handler was changed");
692
+ }
693
+ const configured = await runner(["serve", "--bg", `--https=${String(httpsPort)}`, proxyTarget]);
694
+ if (configured.code !== 0) {
695
+ return await failMigration(commandDetail(configured) || `tailscale serve exited ${String(configured.code)}`);
696
+ }
697
+ const [after, observedDnsName] = await Promise.all([
698
+ readTailscaleServeStatus(runner),
699
+ readTailscaleDnsName(runner, deps.sleep),
700
+ ]);
701
+ if (after.kind === "error") {
702
+ const rollback = await rollbackJustCreatedTailscaleRoute(runner, httpsPort, proxyTarget, expectedDnsName ?? observedDnsName);
703
+ return await failMigration(`handler command succeeded but verification failed: ${after.detail}; ${rollback}`);
704
+ }
705
+ if (expectedDnsName !== undefined && observedDnsName !== expectedDnsName) {
706
+ const rollback = await rollbackJustCreatedTailscaleRoute(runner, httpsPort, proxyTarget, observedDnsName);
707
+ return await failMigration(`handler command succeeded but the node's exact Tailscale DNS name changed or became unavailable; ${rollback}`);
708
+ }
709
+ const dnsName = expectedDnsName ?? observedDnsName;
710
+ const webKey = findTailscaleWebKey(after.status, httpsPort, proxyTarget, dnsName);
711
+ if (webKey === undefined) {
712
+ const rollback = await rollbackJustCreatedTailscaleRoute(runner, httpsPort, proxyTarget, dnsName);
713
+ return await failMigration(`handler command succeeded but the exact proxy target could not be verified; ${rollback}`);
714
+ }
715
+ if (!isExactExpectedTailscaleRoute(after.status, webKey, httpsPort, proxyTarget)) {
716
+ const rollback = await rollbackJustCreatedTailscaleRoute(runner, httpsPort, proxyTarget, dnsName);
717
+ return await failMigration(`handler command succeeded but its TCP or Web handler set was not the exact root Proxy-only shape; ${rollback}`);
718
+ }
719
+ if (expectedDnsName !== undefined && tailscaleWebHostname(webKey) !== expectedDnsName) {
720
+ const rollback = await rollbackJustCreatedTailscaleRoute(runner, httpsPort, proxyTarget, dnsName);
721
+ return await failMigration(`handler command succeeded under an unexpected Tailscale hostname; ${rollback}`);
722
+ }
723
+ const hostname = webKey.slice(0, webKey.lastIndexOf(":"));
724
+ const ownership = {
725
+ schema: TAILSCALE_OWNERSHIP_SCHEMA,
726
+ webKey,
727
+ httpsPort,
728
+ proxyTarget,
729
+ configSha256: tailscalePortConfigSha256(after.status, webKey, httpsPort),
730
+ url: `https://${hostname}${httpsPort === 443 ? "" : `:${String(httpsPort)}`}/`,
731
+ configuredAt: new Date((deps.now ?? Date.now)()).toISOString(),
732
+ };
210
733
  try {
211
- return dedupeNetworkAddresses(injected?.() ?? discoverNetworkAddresses());
734
+ await (deps.writePrivateFile ?? writeOwnerPrivateLaunchdFile)(paths.tailscalePath, `${JSON.stringify(ownership, undefined, 2)}\n`);
212
735
  }
213
- catch {
214
- return [];
736
+ catch (error) {
737
+ const rollback = await rollbackExactTailscaleRoute(runner, after.status, ownership);
738
+ return await failMigration(`could not durably record Tailscale handler ownership: ${errorMessage(error)}; ${rollback}`);
215
739
  }
740
+ return { kind: "active", ownership, reused: false };
216
741
  }
217
- function discoverNetworkAddresses() {
218
- const addresses = [];
219
- for (const interfaces of Object.values(networkInterfaces())) {
220
- for (const info of interfaces ?? []) {
221
- if (info.internal || info.family !== "IPv4") {
222
- continue;
223
- }
224
- const kind = classifyIpv4Address(info.address);
225
- if (kind !== undefined) {
226
- addresses.push({ address: info.address, kind });
227
- }
742
+ async function restorePriorTailscaleRoute(paths, ownership, ownershipContents, runner, deps) {
743
+ const before = await readTailscaleServeStatus(runner);
744
+ if (before.kind === "error")
745
+ return `the prior HTTPS route could not be restored because status failed: ${before.detail}`;
746
+ const tcp = isRecord(before.status.TCP) ? before.status.TCP : {};
747
+ if (Object.hasOwn(tcp, String(ownership.httpsPort))) {
748
+ return "the prior HTTPS route could not be restored because its port is no longer free; its old ownership record was not republished";
749
+ }
750
+ const configured = await runner([
751
+ "serve",
752
+ "--bg",
753
+ `--https=${String(ownership.httpsPort)}`,
754
+ ownership.proxyTarget,
755
+ ]);
756
+ if (configured.code !== 0) {
757
+ return `the prior HTTPS route could not be restored: ${commandDetail(configured) || `exit ${String(configured.code)}`}`;
758
+ }
759
+ const after = await readTailscaleServeStatus(runner);
760
+ if (after.kind === "error" || !routeMatches(after.status, ownership)) {
761
+ if (after.kind === "ok") {
762
+ await rollbackJustCreatedTailscaleRoute(runner, ownership.httpsPort, ownership.proxyTarget, undefined);
228
763
  }
764
+ return "the prior HTTPS route command succeeded but its exact previous handler set was not restored";
765
+ }
766
+ try {
767
+ await (deps.writePrivateFile ?? writeOwnerPrivateLaunchdFile)(paths.tailscalePath, ownershipContents);
768
+ }
769
+ catch (error) {
770
+ const rollback = await rollbackExactTailscaleRoute(runner, after.status, ownership);
771
+ return `the prior HTTPS route was recreated but ownership could not be republished (${errorMessage(error)}); ${rollback}`;
229
772
  }
230
- return addresses;
773
+ return "the prior exact HTTPS route and ownership record were restored";
231
774
  }
232
- function classifyIpv4Address(address) {
233
- const octets = address.split(".").map((part) => Number.parseInt(part, 10));
234
- if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
775
+ export function tailscaleProxyTarget(bindHost, appPort) {
776
+ const normalized = bindHost.startsWith("[") && bindHost.endsWith("]")
777
+ ? bindHost.slice(1, -1).toLowerCase()
778
+ : bindHost.toLowerCase();
779
+ if (normalized === "0.0.0.0") {
780
+ return `http://127.0.0.1:${String(appPort)}`;
781
+ }
782
+ if (normalized === "::")
783
+ return `http://[::1]:${String(appPort)}`;
784
+ // Node may resolve localhost to either loopback family. Without the actual
785
+ // bound address, publishing a numeric Serve target could point at the other
786
+ // family. --loopback is the deterministic 127.0.0.1 spelling.
787
+ if (normalized === "localhost")
788
+ return undefined;
789
+ if (!isLoopbackHost(normalized))
235
790
  return undefined;
791
+ return `http://${urlHost(normalized)}:${String(appPort)}`;
792
+ }
793
+ async function rollbackJustCreatedTailscaleRoute(runner, httpsPort, proxyTarget, dnsName) {
794
+ const current = await readTailscaleServeStatus(runner);
795
+ if (current.kind === "error") {
796
+ return `rollback could not verify an exact handler (${current.detail}); no unrelated handler was changed`;
797
+ }
798
+ const webKey = findTailscaleWebKey(current.status, httpsPort, proxyTarget, dnsName);
799
+ if (webKey === undefined)
800
+ return "no exact newly-created handler remained to remove";
801
+ if (!isExactExpectedTailscaleRoute(current.status, webKey, httpsPort, proxyTarget)) {
802
+ return "rollback refused because the newly-created port no longer had the exact root Proxy-only shape";
803
+ }
804
+ const ownership = {
805
+ schema: TAILSCALE_OWNERSHIP_SCHEMA,
806
+ webKey,
807
+ httpsPort,
808
+ proxyTarget,
809
+ configSha256: tailscalePortConfigSha256(current.status, webKey, httpsPort),
810
+ url: "",
811
+ configuredAt: new Date(0).toISOString(),
812
+ };
813
+ return await rollbackExactTailscaleRoute(runner, current.status, ownership);
814
+ }
815
+ async function rollbackExactTailscaleRoute(runner, status, ownership) {
816
+ if (!routeMatches(status, ownership))
817
+ return "rollback refused because the handler no longer matched exactly";
818
+ const removed = await runner(["serve", `--https=${String(ownership.httpsPort)}`, "off"]);
819
+ return removed.code === 0
820
+ ? "the exact newly-created handler was rolled back"
821
+ : `rollback of the exact newly-created handler failed: ${commandDetail(removed) || `exit ${String(removed.code)}`}`;
822
+ }
823
+ export async function removeOwnedTailscaleServe(paths, deps) {
824
+ const ownershipRead = await readTailscaleOwnership(paths.tailscalePath);
825
+ if (ownershipRead.kind === "absent")
826
+ return { kind: "absent" };
827
+ if (ownershipRead.kind === "invalid")
828
+ return { kind: "unavailable", detail: ownershipRead.detail };
829
+ const ownership = ownershipRead.ownership;
830
+ const runner = deps.tailscale ?? makeTailscaleRunner(process.env);
831
+ const inspection = await inspectOwnedTailscaleRoute(ownership, deps, runner);
832
+ if (inspection.kind === "absent") {
833
+ return await clearProvablyAbsentTailscaleOwnership(paths, ownership, runner);
834
+ }
835
+ if (inspection.kind !== "exact") {
836
+ return { kind: "unavailable", detail: "the recorded handler no longer matches its exact host, port, and proxy target; refusing to remove it" };
236
837
  }
237
- const [first = -1, second = -1] = octets;
238
- if (first === 100 && second >= 64 && second <= 127) {
239
- return "tailscale";
838
+ const removed = await runner(["serve", `--https=${String(ownership.httpsPort)}`, "off"]);
839
+ if (removed.code !== 0) {
840
+ return { kind: "unavailable", detail: commandDetail(removed) || `tailscale serve off exited ${String(removed.code)}` };
240
841
  }
241
- if (first === 10
242
- || (first === 172 && second >= 16 && second <= 31)
243
- || (first === 192 && second === 168)) {
244
- return "lan";
842
+ const after = await readTailscaleServeStatus(runner);
843
+ if (after.kind === "error" || routeMatches(after.status, ownership)) {
844
+ return { kind: "unavailable", detail: after.kind === "error" ? after.detail : "the handler is still present after tailscale serve off" };
845
+ }
846
+ await rm(paths.tailscalePath, { force: true });
847
+ return { kind: "removed" };
848
+ }
849
+ async function inspectOwnedTailscaleRoute(ownership, deps, suppliedRunner) {
850
+ const status = await readTailscaleServeStatus(suppliedRunner ?? deps.tailscale ?? makeTailscaleRunner(process.env));
851
+ if (status.kind === "error")
852
+ return { kind: "unavailable", detail: status.detail };
853
+ return classifyOwnedTailscaleRoute(status.status, ownership);
854
+ }
855
+ function classifyOwnedTailscaleRoute(status, ownership) {
856
+ if (routeMatches(status, ownership))
857
+ return { kind: "exact" };
858
+ const tcp = isRecord(status.TCP) ? status.TCP : {};
859
+ const web = isRecord(status.Web) ? status.Web : {};
860
+ if (!Object.hasOwn(tcp, String(ownership.httpsPort)) && !Object.hasOwn(web, ownership.webKey)) {
861
+ return { kind: "absent" };
862
+ }
863
+ return { kind: "changed" };
864
+ }
865
+ async function clearProvablyAbsentTailscaleOwnership(paths, ownership, runner) {
866
+ const confirmation = await readTailscaleServeStatus(runner);
867
+ if (confirmation.kind === "error")
868
+ return { kind: "unavailable", detail: confirmation.detail };
869
+ if (classifyOwnedTailscaleRoute(confirmation.status, ownership).kind !== "absent") {
870
+ return { kind: "unavailable", detail: "the recorded Tailscale handler changed while confirming its absence; ownership was preserved" };
871
+ }
872
+ await rm(paths.tailscalePath, { force: true });
873
+ return { kind: "absent" };
874
+ }
875
+ async function readTailscaleServeStatus(runner) {
876
+ const result = await runner(["serve", "status", "--json"]);
877
+ if (result.code !== 0) {
878
+ return { kind: "error", detail: commandDetail(result) || `tailscale serve status exited ${String(result.code)}` };
879
+ }
880
+ try {
881
+ const parsed = JSON.parse(result.stdout);
882
+ if (!isRecord(parsed))
883
+ throw new Error("expected an object");
884
+ return { kind: "ok", status: parsed };
885
+ }
886
+ catch (error) {
887
+ return { kind: "error", detail: `tailscale serve status returned invalid JSON: ${errorMessage(error)}` };
888
+ }
889
+ }
890
+ export function chooseTailscaleHttpsPort(status) {
891
+ const tcp = isRecord(status.TCP) ? status.TCP : {};
892
+ for (const port of [443, ...integerRange(TAILSCALE_FALLBACK_PORT_START, TAILSCALE_FALLBACK_PORT_END)]) {
893
+ if (!Object.hasOwn(tcp, String(port)))
894
+ return port;
245
895
  }
246
896
  return undefined;
247
897
  }
248
- function dedupeNetworkAddresses(addresses) {
249
- const seen = new Set();
250
- return [...addresses]
251
- .filter((entry) => {
252
- if (seen.has(entry.address)) {
253
- return false;
254
- }
255
- seen.add(entry.address);
256
- return true;
257
- })
258
- .sort((left, right) => {
259
- if (left.kind !== right.kind) {
260
- return left.kind === "lan" ? -1 : 1;
898
+ function findTailscaleWebKey(status, port, proxyTarget, dnsName) {
899
+ const web = isRecord(status.Web) ? status.Web : {};
900
+ const preferred = dnsName === undefined ? undefined : `${dnsName}:${String(port)}`;
901
+ if (preferred !== undefined && webHandlerProxy(web[preferred]) === proxyTarget)
902
+ return preferred;
903
+ return Object.keys(web).find((key) => key.endsWith(`:${String(port)}`) && webHandlerProxy(web[key]) === proxyTarget);
904
+ }
905
+ function tailscaleWebHostname(webKey) {
906
+ return webKey.slice(0, webKey.lastIndexOf(":"));
907
+ }
908
+ function routeMatches(status, ownership) {
909
+ const tcp = isRecord(status.TCP) ? status.TCP : {};
910
+ const port = tcp[String(ownership.httpsPort)];
911
+ const web = isRecord(status.Web) ? status.Web : {};
912
+ return isRecord(port)
913
+ && port.HTTPS === true
914
+ && webHandlerProxy(web[ownership.webKey]) === ownership.proxyTarget
915
+ && tailscalePortConfigSha256(status, ownership.webKey, ownership.httpsPort) === ownership.configSha256;
916
+ }
917
+ function isExactExpectedTailscaleRoute(status, webKey, httpsPort, proxyTarget) {
918
+ const tcp = isRecord(status.TCP) ? status.TCP : {};
919
+ const port = tcp[String(httpsPort)];
920
+ const web = isRecord(status.Web) ? status.Web : {};
921
+ const entry = web[webKey];
922
+ if (!isRecord(port) || !hasExactKeys(port, ["HTTPS"]) || port.HTTPS !== true)
923
+ return false;
924
+ if (!isRecord(entry) || !hasExactKeys(entry, ["Handlers"]) || !isRecord(entry.Handlers))
925
+ return false;
926
+ if (!hasExactKeys(entry.Handlers, ["/"]) || !isRecord(entry.Handlers["/"]))
927
+ return false;
928
+ return hasExactKeys(entry.Handlers["/"], ["Proxy"])
929
+ && entry.Handlers["/"].Proxy === proxyTarget;
930
+ }
931
+ function hasExactKeys(value, expected) {
932
+ const actual = Object.keys(value).sort();
933
+ const sortedExpected = [...expected].sort();
934
+ return actual.length === sortedExpected.length && actual.every((key, index) => key === sortedExpected[index]);
935
+ }
936
+ function tailscalePortConfigSha256(status, webKey, httpsPort) {
937
+ const tcp = isRecord(status.TCP) ? status.TCP : {};
938
+ const web = isRecord(status.Web) ? status.Web : {};
939
+ const canonical = canonicalJson({ tcp: tcp[String(httpsPort)], web: web[webKey] });
940
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
941
+ }
942
+ function canonicalJson(value) {
943
+ if (Array.isArray(value))
944
+ return `[${value.map(canonicalJson).join(",")}]`;
945
+ if (isRecord(value)) {
946
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
947
+ }
948
+ return JSON.stringify(value) ?? "null";
949
+ }
950
+ function webHandlerProxy(value) {
951
+ if (!isRecord(value) || !isRecord(value.Handlers) || !isRecord(value.Handlers["/"]))
952
+ return undefined;
953
+ return typeof value.Handlers["/"].Proxy === "string" ? value.Handlers["/"].Proxy : undefined;
954
+ }
955
+ async function readTailscaleDnsName(runner, suppliedSleep) {
956
+ const sleep = suppliedSleep ?? ((ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)));
957
+ for (let attempt = 0; attempt < TAILSCALE_STATUS_ATTEMPTS; attempt += 1) {
958
+ const result = await runner(["status", "--json"]);
959
+ if (result.code === 0) {
960
+ try {
961
+ const parsed = JSON.parse(result.stdout);
962
+ if (isRecord(parsed) && isRecord(parsed.Self) && typeof parsed.Self.DNSName === "string") {
963
+ return parsed.Self.DNSName.replace(/\.$/u, "").toLowerCase();
964
+ }
965
+ }
966
+ catch {
967
+ // A transiently truncated LocalAPI response is retried below.
968
+ }
261
969
  }
262
- return left.address.localeCompare(right.address);
970
+ if (attempt + 1 < TAILSCALE_STATUS_ATTEMPTS)
971
+ await sleep(TAILSCALE_STATUS_RETRY_MS);
972
+ }
973
+ return undefined;
974
+ }
975
+ function makeTailscaleRunner(env) {
976
+ return (args) => spawnCapture("tailscale", args, env);
977
+ }
978
+ function spawnCapture(command, args, env) {
979
+ return new Promise((resolvePromise) => {
980
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"], env: env });
981
+ let stdout = "";
982
+ let stderr = "";
983
+ child.stdout.setEncoding("utf8");
984
+ child.stderr.setEncoding("utf8");
985
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
986
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
987
+ child.on("error", (error) => resolvePromise({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
988
+ child.on("close", (code) => resolvePromise({ code: code ?? 1, stdout, stderr }));
263
989
  });
264
990
  }
265
- /** Resolve + dedupe registry dirs, preserving precedence order. */
266
- function dedupePaths(dirs) {
267
- const seen = new Set();
268
- const out = [];
269
- for (const dir of dirs) {
270
- const resolved = resolve(dir);
271
- if (!seen.has(resolved)) {
272
- seen.add(resolved);
273
- out.push(resolved);
274
- }
991
+ async function readServiceRecord(path) {
992
+ let contents;
993
+ try {
994
+ contents = await readFile(path, "utf8");
995
+ }
996
+ catch (error) {
997
+ if (isRecord(error) && error.code === "ENOENT")
998
+ return { kind: "absent" };
999
+ return { kind: "invalid", detail: "the web service record is unreadable; repair or remove ~/.mono-agent/web/service.json" };
1000
+ }
1001
+ let value;
1002
+ try {
1003
+ value = JSON.parse(contents);
1004
+ }
1005
+ catch {
1006
+ return { kind: "invalid", detail: "the web service record is malformed; repair or remove ~/.mono-agent/web/service.json" };
1007
+ }
1008
+ if (!isRecord(value) || value.schema !== WEB_SERVICE_SCHEMA || typeof value.host !== "string"
1009
+ || !Number.isSafeInteger(value.port) || value.port < 1 || value.port > 65_535
1010
+ || typeof value.updatedAt !== "string") {
1011
+ return { kind: "invalid", detail: "the web service record has an invalid schema; repair or remove ~/.mono-agent/web/service.json" };
1012
+ }
1013
+ return { kind: "valid", record: value, contents };
1014
+ }
1015
+ async function readTailscaleOwnership(path) {
1016
+ let contents;
1017
+ try {
1018
+ contents = await readFile(path, "utf8");
1019
+ }
1020
+ catch (error) {
1021
+ if (isRecord(error) && error.code === "ENOENT")
1022
+ return { kind: "absent" };
1023
+ return { kind: "invalid", detail: "the Tailscale ownership record is unreadable; refusing to change any Serve handler" };
1024
+ }
1025
+ let value;
1026
+ try {
1027
+ value = JSON.parse(contents);
1028
+ }
1029
+ catch {
1030
+ return { kind: "invalid", detail: "the Tailscale ownership record is malformed; refusing to change any Serve handler" };
1031
+ }
1032
+ if (!isRecord(value) || value.schema !== TAILSCALE_OWNERSHIP_SCHEMA
1033
+ || typeof value.webKey !== "string" || typeof value.proxyTarget !== "string" || typeof value.url !== "string"
1034
+ || typeof value.configSha256 !== "string" || !/^[0-9a-f]{64}$/u.test(value.configSha256)
1035
+ || typeof value.configuredAt !== "string" || !Number.isSafeInteger(value.httpsPort)
1036
+ || value.httpsPort < 1 || value.httpsPort > 65_535) {
1037
+ return { kind: "invalid", detail: "the Tailscale ownership record has an invalid schema; refusing to change any Serve handler" };
1038
+ }
1039
+ return { kind: "valid", ownership: value, contents };
1040
+ }
1041
+ async function readOptionalText(path) {
1042
+ try {
1043
+ return await readFile(path, "utf8");
1044
+ }
1045
+ catch (error) {
1046
+ if (isRecord(error) && error.code === "ENOENT")
1047
+ return undefined;
1048
+ throw error;
275
1049
  }
276
- return out;
277
1050
  }
278
- /** Resolve once SIGINT/SIGTERM arrives — the foreground command's stop signal. */
279
- function waitForSignal() {
1051
+ function requireDarwin(deps, action) {
1052
+ if ((deps.platform ?? process.platform) === "darwin")
1053
+ return undefined;
1054
+ (deps.stderr ?? process.stderr).write(ui.errorLine(`mono-agent web ${action} background management requires macOS launchd; use mono-agent web run on this platform.`));
1055
+ return 1;
1056
+ }
1057
+ function requiredUid() {
1058
+ if (typeof process.getuid !== "function")
1059
+ throw new Error("launchd requires a numeric user id");
1060
+ return process.getuid();
1061
+ }
1062
+ function processIsAlive(pid) {
1063
+ try {
1064
+ process.kill(pid, 0);
1065
+ return true;
1066
+ }
1067
+ catch (error) {
1068
+ return isRecord(error) && error.code === "EPERM";
1069
+ }
1070
+ }
1071
+ function waitForShutdownSignal() {
280
1072
  return new Promise((resolvePromise) => {
281
- const onSignal = () => {
282
- process.off("SIGINT", onSignal);
283
- process.off("SIGTERM", onSignal);
1073
+ const done = () => {
1074
+ process.off("SIGINT", done);
1075
+ process.off("SIGTERM", done);
284
1076
  resolvePromise();
285
1077
  };
286
- process.once("SIGINT", onSignal);
287
- process.once("SIGTERM", onSignal);
1078
+ process.once("SIGINT", done);
1079
+ process.once("SIGTERM", done);
288
1080
  });
289
1081
  }
290
- /** Best-effort OS browser launch. Never throws into the caller's happy path. */
291
- function openInBrowser(url) {
292
- void (async () => {
293
- try {
294
- const { spawn } = await import("node:child_process");
295
- const platform = process.platform;
296
- const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
297
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
298
- const child = spawn(command, args, { stdio: "ignore", detached: true });
299
- child.on("error", () => {
300
- /* no browser available the URL was printed */
301
- });
302
- child.unref();
1082
+ function defaultSpawnTail(args) {
1083
+ return new Promise((resolvePromise) => {
1084
+ const child = spawn("tail", [...args], { stdio: "inherit" });
1085
+ child.on("error", () => resolvePromise(127));
1086
+ child.on("close", (code) => resolvePromise(code ?? 0));
1087
+ });
1088
+ }
1089
+ function printWebUrls(stdout, serverUrl, port, host, discover) {
1090
+ if (host === "0.0.0.0" || host === "::" || host === "[::]") {
1091
+ const ipv6Bind = host === "::" || host === "[::]";
1092
+ stdout.write(`Local → http://${ipv6Bind ? "[::1]" : "127.0.0.1"}:${String(port)}/\n`);
1093
+ for (const address of advertisableNetworkAddresses((discover ?? discoverNetworkAddresses)())
1094
+ .filter((candidate) => candidate.includes(":") === ipv6Bind)) {
1095
+ const label = isTailscaleAddress(address) ? "Tailscale" : "LAN";
1096
+ stdout.write(`${label.padEnd(10)} → http://${urlHost(address)}:${String(port)}/\n`);
303
1097
  }
304
- catch {
305
- /* ignore */
1098
+ return;
1099
+ }
1100
+ stdout.write(`Web → ${serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`}\n`);
1101
+ }
1102
+ function isTailscaleAddress(address) {
1103
+ const normalized = normalizeNetworkAddress(address);
1104
+ if (normalized.split("%", 1)[0]?.startsWith("fd7a:115c:a1e0:"))
1105
+ return true;
1106
+ const match = /^(\d{1,3})\.(\d{1,3})\./u.exec(normalized);
1107
+ if (match?.[1] !== "100" || match[2] === undefined)
1108
+ return false;
1109
+ const second = Number(match[2]);
1110
+ return second >= 64 && second <= 127;
1111
+ }
1112
+ function discoverNetworkAddresses() {
1113
+ const addresses = [];
1114
+ for (const entries of Object.values(networkInterfaces())) {
1115
+ for (const entry of entries ?? []) {
1116
+ if (!entry.internal && (entry.family === "IPv4" || entry.family === "IPv6"))
1117
+ addresses.push(entry.address);
306
1118
  }
307
- })();
1119
+ }
1120
+ return advertisableNetworkAddresses(addresses);
1121
+ }
1122
+ function advertisableNetworkAddresses(addresses) {
1123
+ return [...new Set(addresses.map(normalizeNetworkAddress).filter(isAdvertisableNetworkAddress))]
1124
+ .sort((left, right) => left.localeCompare(right, "en", { numeric: true, sensitivity: "base" }));
1125
+ }
1126
+ function normalizeNetworkAddress(address) {
1127
+ const unbracketed = address.startsWith("[") && address.endsWith("]") ? address.slice(1, -1) : address;
1128
+ return unbracketed.toLowerCase();
1129
+ }
1130
+ function mergeWebAllowedHosts(configured, tailscaleDnsName) {
1131
+ const hosts = [
1132
+ ...(configured?.split(",") ?? []),
1133
+ ...(tailscaleDnsName === undefined ? [] : [tailscaleDnsName]),
1134
+ ].map((host) => host.trim()).filter((host) => host.length > 0);
1135
+ const merged = [...new Set(hosts)].join(",");
1136
+ return merged.length === 0 ? undefined : merged;
1137
+ }
1138
+ function isAdvertisableNetworkAddress(address) {
1139
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(address);
1140
+ if (ipv4 !== null) {
1141
+ const octets = ipv4.slice(1).map(Number);
1142
+ if (octets.some((value) => value > 255))
1143
+ return false;
1144
+ const [first = -1, second = -1] = octets;
1145
+ return first === 10
1146
+ || (first === 172 && second >= 16 && second <= 31)
1147
+ || (first === 192 && second === 168)
1148
+ || (first === 100 && second >= 64 && second <= 127)
1149
+ || (first === 169 && second === 254);
1150
+ }
1151
+ const [literal = ""] = address.split("%", 2);
1152
+ const firstHextet = Number.parseInt(literal.split(":", 1)[0] ?? "", 16);
1153
+ if (!Number.isFinite(firstHextet))
1154
+ return false;
1155
+ // Browser URL implementations do not consistently support IPv6 zone IDs,
1156
+ // so even scoped link-local literals are not advertised as concrete links.
1157
+ return (firstHextet & 0xfe00) === 0xfc00;
1158
+ }
1159
+ function urlHost(host) {
1160
+ const normalized = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
1161
+ return normalized.includes(":") ? `[${normalized.replace("%", "%25")}]` : normalized;
1162
+ }
1163
+ function commandDetail(result) {
1164
+ return (result.stderr || result.stdout).trim();
1165
+ }
1166
+ function writeCommandDetail(stream, result) {
1167
+ const detail = commandDetail(result);
1168
+ if (detail.length > 0)
1169
+ stream.write(ui.style.dim(detail) + "\n");
1170
+ }
1171
+ function errorMessage(error) {
1172
+ return error instanceof Error ? error.message : String(error);
1173
+ }
1174
+ function isRecord(value) {
1175
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1176
+ }
1177
+ function integerRange(start, end) {
1178
+ return Array.from({ length: end - start + 1 }, (_, index) => start + index);
308
1179
  }
309
1180
  //# sourceMappingURL=web-command.js.map