start-command 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +5 -0
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +20 -1
  7. package/src/lib/command-builder.js +3 -0
  8. package/src/lib/docker-cleanup.js +12 -5
  9. package/src/lib/docker-command-handoff.js +75 -0
  10. package/src/lib/docker-labels.js +33 -0
  11. package/src/lib/docker-runtime-args.js +8 -0
  12. package/src/lib/docker-snapshot-safety.js +164 -0
  13. package/src/lib/execution-recovery.js +8 -1
  14. package/src/lib/execution-resume.js +184 -37
  15. package/src/lib/failure-handler.js +15 -5
  16. package/src/lib/isolation.js +20 -4
  17. package/src/lib/log-sanitizer.js +267 -0
  18. package/src/lib/log-uploader.js +38 -19
  19. package/src/lib/query-commands.js +4 -3
  20. package/src/lib/shell-utils.js +67 -17
  21. package/src/lib/usage.js +5 -1
  22. package/test/ci-workflow-invariants.js +15 -14
  23. package/test/docker-command-handoff.js +107 -0
  24. package/test/docker-runtime-options.js +4 -0
  25. package/test/docker-snapshot-safety.js +106 -0
  26. package/test/execution-resume.js +10 -8
  27. package/test/failure-handler.js +11 -0
  28. package/test/javascript-first-ci.js +216 -0
  29. package/test/log-sanitizer.js +306 -0
  30. package/test/regression-176.js +7 -0
  31. package/test/regression-187.js +9 -1
  32. package/test/regression-191.js +9 -1
  33. package/test/regression-193.js +11 -1
  34. package/test/regression-198.js +281 -0
  35. package/test/regression-199.js +79 -0
  36. package/test/regression-201.js +25 -0
  37. package/test/regression-202.js +92 -0
  38. package/test/regression-91.js +23 -20
  39. package/test/shared-golden.js +54 -0
  40. package/test/status-query.js +97 -15
@@ -7,13 +7,13 @@
7
7
  * addressing one logical session across restarts.
8
8
  *
9
9
  * Three strategies, chosen from the probed session state:
10
- * - DOCKER_START: the container still exists and the stored command is
11
- * re-run by `docker start` (its original entrypoint).
12
- * - DOCKER_SNAPSHOT: the container still exists but a new command was given,
13
- * so its filesystem is committed to an image and a derived
14
- * container runs the new command. This avoids
15
- * `docker start -ai`, which would re-run the original
16
- * entrypoint from scratch.
10
+ * - DOCKER_START: the container still exists. Newly managed containers
11
+ * accept replacement commands through a small handoff
12
+ * script, then restart in place without a snapshot.
13
+ * - DOCKER_SNAPSHOT: a legacy container needs a replacement command, or
14
+ * immutable labels need changing. After disk preflight,
15
+ * its filesystem is committed under a host-wide lock and
16
+ * a derived container runs the command.
17
17
  * - RELAUNCH: nothing is left of the session, so the command is
18
18
  * launched again through the stored isolation options.
19
19
  */
@@ -44,6 +44,16 @@ const {
44
44
  } = require('./launch-persistence');
45
45
  const { SessionState, probeSession } = require('./session-probe');
46
46
  const { getDockerNetworks } = require('./docker-network-lifecycle');
47
+ const {
48
+ writeCommandHandoff,
49
+ buildCommandHandoffArgs,
50
+ commandHandoffPath,
51
+ } = require('./docker-command-handoff');
52
+ const {
53
+ acquireSnapshotLock,
54
+ preflightSnapshot,
55
+ SNAPSHOT_IMAGE_LABEL,
56
+ } = require('./docker-snapshot-safety');
47
57
  const {
48
58
  ResumeAllAction,
49
59
  resumeAllExecutions,
@@ -94,6 +104,11 @@ function buildLaunchOptions(record) {
94
104
  const opts = record.options || {};
95
105
  return {
96
106
  ...opts,
107
+ labels: opts.labels || [],
108
+ sessionId: record.uuid,
109
+ rootSession:
110
+ opts.rootSession || opts.sessionNameHistory?.[0] || opts.sessionName,
111
+ resumeCount: (Number(opts.resumeCount) || 0) + 1,
97
112
  image: opts.image || null,
98
113
  session: opts.sessionName,
99
114
  detached: true,
@@ -214,12 +229,13 @@ function buildResumePlan(
214
229
  const attempt = (Number(opts.resumeCount) || 0) + 1;
215
230
 
216
231
  if (backend === 'docker' && probe.state === SessionState.STOPPED) {
217
- if (!newCommand) {
232
+ if (!opts.forceSnapshot && (!newCommand || opts.commandHandoff)) {
218
233
  return {
219
234
  mode: ResumeMode.DOCKER_START,
220
235
  backend,
221
236
  sessionName,
222
237
  command,
238
+ ...(newCommand ? { handoffCommand: command } : {}),
223
239
  attempt,
224
240
  steps: [
225
241
  {
@@ -228,7 +244,9 @@ function buildResumePlan(
228
244
  description: `Start stopped container ${sessionName}`,
229
245
  },
230
246
  ],
231
- message: `Resumed detached docker container: ${sessionName}`,
247
+ message: newCommand
248
+ ? `Resumed new command in the same detached docker container: ${sessionName} (no filesystem snapshot)`
249
+ : `Resumed detached docker container: ${sessionName}`,
232
250
  };
233
251
  }
234
252
 
@@ -261,12 +279,24 @@ function buildResumePlan(
261
279
  containerArgs: [
262
280
  '--name',
263
281
  newSessionName,
282
+ '--label',
283
+ `${SNAPSHOT_IMAGE_LABEL}=${snapshotImage}`,
264
284
  ...(opts.user ? ['--user', opts.user] : []),
265
- ...buildDockerRuntimeArgs({ ...opts, resourceLimits }),
285
+ ...buildDockerRuntimeArgs({
286
+ ...opts,
287
+ resourceLimits,
288
+ session: newSessionName,
289
+ containerName: newSessionName,
290
+ sessionId: record.uuid,
291
+ rootSession:
292
+ opts.rootSession || opts.sessionNameHistory?.[0] || sessionName,
293
+ resumeCount: attempt,
294
+ }),
266
295
  snapshotImage,
267
- 'sh',
268
- '-c',
269
- command,
296
+ ...buildCommandHandoffArgs(
297
+ ['sh', '-c', command],
298
+ commandHandoffPath(newSessionName)
299
+ ),
270
300
  ],
271
301
  extraNetworks: getDockerNetworks(opts).slice(1),
272
302
  newSessionName,
@@ -360,6 +390,10 @@ function applyResumeToRecord(
360
390
  archiveAttempt(record);
361
391
  record.attempt = attempt;
362
392
  const options = { ...(record.options || {}) };
393
+ options.rootSession =
394
+ options.rootSession ||
395
+ options.sessionNameHistory?.[0] ||
396
+ options.sessionName;
363
397
  options.resumeCount = plan.attempt;
364
398
  options.resumedAt = attempt.startedAt;
365
399
  // A resume is a new deliberate start: launch-time recovery applies again.
@@ -378,6 +412,12 @@ function applyResumeToRecord(
378
412
  }
379
413
  if (plan.snapshotImage) {
380
414
  options.image = plan.snapshotImage;
415
+ options.snapshotImage = plan.snapshotImage;
416
+ // Every new detached container, including a snapshot successor, supports handoff.
417
+ options.commandHandoff = true;
418
+ }
419
+ if (plan.mode === ResumeMode.RELAUNCH && plan.backend === 'docker') {
420
+ options.commandHandoff = true;
381
421
  }
382
422
  if (containerId) {
383
423
  options.containerId = containerId;
@@ -445,12 +485,34 @@ async function resumeExecution(store, identifier, deps = {}) {
445
485
  } catch (error) {
446
486
  return { success: false, error: error.message };
447
487
  }
448
- const planningRecord = resources
449
- ? {
450
- ...record,
451
- options: { ...opts, resourceLimits: resources.resourceLimits },
452
- }
453
- : record;
488
+ const requestedLabels = deps.resourceOptions?.labels || [];
489
+ const labels = [...(opts.labels || [])];
490
+ for (const label of requestedLabels) {
491
+ const key = label.split('=')[0];
492
+ const existing = labels.findIndex((value) => value.split('=')[0] === key);
493
+ if (existing >= 0) {
494
+ labels[existing] = label;
495
+ } else {
496
+ labels.push(label);
497
+ }
498
+ }
499
+ if (requestedLabels.length && opts.isolated !== 'docker') {
500
+ return {
501
+ success: false,
502
+ error: 'Docker labels require a Docker execution when resuming.',
503
+ };
504
+ }
505
+ const labelsChanged =
506
+ JSON.stringify(labels) !== JSON.stringify(opts.labels || []);
507
+ const planningRecord = {
508
+ ...record,
509
+ options: {
510
+ ...opts,
511
+ ...(resources ? { resourceLimits: resources.resourceLimits } : {}),
512
+ ...(requestedLabels.length ? { labels } : {}),
513
+ ...(labelsChanged ? { forceSnapshot: true } : {}),
514
+ },
515
+ };
454
516
  const plan = buildResumePlan(
455
517
  planningRecord,
456
518
  deps.command || null,
@@ -485,6 +547,9 @@ async function resumeExecution(store, identifier, deps = {}) {
485
547
  };
486
548
  appendLifecycle(lifecycleRecord, 'resume-started');
487
549
  const updated = applyResumeToRecord(record, plan, null, attempt);
550
+ if (requestedLabels.length) {
551
+ updated.options.labels = labels;
552
+ }
488
553
  if (resources) {
489
554
  updated.options.resolvedLimits = resources.resolvedLimits;
490
555
  updated.options.resourceLimitSpecs = resources.resourceLimitSpecs;
@@ -540,26 +605,108 @@ async function resumeExecution(store, identifier, deps = {}) {
540
605
  }
541
606
  containerId = launchResult.containerId || null;
542
607
  } else {
543
- for (const step of plan.steps) {
544
- let result;
545
- try {
546
- result = runner(step.command, step.args);
547
- } catch (error) {
548
- result = { success: false, error: error.message };
608
+ let snapshotLock;
609
+ let snapshotCreated = false;
610
+ let derivedCreated = false;
611
+ try {
612
+ if (plan.mode === ResumeMode.DOCKER_SNAPSHOT) {
613
+ snapshotLock = acquireSnapshotLock(deps.snapshotOptions);
614
+ const preflight = preflightSnapshot(
615
+ plan.sessionName,
616
+ runner,
617
+ deps.snapshotOptions
618
+ );
619
+ plan.message += `\n${preflight.message}`;
620
+ if (record.logPath) {
621
+ appendLogFile(record.logPath, `${preflight.message}\n`);
622
+ }
549
623
  }
550
- if (!result.success) {
551
- rollbackLaunch(store, previous);
552
- const detail =
553
- (result.stderr || '').trim() ||
554
- result.error ||
555
- `exit code ${result.status}`;
556
- appendLifecycle(lifecycleRecord, 'launch-failed', { error: detail });
557
- return {
558
- success: false,
559
- error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${detail}`,
560
- };
624
+ if (plan.handoffCommand) {
625
+ const copied = writeCommandHandoff(
626
+ plan.sessionName,
627
+ plan.handoffCommand,
628
+ runner,
629
+ opts
630
+ );
631
+ if (!copied.success) {
632
+ throw new Error(
633
+ (copied.stderr || '').trim() ||
634
+ copied.error ||
635
+ 'Could not copy replacement command into the stopped container'
636
+ );
637
+ }
561
638
  }
562
- containerId = (result.stdout || '').trim() || containerId;
639
+ for (const step of plan.steps) {
640
+ let result;
641
+ try {
642
+ result = runner(step.command, step.args);
643
+ } catch (error) {
644
+ result = { success: false, error: error.message };
645
+ }
646
+ if (!result.success) {
647
+ const detail =
648
+ (result.stderr || '').trim() ||
649
+ result.error ||
650
+ `exit code ${result.status}`;
651
+ throw new Error(detail);
652
+ }
653
+ if (step.args[0] === 'commit') {
654
+ snapshotCreated = true;
655
+ }
656
+ if (step.args[0] === 'create') {
657
+ derivedCreated = true;
658
+ }
659
+ // commit returns an image ID; cp/update/network commands are not IDs.
660
+ if (['run', 'create'].includes(step.args[0])) {
661
+ containerId = (result.stdout || '').trim() || containerId;
662
+ }
663
+ }
664
+ if (plan.mode === ResumeMode.DOCKER_SNAPSHOT && deps.removeOriginal) {
665
+ const running = runner(getDockerCommand(), [
666
+ 'inspect',
667
+ '-f',
668
+ '{{.State.Running}}',
669
+ plan.newSessionName,
670
+ ]);
671
+ if (running.success && String(running.stdout).trim() === 'true') {
672
+ const removed = runner(getDockerCommand(), ['rm', plan.sessionName]);
673
+ const note = removed.success
674
+ ? `Stopped original container removed: ${plan.sessionName}`
675
+ : `Original container retained: ${(removed.stderr || removed.error || 'docker rm failed').trim()}`;
676
+ plan.message += `\n${note}`;
677
+ if (record.logPath) {
678
+ appendLogFile(record.logPath, `${note}\n`);
679
+ }
680
+ } else {
681
+ plan.message +=
682
+ '\nOriginal container retained: the snapshot container is not running.';
683
+ }
684
+ }
685
+ } catch (error) {
686
+ if (derivedCreated) {
687
+ runner(getDockerCommand(), ['rm', plan.newSessionName]);
688
+ }
689
+ if (snapshotCreated) {
690
+ runner(getDockerCommand(), ['rmi', plan.snapshotImage]);
691
+ }
692
+ if (plan.handoffCommand) {
693
+ // A failed start must not leave a replacement hidden behind the old record.
694
+ try {
695
+ writeCommandHandoff(plan.sessionName, previous.command, runner, opts);
696
+ } catch {
697
+ /* Next explicit command can still repair the handoff. */
698
+ }
699
+ }
700
+ rollbackLaunch(store, previous);
701
+ appendLifecycle(lifecycleRecord, 'launch-failed', {
702
+ error: error.message,
703
+ });
704
+ return {
705
+ success: false,
706
+ error: `Failed to resume ${plan.backend} session "${plan.sessionName}": ${error.message}`,
707
+ };
708
+ } finally {
709
+ snapshotLock?.release();
563
710
  }
564
711
 
565
712
  const limitsLine = buildResourceLimitsStatusLine(plan.resourceLimits);
@@ -9,6 +9,7 @@ const os = require('os');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { getTimestamp } = require('./isolation');
12
+ const { sanitizeLogToTemp, StreamingSanitizer } = require('./log-sanitizer');
12
13
 
13
14
  /**
14
15
  * Handle command failure - detect repository, upload log, create issue
@@ -55,7 +56,7 @@ function handleFailure(config, cmdName, fullCommand, exitCode, logPath) {
55
56
  console.log('Log upload disabled via START_DISABLE_LOG_UPLOAD');
56
57
  }
57
58
  } else if (isGhUploadLogAvailable()) {
58
- logUrl = uploadLog(logPath);
59
+ logUrl = uploadLog(logPath, { verbose: config.verbose });
59
60
  if (logUrl) {
60
61
  console.log(`Log uploaded: ${logUrl}`);
61
62
  }
@@ -273,9 +274,11 @@ function isGhUploadLogAvailable() {
273
274
  * @param {string} logPath - Path to the log file
274
275
  * @returns {string|null} URL of the uploaded log or null
275
276
  */
276
- function uploadLog(logPath) {
277
+ function uploadLog(logPath, options = {}) {
278
+ let prepared;
277
279
  try {
278
- const result = execFileSync('gh-upload-log', [logPath, '--public'], {
280
+ prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
281
+ const result = execFileSync('gh-upload-log', [prepared.path, '--private'], {
279
282
  encoding: 'utf8',
280
283
  stdio: ['pipe', 'pipe', 'pipe'],
281
284
  });
@@ -293,9 +296,13 @@ function uploadLog(logPath) {
293
296
  }
294
297
 
295
298
  return null;
296
- } catch (err) {
297
- console.log(`Warning: Log upload failed - ${err.message}`);
299
+ } catch {
300
+ console.log('Warning: Log preparation or upload failed; upload blocked.');
298
301
  return null;
302
+ } finally {
303
+ if (prepared) {
304
+ prepared.cleanup();
305
+ }
299
306
  }
300
307
  }
301
308
 
@@ -327,6 +334,9 @@ function canCreateIssue(owner, repo) {
327
334
  */
328
335
  function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
329
336
  try {
337
+ fullCommand = new StreamingSanitizer()
338
+ .push(Buffer.from(fullCommand), true)
339
+ .toString();
330
340
  const title = `Command failed with exit code ${exitCode}: ${fullCommand.substring(0, 50)}${fullCommand.length > 50 ? '...' : ''}`;
331
341
 
332
342
  // Get runtime information
@@ -565,6 +565,14 @@ function runInDocker(command, options = {}) {
565
565
  }
566
566
  }
567
567
  const containerName = options.session || generateSessionName('docker');
568
+ const attribution = {
569
+ ...options,
570
+ containerName,
571
+ sessionId:
572
+ options.sessionId ||
573
+ options.executionId ||
574
+ require('crypto').randomUUID(),
575
+ };
568
576
  const containerExistedBeforeLaunch =
569
577
  readDockerContainerStatus(containerName) !== null;
570
578
  const cleanupPolicy = getDockerContainerCleanupPolicy(options);
@@ -604,7 +612,7 @@ function runInDocker(command, options = {}) {
604
612
  dockerArgs.push('--user', options.user);
605
613
  }
606
614
 
607
- dockerArgs.push(...buildDockerRuntimeArgs(options));
615
+ dockerArgs.push(...buildDockerRuntimeArgs(attribution));
608
616
 
609
617
  const effectiveCommand = options.keepAlive
610
618
  ? `${command}; exec ${shellToUse}`
@@ -614,12 +622,12 @@ function runInDocker(command, options = {}) {
614
622
  : [shellToUse];
615
623
  const mainCmdArgs = isBareShell
616
624
  ? toShellWords(command)
617
- : isShellInvocationWithArgs(command)
625
+ : isShellInvocationWithArgs(effectiveCommand)
618
626
  ? buildShellWithArgsCmdArgs(effectiveCommand)
619
627
  : [...shellArgs, '-c', effectiveCommand];
620
628
  // A recovery command is selected by a marker that `docker cp` drops
621
629
  // into the container before it is started again (issue #176).
622
- const cmdArgs = options.recoveryCommand
630
+ const recoveryArgs = options.recoveryCommand
623
631
  ? buildRecoverySelectorArgs(mainCmdArgs, {
624
632
  shell: shellToUse,
625
633
  shellFlag: shellInteractiveFlag,
@@ -628,6 +636,14 @@ function runInDocker(command, options = {}) {
628
636
  : options.recoveryCommand,
629
637
  })
630
638
  : mainCmdArgs;
639
+ const {
640
+ buildCommandHandoffArgs,
641
+ commandHandoffPath,
642
+ } = require('./docker-command-handoff');
643
+ const cmdArgs = buildCommandHandoffArgs(
644
+ recoveryArgs,
645
+ commandHandoffPath(containerName)
646
+ );
631
647
  dockerArgs.push(options.image, ...cmdArgs);
632
648
 
633
649
  if (DEBUG) {
@@ -717,7 +733,7 @@ function runInDocker(command, options = {}) {
717
733
  if (options.user) {
718
734
  dockerArgs.push('--user', options.user);
719
735
  }
720
- dockerArgs.push(...buildDockerRuntimeArgs(options));
736
+ dockerArgs.push(...buildDockerRuntimeArgs(attribution));
721
737
  if (DEBUG) {
722
738
  console.log(`[DEBUG] shell: ${shellToUse}`);
723
739
  }
@@ -0,0 +1,267 @@
1
+ /** Bounded byte-wise sanitization for logs prepared for publication. */
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ const BLOCK_SIZE = 64 * 1024;
7
+ const MARKER = '[REDACTED]';
8
+ const SECRET_ENV_NAME =
9
+ /(?:^|_)(?:TOKEN|(?:API|ACCESS|PRIVATE)?KEY|SECRET|PASSWORD|PASSWD|CREDENTIALS?|AUTH|PAT)(?:_|$)/i;
10
+
11
+ // Prefixes intentionally match even truncated tokens. Unbounded values retain a
12
+ // continuation state, so a token or header larger than a block cannot leak its
13
+ // suffix. Latin-1 provides a reversible byte mapping, including invalid UTF-8.
14
+ const RULES = [
15
+ {
16
+ pattern:
17
+ /\b[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{2,512}\.[A-Za-z0-9_-]{0,512}/g,
18
+ validate: (value) => {
19
+ try {
20
+ return (
21
+ typeof JSON.parse(
22
+ Buffer.from(value.split('.')[0], 'base64url').toString()
23
+ ).alg === 'string'
24
+ );
25
+ } catch {
26
+ return false;
27
+ }
28
+ },
29
+ continuation: /[A-Za-z0-9_.-]/,
30
+ },
31
+ {
32
+ pattern: /[A-Za-z0-9_-]{512,}[A-Za-z0-9_.-]*/g,
33
+ continuation: /[A-Za-z0-9_.-]/,
34
+ },
35
+ {
36
+ pattern: /(?:gh[pousr]_|github_pat_|sk-)[A-Za-z0-9_-]*/g,
37
+ continuation: /[A-Za-z0-9_-]/,
38
+ },
39
+ { pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
40
+ { pattern: /eyJ[A-Za-z0-9_.-]*/g, continuation: /[A-Za-z0-9_.-]/ },
41
+ {
42
+ pattern: /(?:Proxy-)?Authorization[ \t]{0,32}:[^\r\n]*/gi,
43
+ continuation: /[^\r\n]/,
44
+ },
45
+ {
46
+ pattern:
47
+ /(?:AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN)[ \t]{0,32}[=:][ \t]*["']?[^\s"';,]*/g,
48
+ continuation: /[^\s"';,]/,
49
+ },
50
+ {
51
+ pattern: /(?:https?|git|ssh):\/\/[^\s:/@]{1,256}:[^\s@]*/g,
52
+ continuation: /[^\s@]/,
53
+ },
54
+ ];
55
+
56
+ class StreamingSanitizer {
57
+ constructor(env = process.env) {
58
+ this.secrets = [
59
+ ...new Set(
60
+ Object.entries(env)
61
+ .filter(
62
+ ([name, value]) =>
63
+ SECRET_ENV_NAME.test(name) &&
64
+ typeof value === 'string' &&
65
+ value.length
66
+ )
67
+ .map(([, value]) => Buffer.from(value).toString('latin1'))
68
+ ),
69
+ ].sort((a, b) => b.length - a.length);
70
+ if (this.secrets.some((value) => value.length > BLOCK_SIZE)) {
71
+ throw new Error(
72
+ 'Known environment secret is too large for bounded sanitization.'
73
+ );
74
+ }
75
+ this.holdback = this.secrets.reduce(
76
+ (size, value) => Math.max(size, value.length),
77
+ 2048
78
+ );
79
+ this.pending = '';
80
+ this.continuation = null;
81
+ this.redactions = 0;
82
+ }
83
+
84
+ get pendingBytes() {
85
+ return this.pending.length;
86
+ }
87
+
88
+ findMatches(input) {
89
+ const matches = [];
90
+ for (const rule of RULES) {
91
+ rule.pattern.lastIndex = 0;
92
+ for (
93
+ let match = rule.pattern.exec(input);
94
+ match;
95
+ match = rule.pattern.exec(input)
96
+ ) {
97
+ if (!rule.validate || rule.validate(match[0])) {
98
+ matches.push({
99
+ index: match.index,
100
+ end: match.index + match[0].length,
101
+ continuation: rule.continuation,
102
+ });
103
+ }
104
+ }
105
+ }
106
+ for (const secret of this.secrets) {
107
+ for (
108
+ let index = input.indexOf(secret);
109
+ index >= 0;
110
+ index = input.indexOf(secret, index + 1)
111
+ ) {
112
+ matches.push({ index, end: index + secret.length });
113
+ }
114
+ }
115
+ matches.sort((a, b) => a.index - b.index || b.end - a.end);
116
+ const merged = [];
117
+ for (const match of matches) {
118
+ const previous = merged[merged.length - 1];
119
+ if (previous && match.index <= previous.end) {
120
+ if (match.end > previous.end) {
121
+ previous.end = match.end;
122
+ previous.continuation = match.continuation;
123
+ } else if (match.end === previous.end && match.continuation) {
124
+ // Nested tokens must not narrow a containing header/URL's suffix.
125
+ const retained = previous.continuation;
126
+ const additional = match.continuation;
127
+ previous.continuation = retained
128
+ ? { test: (byte) => retained.test(byte) || additional.test(byte) }
129
+ : additional;
130
+ }
131
+ } else {
132
+ merged.push({ ...match });
133
+ }
134
+ }
135
+ return merged;
136
+ }
137
+
138
+ push(block, final = false) {
139
+ let input = this.pending + block.toString('latin1');
140
+ this.pending = '';
141
+ if (this.continuation) {
142
+ let end = 0;
143
+ while (end < input.length && this.continuation.test(input[end])) {
144
+ end++;
145
+ }
146
+ input = input.slice(end);
147
+ if (!input.length && !final) {
148
+ return Buffer.alloc(0);
149
+ }
150
+ this.continuation = null;
151
+ }
152
+ const safeEnd = final
153
+ ? input.length
154
+ : Math.max(0, input.length - this.holdback);
155
+ let offset = 0;
156
+ const output = [];
157
+ for (const match of this.findMatches(input)) {
158
+ if (match.index >= safeEnd) {
159
+ break;
160
+ }
161
+ output.push(input.slice(offset, match.index), MARKER);
162
+ this.redactions++;
163
+ offset = match.end;
164
+ if (offset === input.length && match.continuation && !final) {
165
+ this.continuation = match.continuation;
166
+ break;
167
+ }
168
+ }
169
+ const end = Math.max(offset, safeEnd);
170
+ output.push(input.slice(offset, end));
171
+ this.pending = input.slice(end);
172
+ return Buffer.from(output.join(''), 'latin1');
173
+ }
174
+ }
175
+
176
+ /** Returns a private copy plus cleanup; every preparation error blocks upload. */
177
+ function sanitizeLogToTemp(sourcePath, options = {}) {
178
+ let directory;
179
+ let source;
180
+ let destination;
181
+ let ready = false;
182
+ let stage = 'environment';
183
+ try {
184
+ const sanitizer = new StreamingSanitizer(options.env || process.env);
185
+ stage = 'source';
186
+ source = fs.openSync(sourcePath, 'r');
187
+ if (!fs.fstatSync(source).isFile()) {
188
+ throw new Error('Source must be a regular file.');
189
+ }
190
+ stage = 'private-file';
191
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'start-sanitized-'));
192
+ fs.chmodSync(directory, 0o700);
193
+ const target = path.join(directory, 'execution.log');
194
+ destination = fs.openSync(target, 'wx', 0o600);
195
+ fs.fchmodSync(destination, 0o600);
196
+ const buffer = Buffer.alloc(BLOCK_SIZE);
197
+ let bytes = 0;
198
+ const write = (output) => {
199
+ stage = 'write';
200
+ let offset = 0;
201
+ while (offset < output.length) {
202
+ const written = fs.writeSync(
203
+ destination,
204
+ output,
205
+ offset,
206
+ output.length - offset
207
+ );
208
+ if (!written) {
209
+ throw new Error('No progress writing sanitized log.');
210
+ }
211
+ offset += written;
212
+ }
213
+ };
214
+ for (;;) {
215
+ stage = 'read';
216
+ const length = fs.readSync(source, buffer, 0, buffer.length, null);
217
+ if (!length) {
218
+ break;
219
+ }
220
+ bytes += length;
221
+ stage = 'scan';
222
+ write(sanitizer.push(buffer.subarray(0, length)));
223
+ }
224
+ write(sanitizer.push(Buffer.alloc(0), true));
225
+ stage = 'sync';
226
+ fs.fsyncSync(destination);
227
+ fs.closeSync(destination);
228
+ destination = undefined;
229
+ fs.closeSync(source);
230
+ source = undefined;
231
+ if (options.verbose) {
232
+ console.log(
233
+ `Prepared private sanitized log: ${bytes} bytes scanned, ${sanitizer.redactions} redactions.`
234
+ );
235
+ }
236
+ ready = true;
237
+ return {
238
+ path: target,
239
+ cleanup: () => fs.rmSync(directory, { recursive: true, force: true }),
240
+ };
241
+ } catch {
242
+ if (options.verbose) {
243
+ console.log(`Log sanitization failed at stage: ${stage}.`);
244
+ }
245
+ // Do not echo error messages: paths and exception details can carry secrets.
246
+ throw new Error('Log sanitization failed; upload blocked.');
247
+ } finally {
248
+ for (const fd of [destination, source]) {
249
+ if (fd !== undefined) {
250
+ try {
251
+ fs.closeSync(fd);
252
+ } catch {
253
+ /* Best effort after failure. */
254
+ }
255
+ }
256
+ }
257
+ if (directory && !ready) {
258
+ try {
259
+ fs.rmSync(directory, { recursive: true, force: true });
260
+ } catch {
261
+ /* Upload remains blocked. */
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ module.exports = { StreamingSanitizer, sanitizeLogToTemp };