@zswarm/core 0.1.7 → 0.1.9

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.
@@ -1,5 +1,6 @@
1
1
  import { hostname } from "node:os";
2
2
  import { sessionFromEnv } from "../zellij/session.js";
3
+ import { describeServeTarget } from "./serve-tunnel.js";
3
4
  import { isTrue, optionalString } from "./util.js";
4
5
  export function invocationContext(args, env, injected) {
5
6
  const serve = optionalString(env.ZSWARM_SERVE);
@@ -9,7 +10,7 @@ export function invocationContext(args, env, injected) {
9
10
  const selected = sessionFromEnv(serve && !injected ? {} : env, optionalString(args.session));
10
11
  return {
11
12
  transport: injected?.kind ?? (serve ? "serve" : ssh ? "ssh" : "local"),
12
- host: injected ? injected.host ?? hostname() : serve ?? ssh ?? hostname(),
13
+ host: injected ? injected.host ?? hostname() : serve ? describeServeTarget(serve) : ssh ?? hostname(),
13
14
  session: selected?.session ?? null,
14
15
  origin: {
15
16
  transport: injected ? "injected" : isTrue(args.local) ? "--local"
@@ -0,0 +1,86 @@
1
+ import { type NetworkInterfaceInfo } from "node:os";
2
+ /** Bounded read of `tailscale status --json` for serve bind authorization. */
3
+ export declare const SERVE_BIND_STATUS_MAX_BYTES: number;
4
+ /** Default verification/startup budget for foreground `serve --listen` (not the server lifetime). */
5
+ export declare const SERVE_BIND_VERIFY_TIMEOUT_MS = 10000;
6
+ /** Env override for the Tailscale CLI binary (persisted on Windows install when set). */
7
+ export declare const SERVE_TAILSCALE_BIN_ENV = "ZSWARM_TAILSCALE_BIN";
8
+ export type TailscaleStatusRunner = (input: {
9
+ timeoutMs: number;
10
+ signal?: AbortSignal;
11
+ env: NodeJS.ProcessEnv;
12
+ }) => Promise<{
13
+ code: number;
14
+ stdout: string;
15
+ stderr: string;
16
+ }>;
17
+ export type NetworkInterfacesFn = () => NodeJS.Dict<NetworkInterfaceInfo[]>;
18
+ export type ServeBindDeps = {
19
+ env?: NodeJS.ProcessEnv;
20
+ signal?: AbortSignal;
21
+ /**
22
+ * Relative verification budget when `deadline` is omitted. Callers that
23
+ * already fixed an absolute startup deadline should pass `deadline` instead
24
+ * of refreshing a new allowance from `now + timeoutMs`.
25
+ */
26
+ timeoutMs?: number;
27
+ /** Absolute verify/bind deadline (same clock as `now`). Preferred over `timeoutMs`. */
28
+ deadline?: number;
29
+ now?: () => number;
30
+ tailscaleStatus?: TailscaleStatusRunner;
31
+ networkInterfaces?: NetworkInterfacesFn;
32
+ };
33
+ export type ServeBindAuthorization = {
34
+ /** Exact host string passed to `server.listen` (literal, never a hostname). */
35
+ bindHost: string;
36
+ /** Canonical form used for membership checks. */
37
+ canonical: string;
38
+ family: 4 | 6;
39
+ mode: "loopback" | "tailscale";
40
+ };
41
+ export declare const SERVE_BIND_REMEDY: {
42
+ readonly literal: "Pass an explicit literal Tailscale IP from `tailscale ip -4` or `tailscale ip -6` (for example --listen 100.x.y.z:9419 or --listen '[fd7a:…]:9419'). Wildcards, hostnames, and non-Tailscale LAN/public addresses are refused.";
43
+ readonly loopbackDefault: "Default loopback (`127.0.0.1` / `::1`) needs no Tailscale CLI. Off-machine access stays an SSH tunnel to that loopback, or bind an owned Tailscale IP explicitly.";
44
+ readonly token: "Set ZSWARM_SERVE_TOKEN before serve. Tailnet membership does not replace the application token.";
45
+ readonly cliMissing: "Install the Tailscale CLI on this host (or set ZSWARM_TAILSCALE_BIN to its path), then retry. Loopback serve does not need Tailscale.";
46
+ readonly daemon: "Start Tailscale on this host (`tailscale up`) so `tailscale status --json` reports BackendState Running, then retry with the address from `tailscale ip`.";
47
+ readonly statusShape: "Tailscale status JSON was missing, truncated, oversized, or not the expected shape. Retry when `tailscale status --json --peers=false` succeeds locally.";
48
+ readonly notSelf: "That address is not in this node's Tailscale self addresses. Use `tailscale ip -4` / `-6` on this host; peer, subnet, and MagicDNS names are not bind identity.";
49
+ readonly conflict: "Tailscale status Self.TailscaleIPs and top-level TailscaleIPs disagree. Fix the local Tailscale profile, then retry.";
50
+ readonly osMissing: "Tailscale reported the address, but it is not assigned on a local OS interface (userspace/proxy-only Tailscale cannot use this mode). Use loopback + managed SSH, or enable a kernel/TUN Tailscale interface that owns the address.";
51
+ readonly cancelled: "Serve bind verification was cancelled before listen.";
52
+ readonly timeout: "Serve bind verification timed out before listen. Retry with a larger --timeout-ms on install, or check that the Tailscale CLI responds quickly.";
53
+ readonly bind: "Bind failed on the verified address with no fallback to 0.0.0.0, ::, loopback, or SSH. Free the port or restore the Tailscale address, then restart serve so verification runs again.";
54
+ };
55
+ /** Expand IPv6 to a fixed lowercase 8-hextet form for exact membership checks. */
56
+ export declare function expandIPv6(address: string): string | null;
57
+ /**
58
+ * Canonicalize a strict literal IP for comparison. Rejects hostnames, wildcards,
59
+ * unspecified addresses, and IPv4-mapped IPv6 after full expansion (no silent
60
+ * bypass onto the embedded v4 address).
61
+ */
62
+ export declare function canonicalizeListenIp(raw: string): {
63
+ family: 4 | 6;
64
+ canonical: string;
65
+ } | null;
66
+ export declare function sameCanonicalIp(a: string, b: string): boolean;
67
+ export declare function resolveTailscaleBin(env: NodeJS.ProcessEnv): string;
68
+ export declare function defaultServeTailscaleStatus(input: {
69
+ timeoutMs: number;
70
+ signal?: AbortSignal;
71
+ env: NodeJS.ProcessEnv;
72
+ }): Promise<{
73
+ code: number;
74
+ stdout: string;
75
+ stderr: string;
76
+ }>;
77
+ /**
78
+ * Authorize a serve listen host. Loopback needs no Tailscale evidence. Any other
79
+ * host requires a strict literal IP present in fresh local Tailscale self
80
+ * addresses and assigned on a local OS interface.
81
+ *
82
+ * Uses one absolute startup deadline across CLI and OS stages (shared with
83
+ * `startServe` when `deadline` is passed). Does not install a lifetime timer
84
+ * on a healthy server.
85
+ */
86
+ export declare function authorizeServeListen(host: string, deps?: ServeBindDeps): Promise<ServeBindAuthorization>;
@@ -0,0 +1,354 @@
1
+ import { execFile } from "node:child_process";
2
+ import { isIP } from "node:net";
3
+ import { networkInterfaces as osNetworkInterfaces } from "node:os";
4
+ import { ZellijError } from "../errors.js";
5
+ import { NOT_FOUND_EXIT } from "../exec.js";
6
+ import { throwIfAborted } from "./util.js";
7
+ function isLoopbackListenHost(host) {
8
+ const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
9
+ return (h === "127.0.0.1" ||
10
+ h === "localhost" ||
11
+ h === "::1" ||
12
+ h === "0:0:0:0:0:0:0:1");
13
+ }
14
+ /** Bounded read of `tailscale status --json` for serve bind authorization. */
15
+ export const SERVE_BIND_STATUS_MAX_BYTES = 256 * 1024;
16
+ /** Default verification/startup budget for foreground `serve --listen` (not the server lifetime). */
17
+ export const SERVE_BIND_VERIFY_TIMEOUT_MS = 10_000;
18
+ /** Env override for the Tailscale CLI binary (persisted on Windows install when set). */
19
+ export const SERVE_TAILSCALE_BIN_ENV = "ZSWARM_TAILSCALE_BIN";
20
+ export const SERVE_BIND_REMEDY = {
21
+ literal: "Pass an explicit literal Tailscale IP from `tailscale ip -4` or `tailscale ip -6` (for example --listen 100.x.y.z:9419 or --listen '[fd7a:…]:9419'). Wildcards, hostnames, and non-Tailscale LAN/public addresses are refused.",
22
+ loopbackDefault: "Default loopback (`127.0.0.1` / `::1`) needs no Tailscale CLI. Off-machine access stays an SSH tunnel to that loopback, or bind an owned Tailscale IP explicitly.",
23
+ token: "Set ZSWARM_SERVE_TOKEN before serve. Tailnet membership does not replace the application token.",
24
+ cliMissing: "Install the Tailscale CLI on this host (or set ZSWARM_TAILSCALE_BIN to its path), then retry. Loopback serve does not need Tailscale.",
25
+ daemon: "Start Tailscale on this host (`tailscale up`) so `tailscale status --json` reports BackendState Running, then retry with the address from `tailscale ip`.",
26
+ statusShape: "Tailscale status JSON was missing, truncated, oversized, or not the expected shape. Retry when `tailscale status --json --peers=false` succeeds locally.",
27
+ notSelf: "That address is not in this node's Tailscale self addresses. Use `tailscale ip -4` / `-6` on this host; peer, subnet, and MagicDNS names are not bind identity.",
28
+ conflict: "Tailscale status Self.TailscaleIPs and top-level TailscaleIPs disagree. Fix the local Tailscale profile, then retry.",
29
+ osMissing: "Tailscale reported the address, but it is not assigned on a local OS interface (userspace/proxy-only Tailscale cannot use this mode). Use loopback + managed SSH, or enable a kernel/TUN Tailscale interface that owns the address.",
30
+ cancelled: "Serve bind verification was cancelled before listen.",
31
+ timeout: "Serve bind verification timed out before listen. Retry with a larger --timeout-ms on install, or check that the Tailscale CLI responds quickly.",
32
+ bind: "Bind failed on the verified address with no fallback to 0.0.0.0, ::, loopback, or SSH. Free the port or restore the Tailscale address, then restart serve so verification runs again.",
33
+ };
34
+ function fail(message, details, remedy) {
35
+ throw new ZellijError("serve_auth", message, { ...details, remedy });
36
+ }
37
+ /** Expand IPv6 to a fixed lowercase 8-hextet form for exact membership checks. */
38
+ export function expandIPv6(address) {
39
+ const raw = address.trim().toLowerCase().replace(/^\[|\]$/g, "");
40
+ if (!raw || raw.includes(".") || isIP(raw) !== 6)
41
+ return null;
42
+ if (raw.includes(":::"))
43
+ return null;
44
+ const sides = raw.split("::");
45
+ if (sides.length > 2)
46
+ return null;
47
+ const head = sides[0] ? sides[0].split(":") : [];
48
+ const tail = sides.length === 2 && sides[1] ? sides[1].split(":") : [];
49
+ if (sides.length === 1 && head.length !== 8)
50
+ return null;
51
+ const missing = 8 - (head.length + tail.length);
52
+ if (sides.length === 2 && missing < 0)
53
+ return null;
54
+ if (sides.length === 1 && missing !== 0)
55
+ return null;
56
+ const mid = sides.length === 2 ? Array.from({ length: missing }, () => "0") : [];
57
+ const parts = [...head, ...mid, ...tail];
58
+ if (parts.length !== 8)
59
+ return null;
60
+ const hextets = [];
61
+ for (const part of parts) {
62
+ if (!/^[0-9a-f]{1,4}$/.test(part))
63
+ return null;
64
+ hextets.push(part.padStart(4, "0"));
65
+ }
66
+ return hextets.join(":");
67
+ }
68
+ const UNSPECIFIED_V6 = "0000:0000:0000:0000:0000:0000:0000:0000";
69
+ function isUnsafeCanonicalIPv6(canonical) {
70
+ if (canonical === UNSPECIFIED_V6)
71
+ return true;
72
+ // IPv4-mapped (::ffff:x) after expansion, any spelling of the input.
73
+ const parts = canonical.split(":");
74
+ return (parts.length === 8 &&
75
+ parts[0] === "0000" &&
76
+ parts[1] === "0000" &&
77
+ parts[2] === "0000" &&
78
+ parts[3] === "0000" &&
79
+ parts[4] === "0000" &&
80
+ parts[5] === "ffff");
81
+ }
82
+ /**
83
+ * Canonicalize a strict literal IP for comparison. Rejects hostnames, wildcards,
84
+ * unspecified addresses, and IPv4-mapped IPv6 after full expansion (no silent
85
+ * bypass onto the embedded v4 address).
86
+ */
87
+ export function canonicalizeListenIp(raw) {
88
+ const host = raw.trim().replace(/^\[|\]$/g, "");
89
+ if (!host)
90
+ return null;
91
+ if (host === "0.0.0.0" || host === "::" || host === "*" || host === "0")
92
+ return null;
93
+ const kind = isIP(host);
94
+ if (kind === 4) {
95
+ const parts = host.split(".");
96
+ if (parts.length !== 4)
97
+ return null;
98
+ const octets = [];
99
+ for (const part of parts) {
100
+ if (!/^\d{1,3}$/.test(part))
101
+ return null;
102
+ if (part.length > 1 && part.startsWith("0"))
103
+ return null;
104
+ const n = Number(part);
105
+ if (!Number.isInteger(n) || n < 0 || n > 255)
106
+ return null;
107
+ octets.push(n);
108
+ }
109
+ const canonical = octets.join(".");
110
+ if (canonical === "0.0.0.0")
111
+ return null;
112
+ return { family: 4, canonical };
113
+ }
114
+ if (kind === 6) {
115
+ const canonical = expandIPv6(host);
116
+ if (!canonical || isUnsafeCanonicalIPv6(canonical))
117
+ return null;
118
+ return { family: 6, canonical };
119
+ }
120
+ return null;
121
+ }
122
+ export function sameCanonicalIp(a, b) {
123
+ const left = canonicalizeListenIp(a);
124
+ const right = canonicalizeListenIp(b);
125
+ return Boolean(left && right && left.family === right.family && left.canonical === right.canonical);
126
+ }
127
+ function setKey(entry) {
128
+ return `${entry.family}/${entry.canonical}`;
129
+ }
130
+ function sameAddressSet(a, b) {
131
+ if (a.length !== b.length)
132
+ return false;
133
+ const keys = new Set(a.map(setKey));
134
+ return b.every((entry) => keys.has(setKey(entry)));
135
+ }
136
+ /**
137
+ * Parse a present TailscaleIP list field. Fail closed on non-arrays and any
138
+ * non-canonicalizable member; do not filter invalid entries away.
139
+ */
140
+ function parsePresentIpList(value, field) {
141
+ if (!Array.isArray(value)) {
142
+ fail(`zswarm serve Tailscale ${field} is not a valid address list`, { phase: "verify", cause: "invalid_ip_list", field }, SERVE_BIND_REMEDY.statusShape);
143
+ }
144
+ const out = [];
145
+ const seen = new Set();
146
+ for (const item of value) {
147
+ if (typeof item !== "string" || !item.trim()) {
148
+ fail(`zswarm serve Tailscale ${field} contains a malformed address entry`, { phase: "verify", cause: "malformed_ip_entry", field }, SERVE_BIND_REMEDY.statusShape);
149
+ }
150
+ const normalized = canonicalizeListenIp(item);
151
+ if (!normalized) {
152
+ fail(`zswarm serve Tailscale ${field} contains an unusable address`, { phase: "verify", cause: "invalid_ip_entry", field }, SERVE_BIND_REMEDY.statusShape);
153
+ }
154
+ const key = setKey(normalized);
155
+ if (seen.has(key))
156
+ continue;
157
+ seen.add(key);
158
+ out.push(normalized);
159
+ }
160
+ return out;
161
+ }
162
+ export function resolveTailscaleBin(env) {
163
+ const override = env[SERVE_TAILSCALE_BIN_ENV]?.trim();
164
+ return override || "tailscale";
165
+ }
166
+ export function defaultServeTailscaleStatus(input) {
167
+ const bin = resolveTailscaleBin(input.env);
168
+ return new Promise((resolve) => {
169
+ execFile(bin, ["status", "--json", "--peers=false"], {
170
+ timeout: Math.max(1, input.timeoutMs),
171
+ maxBuffer: SERVE_BIND_STATUS_MAX_BYTES,
172
+ windowsHide: true,
173
+ signal: input.signal,
174
+ env: input.env,
175
+ }, (error, stdout, stderr) => {
176
+ const failure = error;
177
+ if (failure && typeof failure.code === "string") {
178
+ const missing = failure.code === "ENOENT" || failure.code === "ENOTDIR";
179
+ resolve({
180
+ code: missing ? NOT_FOUND_EXIT : 1,
181
+ stdout: "",
182
+ stderr: `${failure.code}: ${failure.message}`,
183
+ });
184
+ return;
185
+ }
186
+ if (failure && (failure.killed === true || failure.name === "AbortError")) {
187
+ resolve({
188
+ code: -1,
189
+ stdout: String(stdout ?? ""),
190
+ stderr: failure.name === "AbortError" ? "tailscale cancelled" : "tailscale timed out",
191
+ });
192
+ return;
193
+ }
194
+ resolve({
195
+ code: failure && typeof failure.code === "number"
196
+ ? failure.code
197
+ : failure
198
+ ? 1
199
+ : 0,
200
+ stdout: String(stdout ?? "").slice(0, SERVE_BIND_STATUS_MAX_BYTES),
201
+ stderr: String(stderr ?? "").slice(0, 4_096),
202
+ });
203
+ });
204
+ });
205
+ }
206
+ function osOwnsCanonical(target, ifaces) {
207
+ for (const list of Object.values(ifaces)) {
208
+ if (!list)
209
+ continue;
210
+ for (const info of list) {
211
+ const normalized = canonicalizeListenIp(info.address);
212
+ if (normalized &&
213
+ normalized.family === target.family &&
214
+ normalized.canonical === target.canonical) {
215
+ return true;
216
+ }
217
+ }
218
+ }
219
+ return false;
220
+ }
221
+ /**
222
+ * Authorize a serve listen host. Loopback needs no Tailscale evidence. Any other
223
+ * host requires a strict literal IP present in fresh local Tailscale self
224
+ * addresses and assigned on a local OS interface.
225
+ *
226
+ * Uses one absolute startup deadline across CLI and OS stages (shared with
227
+ * `startServe` when `deadline` is passed). Does not install a lifetime timer
228
+ * on a healthy server.
229
+ */
230
+ export async function authorizeServeListen(host, deps = {}) {
231
+ const env = deps.env ?? process.env;
232
+ const signal = deps.signal;
233
+ const now = deps.now ?? Date.now;
234
+ throwIfAborted(signal);
235
+ if (isLoopbackListenHost(host)) {
236
+ const bindHost = host.trim().replace(/^\[|\]$/g, "");
237
+ return {
238
+ bindHost,
239
+ canonical: bindHost.toLowerCase(),
240
+ family: bindHost.includes(":") ? 6 : 4,
241
+ mode: "loopback",
242
+ };
243
+ }
244
+ const literal = canonicalizeListenIp(host);
245
+ if (!literal) {
246
+ fail(`zswarm serve refuses non-literal or unsafe listen host (${host}); bind only loopback or a verified local Tailscale IP`, { phase: "verify", cause: "listen_host", host }, SERVE_BIND_REMEDY.literal);
247
+ }
248
+ const deadline = typeof deps.deadline === "number" && Number.isFinite(deps.deadline)
249
+ ? deps.deadline
250
+ : now() + Math.max(1, Math.floor(deps.timeoutMs ?? SERVE_BIND_VERIFY_TIMEOUT_MS));
251
+ const remaining = () => deadline - now();
252
+ const assertRunnable = (stage) => {
253
+ throwIfAborted(signal);
254
+ const left = remaining();
255
+ if (left <= 0) {
256
+ fail(`zswarm serve bind verification timed out during ${stage} before listen`, { phase: "verify", cause: "timeout", stage }, SERVE_BIND_REMEDY.timeout);
257
+ }
258
+ return left;
259
+ };
260
+ const runner = deps.tailscaleStatus ?? defaultServeTailscaleStatus;
261
+ const ifacesFn = deps.networkInterfaces ?? osNetworkInterfaces;
262
+ const statusBudget = assertRunnable("status");
263
+ let statusResult;
264
+ try {
265
+ statusResult = await runner({ timeoutMs: statusBudget, signal, env });
266
+ }
267
+ catch (err) {
268
+ if (err instanceof ZellijError)
269
+ throw err;
270
+ fail(`Tailscale status failed during serve bind verification (${err instanceof Error ? err.message : "error"})`, { phase: "verify", cause: "tailscale_status" }, SERVE_BIND_REMEDY.daemon);
271
+ }
272
+ assertRunnable("status");
273
+ if (statusResult.code === NOT_FOUND_EXIT) {
274
+ fail("zswarm serve cannot verify a Tailscale listen address because the Tailscale CLI is missing", { phase: "verify", cause: "cli_missing" }, SERVE_BIND_REMEDY.cliMissing);
275
+ }
276
+ if (statusResult.code === -1) {
277
+ const cancelled = /cancel/i.test(statusResult.stderr) || signal?.aborted;
278
+ fail(cancelled
279
+ ? "zswarm serve bind verification was cancelled before listen"
280
+ : "zswarm serve bind verification timed out before listen", { phase: "verify", cause: cancelled ? "cancelled" : "timeout" }, cancelled ? SERVE_BIND_REMEDY.cancelled : SERVE_BIND_REMEDY.timeout);
281
+ }
282
+ if (statusResult.code !== 0) {
283
+ const combined = `${statusResult.stdout}\n${statusResult.stderr}`.toLowerCase();
284
+ const denied = /permission denied|access denied|failed to connect|tailscaled|daemon|stopped|needs.?login|logged out/.test(combined);
285
+ fail(denied
286
+ ? "zswarm serve cannot reach the local Tailscale daemon to verify the listen address"
287
+ : "zswarm serve Tailscale status failed while verifying the listen address", { phase: "verify", cause: denied ? "daemon_unavailable" : "status_failed", exit: statusResult.code }, SERVE_BIND_REMEDY.daemon);
288
+ }
289
+ const rawStdout = statusResult.stdout;
290
+ if (!rawStdout.trim()) {
291
+ fail("zswarm serve received empty Tailscale status JSON while verifying the listen address", { phase: "verify", cause: "empty_status" }, SERVE_BIND_REMEDY.statusShape);
292
+ }
293
+ if (Buffer.byteLength(rawStdout, "utf8") >= SERVE_BIND_STATUS_MAX_BYTES) {
294
+ fail("zswarm serve Tailscale status JSON exceeded the verification size cap", { phase: "verify", cause: "oversize_status" }, SERVE_BIND_REMEDY.statusShape);
295
+ }
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(rawStdout);
299
+ }
300
+ catch {
301
+ fail("zswarm serve Tailscale status JSON was malformed while verifying the listen address", { phase: "verify", cause: "malformed_json" }, SERVE_BIND_REMEDY.statusShape);
302
+ }
303
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
304
+ fail("zswarm serve Tailscale status JSON was not an object", { phase: "verify", cause: "invalid_status" }, SERVE_BIND_REMEDY.statusShape);
305
+ }
306
+ const status = parsed;
307
+ const backend = typeof status.BackendState === "string" ? status.BackendState.trim() : "";
308
+ if (backend !== "Running") {
309
+ fail(`zswarm serve requires Tailscale BackendState Running to bind a Tailscale address (saw ${backend || "missing"})`, { phase: "verify", cause: "backend_state", backendState: backend || null }, SERVE_BIND_REMEDY.daemon);
310
+ }
311
+ if (!status.Self || typeof status.Self !== "object" || Array.isArray(status.Self)) {
312
+ fail("zswarm serve Tailscale status is missing Self; cannot authorize a Tailscale listen address", { phase: "verify", cause: "missing_self" }, SERVE_BIND_REMEDY.statusShape);
313
+ }
314
+ const selfRow = status.Self;
315
+ if (!Object.prototype.hasOwnProperty.call(selfRow, "TailscaleIPs")) {
316
+ fail("zswarm serve Tailscale Self.TailscaleIPs is missing; cannot authorize a Tailscale listen address", { phase: "verify", cause: "missing_self_ips" }, SERVE_BIND_REMEDY.statusShape);
317
+ }
318
+ const selfIps = parsePresentIpList(selfRow.TailscaleIPs, "Self.TailscaleIPs");
319
+ if (selfIps.length === 0) {
320
+ fail("zswarm serve Tailscale Self.TailscaleIPs is empty; cannot authorize a Tailscale listen address", { phase: "verify", cause: "empty_self_ips" }, SERVE_BIND_REMEDY.notSelf);
321
+ }
322
+ // Absent root TailscaleIPs is allowed (Self is authoritative). A present
323
+ // field — including [] or a non-array — must be structurally valid and match Self.
324
+ if (Object.prototype.hasOwnProperty.call(status, "TailscaleIPs")) {
325
+ const rootIps = parsePresentIpList(status.TailscaleIPs, "TailscaleIPs");
326
+ if (!sameAddressSet(selfIps, rootIps)) {
327
+ fail("zswarm serve Tailscale Self.TailscaleIPs and TailscaleIPs conflict; refusing bind", { phase: "verify", cause: "self_root_conflict" }, SERVE_BIND_REMEDY.conflict);
328
+ }
329
+ }
330
+ // Peer entries are never identity evidence, even if a buggy CLI still emits them.
331
+ const matched = selfIps.find((entry) => entry.family === literal.family && entry.canonical === literal.canonical);
332
+ if (!matched) {
333
+ fail(`zswarm serve listen address ${host} is not a verified local Tailscale self address`, { phase: "verify", cause: "not_self_address", host: literal.canonical }, SERVE_BIND_REMEDY.notSelf);
334
+ }
335
+ assertRunnable("interfaces");
336
+ let ifaces;
337
+ try {
338
+ ifaces = ifacesFn();
339
+ }
340
+ catch (err) {
341
+ fail(`zswarm serve could not read OS interface addresses (${err instanceof Error ? err.message : "error"})`, { phase: "verify", cause: "os_interfaces" }, SERVE_BIND_REMEDY.osMissing);
342
+ }
343
+ assertRunnable("interfaces");
344
+ if (!osOwnsCanonical(matched, ifaces ?? {})) {
345
+ fail(`zswarm serve listen address ${literal.canonical} is not assigned on a local OS interface`, { phase: "verify", cause: "os_unassigned", host: literal.canonical }, SERVE_BIND_REMEDY.osMissing);
346
+ }
347
+ assertRunnable("authorize");
348
+ return {
349
+ bindHost: host.trim().replace(/^\[|\]$/g, ""),
350
+ canonical: matched.canonical,
351
+ family: matched.family,
352
+ mode: "tailscale",
353
+ };
354
+ }
@@ -0,0 +1,88 @@
1
+ import { type DoctorCheck } from "./doctor.js";
2
+ import { type ServeHelloData } from "./serve.js";
3
+ import { type NetworkInterfacesFn, type TailscaleStatusRunner } from "./serve-bind.js";
4
+ import type { ServeInstallDeps, ServePowerShellResult } from "./types.js";
5
+ /** Overall install/readiness deadline when `timeoutMs` is omitted. */
6
+ export declare const DEFAULT_SERVE_INSTALL_TIMEOUT_MS = 30000;
7
+ export declare const SERVE_NOT_READY_CODE = "serve_not_ready";
8
+ export declare const SERVE_TASK_OWNED_CODE = "serve_task_owned";
9
+ export declare const SERVE_TASK_VERSION_ENV = "ZSWARM_CORE_VERSION";
10
+ export type ServeTaskAction = "inspect" | "register" | "start" | "stop" | "unregister";
11
+ export type ServePowerShellExec = (script: string, options: {
12
+ timeoutMs: number;
13
+ signal?: AbortSignal;
14
+ }) => Promise<ServePowerShellResult>;
15
+ export type ServeCliLaunch = {
16
+ execPath: string;
17
+ scriptPath: string;
18
+ };
19
+ export type ServeInstallInput = ServeInstallDeps & {
20
+ listen?: string;
21
+ token?: string;
22
+ env?: NodeJS.ProcessEnv;
23
+ session?: string | null;
24
+ timeoutMs?: number;
25
+ signal?: AbortSignal;
26
+ now?: () => number;
27
+ sleep?: (ms: number) => Promise<void>;
28
+ /** Test seams for Tailscale listen verification (before task mutation). */
29
+ tailscaleStatus?: TailscaleStatusRunner;
30
+ networkInterfaces?: NetworkInterfacesFn;
31
+ };
32
+ export type ServeInstallSuccess = {
33
+ installed: true;
34
+ ready: true;
35
+ running: true;
36
+ task: string;
37
+ listen: string;
38
+ command: string;
39
+ principal: {
40
+ userId: string;
41
+ logonType: string;
42
+ runLevel: string;
43
+ userSid?: string;
44
+ };
45
+ server: ServeHelloData;
46
+ session: string | null;
47
+ sessions: string[];
48
+ warning?: string;
49
+ inspection: {
50
+ checks: DoctorCheck[];
51
+ };
52
+ };
53
+ export declare function looksLikeMcpEntrypoint(scriptPath: string): boolean;
54
+ export declare function looksLikeCliEntrypoint(scriptPath: string): boolean;
55
+ /**
56
+ * Resolve node + CLI script for the logon task. MCP argv[1] is never installed.
57
+ */
58
+ export declare function resolveServeCliLaunch(input: {
59
+ execPath?: string;
60
+ scriptPath?: string;
61
+ argv?: string[];
62
+ env?: NodeJS.ProcessEnv;
63
+ }): ServeCliLaunch;
64
+ /** Host Zellij/IPC + configured server policy; controller routing is stripped. */
65
+ export declare function serveTaskChildEnv(env: NodeJS.ProcessEnv): Record<string, string>;
66
+ /** Child env persisted in the task, including package version and optional launch id. */
67
+ export declare function serveTaskLaunchEnv(env: NodeJS.ProcessEnv, launchId?: string): Record<string, string>;
68
+ export declare function isWindowsSid(value: string): boolean;
69
+ /** Verified SID equality. Account-name / UPN resemblance is not identity. */
70
+ export declare function sameWindowsSid(a: string, b: string): boolean;
71
+ /** @deprecated Use sameWindowsSid; names are not verified identity. */
72
+ export declare function sameWindowsAccount(a: string, b: string): boolean;
73
+ export declare function buildServeTaskScript(action: ServeTaskAction, params?: {
74
+ command?: string;
75
+ }): string;
76
+ export declare function serveCommandFingerprint(command: string): string;
77
+ /**
78
+ * Register/update the current-user Interactive logon task and wait until the
79
+ * requested server instance is authenticated and the host crew is visible.
80
+ */
81
+ export declare function installServeLogon(input?: ServeInstallInput): Promise<ServeInstallSuccess>;
82
+ export declare function uninstallServeLogon(input?: ServeInstallInput): Promise<{
83
+ task: string;
84
+ cleared: true;
85
+ stopped?: boolean;
86
+ missing?: boolean;
87
+ stopFailed?: boolean;
88
+ }>;