@zswarm/core 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +4 -1
- package/dist/exec.d.ts +8 -0
- package/dist/exec.js +12 -12
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/ops/bus.js +7 -0
- package/dist/ops/dispatch.js +39 -5
- package/dist/ops/doctor.d.ts +80 -0
- package/dist/ops/doctor.js +1377 -0
- package/dist/ops/routing.js +2 -1
- package/dist/ops/serve-bind.d.ts +86 -0
- package/dist/ops/serve-bind.js +354 -0
- package/dist/ops/serve-install.d.ts +88 -0
- package/dist/ops/serve-install.js +1171 -0
- package/dist/ops/serve-tunnel.d.ts +109 -0
- package/dist/ops/serve-tunnel.js +1179 -0
- package/dist/ops/serve.d.ts +89 -21
- package/dist/ops/serve.js +622 -156
- package/dist/ops/types.d.ts +49 -0
- package/dist/ops/util.js +8 -1
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +8 -7
- package/dist/state.js +158 -38
- package/dist/zellij/binary.d.ts +7 -0
- package/dist/zellij/binary.js +20 -7
- package/dist/zellij/client.js +7 -7
- package/package.json +1 -1
package/dist/ops/serve.js
CHANGED
|
@@ -1,18 +1,67 @@
|
|
|
1
1
|
import { createServer, connect } from "node:net";
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { hostname } from "node:os";
|
|
3
5
|
import { ZellijError } from "../errors.js";
|
|
4
|
-
import {
|
|
6
|
+
import { authorizeServeListen, SERVE_BIND_REMEDY, SERVE_BIND_VERIFY_TIMEOUT_MS, } from "./serve-bind.js";
|
|
5
7
|
export const DEFAULT_SERVE_LISTEN = "127.0.0.1:9419";
|
|
6
8
|
export const SERVE_TASK_NAME = "zswarm-serve";
|
|
7
9
|
/** One JSONL request; a client that never sends newline cannot grow forever. */
|
|
8
10
|
export const SERVE_MAX_REQUEST_BYTES = 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Zellij `execFile` capture budget (`maxBuffer`). `dump --max 0` can return
|
|
13
|
+
* that much pane text; serve JSONL is UTF-8 and may expand (newlines → `\n`).
|
|
14
|
+
*/
|
|
15
|
+
export const SERVE_CAPTURE_BUDGET_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
/**
|
|
17
|
+
* Ordinary JSONL reply cap, UTF-8 bytes of the complete frame including the
|
|
18
|
+
* terminating newline. Sized for an 8MiB capture with newline escaping (2×)
|
|
19
|
+
* plus envelope slack. Not a silent truncate: over-cap fails with
|
|
20
|
+
* `serve_protocol`. Override with `ZSWARM_SERVE_MAX_REPLY_BYTES`.
|
|
21
|
+
*/
|
|
22
|
+
export const SERVE_MAX_REPLY_BYTES = SERVE_CAPTURE_BUDGET_BYTES * 2 + 256 * 1024;
|
|
23
|
+
/** Hello is a small control reply; keep it independent of dump-sized ops. */
|
|
24
|
+
export const SERVE_MAX_HELLO_BYTES = 16 * 1024;
|
|
9
25
|
/** Drop a socket that never finishes a JSONL line. */
|
|
10
26
|
export const SERVE_IDLE_TIMEOUT_MS = 30_000;
|
|
11
27
|
/** Concurrent TCP clients, including in-flight wait ops. */
|
|
12
28
|
export const SERVE_MAX_CONNECTIONS = 32;
|
|
13
29
|
/** Wait/await allow 15 minutes; this is that plus slack. */
|
|
14
30
|
export const SERVE_CALL_TIMEOUT_CAP_MS = 16 * 60_000;
|
|
31
|
+
/** TCP connect is bounded even when the op wait budget is long. */
|
|
32
|
+
export const SERVE_CONNECT_TIMEOUT_MS = 15_000;
|
|
33
|
+
/** Hello probe wait is bounded within the overall deadline. */
|
|
34
|
+
export const SERVE_HELLO_TIMEOUT_MS = 15_000;
|
|
35
|
+
/** Wire protocol number returned by hello. */
|
|
36
|
+
export const SERVE_PROTOCOL = 1;
|
|
37
|
+
export const SERVE_CONTROL_FIELD = "serveControl";
|
|
38
|
+
export const SERVE_HELLO_CONTROL = "hello";
|
|
39
|
+
export const SERVE_CAPABILITY_HELLO = "hello";
|
|
40
|
+
/** Per-launch identity for Windows install verification. Additive hello field. */
|
|
41
|
+
export const SERVE_LAUNCH_ID_ENV = "ZSWARM_SERVE_LAUNCH_ID";
|
|
42
|
+
/** Package version baked into the Windows logon-task command so upgrades restart. */
|
|
43
|
+
export const SERVE_CORE_VERSION = readCoreVersion();
|
|
15
44
|
const SERVE_TOKEN_FIELD = "serveToken";
|
|
45
|
+
const CORE_VERSION = SERVE_CORE_VERSION;
|
|
46
|
+
function readCoreVersion() {
|
|
47
|
+
const pkg = createRequire(import.meta.url)("../../package.json");
|
|
48
|
+
if (typeof pkg.version !== "string" || !pkg.version.trim()) {
|
|
49
|
+
throw new Error("@zswarm/core package.json is missing version");
|
|
50
|
+
}
|
|
51
|
+
return pkg.version.trim();
|
|
52
|
+
}
|
|
53
|
+
export const SERVE_REMEDY = {
|
|
54
|
+
connect: "Check that zswarm serve is running and the tunnel still forwards to this endpoint. Serve does not fall back to SSH.",
|
|
55
|
+
unauthorized: "Set the same ZSWARM_SERVE_TOKEN on the server and this caller.",
|
|
56
|
+
helloUnsupported: "This endpoint does not speak serve hello (protocol 1). Upgrade zswarm serve. Ordinary commands still work without probing hello first.",
|
|
57
|
+
incompatible: "This serve protocol is not supported by this client. Upgrade zswarm on both sides.",
|
|
58
|
+
protocol: "The endpoint returned a malformed or unexpected serve reply. Do not echo or retry blindly.",
|
|
59
|
+
timeoutConnect: "The TCP connection did not complete in time. Check the tunnel and that zswarm serve is listening.",
|
|
60
|
+
timeoutHello: "Connected but serve hello did not finish. Check the tunnel; do not treat this as a completed command.",
|
|
61
|
+
timeoutRequest: "The request was sent but no reply arrived. Treat the remote outcome as uncertain; do not retry automatically.",
|
|
62
|
+
incomplete: "The connection closed before a complete reply. If a request was already sent, treat the remote outcome as uncertain; do not retry automatically.",
|
|
63
|
+
cancelled: "The caller cancelled this serve call. If a request was already sent, treat the remote outcome as uncertain; do not retry automatically.",
|
|
64
|
+
};
|
|
16
65
|
export function isLoopbackHost(host) {
|
|
17
66
|
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
18
67
|
return (h === "127.0.0.1" ||
|
|
@@ -75,6 +124,15 @@ function unauthorized() {
|
|
|
75
124
|
},
|
|
76
125
|
};
|
|
77
126
|
}
|
|
127
|
+
function protocolError(message) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: {
|
|
131
|
+
code: "serve_protocol",
|
|
132
|
+
message,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
78
136
|
function takeServeToken(args) {
|
|
79
137
|
const token = typeof args[SERVE_TOKEN_FIELD] === "string"
|
|
80
138
|
? args[SERVE_TOKEN_FIELD]
|
|
@@ -83,21 +141,116 @@ function takeServeToken(args) {
|
|
|
83
141
|
delete request[SERVE_TOKEN_FIELD];
|
|
84
142
|
return { token, request };
|
|
85
143
|
}
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
144
|
+
function helloData(serverId, launchId) {
|
|
145
|
+
const data = {
|
|
146
|
+
protocol: SERVE_PROTOCOL,
|
|
147
|
+
serverId,
|
|
148
|
+
hostname: hostname() || "unknown",
|
|
149
|
+
platform: process.platform,
|
|
150
|
+
version: CORE_VERSION,
|
|
151
|
+
capabilities: [SERVE_CAPABILITY_HELLO],
|
|
152
|
+
};
|
|
153
|
+
const id = launchId?.trim();
|
|
154
|
+
if (id)
|
|
155
|
+
data.launchId = id;
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
function isHelloOp(op) {
|
|
159
|
+
return typeof op === "string" && op.trim() === SERVE_HELLO_CONTROL;
|
|
160
|
+
}
|
|
161
|
+
/** Control plane after auth; never dispatches an application op for hello. */
|
|
162
|
+
function handleServeControl(request, hello) {
|
|
163
|
+
const hasControl = Object.prototype.hasOwnProperty.call(request, SERVE_CONTROL_FIELD);
|
|
164
|
+
const hasOp = Object.prototype.hasOwnProperty.call(request, "op");
|
|
165
|
+
if (hasControl && hasOp) {
|
|
166
|
+
return {
|
|
167
|
+
handled: true,
|
|
168
|
+
result: protocolError("serve request cannot include both serveControl and op"),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (hasControl) {
|
|
172
|
+
const control = request[SERVE_CONTROL_FIELD];
|
|
173
|
+
if (typeof control !== "string" || !control.trim()) {
|
|
174
|
+
return {
|
|
175
|
+
handled: true,
|
|
176
|
+
result: protocolError("serveControl must be a non-empty string"),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const name = control.trim();
|
|
180
|
+
if (name !== SERVE_HELLO_CONTROL) {
|
|
181
|
+
return {
|
|
182
|
+
handled: true,
|
|
183
|
+
result: protocolError(`unknown serve control (${name})`),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
handled: true,
|
|
188
|
+
result: { ok: true, data: { ...hello, capabilities: [...hello.capabilities] } },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
if (isHelloOp(request.op)) {
|
|
192
|
+
return {
|
|
193
|
+
handled: true,
|
|
194
|
+
result: { ok: true, data: { ...hello, capabilities: [...hello.capabilities] } },
|
|
195
|
+
};
|
|
90
196
|
}
|
|
197
|
+
return { handled: false };
|
|
198
|
+
}
|
|
199
|
+
export async function startServe(listen, dispatch, options = {}) {
|
|
200
|
+
const { host, port } = parseListenAddress(listen);
|
|
201
|
+
// Token and basic input before any Tailscale CLI / listen effects.
|
|
91
202
|
const token = options.token?.trim() || undefined;
|
|
92
203
|
if (!token) {
|
|
93
|
-
|
|
204
|
+
throw new ZellijError("serve_auth", "zswarm serve requires ZSWARM_SERVE_TOKEN; another local OS user can connect to 127.0.0.1", { remedy: SERVE_BIND_REMEDY.token });
|
|
94
205
|
}
|
|
206
|
+
const now = options.now ?? Date.now;
|
|
207
|
+
const timeoutMs = Math.max(1, Math.floor(options.timeoutMs ?? SERVE_BIND_VERIFY_TIMEOUT_MS));
|
|
208
|
+
const deadline = now() + timeoutMs;
|
|
209
|
+
const assertStartupOpen = (stage) => {
|
|
210
|
+
if (options.signal?.aborted) {
|
|
211
|
+
throw new ZellijError("cancelled", "serve startup cancelled before listen", {
|
|
212
|
+
phase: "bind",
|
|
213
|
+
cause: "cancelled",
|
|
214
|
+
stage,
|
|
215
|
+
remedy: SERVE_BIND_REMEDY.cancelled,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (now() >= deadline) {
|
|
219
|
+
throw new ZellijError("serve_auth", `zswarm serve startup timed out during ${stage} before listen`, {
|
|
220
|
+
phase: "bind",
|
|
221
|
+
cause: "timeout",
|
|
222
|
+
stage,
|
|
223
|
+
remedy: SERVE_BIND_REMEDY.timeout,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
// Share the absolute startup deadline with verification; do not refresh a
|
|
228
|
+
// new relative allowance after time has already elapsed.
|
|
229
|
+
assertStartupOpen("authorize");
|
|
230
|
+
const authorized = await authorizeServeListen(host, {
|
|
231
|
+
env: options.env,
|
|
232
|
+
signal: options.signal,
|
|
233
|
+
deadline,
|
|
234
|
+
now,
|
|
235
|
+
tailscaleStatus: options.tailscaleStatus,
|
|
236
|
+
networkInterfaces: options.networkInterfaces,
|
|
237
|
+
});
|
|
238
|
+
// Abort queued during authorization must be observed before listener effects.
|
|
239
|
+
assertStartupOpen("authorize");
|
|
240
|
+
const bindHost = authorized.bindHost;
|
|
95
241
|
const maxRequestBytes = options.maxRequestBytes ?? SERVE_MAX_REQUEST_BYTES;
|
|
96
242
|
const idleTimeoutMs = options.idleTimeoutMs ?? SERVE_IDLE_TIMEOUT_MS;
|
|
97
243
|
const maxConnections = options.maxConnections ?? SERVE_MAX_CONNECTIONS;
|
|
244
|
+
const hello = helloData(randomUUID(), options.launchId);
|
|
245
|
+
const makeServer = options.createServer ?? createServer;
|
|
246
|
+
// Recheck immediately before allocating a listener (microtask aborts land here).
|
|
247
|
+
assertStartupOpen("listen");
|
|
98
248
|
return new Promise((resolve, reject) => {
|
|
249
|
+
let settled = false;
|
|
250
|
+
let startupTimer;
|
|
251
|
+
let onAbort;
|
|
99
252
|
const sockets = new Set();
|
|
100
|
-
const server =
|
|
253
|
+
const server = makeServer((socket) => {
|
|
101
254
|
socket.on("error", () => {
|
|
102
255
|
socket.destroy();
|
|
103
256
|
});
|
|
@@ -143,20 +296,36 @@ export function startServe(listen, dispatch, options = {}) {
|
|
|
143
296
|
buf = buf.slice(nl + 1);
|
|
144
297
|
if (line) {
|
|
145
298
|
let result;
|
|
299
|
+
let parsed;
|
|
146
300
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
301
|
+
parsed = JSON.parse(line);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
result = { ok: false, error: { code: "bad_arg", message: "serve request is not JSON" } };
|
|
305
|
+
}
|
|
306
|
+
if (!result) {
|
|
307
|
+
try {
|
|
308
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
309
|
+
result = protocolError("serve request must be a JSON object");
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const taken = takeServeToken(parsed);
|
|
313
|
+
if (taken.token !== token) {
|
|
314
|
+
result = unauthorized();
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const control = handleServeControl(taken.request, hello);
|
|
318
|
+
result = control.handled
|
|
319
|
+
? control.result
|
|
320
|
+
: await dispatch(taken.request);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
151
323
|
}
|
|
152
|
-
|
|
153
|
-
|
|
324
|
+
catch (err) {
|
|
325
|
+
const message = err instanceof Error ? err.message : "serve dispatch failed";
|
|
326
|
+
result = { ok: false, error: { code: "failed", message } };
|
|
154
327
|
}
|
|
155
328
|
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
-
result = { ok: false, error: { code: "bad_arg", message } };
|
|
159
|
-
}
|
|
160
329
|
if (!socket.destroyed) {
|
|
161
330
|
socket.write(`${JSON.stringify(result)}\n`);
|
|
162
331
|
}
|
|
@@ -177,177 +346,474 @@ export function startServe(listen, dispatch, options = {}) {
|
|
|
177
346
|
}
|
|
178
347
|
}
|
|
179
348
|
});
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
349
|
+
const clearStartupWatchers = () => {
|
|
350
|
+
if (startupTimer !== undefined) {
|
|
351
|
+
clearTimeout(startupTimer);
|
|
352
|
+
startupTimer = undefined;
|
|
353
|
+
}
|
|
354
|
+
if (onAbort && options.signal) {
|
|
355
|
+
options.signal.removeEventListener("abort", onAbort);
|
|
356
|
+
onAbort = undefined;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const closeStartupListener = () => {
|
|
360
|
+
for (const open of sockets)
|
|
361
|
+
open.destroy();
|
|
362
|
+
try {
|
|
363
|
+
server.close(() => { });
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// ignore close races on fake/test listeners
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const rejectStartup = (err) => {
|
|
370
|
+
if (settled)
|
|
371
|
+
return;
|
|
372
|
+
settled = true;
|
|
373
|
+
clearStartupWatchers();
|
|
374
|
+
closeStartupListener();
|
|
375
|
+
reject(err);
|
|
376
|
+
};
|
|
377
|
+
const resolveStartup = (handle) => {
|
|
378
|
+
if (settled) {
|
|
379
|
+
closeStartupListener();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
settled = true;
|
|
383
|
+
clearStartupWatchers();
|
|
384
|
+
resolve(handle);
|
|
385
|
+
};
|
|
386
|
+
server.on("error", (err) => {
|
|
387
|
+
const code = typeof err.code === "string" ? err.code : "listen_failed";
|
|
388
|
+
rejectStartup(new ZellijError("serve_auth", `zswarm serve failed to bind ${formatListenLabel(bindHost, port)} (${code}); no fallback listen address`, {
|
|
389
|
+
phase: "bind",
|
|
390
|
+
cause: code,
|
|
391
|
+
host: bindHost,
|
|
392
|
+
port,
|
|
393
|
+
remedy: SERVE_BIND_REMEDY.bind,
|
|
394
|
+
}));
|
|
192
395
|
});
|
|
396
|
+
// Active ownership while listen completion is pending: do not wait for the
|
|
397
|
+
// listening callback to observe cancel/expiry. Cleared on success so the
|
|
398
|
+
// startup budget is not a lifetime timer for a healthy server.
|
|
399
|
+
onAbort = () => {
|
|
400
|
+
rejectStartup(new ZellijError("cancelled", "serve startup cancelled before listen completed", {
|
|
401
|
+
phase: "bind",
|
|
402
|
+
cause: "cancelled",
|
|
403
|
+
stage: "listening",
|
|
404
|
+
remedy: SERVE_BIND_REMEDY.cancelled,
|
|
405
|
+
}));
|
|
406
|
+
};
|
|
407
|
+
if (options.signal) {
|
|
408
|
+
if (options.signal.aborted) {
|
|
409
|
+
onAbort();
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
413
|
+
}
|
|
414
|
+
const remainingMs = deadline - now();
|
|
415
|
+
if (remainingMs <= 0) {
|
|
416
|
+
rejectStartup(new ZellijError("serve_auth", "zswarm serve startup timed out before listen completed", {
|
|
417
|
+
phase: "bind",
|
|
418
|
+
cause: "timeout",
|
|
419
|
+
stage: "listening",
|
|
420
|
+
remedy: SERVE_BIND_REMEDY.timeout,
|
|
421
|
+
}));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
startupTimer = setTimeout(() => {
|
|
425
|
+
// Re-read injected clocks so tests can advance `now` after listen starts.
|
|
426
|
+
if (now() >= deadline) {
|
|
427
|
+
rejectStartup(new ZellijError("serve_auth", "zswarm serve startup timed out before listen completed", {
|
|
428
|
+
phase: "bind",
|
|
429
|
+
cause: "timeout",
|
|
430
|
+
stage: "listening",
|
|
431
|
+
remedy: SERVE_BIND_REMEDY.timeout,
|
|
432
|
+
}));
|
|
433
|
+
}
|
|
434
|
+
}, Math.max(1, remainingMs));
|
|
435
|
+
// Ensure the timer does not keep the process alive after success on Node.
|
|
436
|
+
startupTimer.unref?.();
|
|
437
|
+
try {
|
|
438
|
+
assertStartupOpen("listen");
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
rejectStartup(err);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Exact verified address only — never 0.0.0.0 / :: / a different host.
|
|
445
|
+
// Startup budget ends once listening succeeds; it is not a lifetime timer.
|
|
446
|
+
try {
|
|
447
|
+
server.listen(port, bindHost, () => {
|
|
448
|
+
if (settled) {
|
|
449
|
+
closeStartupListener();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (options.signal?.aborted) {
|
|
453
|
+
rejectStartup(new ZellijError("cancelled", "serve startup cancelled before listen completed", {
|
|
454
|
+
phase: "bind",
|
|
455
|
+
cause: "cancelled",
|
|
456
|
+
stage: "listening",
|
|
457
|
+
remedy: SERVE_BIND_REMEDY.cancelled,
|
|
458
|
+
}));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (now() >= deadline) {
|
|
462
|
+
rejectStartup(new ZellijError("serve_auth", "zswarm serve startup timed out before listen completed", {
|
|
463
|
+
phase: "bind",
|
|
464
|
+
cause: "timeout",
|
|
465
|
+
stage: "listening",
|
|
466
|
+
remedy: SERVE_BIND_REMEDY.timeout,
|
|
467
|
+
}));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const addr = server.address();
|
|
471
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
472
|
+
resolveStartup({
|
|
473
|
+
label: formatListenLabel(bindHost, actualPort),
|
|
474
|
+
close: () => new Promise((done, fail) => {
|
|
475
|
+
for (const open of sockets)
|
|
476
|
+
open.destroy();
|
|
477
|
+
server.close((err) => (err ? fail(err) : done()));
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
// Synchronous listen failures must clear abort/timer watchers and close
|
|
484
|
+
// the owned listener; preserve the original error cause/message.
|
|
485
|
+
rejectStartup(err);
|
|
486
|
+
}
|
|
193
487
|
});
|
|
194
488
|
}
|
|
489
|
+
/** Direct host:port / tcp:// only. ssh:// URIs are resolved by serve-tunnel. */
|
|
195
490
|
export function parseServeTarget(raw) {
|
|
196
491
|
return parseListenAddress(raw);
|
|
197
492
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
493
|
+
function serveFail(code, message, details) {
|
|
494
|
+
return { ok: false, error: { code, message, details } };
|
|
495
|
+
}
|
|
496
|
+
function remedyFor(code, phase, delivery) {
|
|
497
|
+
if (code === "serve_unauthorized")
|
|
498
|
+
return SERVE_REMEDY.unauthorized;
|
|
499
|
+
if (code === "cancelled")
|
|
500
|
+
return SERVE_REMEDY.cancelled;
|
|
501
|
+
if (code === "serve_unreachable")
|
|
502
|
+
return SERVE_REMEDY.connect;
|
|
503
|
+
if (code === "serve_hello_unsupported")
|
|
504
|
+
return SERVE_REMEDY.helloUnsupported;
|
|
505
|
+
if (code === "serve_incompatible")
|
|
506
|
+
return SERVE_REMEDY.incompatible;
|
|
507
|
+
if (code === "timeout") {
|
|
508
|
+
if (phase === "connect")
|
|
509
|
+
return SERVE_REMEDY.timeoutConnect;
|
|
510
|
+
if (phase === "hello")
|
|
511
|
+
return SERVE_REMEDY.timeoutHello;
|
|
512
|
+
if (delivery === "uncertain")
|
|
513
|
+
return SERVE_REMEDY.timeoutRequest;
|
|
514
|
+
return SERVE_REMEDY.timeoutConnect;
|
|
515
|
+
}
|
|
516
|
+
if (delivery === "uncertain")
|
|
517
|
+
return SERVE_REMEDY.incomplete;
|
|
518
|
+
return SERVE_REMEDY.protocol;
|
|
519
|
+
}
|
|
520
|
+
function asOpsResult(value) {
|
|
521
|
+
if (!value || typeof value !== "object")
|
|
522
|
+
return null;
|
|
523
|
+
const v = value;
|
|
524
|
+
if (v.ok === true) {
|
|
525
|
+
if (!Object.prototype.hasOwnProperty.call(v, "data"))
|
|
526
|
+
return null;
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
if (v.ok !== false || !v.error || typeof v.error !== "object")
|
|
530
|
+
return null;
|
|
531
|
+
const error = v.error;
|
|
532
|
+
if (typeof error.code !== "string" || typeof error.message !== "string")
|
|
533
|
+
return null;
|
|
534
|
+
return value;
|
|
535
|
+
}
|
|
536
|
+
/** CLI/MCP inherit `process.env`; a positive integer overrides the default reply cap. */
|
|
537
|
+
export function serveMaxReplyBytes(env = process.env) {
|
|
538
|
+
const raw = env.ZSWARM_SERVE_MAX_REPLY_BYTES?.trim();
|
|
539
|
+
if (!raw)
|
|
540
|
+
return SERVE_MAX_REPLY_BYTES;
|
|
541
|
+
const n = Number(raw);
|
|
542
|
+
if (!Number.isFinite(n) || n < 1)
|
|
543
|
+
return SERVE_MAX_REPLY_BYTES;
|
|
544
|
+
return Math.floor(n);
|
|
545
|
+
}
|
|
546
|
+
function replyByteLimit(phase, override) {
|
|
547
|
+
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
|
548
|
+
return Math.floor(override);
|
|
549
|
+
}
|
|
550
|
+
if (phase === "hello")
|
|
551
|
+
return SERVE_MAX_HELLO_BYTES;
|
|
552
|
+
return serveMaxReplyBytes();
|
|
553
|
+
}
|
|
554
|
+
function isServeHelloData(data) {
|
|
555
|
+
if (!data || typeof data !== "object")
|
|
556
|
+
return false;
|
|
557
|
+
const d = data;
|
|
558
|
+
return (typeof d.protocol === "number" &&
|
|
559
|
+
Number.isFinite(d.protocol) &&
|
|
560
|
+
typeof d.serverId === "string" &&
|
|
561
|
+
d.serverId.trim() !== "" &&
|
|
562
|
+
typeof d.hostname === "string" &&
|
|
563
|
+
d.hostname.trim() !== "" &&
|
|
564
|
+
typeof d.platform === "string" &&
|
|
565
|
+
d.platform.trim() !== "" &&
|
|
566
|
+
typeof d.version === "string" &&
|
|
567
|
+
d.version.trim() !== "" &&
|
|
568
|
+
Array.isArray(d.capabilities) &&
|
|
569
|
+
d.capabilities.every((cap) => typeof cap === "string" && cap.trim() !== ""));
|
|
570
|
+
}
|
|
571
|
+
function attachToken(args, token) {
|
|
572
|
+
return token && token.trim() ? { ...args, [SERVE_TOKEN_FIELD]: token.trim() } : args;
|
|
573
|
+
}
|
|
574
|
+
function exchangeServe(input) {
|
|
575
|
+
const { host, port } = parseServeTarget(input.target);
|
|
576
|
+
const endpoint = formatListenLabel(host, port);
|
|
577
|
+
const timeoutMs = Math.max(1, input.timeoutMs);
|
|
578
|
+
const deadline = Date.now() + timeoutMs;
|
|
579
|
+
const maxReplyBytes = replyByteLimit(input.phase, input.maxReplyBytes);
|
|
580
|
+
const remaining = () => Math.max(0, deadline - Date.now());
|
|
203
581
|
return new Promise((resolve) => {
|
|
204
|
-
|
|
205
|
-
let
|
|
582
|
+
let phase = "connect";
|
|
583
|
+
let sent = false;
|
|
206
584
|
let settled = false;
|
|
585
|
+
let pending = Buffer.alloc(0);
|
|
586
|
+
let connectTimer;
|
|
587
|
+
let waitTimer;
|
|
588
|
+
let overallTimer;
|
|
589
|
+
let socket;
|
|
590
|
+
const delivery = () => (sent ? "uncertain" : "not_sent");
|
|
207
591
|
const finish = (result) => {
|
|
208
592
|
if (settled)
|
|
209
593
|
return;
|
|
210
594
|
settled = true;
|
|
211
|
-
|
|
212
|
-
|
|
595
|
+
if (connectTimer)
|
|
596
|
+
clearTimeout(connectTimer);
|
|
597
|
+
if (waitTimer)
|
|
598
|
+
clearTimeout(waitTimer);
|
|
599
|
+
if (overallTimer)
|
|
600
|
+
clearTimeout(overallTimer);
|
|
601
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
602
|
+
if (socket) {
|
|
603
|
+
socket.removeAllListeners();
|
|
604
|
+
socket.destroy();
|
|
605
|
+
}
|
|
213
606
|
resolve(result);
|
|
214
607
|
};
|
|
215
|
-
const
|
|
216
|
-
finish({
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
608
|
+
const fail = (code, message, state = delivery()) => {
|
|
609
|
+
finish(serveFail(code, message, {
|
|
610
|
+
phase,
|
|
611
|
+
endpoint,
|
|
612
|
+
delivery: state,
|
|
613
|
+
remedy: remedyFor(code, phase, state),
|
|
614
|
+
}));
|
|
615
|
+
};
|
|
616
|
+
const onAbort = () => {
|
|
617
|
+
fail("cancelled", "operation cancelled");
|
|
618
|
+
};
|
|
619
|
+
if (input.signal?.aborted) {
|
|
620
|
+
fail("cancelled", "operation cancelled");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (remaining() <= 0) {
|
|
624
|
+
fail("timeout", `zswarm serve at ${endpoint} timed out after ${timeoutMs}ms`);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
socket = connect({ host, port });
|
|
628
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
629
|
+
overallTimer = setTimeout(() => {
|
|
630
|
+
fail("timeout", `zswarm serve at ${endpoint} timed out after ${timeoutMs}ms`);
|
|
223
631
|
}, timeoutMs);
|
|
224
|
-
|
|
632
|
+
connectTimer = setTimeout(() => {
|
|
633
|
+
fail("timeout", `zswarm serve at ${endpoint} timed out after ${timeoutMs}ms`);
|
|
634
|
+
}, Math.max(1, Math.min(input.connectTimeoutMs ?? SERVE_CONNECT_TIMEOUT_MS, remaining())));
|
|
225
635
|
socket.on("error", (err) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
},
|
|
232
|
-
});
|
|
636
|
+
if (phase === "connect" && !sent) {
|
|
637
|
+
fail("serve_unreachable", `cannot reach zswarm serve at ${endpoint}: ${err.message}`);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
fail("serve_protocol", `zswarm serve at ${endpoint} closed before a complete reply`);
|
|
233
641
|
});
|
|
234
|
-
socket.on("
|
|
235
|
-
|
|
236
|
-
const nl = buf.indexOf("\n");
|
|
237
|
-
if (nl === -1)
|
|
642
|
+
socket.on("connect", () => {
|
|
643
|
+
if (settled || !socket)
|
|
238
644
|
return;
|
|
239
|
-
|
|
645
|
+
if (connectTimer) {
|
|
646
|
+
clearTimeout(connectTimer);
|
|
647
|
+
connectTimer = undefined;
|
|
648
|
+
}
|
|
649
|
+
phase = input.phase;
|
|
650
|
+
const waitBound = phase === "hello" ? Math.min(SERVE_HELLO_TIMEOUT_MS, remaining()) : remaining();
|
|
651
|
+
if (waitBound <= 0) {
|
|
652
|
+
fail("timeout", `zswarm serve at ${endpoint} timed out after ${timeoutMs}ms`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (phase === "hello") {
|
|
656
|
+
waitTimer = setTimeout(() => {
|
|
657
|
+
fail("timeout", `zswarm serve at ${endpoint} timed out after ${timeoutMs}ms`);
|
|
658
|
+
}, Math.max(1, waitBound));
|
|
659
|
+
}
|
|
660
|
+
sent = true;
|
|
240
661
|
try {
|
|
241
|
-
|
|
662
|
+
socket.write(`${JSON.stringify(input.payload)}\n`);
|
|
242
663
|
}
|
|
243
664
|
catch {
|
|
244
|
-
|
|
245
|
-
ok: false,
|
|
246
|
-
error: { code: "failed", message: `serve returned non-JSON: ${line}` },
|
|
247
|
-
});
|
|
665
|
+
fail("serve_protocol", `zswarm serve at ${endpoint} closed before a complete reply`);
|
|
248
666
|
}
|
|
249
667
|
});
|
|
250
|
-
socket.on("
|
|
251
|
-
|
|
668
|
+
socket.on("data", (chunk) => {
|
|
669
|
+
if (settled)
|
|
670
|
+
return;
|
|
671
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, "utf8");
|
|
672
|
+
pending = Buffer.concat([pending, bytes]);
|
|
673
|
+
// Cap counts the complete JSONL frame in UTF-8, including the newline.
|
|
674
|
+
if (pending.length > maxReplyBytes) {
|
|
675
|
+
fail("serve_protocol", `serve reply exceeded ${maxReplyBytes} bytes`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
while (!settled) {
|
|
679
|
+
const nl = pending.indexOf(0x0a);
|
|
680
|
+
if (nl === -1)
|
|
681
|
+
return;
|
|
682
|
+
const line = pending.subarray(0, nl).toString("utf8").trim();
|
|
683
|
+
pending = pending.subarray(nl + 1);
|
|
684
|
+
if (!line)
|
|
685
|
+
continue;
|
|
686
|
+
try {
|
|
687
|
+
const parsed = asOpsResult(JSON.parse(line));
|
|
688
|
+
if (!parsed) {
|
|
689
|
+
fail("serve_protocol", `zswarm serve at ${endpoint} returned an invalid reply`, "replied");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
finish(parsed);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
fail("serve_protocol", `zswarm serve at ${endpoint} returned non-JSON`, "replied");
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
252
699
|
});
|
|
700
|
+
const onClosed = () => {
|
|
701
|
+
if (settled)
|
|
702
|
+
return;
|
|
703
|
+
if (pending.length > 0) {
|
|
704
|
+
fail("serve_protocol", `zswarm serve at ${endpoint} returned a truncated reply`);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
fail("serve_protocol", `zswarm serve at ${endpoint} closed before a complete reply`);
|
|
708
|
+
};
|
|
709
|
+
socket.on("end", onClosed);
|
|
710
|
+
socket.on("close", onClosed);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
function interpretServeHello(result, endpoint) {
|
|
714
|
+
if (!result.ok) {
|
|
715
|
+
if (result.error.code === "serve_unauthorized" ||
|
|
716
|
+
result.error.code === "serve_unreachable" ||
|
|
717
|
+
result.error.code === "timeout" ||
|
|
718
|
+
result.error.code === "cancelled" ||
|
|
719
|
+
result.error.code === "serve_protocol" ||
|
|
720
|
+
result.error.code === "serve_incompatible" ||
|
|
721
|
+
result.error.code === "serve_hello_unsupported") {
|
|
722
|
+
return result;
|
|
723
|
+
}
|
|
724
|
+
return serveFail("serve_hello_unsupported", `zswarm serve at ${endpoint} does not speak hello (protocol ${SERVE_PROTOCOL})`, {
|
|
725
|
+
phase: "hello",
|
|
726
|
+
endpoint,
|
|
727
|
+
delivery: "replied",
|
|
728
|
+
remedy: SERVE_REMEDY.helloUnsupported,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const data = result.data;
|
|
732
|
+
if (data && typeof data === "object" && typeof data.protocol === "number") {
|
|
733
|
+
const protocol = data.protocol;
|
|
734
|
+
if (protocol !== SERVE_PROTOCOL) {
|
|
735
|
+
return serveFail("serve_incompatible", `zswarm serve at ${endpoint} speaks protocol ${protocol}; this client supports ${SERVE_PROTOCOL}`, {
|
|
736
|
+
phase: "hello",
|
|
737
|
+
endpoint,
|
|
738
|
+
delivery: "replied",
|
|
739
|
+
remedy: SERVE_REMEDY.incompatible,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
if (!isServeHelloData(data) ||
|
|
743
|
+
!data.capabilities.includes(SERVE_CAPABILITY_HELLO)) {
|
|
744
|
+
return serveFail("serve_protocol", `zswarm serve at ${endpoint} returned an invalid hello`, {
|
|
745
|
+
phase: "hello",
|
|
746
|
+
endpoint,
|
|
747
|
+
delivery: "replied",
|
|
748
|
+
remedy: SERVE_REMEDY.protocol,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return { ok: true, data };
|
|
752
|
+
}
|
|
753
|
+
return serveFail("serve_hello_unsupported", `zswarm serve at ${endpoint} does not speak hello (protocol ${SERVE_PROTOCOL})`, {
|
|
754
|
+
phase: "hello",
|
|
755
|
+
endpoint,
|
|
756
|
+
delivery: "replied",
|
|
757
|
+
remedy: SERVE_REMEDY.helloUnsupported,
|
|
253
758
|
});
|
|
254
759
|
}
|
|
760
|
+
export function callServe(target, args, timeoutMsOrOptions = 15_000, token, signal) {
|
|
761
|
+
const options = typeof timeoutMsOrOptions === "object" && timeoutMsOrOptions !== null
|
|
762
|
+
? timeoutMsOrOptions
|
|
763
|
+
: { timeoutMs: timeoutMsOrOptions, token, signal };
|
|
764
|
+
return exchangeServe({
|
|
765
|
+
target,
|
|
766
|
+
payload: attachToken(args, options.token),
|
|
767
|
+
timeoutMs: options.timeoutMs ?? 15_000,
|
|
768
|
+
phase: "request",
|
|
769
|
+
signal: options.signal,
|
|
770
|
+
maxReplyBytes: options.maxReplyBytes,
|
|
771
|
+
connectTimeoutMs: options.connectTimeoutMs,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
/** Authenticated hello probe. Never reports a legacy or incompatible endpoint as healthy. */
|
|
775
|
+
export function probeServe(target, options = {}) {
|
|
776
|
+
const { label: endpoint } = parseListenAddress(target);
|
|
777
|
+
return exchangeServe({
|
|
778
|
+
target,
|
|
779
|
+
payload: attachToken({ [SERVE_CONTROL_FIELD]: SERVE_HELLO_CONTROL }, options.token),
|
|
780
|
+
timeoutMs: options.timeoutMs ?? 15_000,
|
|
781
|
+
phase: "hello",
|
|
782
|
+
signal: options.signal,
|
|
783
|
+
}).then((result) => interpretServeHello(result, endpoint));
|
|
784
|
+
}
|
|
255
785
|
export function redactServeSecret(command, token) {
|
|
256
786
|
const secret = token.trim();
|
|
257
787
|
if (!secret)
|
|
258
788
|
return command;
|
|
259
789
|
return command.split(secret).join("***");
|
|
260
790
|
}
|
|
261
|
-
export function serveLogonCommand(execPath, scriptPath, listen, token) {
|
|
791
|
+
export function serveLogonCommand(execPath, scriptPath, listen, token, extraEnv = {}) {
|
|
792
|
+
for (const path of [execPath, scriptPath]) {
|
|
793
|
+
if (/[\0\r\n"]/.test(path)) {
|
|
794
|
+
throw new ZellijError("bad_arg", "serve executable/script path must not contain quotes or newlines");
|
|
795
|
+
}
|
|
796
|
+
}
|
|
262
797
|
const launch = `"${execPath}" "${scriptPath}" serve --listen ${listen}`;
|
|
798
|
+
const env = { ...extraEnv };
|
|
263
799
|
const secret = token?.trim();
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
function runPowerShell(script) {
|
|
281
|
-
return new Promise((resolve) => {
|
|
282
|
-
execFile("powershell.exe", [
|
|
283
|
-
"-NoProfile",
|
|
284
|
-
"-ExecutionPolicy",
|
|
285
|
-
"Bypass",
|
|
286
|
-
"-EncodedCommand",
|
|
287
|
-
encodePowerShellCommand(script),
|
|
288
|
-
], { windowsHide: true, timeout: 20_000 }, (error, stdout, stderr) => {
|
|
289
|
-
const code = error && typeof error.code === "number"
|
|
290
|
-
? error.code
|
|
291
|
-
: error
|
|
292
|
-
? 1
|
|
293
|
-
: 0;
|
|
294
|
-
resolve({
|
|
295
|
-
code,
|
|
296
|
-
stdout: String(stdout ?? ""),
|
|
297
|
-
stderr: String(stderr ?? ""),
|
|
298
|
-
});
|
|
299
|
-
});
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
export async function installServeLogon(input) {
|
|
303
|
-
if ((input.platform ?? process.platform) !== "win32") {
|
|
304
|
-
throw new ZellijError("usage", "serve --install registers a Windows logon task; on Unix start `zswarm serve --listen` in the session that owns Zellij");
|
|
305
|
-
}
|
|
306
|
-
const { host, label } = parseListenAddress(input.listen);
|
|
307
|
-
if (!isLoopbackHost(host)) {
|
|
308
|
-
throw new ZellijError("serve_auth", `zswarm serve only listens on loopback (127.0.0.1 / ::1); off-machine access is an SSH tunnel to 127.0.0.1 (${host} refused)`);
|
|
309
|
-
}
|
|
310
|
-
const token = serveInstallToken(input);
|
|
311
|
-
const execPath = input.execPath ?? process.execPath;
|
|
312
|
-
const scriptPath = input.scriptPath ?? process.argv[1];
|
|
313
|
-
if (!scriptPath) {
|
|
314
|
-
throw new ZellijError("usage", "cannot resolve the zswarm script path for the logon task");
|
|
315
|
-
}
|
|
316
|
-
const command = serveLogonCommand(execPath, scriptPath, label, token);
|
|
317
|
-
const commandB64 = Buffer.from(command, "utf8").toString("base64");
|
|
318
|
-
const script = [
|
|
319
|
-
"$cmd = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" +
|
|
320
|
-
commandB64 +
|
|
321
|
-
"'))",
|
|
322
|
-
"$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument \"/c $cmd\"",
|
|
323
|
-
"$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Limited",
|
|
324
|
-
"$trigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME",
|
|
325
|
-
`Register-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Action $action -Principal $principal -Trigger $trigger -Force | Out-Null`,
|
|
326
|
-
`Start-ScheduledTask -TaskName '${SERVE_TASK_NAME}'`,
|
|
327
|
-
].join("\n");
|
|
328
|
-
const result = await runPowerShell(script);
|
|
329
|
-
if (result.code !== 0) {
|
|
330
|
-
throw new ZellijError("zellij_failed", `serve --install failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
331
|
-
}
|
|
332
|
-
return { task: SERVE_TASK_NAME, listen: label, command: redactServeSecret(command, token) };
|
|
333
|
-
}
|
|
334
|
-
export async function uninstallServeLogon(input = {}) {
|
|
335
|
-
if ((input.platform ?? process.platform) !== "win32") {
|
|
336
|
-
throw new ZellijError("usage", "serve --clear is Windows-only");
|
|
337
|
-
}
|
|
338
|
-
const script = [
|
|
339
|
-
"$ProgressPreference = 'SilentlyContinue'",
|
|
340
|
-
"try {",
|
|
341
|
-
` Unregister-ScheduledTask -TaskName '${SERVE_TASK_NAME}' -Confirm:$false`,
|
|
342
|
-
"} catch {",
|
|
343
|
-
" if ($_.CategoryInfo.Category -eq 'ObjectNotFound') { exit 0 }",
|
|
344
|
-
" [Console]::Error.Write(($_ | Out-String).Trim())",
|
|
345
|
-
" exit 1",
|
|
346
|
-
"}",
|
|
347
|
-
].join("\n");
|
|
348
|
-
const result = await runPowerShell(script);
|
|
349
|
-
if (result.code !== 0) {
|
|
350
|
-
throw new ZellijError("zellij_failed", `serve --clear failed: ${(result.stderr || result.stdout).trim() || "no output"}`);
|
|
800
|
+
if (secret)
|
|
801
|
+
env.ZSWARM_SERVE_TOKEN = secret;
|
|
802
|
+
const assignments = [];
|
|
803
|
+
for (const [key, value] of Object.entries(env)) {
|
|
804
|
+
if (!value)
|
|
805
|
+
continue;
|
|
806
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
807
|
+
throw new ZellijError("bad_arg", `cannot persist environment key ${key} in the logon task`);
|
|
808
|
+
}
|
|
809
|
+
if (/[\0\r\n"%]/.test(value)) {
|
|
810
|
+
throw new ZellijError("bad_arg", key === "ZSWARM_SERVE_TOKEN"
|
|
811
|
+
? "ZSWARM_SERVE_TOKEN must not contain quotes, newlines, or %"
|
|
812
|
+
: `${key} must not contain quotes, newlines, or %`);
|
|
813
|
+
}
|
|
814
|
+
assignments.push(`set "${key}=${value}"`);
|
|
351
815
|
}
|
|
352
|
-
|
|
816
|
+
if (assignments.length === 0)
|
|
817
|
+
return launch;
|
|
818
|
+
return `${assignments.join("&& ")}&& ${launch}`;
|
|
353
819
|
}
|