start-command 0.36.0 → 0.37.1

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 (45) hide show
  1. package/CHANGELOG.md +14 -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/cpu-penalty-monitor.js +209 -87
  9. package/src/lib/cpu-penalty.js +30 -1
  10. package/src/lib/docker-cleanup.js +12 -5
  11. package/src/lib/docker-command-handoff.js +75 -0
  12. package/src/lib/docker-labels.js +33 -0
  13. package/src/lib/docker-runtime-args.js +8 -0
  14. package/src/lib/docker-snapshot-safety.js +164 -0
  15. package/src/lib/execution-recovery.js +8 -1
  16. package/src/lib/execution-resume.js +184 -37
  17. package/src/lib/failure-handler.js +15 -5
  18. package/src/lib/isolation.js +20 -4
  19. package/src/lib/log-sanitizer.js +267 -0
  20. package/src/lib/log-uploader.js +38 -19
  21. package/src/lib/query-commands.js +4 -3
  22. package/src/lib/resume-resources.js +15 -3
  23. package/src/lib/shell-utils.js +67 -17
  24. package/src/lib/store-lock.js +54 -3
  25. package/src/lib/usage.js +5 -1
  26. package/test/ci-workflow-invariants.js +15 -14
  27. package/test/docker-command-handoff.js +107 -0
  28. package/test/docker-runtime-options.js +4 -0
  29. package/test/docker-snapshot-safety.js +106 -0
  30. package/test/execution-resume.js +10 -8
  31. package/test/failure-handler.js +11 -0
  32. package/test/javascript-first-ci.js +216 -0
  33. package/test/log-sanitizer.js +306 -0
  34. package/test/regression-176.js +7 -0
  35. package/test/regression-187.js +9 -1
  36. package/test/regression-191.js +9 -1
  37. package/test/regression-193.js +11 -1
  38. package/test/regression-198.js +281 -0
  39. package/test/regression-199.js +79 -0
  40. package/test/regression-201.js +25 -0
  41. package/test/regression-202.js +92 -0
  42. package/test/regression-207.js +249 -0
  43. package/test/regression-91.js +23 -20
  44. package/test/shared-golden.js +77 -0
  45. package/test/status-query.js +97 -15
@@ -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 };
@@ -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
  }
@@ -67,6 +67,7 @@ async function handleResumeQuery(
67
67
  command,
68
68
  outputFormat,
69
69
  resourceOptions,
70
+ removeOriginal: resourceOptions.removeOriginal === true,
70
71
  })
71
72
  );
72
73
  }
@@ -164,7 +165,7 @@ async function dispatchQueryCommand(options, context = {}) {
164
165
  return handleListQuery(store, outputFormat, { running: options.running });
165
166
  }
166
167
  if (hasValue(options.uploadLog)) {
167
- return handleUploadLogQuery(store, options.uploadLog);
168
+ return handleUploadLogQuery(store, options.uploadLog, options);
168
169
  }
169
170
  if (hasValue(options.stop)) {
170
171
  return handleControlQuery(store, options.stop, ControlAction.STOP);
@@ -7,13 +7,23 @@ const {
7
7
  const { resolveResourceOptions } = require('./docker-resource-options');
8
8
 
9
9
  // Temporary penalty quotas must never become a resumed execution's base quota.
10
- function restoreBaseCpu(limits, options) {
10
+ function restoreBaseCpu(limits, options, saved = null, inspected = false) {
11
11
  if (!options.cpuPenaltyConfig) {
12
12
  return limits;
13
13
  }
14
- const base = normalizeResourceLimits(
14
+ if (
15
+ saved &&
16
+ inspected &&
17
+ !(saved.phase === 'penalized' && cpuCount(limits) === saved.limitCpus)
18
+ ) {
19
+ return limits;
20
+ }
21
+ let base = normalizeResourceLimits(
15
22
  options.baseResourceLimits || options.resourceLimits
16
23
  );
24
+ if (saved && Object.hasOwn(saved, 'baseCpus')) {
25
+ base = saved.baseCpus > 0 ? withCpuLimit(limits, saved.baseCpus) : [];
26
+ }
17
27
  return [
18
28
  ...limits.filter((s) => !/^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
19
29
  ...base.filter((s) => /^--(?:cpus|cpu-quota|cpu-period)=/.test(s)),
@@ -38,7 +48,9 @@ function prepareResumeResources(record, probe, overrides, runner, random) {
38
48
  : null;
39
49
  const base = restoreBaseCpu(
40
50
  normalizeResourceLimits(live || options.resourceLimits),
41
- options
51
+ options,
52
+ require('./cpu-penalty-monitor').readState(record),
53
+ live !== null
42
54
  );
43
55
  const oldMemory = base.find((s) => s.startsWith('--memory='))?.slice(9);
44
56
  const result = resolveResourceOptions(
@@ -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). */