start-command 0.35.4 → 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 (67) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -0
  3. package/package.json +1 -1
  4. package/src/bin/cli.js +48 -7
  5. package/src/lib/args-parser-queries.js +14 -0
  6. package/src/lib/args-parser.js +32 -1
  7. package/src/lib/atomic-write.js +34 -0
  8. package/src/lib/attached-diagnostics.js +90 -0
  9. package/src/lib/cgroup-memory.js +43 -11
  10. package/src/lib/command-builder.js +43 -0
  11. package/src/lib/cpu-penalty-monitor.js +226 -0
  12. package/src/lib/cpu-penalty.js +175 -0
  13. package/src/lib/detached-finalize.js +16 -0
  14. package/src/lib/docker-cleanup.js +35 -7
  15. package/src/lib/docker-command-handoff.js +75 -0
  16. package/src/lib/docker-labels.js +33 -0
  17. package/src/lib/docker-resource-limits.js +28 -0
  18. package/src/lib/docker-resource-options.js +180 -0
  19. package/src/lib/docker-runtime-args.js +23 -0
  20. package/src/lib/docker-snapshot-safety.js +164 -0
  21. package/src/lib/execution-recovery.js +153 -30
  22. package/src/lib/execution-resume-all.js +11 -0
  23. package/src/lib/execution-resume.js +314 -54
  24. package/src/lib/execution-store.js +25 -121
  25. package/src/lib/exit-evidence.js +123 -0
  26. package/src/lib/exit-reason.js +24 -6
  27. package/src/lib/failure-handler.js +15 -5
  28. package/src/lib/isolation.js +48 -7
  29. package/src/lib/launch-owner.js +25 -0
  30. package/src/lib/launch-persistence.js +54 -0
  31. package/src/lib/log-sanitizer.js +267 -0
  32. package/src/lib/log-uploader.js +38 -19
  33. package/src/lib/query-commands.js +18 -6
  34. package/src/lib/resume-resources.js +88 -0
  35. package/src/lib/shell-utils.js +67 -17
  36. package/src/lib/status-formatter.js +28 -0
  37. package/src/lib/store-lock.js +133 -0
  38. package/src/lib/usage.js +16 -1
  39. package/test/ci-workflow-invariants.js +15 -14
  40. package/test/docker-command-handoff.js +107 -0
  41. package/test/docker-runtime-options.js +10 -0
  42. package/test/docker-snapshot-safety.js +106 -0
  43. package/test/execution-resume.js +19 -11
  44. package/test/exit-reason.js +6 -1
  45. package/test/failure-handler.js +11 -0
  46. package/test/isolation.js +5 -4
  47. package/test/javascript-first-ci.js +216 -0
  48. package/test/log-sanitizer.js +306 -0
  49. package/test/regression-174.js +3 -1
  50. package/test/regression-176.js +7 -0
  51. package/test/regression-180.js +17 -10
  52. package/test/regression-182.js +7 -3
  53. package/test/regression-185.js +4 -9
  54. package/test/regression-187.js +11 -2
  55. package/test/regression-189.js +224 -0
  56. package/test/regression-190.js +111 -0
  57. package/test/regression-191.js +160 -0
  58. package/test/regression-192.js +66 -0
  59. package/test/regression-193.js +251 -0
  60. package/test/regression-194.js +182 -0
  61. package/test/regression-198.js +281 -0
  62. package/test/regression-199.js +79 -0
  63. package/test/regression-201.js +25 -0
  64. package/test/regression-202.js +92 -0
  65. package/test/regression-91.js +23 -20
  66. package/test/shared-golden.js +54 -0
  67. package/test/status-query.js +97 -15
@@ -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 };
@@ -6,6 +6,7 @@ const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
8
  const { spawnSync } = require('child_process');
9
+ const { sanitizeLogToTemp } = require('./log-sanitizer');
9
10
 
10
11
  function isExecutable(filePath) {
11
12
  try {
@@ -135,7 +136,7 @@ function ensureGhUploadLogAvailable() {
135
136
  };
136
137
  }
137
138
 
138
- function uploadLogPath(logPath) {
139
+ function uploadLogPath(logPath, options = {}) {
139
140
  if (!logPath) {
140
141
  return {
141
142
  success: false,
@@ -146,29 +147,47 @@ function uploadLogPath(logPath) {
146
147
  return { success: false, error: `Log file not found: ${logPath}` };
147
148
  }
148
149
 
149
- const availability = ensureGhUploadLogAvailable();
150
- if (!availability.success) {
151
- return availability;
152
- }
153
-
154
- const result = runCommand(availability.command, [logPath], {
155
- stdio: 'inherit',
156
- });
157
-
158
- const exitCode =
159
- result.status !== null && result.status !== undefined ? result.status : 1;
160
- if (exitCode !== 0) {
150
+ let prepared;
151
+ try {
152
+ if (!options.noSanitize) {
153
+ // Wrapper options.env holds Docker arguments, not the process secrets.
154
+ prepared = sanitizeLogToTemp(logPath, { verbose: options.verbose });
155
+ }
156
+ const availability = ensureGhUploadLogAvailable();
157
+ if (!availability.success) {
158
+ return availability;
159
+ }
160
+ const result = runCommand(
161
+ availability.command,
162
+ [prepared ? prepared.path : logPath, '--private'],
163
+ {
164
+ stdio: 'inherit',
165
+ }
166
+ );
167
+ const exitCode =
168
+ result.status !== null && result.status !== undefined ? result.status : 1;
169
+ if (exitCode !== 0) {
170
+ return {
171
+ success: false,
172
+ exitCode,
173
+ error: `gh-upload-log exited with code ${exitCode}`,
174
+ };
175
+ }
176
+ return { success: true, exitCode: 0 };
177
+ } catch {
161
178
  return {
162
179
  success: false,
163
- exitCode,
164
- error: `gh-upload-log exited with code ${exitCode}`,
180
+ exitCode: 1,
181
+ error: 'Log preparation or upload failed; upload blocked.',
165
182
  };
183
+ } finally {
184
+ if (prepared) {
185
+ prepared.cleanup();
186
+ }
166
187
  }
167
-
168
- return { success: true, exitCode: 0 };
169
188
  }
170
189
 
171
- function uploadExecutionLog(store, identifier) {
190
+ function uploadExecutionLog(store, identifier, options = {}) {
172
191
  if (!store) {
173
192
  return { success: false, error: 'Execution tracking is disabled.' };
174
193
  }
@@ -181,7 +200,7 @@ function uploadExecutionLog(store, identifier) {
181
200
  };
182
201
  }
183
202
 
184
- return uploadLogPath(record.logPath);
203
+ return uploadLogPath(record.logPath, options);
185
204
  }
186
205
 
187
206
  module.exports = {
@@ -38,8 +38,8 @@ function handleListQuery(store, outputFormat, options = {}) {
38
38
  return reportResult(listExecutions(store, outputFormat, options));
39
39
  }
40
40
 
41
- function handleUploadLogQuery(store, identifier) {
42
- const result = uploadExecutionLog(store, identifier);
41
+ function handleUploadLogQuery(store, identifier, options = {}) {
42
+ const result = uploadExecutionLog(store, identifier, options);
43
43
  if (result.success) {
44
44
  return result.exitCode || 0;
45
45
  }
@@ -55,9 +55,20 @@ function handleAttachQuery(store, identifier, readOnly) {
55
55
  return reportResult(attachExecution(store, identifier, { readOnly }));
56
56
  }
57
57
 
58
- async function handleResumeQuery(store, identifier, command, outputFormat) {
58
+ async function handleResumeQuery(
59
+ store,
60
+ identifier,
61
+ command,
62
+ outputFormat,
63
+ resourceOptions = {}
64
+ ) {
59
65
  return reportResult(
60
- await resumeExecution(store, identifier, { command, outputFormat })
66
+ await resumeExecution(store, identifier, {
67
+ command,
68
+ outputFormat,
69
+ resourceOptions,
70
+ removeOriginal: resourceOptions.removeOriginal === true,
71
+ })
61
72
  );
62
73
  }
63
74
 
@@ -154,7 +165,7 @@ async function dispatchQueryCommand(options, context = {}) {
154
165
  return handleListQuery(store, outputFormat, { running: options.running });
155
166
  }
156
167
  if (hasValue(options.uploadLog)) {
157
- return handleUploadLogQuery(store, options.uploadLog);
168
+ return handleUploadLogQuery(store, options.uploadLog, options);
158
169
  }
159
170
  if (hasValue(options.stop)) {
160
171
  return handleControlQuery(store, options.stop, ControlAction.STOP);
@@ -174,7 +185,8 @@ async function dispatchQueryCommand(options, context = {}) {
174
185
  store,
175
186
  options.resume,
176
187
  context.command || null,
177
- outputFormat
188
+ outputFormat,
189
+ options
178
190
  );
179
191
  }
180
192
  if (options.resumeAll) {
@@ -0,0 +1,88 @@
1
+ const {
2
+ normalizeResourceLimits,
3
+ cpuCount,
4
+ withCpuLimit,
5
+ readDockerResourceLimits,
6
+ } = require('./docker-resource-limits');
7
+ const { resolveResourceOptions } = require('./docker-resource-options');
8
+
9
+ // Temporary penalty quotas must never become a resumed execution's base quota.
10
+ function restoreBaseCpu(limits, options) {
11
+ if (!options.cpuPenaltyConfig) {
12
+ return limits;
13
+ }
14
+ const base = normalizeResourceLimits(
15
+ options.baseResourceLimits || options.resourceLimits
16
+ );
17
+ return [
18
+ ...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
19
+ ...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
20
+ ];
21
+ }
22
+
23
+ function prepareResumeResources(record, probe, overrides, runner, random) {
24
+ const options = record.options || {};
25
+ if (options.isolated !== 'docker') {
26
+ if (
27
+ ['memory', 'memorySwap', 'cpus'].some(
28
+ (k) => overrides[k] !== null && overrides[k] !== undefined
29
+ )
30
+ ) {
31
+ throw new Error('Resource overrides require a Docker execution');
32
+ }
33
+ return null;
34
+ }
35
+ const live =
36
+ probe.state === 'stopped'
37
+ ? readDockerResourceLimits(options.sessionName, runner)
38
+ : null;
39
+ const base = restoreBaseCpu(
40
+ normalizeResourceLimits(live || options.resourceLimits),
41
+ options
42
+ );
43
+ const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
44
+ const result = resolveResourceOptions(
45
+ { ...overrides, resourceLimits: base },
46
+ runner,
47
+ random
48
+ );
49
+ if (overrides.cpus && probe.state === 'stopped') {
50
+ // Docker cannot switch an existing legacy quota to NanoCPUs in place.
51
+ const cpu = withCpuLimit(base, result.resolvedLimits.cpus);
52
+ result.resourceLimits = [
53
+ ...result.resourceLimits.filter(
54
+ (s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)
55
+ ),
56
+ ...cpu.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
57
+ ];
58
+ }
59
+ result.oldMemory = oldMemory
60
+ ? require('./docker-resource-options').parseResourceSpec(oldMemory).amount
61
+ : 'unlimited';
62
+ result.resolvedLimits = {
63
+ ...options.resolvedLimits,
64
+ ...result.resolvedLimits,
65
+ };
66
+ result.resourceLimitSpecs = {
67
+ ...options.resourceLimitSpecs,
68
+ ...result.resourceLimitSpecs,
69
+ };
70
+ result.update =
71
+ probe.state === 'stopped' &&
72
+ (['memory', 'memorySwap', 'cpus'].some(
73
+ (k) => overrides[k] !== null && overrides[k] !== undefined
74
+ ) ||
75
+ Boolean(options.cpuPenaltyConfig));
76
+ if (options.cpuPenaltyConfig && !overrides.cpus) {
77
+ result.resourceLimits = clampCpuToDaemon(result.resourceLimits, runner);
78
+ result.resolvedLimits.cpus = cpuCount(result.resourceLimits);
79
+ }
80
+ return result;
81
+ }
82
+
83
+ function clampCpuToDaemon(limits, runner) {
84
+ const { readDaemonCapacity } = require('./docker-resource-options');
85
+ const capacity = readDaemonCapacity(runner).cpus;
86
+ return withCpuLimit(limits, Math.min(cpuCount(limits) || capacity, capacity));
87
+ }
88
+ module.exports = { prepareResumeResources, restoreBaseCpu, clampCpuToDaemon };
@@ -86,6 +86,10 @@ function splitShellWords(command, style = defaultShellQuotingStyle()) {
86
86
  if (i + 1 >= command.length) {
87
87
  return null;
88
88
  }
89
+ if (quote === '"' && !'$`"\\\n'.includes(command[i + 1])) {
90
+ current += char;
91
+ continue;
92
+ }
89
93
  current += command[++i];
90
94
  continue;
91
95
  }
@@ -121,10 +125,69 @@ function toShellWords(command) {
121
125
  );
122
126
  }
123
127
 
128
+ /**
129
+ * Return literal argv only when no outer shell evaluation is needed. Operators,
130
+ * expansions and comments belong to the outer command line, not the -c script.
131
+ * Single quotes protect their contents; double quotes still permit expansion.
132
+ */
133
+ function simpleShellWords(command) {
134
+ let quote = null;
135
+ for (let i = 0; i < command.length; i++) {
136
+ const char = command[i];
137
+ if (char === '\\' && quote !== "'") {
138
+ if (command[i + 1] === '\n') {
139
+ return null;
140
+ }
141
+ if (quote !== '"' || '$`"\\\n'.includes(command[i + 1])) {
142
+ i++;
143
+ }
144
+ continue;
145
+ }
146
+ if (quote === "'") {
147
+ if (char === "'") {
148
+ quote = null;
149
+ }
150
+ continue;
151
+ }
152
+ if (char === '$' || char === '`') {
153
+ return null;
154
+ }
155
+ if (quote === '"') {
156
+ if (char === '"') {
157
+ quote = null;
158
+ }
159
+ continue;
160
+ }
161
+ if (';&|<>(){}\n\r*?[]~#'.includes(char)) {
162
+ return null;
163
+ }
164
+ if (/\s/.test(char) && !' \t'.includes(char)) {
165
+ return null;
166
+ }
167
+ if (char === "'" || char === '"') {
168
+ quote = char;
169
+ }
170
+ }
171
+ return splitShellWords(command, 'posix');
172
+ }
173
+
174
+ function shellScriptIndex(parts) {
175
+ if (!parts || !SHELL_NAMES.includes(path.basename(parts[0] || ''))) {
176
+ return -1;
177
+ }
178
+ const index = parts.indexOf('-c');
179
+ return index > 0 &&
180
+ index + 1 < parts.length &&
181
+ parts.slice(1, index).every((part) => /^--?[a-zA-Z][a-zA-Z-]*$/.test(part))
182
+ ? index
183
+ : -1;
184
+ }
185
+
124
186
  /** True if command is a bare shell invocation (no -c); avoids bash-inside-bash (issue #84). */
125
187
  function isInteractiveShellCommand(command) {
126
- const parts = toShellWords(command);
188
+ const parts = simpleShellWords(command);
127
189
  return (
190
+ parts !== null &&
128
191
  parts.length > 0 &&
129
192
  SHELL_NAMES.includes(path.basename(parts[0])) &&
130
193
  !parts.includes('-c')
@@ -133,25 +196,12 @@ function isInteractiveShellCommand(command) {
133
196
 
134
197
  /** True if command is a shell invocation with -c (e.g. `bash -i -c "cmd"`); avoids double-wrapping (issue #91). */
135
198
  function isShellInvocationWithArgs(command) {
136
- const parts = toShellWords(command);
137
- return (
138
- parts.length > 0 &&
139
- SHELL_NAMES.includes(path.basename(parts[0])) &&
140
- parts.includes('-c')
141
- );
199
+ return shellScriptIndex(simpleShellWords(command)) !== -1;
142
200
  }
143
201
 
144
- /** Build argv for shell-with-c command; everything after -c is one script argument. */
202
+ /** Preserve the script word and every positional argument as separate argv. */
145
203
  function buildShellWithArgsCmdArgs(command) {
146
- const parts = toShellWords(command);
147
- const cIdx = parts.indexOf('-c');
148
- if (cIdx === -1) {
149
- return parts;
150
- }
151
- const scriptArg = parts.slice(cIdx + 1).join(' ');
152
- return scriptArg.length > 0
153
- ? [...parts.slice(0, cIdx + 1), scriptArg]
154
- : parts.slice(0, cIdx + 1);
204
+ return simpleShellWords(command) || toShellWords(command);
155
205
  }
156
206
 
157
207
  /** Quote an argument for display only, keeping the user-facing double-quoted form (issue #91). */
@@ -326,6 +326,9 @@ function applyEndTime(enriched, dockerState, footerFinishedAt) {
326
326
  }
327
327
 
328
328
  function resolveDetachedStatus(record, logTail) {
329
+ if (require('./launch-owner').hasActiveLaunch(record)) {
330
+ return record;
331
+ }
329
332
  const footer = parseFooterFromTail(logTail);
330
333
  const footerExit = footer.exitCode;
331
334
  const dockerState = isDetachedDockerRecord(record)
@@ -444,6 +447,7 @@ function attachExitReason(record, logTail) {
444
447
  logTail,
445
448
  oomKilled: record.oomKilled,
446
449
  cgroupMemory: record.cgroupMemory,
450
+ exitEvidence: record.options?.exitEvidence,
447
451
  });
448
452
 
449
453
  if (!exitReason) {
@@ -490,9 +494,22 @@ function attachMemoryExhaustion(record, logTail) {
490
494
  logTail,
491
495
  oomKilled: record.oomKilled,
492
496
  cgroupMemory: record.cgroupMemory,
497
+ exitEvidence: record.options?.exitEvidence,
493
498
  });
494
499
 
495
500
  if (!memory) {
501
+ if (
502
+ record.memoryExhaustedReason?.startsWith('Docker reported') ||
503
+ record.memoryExhaustedReason?.startsWith('cgroup memory.events')
504
+ ) {
505
+ const cleared = Object.assign(
506
+ Object.create(Object.getPrototypeOf(record)),
507
+ record
508
+ );
509
+ delete cleared.memoryExhausted;
510
+ delete cleared.memoryExhaustedReason;
511
+ return cleared;
512
+ }
496
513
  return record;
497
514
  }
498
515
 
@@ -517,6 +534,14 @@ function enrichDetachedStatus(record) {
517
534
  if (!record) {
518
535
  return record;
519
536
  }
537
+ const cpuPenalty = require('./cpu-penalty-monitor').readState(record);
538
+ if (cpuPenalty) {
539
+ record = Object.assign(
540
+ Object.create(Object.getPrototypeOf(record)),
541
+ record
542
+ );
543
+ record.cpuPenalty = cpuPenalty;
544
+ }
520
545
  const {
521
546
  readAttemptActivity,
522
547
  readAttemptLogTail,
@@ -693,6 +718,9 @@ function formatRecordAsText(record) {
693
718
  ...(formatCgroupMemory(obj.cgroupMemory)
694
719
  ? [`Cgroup Memory: ${formatCgroupMemory(obj.cgroupMemory)}`]
695
720
  : []),
721
+ ...(obj.cpuPenalty
722
+ ? [`CPU Penalty: ${JSON.stringify(obj.cpuPenalty)}`]
723
+ : []),
696
724
  `PID: ${obj.pid !== null ? obj.pid : 'N/A'}`,
697
725
  `Working Directory: ${obj.workingDirectory}`,
698
726
  `Shell: ${obj.shell}`,