@swmansion/argent 0.24.1-next.3 → 0.24.1-next.4

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/dist/cli-cmds.mjs CHANGED
@@ -17822,6 +17822,11 @@ function asString(raw) {
17822
17822
  function asNumber(raw) {
17823
17823
  return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
17824
17824
  }
17825
+ function asPositiveInteger(raw) {
17826
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0 ? raw : void 0;
17827
+ }
17828
+ var MIN_SCRIPT_HEAP_LIMIT_MB = 32;
17829
+ var MIN_SCRIPT_TIMEOUT_MS = 100;
17825
17830
  function asStringArray(raw) {
17826
17831
  if (!Array.isArray(raw)) return void 0;
17827
17832
  const out = [];
@@ -17834,6 +17839,7 @@ var PARSER_EXPECTATIONS = /* @__PURE__ */ new Map([
17834
17839
  [asBoolean, "a boolean (true or false)"],
17835
17840
  [asString, "a non-empty string"],
17836
17841
  [asNumber, "a number"],
17842
+ [asPositiveInteger, "a whole number greater than zero"],
17837
17843
  [asStringArray, "an array of strings"]
17838
17844
  ]);
17839
17845
  function describeExpectedValue(def) {
@@ -17889,6 +17895,35 @@ var CONFIG_SCHEMA = [
17889
17895
  // remote `argent link` tool-server it is the *client's* config that decides.
17890
17896
  merge: "prioritize-local",
17891
17897
  example: "~/Movies/argent"
17898
+ },
17899
+ // Global-scope only: a checked-in `.argent/config.json` must not raise the
17900
+ // ceiling on how much of the machine a script step may occupy. `merge` is
17901
+ // nominal here — the project scope of a global-only key is never read.
17902
+ {
17903
+ key: "scripts.maxTimeoutMs",
17904
+ description: `Upper bound, in milliseconds, on the time limit a flow \`script\` step may ask for (default 300000 \u2014 five minutes). Bounds how long one script can occupy the host. Values below ${MIN_SCRIPT_TIMEOUT_MS} ms are refused: the step starts a Node process before the script runs, so a smaller ceiling ends a script that did nothing wrong.`,
17905
+ scopes: ["global"],
17906
+ parse: (raw) => {
17907
+ const value = asPositiveInteger(raw);
17908
+ return value !== void 0 && value >= MIN_SCRIPT_TIMEOUT_MS ? value : void 0;
17909
+ },
17910
+ expected: `a whole number of milliseconds, at least ${MIN_SCRIPT_TIMEOUT_MS}`,
17911
+ merge: "prioritize-global",
17912
+ default: 5 * 6e4,
17913
+ example: "300000"
17914
+ },
17915
+ {
17916
+ key: "scripts.heapLimitMb",
17917
+ description: `Old-space heap limit, in MiB, given to each flow \`script\` process (default 512). Values below ${MIN_SCRIPT_HEAP_LIMIT_MB} MiB are refused: that is already below what importing a real npm dependency needs, and under about 5 MiB the process dies inside V8's own startup before any script runs.`,
17918
+ scopes: ["global"],
17919
+ parse: (raw) => {
17920
+ const value = asPositiveInteger(raw);
17921
+ return value !== void 0 && value >= MIN_SCRIPT_HEAP_LIMIT_MB ? value : void 0;
17922
+ },
17923
+ expected: `a whole number of MiB, at least ${MIN_SCRIPT_HEAP_LIMIT_MB}`,
17924
+ merge: "prioritize-global",
17925
+ default: 512,
17926
+ example: "512"
17892
17927
  }
17893
17928
  ];
17894
17929
  function getConfigDefinition(key, registry2 = CONFIG_SCHEMA) {
@@ -21790,7 +21825,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
21790
21825
  var SESSION_ID2 = randomUUID5();
21791
21826
  function readCliVersion() {
21792
21827
  if (true) {
21793
- return "0.24.1-next.3";
21828
+ return "0.24.1-next.4";
21794
21829
  }
21795
21830
  return "0.0.0";
21796
21831
  }
@@ -0,0 +1,796 @@
1
+ // Imports nothing from the tool-server, so it needs no build step: it is copied
2
+ // next to the compiled executor and resolves its watchdogs against its own URL.
3
+
4
+ import fs from "node:fs";
5
+ import { isMainThread, Worker } from "node:worker_threads";
6
+
7
+ const LIFELINE_WATCHDOG = "flow-script-watchdog-lifeline.mjs";
8
+ const DEADLINE_WATCHDOG = "flow-script-watchdog-deadline.mjs";
9
+
10
+ /**
11
+ * `--import` is inherited by a worker thread or `child_process.fork` the script
12
+ * starts, and an active copy there would park in the handshake forever.
13
+ * `isMainThread` covers the thread; clearing this covers the child, whose
14
+ * environment is copied at spawn time.
15
+ */
16
+ const ACTIVATION_ENV = "ARGENT_FLOW_SCRIPT_RUNNER";
17
+
18
+ let finished = false;
19
+
20
+ let maxOutputBytes = 0;
21
+
22
+ let probing = false;
23
+
24
+ let idleResources = [];
25
+
26
+ let runnerIsSending = false;
27
+
28
+ const ENTRY_SETTLE_PROBE_MS = 1_000;
29
+
30
+ /**
31
+ * In step with `flow-script-protocol.ts`, which this file cannot import. An IPC
32
+ * message is deserialized whole into the parent's heap before anything can
33
+ * inspect it, so only the sender can bound script-controlled failure text.
34
+ */
35
+ const MAX_FAILURE_MESSAGE_CHARS = 8 * 1024;
36
+ const MAX_FAILURE_STACK_CHARS = 16 * 1024;
37
+
38
+ /**
39
+ * Taken while this preload is the only code that has run: `process.send` is a
40
+ * property a script may replace or delete, and reading it back later hands the
41
+ * verdict to whatever stub took its place.
42
+ */
43
+ const realSend = typeof process.send === "function" ? process.send : undefined;
44
+
45
+ const realExit = process.exit.bind(process);
46
+
47
+ /**
48
+ * The protocol channel's own descriptor, read before any script code runs:
49
+ * `process.channel` is a property a script may replace, and a wrong descriptor
50
+ * would put the verdict into some other file. See `sendSynchronously`.
51
+ */
52
+ const channelHandle = /** @type {{ fd?: number } | undefined} */ (
53
+ /** @type {unknown} */ (process.channel)
54
+ );
55
+ const channelFd = typeof channelHandle?.fd === "number" ? channelHandle.fd : -1;
56
+
57
+ /**
58
+ * `JSON.stringify` and `JSON.parse` as they were before any script code ran: a
59
+ * patch the script's dependency tree installs on the global would otherwise
60
+ * decide the encoded verdict, including slipping a `__proto__` own key past the
61
+ * validator that exists to reject it.
62
+ */
63
+ const encodeJson = JSON.stringify;
64
+ const decodeJson = JSON.parse;
65
+
66
+ const runnerListeners = [];
67
+
68
+ if (isMainThread && process.env[ACTIVATION_ENV] === "1") {
69
+ delete process.env[ACTIVATION_ENV];
70
+ await prepare();
71
+ }
72
+
73
+ async function prepare() {
74
+ const raw = await nextRequest();
75
+ const request = parseRequest(raw);
76
+ if (!request) {
77
+ finish({
78
+ type: "failure",
79
+ failureType: "protocol",
80
+ message: `The script runner received a malformed request: ${safeStringify(raw)}`,
81
+ });
82
+ return never();
83
+ }
84
+
85
+ maxOutputBytes = request.maxOutputBytes;
86
+ startWatchdogs(request.deadlineMs);
87
+
88
+ try {
89
+ globalThis.output = decodeJson(request.outputJson);
90
+ } catch (err) {
91
+ finish({
92
+ type: "failure",
93
+ failureType: "protocol",
94
+ message: `The script runner could not decode the flow output it was given: ${errorMessage(err)}`,
95
+ });
96
+ return never();
97
+ }
98
+
99
+ // Claim the crash: Node's default is to print and exit 1, which reaches the
100
+ // executor as "the script stopped its own process" and loses the error. An
101
+ // unhandled rejection arrives here too, unless the script claims it.
102
+ keepListener("uncaughtException", (err) => {
103
+ // Unless the script has a handler of its own, which plain `node` would let
104
+ // recover. This one is registered before the script loads, so any second
105
+ // listener is the script's.
106
+ if (process.listenerCount("uncaughtException") > 1) return;
107
+ finish({
108
+ type: "failure",
109
+ failureType: classifyScriptError(err),
110
+ message: errorMessage(err),
111
+ stack: errorStack(err),
112
+ });
113
+ });
114
+
115
+ // Registered before the script loads, so this runs before the script's own
116
+ // `beforeExit` handlers: every firing is spent yielding so their scheduled
117
+ // cleanup gets its round, and if they scheduled anything `beforeExit` comes
118
+ // round again — the same unbounded retry loop plain `node` runs.
119
+ keepListener("beforeExit", () => {
120
+ if (finished || probing) return;
121
+ setImmediate(() => {
122
+ if (finished || probing) return;
123
+ // Read after the yield, not during the emission: by now the handlers and
124
+ // the microtasks an `async` one queued have all run.
125
+ if (scriptScheduledWork()) return;
126
+ probing = true;
127
+ reportWhenEntrySettled(request.scriptUrl);
128
+ });
129
+ });
130
+
131
+ closeChannelToScript();
132
+
133
+ // Registered here rather than at module scope: Node references the IPC
134
+ // channel while a `message` or `disconnect` listener exists, and in the
135
+ // inactive preload a forked child inherits, that reference alone would keep
136
+ // the script's own child alive.
137
+ keepListener("disconnect", exitOnParentDisconnect);
138
+ guardRunnerListeners();
139
+ reportOnScriptExit();
140
+
141
+ // The only thing that lets the executor tell "the runner never began the
142
+ // script" apart from "the script stopped its own process".
143
+ sendToParent({ type: "started" });
144
+
145
+ // A live handle keeps the loop non-empty, so `beforeExit` would never fire.
146
+ // Unreferencing only drops it from the liveness count; the channel stays open.
147
+ if (process.channel && typeof process.channel.unref === "function") {
148
+ process.channel.unref();
149
+ }
150
+
151
+ // Read last, so it is the loop as the script inherits it. Node awaits this
152
+ // module before it loads the entry, so there is no later point that holds.
153
+ idleResources = process.getActiveResourcesInfo();
154
+ }
155
+
156
+ /**
157
+ * Whether anything the script's `beforeExit` handlers just scheduled is still
158
+ * pending. Counted per kind rather than by length: a script that closes a
159
+ * standard stream while also scheduling work would otherwise come out even.
160
+ */
161
+ function scriptScheduledWork() {
162
+ const idle = new Map();
163
+ for (const kind of idleResources) idle.set(kind, (idle.get(kind) ?? 0) + 1);
164
+ for (const kind of process.getActiveResourcesInfo()) {
165
+ const left = idle.get(kind) ?? 0;
166
+ if (left === 0) return true;
167
+ idle.set(kind, left - 1);
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * An empty event loop is not proof the script finished: a top-level `await`
174
+ * that never settles leaves nothing to run either. Re-importing the entry tells
175
+ * them apart — Node caches by URL, so a finished module resolves from the cache
176
+ * without evaluating again, while a parked one awaits the very promise that is
177
+ * not settling. The executor sends the real path Node resolved the entry from,
178
+ * so this is always a cache hit; a rejection counts as settled.
179
+ *
180
+ * The timer both holds the loop open while the probe runs and bounds the wait.
181
+ */
182
+ function reportWhenEntrySettled(scriptUrl) {
183
+ const bound = setTimeout(() => {
184
+ finish({
185
+ type: "failure",
186
+ failureType: "runtime",
187
+ message:
188
+ "The script stopped at a top-level `await` that never settled: nothing was " +
189
+ "left to run and no output was produced.",
190
+ });
191
+ }, ENTRY_SETTLE_PROBE_MS);
192
+ const report = () => {
193
+ clearTimeout(bound);
194
+ const code = process.exitCode;
195
+ if (code !== undefined && code !== null && code !== 0) {
196
+ finish({
197
+ type: "failure",
198
+ failureType: "exit",
199
+ message: `The script set process.exitCode to ${code}, which means it failed.`,
200
+ });
201
+ return;
202
+ }
203
+ // Read the global back rather than a reference captured earlier: a script
204
+ // may mutate the object or replace the binding outright, and both are legal.
205
+ const encoded = encodeOutput(globalThis.output, maxOutputBytes);
206
+ finish(
207
+ encoded.error
208
+ ? { type: "failure", failureType: "output", message: encoded.error }
209
+ : { type: "result", outputJson: encoded.json }
210
+ );
211
+ };
212
+ import(scriptUrl).then(report, report);
213
+ }
214
+
215
+ function keepListener(event, handler) {
216
+ runnerListeners.push({ event, handler });
217
+ process.on(event, handler);
218
+ }
219
+
220
+ /**
221
+ * `process.removeAllListeners()` with no argument is ordinary cleanup code and
222
+ * takes the runner's `beforeExit` probe with it. Re-registering inside the call
223
+ * also restores the runner's handlers to first place, which both depend on.
224
+ */
225
+ function guardRunnerListeners() {
226
+ const realRemoveAllListeners = process.removeAllListeners;
227
+ process.removeAllListeners = (...args) => {
228
+ const result = realRemoveAllListeners.apply(process, args);
229
+ for (const { event, handler } of runnerListeners) {
230
+ if (!process.listeners(event).includes(handler)) process.on(event, handler);
231
+ }
232
+ return result;
233
+ };
234
+ }
235
+
236
+ /**
237
+ * `beforeExit` does not fire after an explicit exit, so the common
238
+ * `main().then(() => process.exit(0))` would report as self-termination with no
239
+ * output. A non-zero exit is left to the parent's `exit` verdict.
240
+ */
241
+ function reportOnScriptExit() {
242
+ // Cast because `process.exit` is typed as returning `never` and an arrow that
243
+ // ends in a call to it is inferred as returning that call's type.
244
+ process.exit = /** @type {typeof process.exit} */ (
245
+ (...args) => {
246
+ const code = args.length > 0 ? args[0] : process.exitCode;
247
+ if (!finished && (code === undefined || code === null || Number(code) === 0)) {
248
+ const encoded = encodeOutput(globalThis.output, maxOutputBytes);
249
+ finishSynchronously(
250
+ encoded.error
251
+ ? { type: "failure", failureType: "output", message: encoded.error }
252
+ : { type: "result", outputJson: encoded.json }
253
+ );
254
+ }
255
+ // Forwarded by arity, not by value: `realExit(undefined)` differs from
256
+ // `realExit()` in whether a `process.exitCode` the script set survives.
257
+ return realExit(...args);
258
+ }
259
+ );
260
+ }
261
+
262
+ /**
263
+ * `fork` leaves a working `process.send` in the child and the executor trusts
264
+ * whatever arrives on it, so a script that pings its parent could tear down a
265
+ * healthy run or forge its own verdict. The channel stays open for the runner;
266
+ * script code gets a `send` that accepts and drops and a `disconnect` that
267
+ * closes nothing.
268
+ *
269
+ * Both stubs must still *answer* the way Node does, or a script awaiting the
270
+ * send callback or the `disconnect` event parks with an empty event loop —
271
+ * which the runner would read as a pass.
272
+ */
273
+ function closeChannelToScript() {
274
+ // `_send` is Node's undocumented implementation behind `send`, reachable by
275
+ // name from a script, so it is guarded too.
276
+ const host = /** @type {{ _send?: Function }} */ (/** @type {unknown} */ (process));
277
+ const realLowLevelSend = host._send;
278
+ // `send` calls `this._send`, so one flag guards both names — the runner's own
279
+ // call sets it for the length of that call.
280
+ process.send = (...args) => {
281
+ if (runnerIsSending) return realSend.apply(process, args);
282
+ acknowledge(args);
283
+ return true;
284
+ };
285
+ if (typeof realLowLevelSend === "function") {
286
+ host._send = (...args) => {
287
+ if (runnerIsSending) return realLowLevelSend.apply(process, args);
288
+ acknowledge(args);
289
+ return true;
290
+ };
291
+ }
292
+ process.disconnect = () => {
293
+ // Nothing is actually closed, so the runner's own handler is skipped, but
294
+ // the script's listeners still expect the event Node would have emitted.
295
+ for (const listener of process.listeners("disconnect")) {
296
+ if (listener === exitOnParentDisconnect) continue;
297
+ setImmediate(() => listener.call(process));
298
+ }
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Call a `process.send` callback the way Node would: asynchronously, with no
304
+ * error. It is the last argument of both `send` and the `_send` behind it.
305
+ */
306
+ function acknowledge(args) {
307
+ const callback = args[args.length - 1];
308
+ if (typeof callback === "function") setImmediate(() => callback(null));
309
+ }
310
+
311
+ /**
312
+ * Only reached while the event loop is still turning; a synchronous infinite
313
+ * loop never gets here, which is what the lifeline watchdog thread is for.
314
+ */
315
+ function exitOnParentDisconnect() {
316
+ realExit(0);
317
+ }
318
+
319
+ function nextRequest() {
320
+ return new Promise((resolve) => {
321
+ process.once("message", resolve);
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Park forever. `finish` exits from inside a stream callback, so returning
327
+ * after a verdict would let Node load the entry module in the meantime.
328
+ */
329
+ function never() {
330
+ return new Promise(() => {});
331
+ }
332
+
333
+ function parseRequest(raw) {
334
+ if (typeof raw !== "object" || raw === null) return null;
335
+ if (raw.type !== "execute") return null;
336
+ if (typeof raw.scriptUrl !== "string") return null;
337
+ if (typeof raw.outputJson !== "string") return null;
338
+ if (!Number.isFinite(raw.deadlineMs) || raw.deadlineMs <= 0) return null;
339
+ if (!Number.isFinite(raw.maxOutputBytes) || raw.maxOutputBytes <= 0) return null;
340
+ return raw;
341
+ }
342
+
343
+ /**
344
+ * Two worker threads, started before the script loads and unref'd so they never
345
+ * hold the process open. A worker has its own OS thread, so a main thread
346
+ * spinning in a synchronous loop cannot starve it. They cannot share one: the
347
+ * deadline's `Atomics.wait` blocks its thread for the whole time limit, so a
348
+ * lifeline there would not see the parent go until the deadline had passed.
349
+ */
350
+ function startWatchdogs(deadlineMs) {
351
+ const here = import.meta.url;
352
+ // Read here rather than in the worker: `process.ppid` is a property script
353
+ // code may replace, and the worker's own `process` is not the main thread's.
354
+ start(new URL(LIFELINE_WATCHDOG, here), { parentPid: process.ppid });
355
+ start(new URL(DEADLINE_WATCHDOG, here), { deadlineMs });
356
+
357
+ function start(url, workerData) {
358
+ try {
359
+ // `execArgv: []` keeps this preload out of the worker, which would
360
+ // otherwise inherit it and re-run this file for nothing.
361
+ const worker = new Worker(url, { execArgv: [], ...(workerData ? { workerData } : {}) });
362
+ worker.on("error", (err) => reportWatchdogProblem(url, err));
363
+ worker.unref();
364
+ } catch (err) {
365
+ reportWatchdogProblem(url, err);
366
+ }
367
+ }
368
+ }
369
+
370
+ function reportWatchdogProblem(url, err) {
371
+ try {
372
+ const name = url.href.slice(url.href.lastIndexOf("/") + 1);
373
+ process.stderr.write(`[argent] script watchdog ${name} unavailable: ${errorMessage(err)}\n`);
374
+ } catch {
375
+ // Reporting must never be what ends the run.
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Which side of the load boundary failed. The module codes below are
381
+ * unambiguous; the two rows after them are not, since the same error class
382
+ * arrives from both sides — a `SyntaxError` is a module that would not parse
383
+ * *or* `JSON.parse` of an HTML error page, and a POSIX errno is the loader
384
+ * failing to open the script *or* the script's own I/O. Loader frames separate
385
+ * them.
386
+ */
387
+ function classifyScriptError(err) {
388
+ const code = err && typeof err === "object" ? err.code : undefined;
389
+ if (
390
+ typeof code === "string" &&
391
+ (code.startsWith("ERR_MODULE") ||
392
+ code.startsWith("ERR_UNSUPPORTED") ||
393
+ code === "MODULE_NOT_FOUND" ||
394
+ code === "ERR_UNKNOWN_FILE_EXTENSION" ||
395
+ code === "ERR_PACKAGE_PATH_NOT_EXPORTED" ||
396
+ code === "ERR_IMPORT_ATTRIBUTE_MISSING" ||
397
+ code === "ERR_IMPORT_ATTRIBUTE_UNSUPPORTED" ||
398
+ code === "ERR_INVALID_MODULE_SPECIFIER")
399
+ ) {
400
+ return "load";
401
+ }
402
+ const fromLoader = isLoaderFailure(err);
403
+ if (err instanceof SyntaxError) return fromLoader ? "load" : "runtime";
404
+ if (typeof code === "string" && POSIX_ERRNO_RE.test(code)) return fromLoader ? "load" : "runtime";
405
+ return "runtime";
406
+ }
407
+
408
+ const POSIX_ERRNO_RE = /^E[A-Z]+$/;
409
+
410
+ /** Node's module loader, ESM and CommonJS alike. */
411
+ const LOADER_FRAME_RE = /node:internal\/modules\//;
412
+
413
+ /**
414
+ * True only for a loader frame with no frame naming a file above it. A file
415
+ * frame settles it the other way whatever else is on the stack — a top-level
416
+ * throw carries `ModuleJob.run` under the script's own frame — and no loader
417
+ * frame at all is not evidence either way, which is the answer for an error
418
+ * raised asynchronously or with no frames.
419
+ */
420
+ function isLoaderFailure(err) {
421
+ const stack = errorStack(err);
422
+ if (typeof stack !== "string") return false;
423
+ let loaderSeen = false;
424
+ for (const line of stack.split("\n").slice(1)) {
425
+ const frame = line.trim();
426
+ if (!frame.startsWith("at ")) continue;
427
+ if (LOADER_FRAME_RE.test(frame)) {
428
+ loaderSeen = true;
429
+ continue;
430
+ }
431
+ if (frame.includes("node:")) continue;
432
+ if (frame.includes("file:") || /[/\\]/.test(frame)) return false;
433
+ }
434
+ return loaderSeen;
435
+ }
436
+
437
+ /**
438
+ * Validation cannot happen in the parent: the IPC channel serializes as JSON,
439
+ * so a function or `undefined` vanishes silently, `NaN` and `Infinity` arrive
440
+ * as `null`, and a BigInt or cycle throws inside `send`.
441
+ *
442
+ * What is encoded is the copy the walk built, never a second read of the live
443
+ * object: a getter, a Proxy trap or a `toJSON` may answer differently the
444
+ * second time, and that answer would be the one that ships.
445
+ */
446
+ function encodeOutput(value, maxOutputBytes) {
447
+ let checked;
448
+ try {
449
+ checked = validate(value);
450
+ } catch (err) {
451
+ return { error: `output could not be read: ${errorMessage(err)}` };
452
+ }
453
+ if (checked.problem) return { error: checked.problem };
454
+
455
+ let json;
456
+ try {
457
+ json = encodeJson(checked.value);
458
+ } catch (err) {
459
+ return { error: `output could not be encoded: ${errorMessage(err)}` };
460
+ }
461
+ const bytes = Buffer.byteLength(json, "utf8");
462
+ if (bytes > maxOutputBytes) {
463
+ return {
464
+ error: `output is ${describeBytes(bytes)} encoded; the limit is ${describeBytes(maxOutputBytes)}`,
465
+ };
466
+ }
467
+ return { json };
468
+ }
469
+
470
+ function validate(root) {
471
+ // The root is what later steps read paths out of: a replaced `output = "done"`
472
+ // has nothing to merge and no path to address.
473
+ if (root === null || typeof root !== "object" || Array.isArray(root) || !isPlainObject(root)) {
474
+ return { problem: `output is ${describeValue(root)}; output must be a plain object` };
475
+ }
476
+ return walk(root, "output", new Set());
477
+ }
478
+
479
+ function walk(value, path, ancestors) {
480
+ if (value === null) return { value: null };
481
+ const type = typeof value;
482
+ if (type === "string" || type === "boolean") return { value };
483
+ if (type === "number") {
484
+ return Number.isFinite(value)
485
+ ? { value }
486
+ : { problem: `${path} is ${describeValue(value)}; output numbers must be finite` };
487
+ }
488
+ if (type !== "object") {
489
+ return { problem: `${path} is ${describeValue(value)}; output must be JSON-compatible data` };
490
+ }
491
+ // Ancestors only, not every value seen: a value referenced twice in different
492
+ // branches encodes fine; only a reference back *up* the tree cannot.
493
+ if (ancestors.has(value)) {
494
+ return { problem: `${path} is a cyclic reference; output must be a tree` };
495
+ }
496
+
497
+ if (Array.isArray(value)) {
498
+ ancestors.add(value);
499
+ const copy = [];
500
+ for (let i = 0; i < value.length; i++) {
501
+ // A hole is not `undefined` written by the author: `JSON.stringify`
502
+ // encodes it as null, and rejecting it would name an index nobody wrote.
503
+ const walked = walk(i in value ? value[i] : null, `${path}[${i}]`, ancestors);
504
+ if (walked.problem) return walked;
505
+ copy.push(walked.value);
506
+ }
507
+ ancestors.delete(value);
508
+ return { value: copy };
509
+ }
510
+ if (value instanceof Date) {
511
+ // Before the `toJSON` branch, which a Date would otherwise take: it encodes
512
+ // to a string a later step cannot read back as a date.
513
+ return {
514
+ problem: `${path} is a Date; output must be JSON-compatible data (use an ISO string)`,
515
+ };
516
+ }
517
+ if (typeof value.toJSON === "function") {
518
+ // Recorded first, because the transform is a route back up the tree the
519
+ // author cannot see: `{ toJSON() { return this; } }` would otherwise
520
+ // recurse until V8 gave up, and report a stack overflow in place of the
521
+ // path the cycle is on.
522
+ ancestors.add(value);
523
+ const walked = walk(value.toJSON(), path, ancestors);
524
+ ancestors.delete(value);
525
+ return walked;
526
+ }
527
+ if (!isPlainObject(value)) {
528
+ return { problem: `${path} is ${describeValue(value)}; output must be JSON-compatible data` };
529
+ }
530
+ ancestors.add(value);
531
+ const copy = {};
532
+ for (const key of Object.keys(value)) {
533
+ if (key === "__proto__") {
534
+ // `JSON.parse` creates this as an own key, so a parsed body would carry
535
+ // it into flow state, where a later `Object.assign` writes a prototype
536
+ // rather than a property.
537
+ return {
538
+ problem: `${path} has an own "__proto__" key; output must be JSON-compatible data`,
539
+ };
540
+ }
541
+ const walked = walk(value[key], `${path}${memberPath(key)}`, ancestors);
542
+ if (walked.problem) return walked;
543
+ copy[key] = walked.value;
544
+ }
545
+ ancestors.delete(value);
546
+ return { value: copy };
547
+ }
548
+
549
+ function isPlainObject(value) {
550
+ const proto = Object.getPrototypeOf(value);
551
+ return proto === Object.prototype || proto === null;
552
+ }
553
+
554
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
555
+
556
+ function memberPath(key) {
557
+ return IDENTIFIER_RE.test(key) ? `.${key}` : `[${encodeJson(key)}]`;
558
+ }
559
+
560
+ function describeValue(value) {
561
+ if (value === null) return "null";
562
+ if (value === undefined) return "undefined";
563
+ const type = typeof value;
564
+ if (type === "number") {
565
+ if (Number.isNaN(value)) return "NaN";
566
+ return value > 0 ? "Infinity" : "-Infinity";
567
+ }
568
+ if (type === "function") return "a function";
569
+ if (type === "symbol") return "a symbol";
570
+ if (type === "bigint") return "a BigInt";
571
+ if (type === "string") return "a string";
572
+ if (type === "boolean") return "a boolean";
573
+ if (Array.isArray(value)) return "an array";
574
+ const name = value.constructor && value.constructor.name;
575
+ return name ? `a ${name}` : "an object with an unusual prototype";
576
+ }
577
+
578
+ function describeBytes(bytes) {
579
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
580
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
581
+ return `${bytes} bytes`;
582
+ }
583
+
584
+ /**
585
+ * Follows the parent's `formatErrorForAgent`, which this file cannot import.
586
+ * The depth bound also guards a `.cause` that points back up its own chain.
587
+ */
588
+ function errorMessage(err) {
589
+ if (!(err instanceof Error)) return describeThrown(err);
590
+ const parts = [];
591
+ visitError(err, 0, CAUSED_BY, parts, new Set());
592
+ return parts.map((part, at) => (at === 0 ? part.text : part.joiner + part.text)).join("");
593
+ }
594
+
595
+ function describeThrown(value) {
596
+ return typeof value === "string" ? value : safeStringify(value);
597
+ }
598
+
599
+ function addPart(parts, joiner, text) {
600
+ if (text && !parts.some((part) => part.text.includes(text))) parts.push({ joiner, text });
601
+ }
602
+
603
+ const MAX_CAUSE_DEPTH = 8;
604
+ const CAUSED_BY = " — caused by: ";
605
+ const ALSO = "; ";
606
+
607
+ function visitError(err, depth, joiner, parts, seen) {
608
+ if (depth > MAX_CAUSE_DEPTH) return;
609
+ if (!(err instanceof Error)) {
610
+ if (err !== undefined) addPart(parts, joiner, describeThrown(err));
611
+ return;
612
+ }
613
+ if (seen.has(err)) return;
614
+ seen.add(err);
615
+ addPart(parts, joiner, err.message || String(err));
616
+ const siblings = /** @type {{ errors?: unknown }} */ (err).errors;
617
+ if (Array.isArray(siblings)) {
618
+ const before = parts.length;
619
+ for (const nested of siblings) {
620
+ visitError(nested, depth + 1, parts.length === before ? CAUSED_BY : ALSO, parts, seen);
621
+ }
622
+ }
623
+ visitError(err.cause, depth + 1, CAUSED_BY, parts, seen);
624
+ }
625
+
626
+ function errorStack(err) {
627
+ return err instanceof Error && typeof err.stack === "string" ? err.stack : undefined;
628
+ }
629
+
630
+ function safeStringify(value) {
631
+ try {
632
+ return encodeJson(value) ?? String(value);
633
+ } catch {
634
+ return String(value);
635
+ }
636
+ }
637
+
638
+ /**
639
+ * `process.stdout` is asynchronous when it is a pipe, so a bare `process.exit`
640
+ * would discard buffered log output; the callback of an empty write on each
641
+ * stream runs after every earlier write has flushed.
642
+ *
643
+ * The exit code is always 0 — the parent classifies on the terminal message.
644
+ */
645
+ function finish(response) {
646
+ if (finished) return;
647
+ finished = true;
648
+ const bounded = boundFailureText(response);
649
+ const exit = () => realExit(0);
650
+ let pending = 2;
651
+ const flushed = () => {
652
+ if (--pending === 0) exit();
653
+ };
654
+ const flush = () => {
655
+ // A stream whose peer is gone never calls back; without this fallback that
656
+ // becomes a hang for the deadline watchdog to clean up.
657
+ setTimeout(exit, 1000).unref();
658
+ flushStream(process.stdout, flushed);
659
+ flushStream(process.stderr, flushed);
660
+ };
661
+ try {
662
+ sendToParent(bounded, flush);
663
+ } catch {
664
+ flush();
665
+ }
666
+ }
667
+
668
+ /**
669
+ * The same verdict, from inside the script's own `process.exit`, where there is
670
+ * no turn of the event loop left to flush a stream in and there must not be
671
+ * one. The buffered stdout lost here is lost under plain `node` too.
672
+ */
673
+ function finishSynchronously(response) {
674
+ if (finished) return;
675
+ finished = true;
676
+ const bounded = boundFailureText(response);
677
+ if (sendSynchronously(bounded)) return;
678
+ try {
679
+ sendToParent(bounded);
680
+ } catch {
681
+ // The channel is gone; the parent's exit verdict is what is left.
682
+ }
683
+ }
684
+
685
+ /**
686
+ * `process.send` only queues: a message past the pipe buffer is written by
687
+ * libuv over later turns of the loop, and the `process.exit` this runs inside
688
+ * leaves before any of them — so a script reporting a result larger than about
689
+ * 64 KiB through `main().then(() => process.exit(0))` delivered nothing, which
690
+ * is the idiom this whole path exists for.
691
+ *
692
+ * The channel carries newline-delimited JSON, which is what `fork` uses unless
693
+ * the parent asks for `serialization: "advanced"` — the executor does not. The
694
+ * descriptor is non-blocking, so a full pipe answers `EAGAIN`, and the parent
695
+ * is reading, so waiting for room is a sleep rather than a spin.
696
+ *
697
+ * The wait carries no clock of its own: the parent's loop blocks for seconds at
698
+ * a time, and a window that ended inside one of those stalls would cut the
699
+ * frame in half. The deadline watchdog ends a wait the parent never answers,
700
+ * and it ends the process rather than the write, so the parent reads the step
701
+ * as the timeout it is instead of a clean exit with nothing captured.
702
+ */
703
+ function sendSynchronously(message) {
704
+ if (channelFd < 0) return false;
705
+ let payload;
706
+ try {
707
+ payload = Buffer.from(`${encodeJson(message)}\n`, "utf8");
708
+ } catch {
709
+ return false;
710
+ }
711
+ const slot = new Int32Array(new SharedArrayBuffer(4));
712
+ let written = 0;
713
+ while (written < payload.length) {
714
+ try {
715
+ written += fs.writeSync(channelFd, payload, written);
716
+ } catch (err) {
717
+ if (err && err.code === "EAGAIN") {
718
+ Atomics.wait(slot, 0, 0, 1);
719
+ continue;
720
+ }
721
+ // The parent is gone rather than slow. Half a frame may already be out,
722
+ // and a second copy behind it would be a line the parent cannot parse —
723
+ // which it parses inside its own stream callback. Sending again is worse
724
+ // than sending nothing.
725
+ return written > 0;
726
+ }
727
+ }
728
+ return true;
729
+ }
730
+
731
+ function boundFailureText(response) {
732
+ if (response.type !== "failure") return response;
733
+ return {
734
+ ...response,
735
+ message: clampText(response.message, MAX_FAILURE_MESSAGE_CHARS),
736
+ ...(response.stack === undefined
737
+ ? {}
738
+ : { stack: clampText(response.stack, MAX_FAILURE_STACK_CHARS) }),
739
+ };
740
+ }
741
+
742
+ /**
743
+ * The marker counts against the ceiling: left outside it the result exceeds the
744
+ * ceiling, and the parent re-clamps at that same number, dropping this marker
745
+ * and writing one that reports only the marker's own length.
746
+ */
747
+ function clampText(text, max) {
748
+ if (typeof text !== "string" || text.length <= max) return text;
749
+ let cut = max;
750
+ let marked = `${text.slice(0, cut)}${omissionMarker(text.length - cut)}`;
751
+ // Two passes at most — the marker only grows by the digits the larger count
752
+ // adds — and the `cut > 0` guard ends it for a ceiling narrower than a marker.
753
+ while (marked.length > max && cut > 0) {
754
+ cut = Math.max(0, cut - (marked.length - max));
755
+ marked = `${text.slice(0, cut)}${omissionMarker(text.length - cut)}`;
756
+ }
757
+ return marked;
758
+ }
759
+
760
+ /** The tail {@link clampText} leaves behind; the parent reads it back. */
761
+ function omissionMarker(omitted) {
762
+ return `… [${omitted} more characters omitted]`;
763
+ }
764
+
765
+ /**
766
+ * An empty write, so the callback runs after everything already buffered. A
767
+ * script may have ended the stream itself, and writing to an ended stream
768
+ * raises an unhandled `error` event that would land in the step's own log.
769
+ */
770
+ function flushStream(stream, done) {
771
+ if (!stream || stream.writableEnded || stream.destroyed) {
772
+ done();
773
+ return;
774
+ }
775
+ stream.once("error", done);
776
+ try {
777
+ stream.write("", done);
778
+ } catch {
779
+ done();
780
+ }
781
+ }
782
+
783
+ /**
784
+ * The only path onto the protocol channel; see `closeChannelToScript`. Goes
785
+ * through the `send` captured at load rather than whatever `process.send` names
786
+ * by now, which is the script's to replace or delete.
787
+ */
788
+ function sendToParent(message, callback) {
789
+ const send = realSend ?? process.send;
790
+ runnerIsSending = true;
791
+ try {
792
+ return send.call(process, message, callback);
793
+ } finally {
794
+ runnerIsSending = false;
795
+ }
796
+ }
@@ -0,0 +1,26 @@
1
+ // The step's time limit applied inside the child, so an orphan has a bounded
2
+ // life even on a host where the lifeline does not fire: `Atomics.wait` behaves
3
+ // identically everywhere, while the lifeline's end-of-file reporting differs by
4
+ // platform. It blocks the thread outright — no event loop, no timer, no CPU —
5
+ // so it costs nothing while the script runs.
6
+ //
7
+ // The deadline the parent sends is deliberately its own limit plus a margin, so
8
+ // that this stays the second line and not the first: a parent that reports
9
+ // "timed out and was stopped" says more than a child that kills its own group
10
+ // and leaves the parent describing an unexplained SIGKILL.
11
+
12
+ import { workerData } from "node:worker_threads";
13
+
14
+ const deadlineMs = workerData && workerData.deadlineMs;
15
+ if (Number.isFinite(deadlineMs) && deadlineMs > 0) {
16
+ const slot = new Int32Array(new SharedArrayBuffer(4));
17
+ Atomics.wait(slot, 0, 0, deadlineMs);
18
+ // The group, so a descendant the script started goes with it: reaching here
19
+ // means the parent that would have reaped them could not.
20
+ try {
21
+ process.kill(-process.pid, "SIGKILL");
22
+ } catch {
23
+ // No process group to name (Windows, or a runner that never led one).
24
+ }
25
+ process.kill(process.pid, "SIGKILL");
26
+ }
@@ -0,0 +1,93 @@
1
+ // Reads fd 4, the extra pipe the executor opens when it forks this process.
2
+ // Nothing is ever written to it; the parent holds the other end, and that end
3
+ // closing is how a runner learns the tool server is gone. This thread then
4
+ // stops the whole process.
5
+ //
6
+ // None of the softer controls reach a runner whose parent died: a tool-server
7
+ // process-group stop does not reach a detached runner, and the runner's
8
+ // `disconnect` handler is a main-thread event-loop callback that a synchronous
9
+ // infinite loop never yields to. A worker thread has its own event loop on its
10
+ // own OS thread, so a spinning main thread cannot starve it.
11
+ //
12
+ // It reads through the event loop, not `fs.readSync`: a thread parked inside a
13
+ // synchronous syscall cannot be joined, and Node joins its worker threads
14
+ // before leaving — so with a blocking read every exit path hangs until the
15
+ // parent's time limit, including a passing script's own exit.
16
+
17
+ import fs from "node:fs";
18
+ import net from "node:net";
19
+ import { workerData } from "node:worker_threads";
20
+
21
+ const LIFELINE_FD = 4;
22
+
23
+ /**
24
+ * How often the pid below is checked. Loose on purpose: it is the second
25
+ * reading, for a descriptor that is gone, and the end of file is what answers
26
+ * in the ordinary case within a millisecond.
27
+ */
28
+ const PARENT_POLL_MS = 1_000;
29
+
30
+ const stop = () => {
31
+ // The *group*, not just this process: the tool server is already gone, so its
32
+ // cleanup will never run and every descendant the script started would be
33
+ // left behind. Killing the group takes this process with it, which is the
34
+ // point — the main thread it has to stop may be in the very synchronous loop
35
+ // this control exists for.
36
+ try {
37
+ process.kill(-process.pid, "SIGKILL");
38
+ } catch {
39
+ // No process group to name (Windows, or a runner that never led one).
40
+ }
41
+ process.kill(process.pid, "SIGKILL");
42
+ };
43
+
44
+ try {
45
+ const lifeline = new net.Socket({ fd: LIFELINE_FD, readable: true, writable: false });
46
+ // End of file reaches a socket as `end`, `close` or a broken-pipe `error`
47
+ // depending on the platform; all three mean the parent is gone.
48
+ lifeline.on("end", stop);
49
+ lifeline.on("close", stop);
50
+ lifeline.on("error", stop);
51
+ // The parent never writes, but a paused stream would never reach its own end
52
+ // event.
53
+ lifeline.resume();
54
+ } catch (err) {
55
+ // Reporting must never be what ends this thread. The descriptor is missing
56
+ // in the first place because the parent is gone or the script took it away,
57
+ // and either way stderr may be a pipe with no reader left, whose write throws
58
+ // `EPIPE` — at module scope, so the thread would die with the watchdog below
59
+ // unarmed, exactly when it is the only one left.
60
+ try {
61
+ fs.writeSync(2, `[argent] script lifeline unavailable: ${err && err.message}\n`);
62
+ } catch {
63
+ // Nothing is listening; the note is not worth the watchdog.
64
+ }
65
+ }
66
+
67
+ // The same news read a second way, because a descriptor is a number script code
68
+ // can name: `fs.closeSync(4)`, or a helper that closes everything above stderr.
69
+ // Closed before this armed, the socket above could not be built; closed after,
70
+ // the kernel drops the descriptor from the poller and no end of file ever
71
+ // fires. Either way the run finished with the group kill never armed, and
72
+ // descendants outlived a tool server that died afterwards.
73
+ //
74
+ // The parent's number, which nothing in this process can take away. POSIX
75
+ // re-parents an orphan the moment its parent dies, so a changed `ppid` is the
76
+ // same news the end of file carries — and it is news at the death rather than
77
+ // at the reaping, unlike asking whether the pid can still be signalled, which a
78
+ // parent nobody has waited on answers yes to. The read is live inside a worker
79
+ // thread. Windows keeps the original number, so there the descriptor is all
80
+ // there is.
81
+ //
82
+ // The runner reads the pid before any script code runs, so it is the real
83
+ // parent's. A parent that is already pid 1 is not watched: an orphan re-parents
84
+ // to pid 1, so the comparison could never come out true.
85
+ const parentPid = workerData && workerData.parentPid;
86
+ if (process.platform !== "win32" && Number.isInteger(parentPid) && parentPid > 1) {
87
+ // Deliberately not unref'd: it is what keeps this thread alive once the
88
+ // socket is gone, and the runner unrefs the whole worker, so it never holds
89
+ // the process open by itself.
90
+ setInterval(() => {
91
+ if (process.ppid !== parentPid) stop();
92
+ }, PARENT_POLL_MS);
93
+ }
@@ -15257,6 +15257,11 @@ function asString(raw) {
15257
15257
  const trimmed = raw.trim();
15258
15258
  return trimmed === "" ? void 0 : trimmed;
15259
15259
  }
15260
+ function asPositiveInteger(raw) {
15261
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0 ? raw : void 0;
15262
+ }
15263
+ var MIN_SCRIPT_HEAP_LIMIT_MB = 32;
15264
+ var MIN_SCRIPT_TIMEOUT_MS = 100;
15260
15265
  function asStringArray(raw) {
15261
15266
  if (!Array.isArray(raw)) return void 0;
15262
15267
  const out = [];
@@ -15315,6 +15320,35 @@ var CONFIG_SCHEMA = [
15315
15320
  // remote `argent link` tool-server it is the *client's* config that decides.
15316
15321
  merge: "prioritize-local",
15317
15322
  example: "~/Movies/argent"
15323
+ },
15324
+ // Global-scope only: a checked-in `.argent/config.json` must not raise the
15325
+ // ceiling on how much of the machine a script step may occupy. `merge` is
15326
+ // nominal here — the project scope of a global-only key is never read.
15327
+ {
15328
+ key: "scripts.maxTimeoutMs",
15329
+ description: `Upper bound, in milliseconds, on the time limit a flow \`script\` step may ask for (default 300000 \u2014 five minutes). Bounds how long one script can occupy the host. Values below ${MIN_SCRIPT_TIMEOUT_MS} ms are refused: the step starts a Node process before the script runs, so a smaller ceiling ends a script that did nothing wrong.`,
15330
+ scopes: ["global"],
15331
+ parse: (raw) => {
15332
+ const value = asPositiveInteger(raw);
15333
+ return value !== void 0 && value >= MIN_SCRIPT_TIMEOUT_MS ? value : void 0;
15334
+ },
15335
+ expected: `a whole number of milliseconds, at least ${MIN_SCRIPT_TIMEOUT_MS}`,
15336
+ merge: "prioritize-global",
15337
+ default: 5 * 6e4,
15338
+ example: "300000"
15339
+ },
15340
+ {
15341
+ key: "scripts.heapLimitMb",
15342
+ description: `Old-space heap limit, in MiB, given to each flow \`script\` process (default 512). Values below ${MIN_SCRIPT_HEAP_LIMIT_MB} MiB are refused: that is already below what importing a real npm dependency needs, and under about 5 MiB the process dies inside V8's own startup before any script runs.`,
15343
+ scopes: ["global"],
15344
+ parse: (raw) => {
15345
+ const value = asPositiveInteger(raw);
15346
+ return value !== void 0 && value >= MIN_SCRIPT_HEAP_LIMIT_MB ? value : void 0;
15347
+ },
15348
+ expected: `a whole number of MiB, at least ${MIN_SCRIPT_HEAP_LIMIT_MB}`,
15349
+ merge: "prioritize-global",
15350
+ default: 512,
15351
+ example: "512"
15318
15352
  }
15319
15353
  ];
15320
15354
  function getConfigDefinition(key, registry = CONFIG_SCHEMA) {
@@ -16644,7 +16678,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
16644
16678
  var SESSION_ID = randomUUID4();
16645
16679
  function readCliVersion() {
16646
16680
  if (true) {
16647
- return "0.24.1-next.3";
16681
+ return "0.24.1-next.4";
16648
16682
  }
16649
16683
  return "0.0.0";
16650
16684
  }
@@ -17177,6 +17177,11 @@ function asString(raw) {
17177
17177
  const trimmed = raw.trim();
17178
17178
  return trimmed === "" ? void 0 : trimmed;
17179
17179
  }
17180
+ function asPositiveInteger(raw) {
17181
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0 ? raw : void 0;
17182
+ }
17183
+ var MIN_SCRIPT_HEAP_LIMIT_MB = 32;
17184
+ var MIN_SCRIPT_TIMEOUT_MS = 100;
17180
17185
  function asStringArray(raw) {
17181
17186
  if (!Array.isArray(raw)) return void 0;
17182
17187
  const out = [];
@@ -17235,6 +17240,35 @@ var CONFIG_SCHEMA = [
17235
17240
  // remote `argent link` tool-server it is the *client's* config that decides.
17236
17241
  merge: "prioritize-local",
17237
17242
  example: "~/Movies/argent"
17243
+ },
17244
+ // Global-scope only: a checked-in `.argent/config.json` must not raise the
17245
+ // ceiling on how much of the machine a script step may occupy. `merge` is
17246
+ // nominal here — the project scope of a global-only key is never read.
17247
+ {
17248
+ key: "scripts.maxTimeoutMs",
17249
+ description: `Upper bound, in milliseconds, on the time limit a flow \`script\` step may ask for (default 300000 \u2014 five minutes). Bounds how long one script can occupy the host. Values below ${MIN_SCRIPT_TIMEOUT_MS} ms are refused: the step starts a Node process before the script runs, so a smaller ceiling ends a script that did nothing wrong.`,
17250
+ scopes: ["global"],
17251
+ parse: (raw) => {
17252
+ const value = asPositiveInteger(raw);
17253
+ return value !== void 0 && value >= MIN_SCRIPT_TIMEOUT_MS ? value : void 0;
17254
+ },
17255
+ expected: `a whole number of milliseconds, at least ${MIN_SCRIPT_TIMEOUT_MS}`,
17256
+ merge: "prioritize-global",
17257
+ default: 5 * 6e4,
17258
+ example: "300000"
17259
+ },
17260
+ {
17261
+ key: "scripts.heapLimitMb",
17262
+ description: `Old-space heap limit, in MiB, given to each flow \`script\` process (default 512). Values below ${MIN_SCRIPT_HEAP_LIMIT_MB} MiB are refused: that is already below what importing a real npm dependency needs, and under about 5 MiB the process dies inside V8's own startup before any script runs.`,
17263
+ scopes: ["global"],
17264
+ parse: (raw) => {
17265
+ const value = asPositiveInteger(raw);
17266
+ return value !== void 0 && value >= MIN_SCRIPT_HEAP_LIMIT_MB ? value : void 0;
17267
+ },
17268
+ expected: `a whole number of MiB, at least ${MIN_SCRIPT_HEAP_LIMIT_MB}`,
17269
+ merge: "prioritize-global",
17270
+ default: 512,
17271
+ example: "512"
17238
17272
  }
17239
17273
  ];
17240
17274
  function getConfigDefinition(key, registry2 = CONFIG_SCHEMA) {
@@ -90954,6 +90954,11 @@ function asString(raw) {
90954
90954
  const trimmed = raw.trim();
90955
90955
  return trimmed === "" ? void 0 : trimmed;
90956
90956
  }
90957
+ function asPositiveInteger(raw) {
90958
+ return typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0 ? raw : void 0;
90959
+ }
90960
+ var MIN_SCRIPT_HEAP_LIMIT_MB = 32;
90961
+ var MIN_SCRIPT_TIMEOUT_MS = 100;
90957
90962
  function asStringArray(raw) {
90958
90963
  if (!Array.isArray(raw)) return void 0;
90959
90964
  const out = [];
@@ -91012,6 +91017,35 @@ var CONFIG_SCHEMA = [
91012
91017
  // remote `argent link` tool-server it is the *client's* config that decides.
91013
91018
  merge: "prioritize-local",
91014
91019
  example: "~/Movies/argent"
91020
+ },
91021
+ // Global-scope only: a checked-in `.argent/config.json` must not raise the
91022
+ // ceiling on how much of the machine a script step may occupy. `merge` is
91023
+ // nominal here — the project scope of a global-only key is never read.
91024
+ {
91025
+ key: "scripts.maxTimeoutMs",
91026
+ description: `Upper bound, in milliseconds, on the time limit a flow \`script\` step may ask for (default 300000 \u2014 five minutes). Bounds how long one script can occupy the host. Values below ${MIN_SCRIPT_TIMEOUT_MS} ms are refused: the step starts a Node process before the script runs, so a smaller ceiling ends a script that did nothing wrong.`,
91027
+ scopes: ["global"],
91028
+ parse: (raw) => {
91029
+ const value = asPositiveInteger(raw);
91030
+ return value !== void 0 && value >= MIN_SCRIPT_TIMEOUT_MS ? value : void 0;
91031
+ },
91032
+ expected: `a whole number of milliseconds, at least ${MIN_SCRIPT_TIMEOUT_MS}`,
91033
+ merge: "prioritize-global",
91034
+ default: 5 * 6e4,
91035
+ example: "300000"
91036
+ },
91037
+ {
91038
+ key: "scripts.heapLimitMb",
91039
+ description: `Old-space heap limit, in MiB, given to each flow \`script\` process (default 512). Values below ${MIN_SCRIPT_HEAP_LIMIT_MB} MiB are refused: that is already below what importing a real npm dependency needs, and under about 5 MiB the process dies inside V8's own startup before any script runs.`,
91040
+ scopes: ["global"],
91041
+ parse: (raw) => {
91042
+ const value = asPositiveInteger(raw);
91043
+ return value !== void 0 && value >= MIN_SCRIPT_HEAP_LIMIT_MB ? value : void 0;
91044
+ },
91045
+ expected: `a whole number of MiB, at least ${MIN_SCRIPT_HEAP_LIMIT_MB}`,
91046
+ merge: "prioritize-global",
91047
+ default: 512,
91048
+ example: "512"
91015
91049
  }
91016
91050
  ];
91017
91051
  function getConfigDefinition(key2, registry2 = CONFIG_SCHEMA) {
@@ -94579,7 +94613,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
94579
94613
  var SESSION_ID = (0, import_node_crypto3.randomUUID)();
94580
94614
  function readCliVersion() {
94581
94615
  if (true) {
94582
- return "0.24.1-next.3";
94616
+ return "0.24.1-next.4";
94583
94617
  }
94584
94618
  return "0.0.0";
94585
94619
  }
@@ -123997,11 +124031,63 @@ ${describeSecretSources(sources)}
123997
124031
  });
123998
124032
  return { text: resolved, secrets };
123999
124033
  }
124034
+ function scrubSecretValues(text, secrets) {
124035
+ return scrubSecretChunk(text, secrets, true).emit;
124036
+ }
124037
+ function scrubSecretChunk(text, secrets, final) {
124038
+ const ordered = orderedSecrets(secrets);
124039
+ if (ordered.length === 0) return { emit: text, held: 0 };
124040
+ const longestValue = ordered[0].value.length;
124041
+ const names = new Set(ordered.map((secret) => secret.name));
124042
+ const longestName = Math.max(...ordered.map((secret) => secret.name.length));
124043
+ let out = "";
124044
+ let copied = 0;
124045
+ let at = 0;
124046
+ while (at < text.length) {
124047
+ const marker = markerLengthAt(text, at, names, longestName);
124048
+ if (marker > 0 && !valueLeavesMarker(text, at, at + marker, ordered)) {
124049
+ at += marker;
124050
+ continue;
124051
+ }
124052
+ if (!final && text.length - at < longestValue && beginsAValue(text, at, ordered)) break;
124053
+ const hit = ordered.find((secret) => text.startsWith(secret.value, at));
124054
+ if (hit) {
124055
+ out += `${text.slice(copied, at)}${SECRET_PLACEHOLDER_MARKER}${hit.name}}}`;
124056
+ at += hit.value.length;
124057
+ copied = at;
124058
+ continue;
124059
+ }
124060
+ at += 1;
124061
+ }
124062
+ return {
124063
+ emit: copied === 0 ? text.slice(0, at) : out + text.slice(copied, at),
124064
+ held: text.length - at
124065
+ };
124066
+ }
124067
+ function valueLeavesMarker(text, from2, end, ordered) {
124068
+ for (let at = from2; at < end; at++) {
124069
+ for (const { value } of ordered) {
124070
+ if (at + value.length >= end && text.startsWith(value, at)) return true;
124071
+ }
124072
+ }
124073
+ return false;
124074
+ }
124075
+ function beginsAValue(text, at, ordered) {
124076
+ const rest = text.slice(at);
124077
+ return ordered.some(({ value }) => value.length > rest.length && value.startsWith(rest));
124078
+ }
124079
+ function orderedSecrets(secrets) {
124080
+ return secrets.filter(({ value }) => value.length > 0).sort((a, b) => b.value.length - a.value.length);
124081
+ }
124082
+ function markerLengthAt(text, at, names, longestName) {
124083
+ if (!text.startsWith(SECRET_PLACEHOLDER_MARKER, at)) return 0;
124084
+ const from2 = at + SECRET_PLACEHOLDER_MARKER.length;
124085
+ const window2 = text.slice(from2, from2 + longestName + 2);
124086
+ const end = window2.indexOf("}}");
124087
+ return end >= 0 && names.has(window2.slice(0, end)) ? SECRET_PLACEHOLDER_MARKER.length + end + 2 : 0;
124088
+ }
124000
124089
  function redactSecretsFromError(err, secrets) {
124001
- const scrub = (s) => secrets.reduce(
124002
- (acc, { name, value }) => value ? acc.split(value).join(`${SECRET_PLACEHOLDER_MARKER}${name}}}`) : acc,
124003
- s
124004
- );
124090
+ const scrub = (s) => scrubSecretValues(s, secrets);
124005
124091
  if (err instanceof Error) {
124006
124092
  err.message = scrub(err.message);
124007
124093
  if (err.stack) err.stack = scrub(err.stack);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/argent",
3
- "version": "0.24.1-next.3",
3
+ "version": "0.24.1-next.4",
4
4
  "mcpName": "io.github.software-mansion/argent",
5
5
  "description": "MCP server for iOS Simulator and Android Emulator control",
6
6
  "license": "Apache-2.0",