start-command 0.32.0 → 0.33.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 (43) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/docker-network-integration.js +22 -14
  30. package/test/echo-integration.js +20 -13
  31. package/test/execution-attach.js +221 -0
  32. package/test/execution-resume.js +433 -0
  33. package/test/execution-store.js +52 -0
  34. package/test/exit-reason.js +209 -0
  35. package/test/failure-handler.js +107 -0
  36. package/test/load-command-stream.mjs +83 -0
  37. package/test/regression-162.js +320 -0
  38. package/test/regression-164.js +308 -0
  39. package/test/regression-165.js +310 -0
  40. package/test/regression-84.js +8 -3
  41. package/test/release-name.mjs +57 -0
  42. package/test/session-probe.js +150 -0
  43. package/test/user-manager.js +23 -0
@@ -15,13 +15,14 @@
15
15
  * - startTime: Timestamp when execution started
16
16
  * - endTime: Timestamp when execution completed (null while executing)
17
17
  * - oomKilled: Docker resource-exhaustion signal when available
18
+ * - memoryExhausted: memory-exhaustion observation derived from the log tail
18
19
  * - options: Execution options (isolation mode, etc.)
19
20
  */
20
21
 
21
22
  const fs = require('fs');
22
23
  const path = require('path');
23
24
  const os = require('os');
24
- const { execSync, spawnSync } = require('child_process');
25
+ const { execFileSync, spawnSync } = require('child_process');
25
26
  const crypto = require('crypto');
26
27
 
27
28
  // Synchronous wrapper using Bun's native ESM support
@@ -76,6 +77,21 @@ class ExecutionRecord {
76
77
  this.endTime = options.endTime || null;
77
78
  this.oomKilled =
78
79
  options.oomKilled !== undefined ? options.oomKilled : undefined;
80
+ // Query-time hint explaining an opaque exit code (issue #162). Derived from
81
+ // the log tail by the status formatter; never persisted as a verdict.
82
+ this.exitReason =
83
+ options.exitReason !== undefined ? options.exitReason : undefined;
84
+ // Query-time memory-exhaustion observation (issue #165). A runtime that
85
+ // aborts on its own heap limit is invisible to `oomKilled`, so the evidence
86
+ // the runtime printed into the log is surfaced separately.
87
+ this.memoryExhausted =
88
+ options.memoryExhausted !== undefined
89
+ ? options.memoryExhausted
90
+ : undefined;
91
+ this.memoryExhaustedReason =
92
+ options.memoryExhaustedReason !== undefined
93
+ ? options.memoryExhaustedReason
94
+ : undefined;
79
95
  this.workingDirectory = options.workingDirectory || process.cwd();
80
96
  this.shell = options.shell || process.env.SHELL || '/bin/sh';
81
97
  this.platform = options.platform || process.platform;
@@ -109,6 +125,18 @@ class ExecutionRecord {
109
125
  if (this.oomKilled !== undefined && this.oomKilled !== null) {
110
126
  obj.oomKilled = this.oomKilled;
111
127
  }
128
+ if (this.exitReason !== undefined && this.exitReason !== null) {
129
+ obj.exitReason = this.exitReason;
130
+ }
131
+ if (this.memoryExhausted !== undefined && this.memoryExhausted !== null) {
132
+ obj.memoryExhausted = this.memoryExhausted;
133
+ }
134
+ if (
135
+ this.memoryExhaustedReason !== undefined &&
136
+ this.memoryExhaustedReason !== null
137
+ ) {
138
+ obj.memoryExhaustedReason = this.memoryExhaustedReason;
139
+ }
112
140
  Object.assign(obj, {
113
141
  workingDirectory: this.workingDirectory,
114
142
  shell: this.shell,
@@ -389,7 +417,10 @@ class ExecutionStore {
389
417
  */
390
418
  execClink(query) {
391
419
  try {
392
- const result = execSync(`clink '${query}' --db "${this.linksDbPath}"`, {
420
+ // The query embeds record values verbatim; a shell string would let a
421
+ // single quote in any of them break out of the argument
422
+ // (CodeQL js/shell-command-constructed-from-input, issue #168).
423
+ const result = execFileSync('clink', [query, '--db', this.linksDbPath], {
393
424
  encoding: 'utf8',
394
425
  timeout: 10000,
395
426
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -480,7 +511,8 @@ class ExecutionStore {
480
511
 
481
512
  /**
482
513
  * Get an execution record by UUID or session name
483
- * First tries exact UUID match, then falls back to session name lookup
514
+ * First tries exact UUID match, then the current session name, then any
515
+ * session name the execution used before it was resumed
484
516
  * @param {string} identifier - UUID or session name
485
517
  * @returns {ExecutionRecord|null}
486
518
  */
@@ -495,7 +527,18 @@ class ExecutionStore {
495
527
  const bySessionName = records.find(
496
528
  (r) => r.options && r.options.sessionName === identifier
497
529
  );
498
- return bySessionName || null;
530
+ if (bySessionName) {
531
+ return bySessionName;
532
+ }
533
+ // Finally, honor session names this record used before it was resumed, so
534
+ // one logical session stays addressable across restarts (issue #162).
535
+ const byPreviousSessionName = records.find(
536
+ (r) =>
537
+ r.options &&
538
+ Array.isArray(r.options.sessionNameHistory) &&
539
+ r.options.sessionNameHistory.includes(identifier)
540
+ );
541
+ return byPreviousSessionName || null;
499
542
  }
500
543
 
501
544
  /**
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Exit reason hints for finished executions.
3
+ *
4
+ * A numeric exit code alone is frequently misleading: a Bun/Node process that
5
+ * exhausts its heap aborts itself, so the container reports `exitCode 139` with
6
+ * `OOMKilled false` even though the run died of memory exhaustion (issue #162,
7
+ * related to #144, #148 and #151). `start` already reads the tail of the log to
8
+ * find the terminal footer, so the same tail is scanned for well-known fatal
9
+ * markers and the finding is surfaced as an extra `exitReason` field.
10
+ *
11
+ * The same scan answers a narrower question for consumers that key off
12
+ * `oomKilled`: was this run killed by memory exhaustion at all? That answer is
13
+ * surfaced as `memoryExhausted` plus `memoryExhaustedReason`, the log line that
14
+ * carried the evidence (issue #165).
15
+ *
16
+ * Both fields are hints, never verdicts: they never change `status`,
17
+ * `exitCode` or `oomKilled`.
18
+ */
19
+
20
+ /**
21
+ * Reasons carrying this prefix report memory exhaustion, whatever the mechanism
22
+ * (runtime self-abort, kernel OOM killer, failed allocation).
23
+ */
24
+ const MEMORY_EXHAUSTION_PREFIX = 'memory-exhaustion';
25
+
26
+ /**
27
+ * Fatal markers, most specific first. The first matching entry wins.
28
+ * @type {{reason: string, pattern: RegExp}[]}
29
+ */
30
+ const EXIT_REASON_MARKERS = [
31
+ {
32
+ reason: 'memory-exhaustion (v8-heap-limit)',
33
+ pattern:
34
+ /FATAL ERROR:[^\r\n]*(?:Reached heap limit|JavaScript heap out of memory)/i,
35
+ },
36
+ {
37
+ reason: 'memory-exhaustion (v8-heap-limit)',
38
+ pattern: /JavaScript heap out of memory/i,
39
+ },
40
+ {
41
+ reason: 'memory-exhaustion (kernel-oom-killer)',
42
+ pattern:
43
+ /Out of memory: Kill(?:ed)? process|oom-kill(?:er)?[: ]|Killed process \d+/i,
44
+ },
45
+ {
46
+ reason: 'memory-exhaustion (go-runtime)',
47
+ pattern: /fatal error: runtime: out of memory/i,
48
+ },
49
+ {
50
+ reason: 'memory-exhaustion (allocation-failure)',
51
+ pattern:
52
+ /std::bad_alloc|Cannot allocate memory|memory allocation of \d+ bytes failed|Allocation failed - process out of memory|Array buffer allocation failed/i,
53
+ },
54
+ ];
55
+
56
+ /**
57
+ * Exit codes above 128 encode the signal that killed the process.
58
+ * Only the signals that actually show up in command logs are named.
59
+ * @type {Object<number, string>}
60
+ */
61
+ const SIGNAL_NAMES = {
62
+ 1: 'SIGHUP',
63
+ 2: 'SIGINT',
64
+ 3: 'SIGQUIT',
65
+ 4: 'SIGILL',
66
+ 6: 'SIGABRT',
67
+ 8: 'SIGFPE',
68
+ 9: 'SIGKILL',
69
+ 11: 'SIGSEGV',
70
+ 13: 'SIGPIPE',
71
+ 15: 'SIGTERM',
72
+ };
73
+
74
+ /**
75
+ * Longest `memoryExhaustedReason` reported. A marker line is a single line of
76
+ * runtime output, but a log can contain arbitrarily long lines, and the reason
77
+ * travels inside status records that stay readable only when bounded.
78
+ */
79
+ const MAX_MARKER_LINE_LENGTH = 300;
80
+
81
+ /**
82
+ * Extract the whole log line containing `index`, trimmed and length-bounded.
83
+ * @param {string} text - Log text
84
+ * @param {number} index - Offset of the match inside `text`
85
+ * @returns {string} The line the match sits on
86
+ */
87
+ function extractLineAt(text, index) {
88
+ const start = text.lastIndexOf('\n', index) + 1;
89
+ const end = text.indexOf('\n', index);
90
+ const line = text.slice(start, end === -1 ? undefined : end).trim();
91
+ return line.length > MAX_MARKER_LINE_LENGTH
92
+ ? `${line.slice(0, MAX_MARKER_LINE_LENGTH)}...`
93
+ : line;
94
+ }
95
+
96
+ /**
97
+ * Find the first known fatal marker in log text.
98
+ * @param {string|null|undefined} text - Log text (usually the log tail)
99
+ * @param {(marker: {reason: string, pattern: RegExp}) => boolean} [accept] - Marker filter
100
+ * @returns {{reason: string, line: string}|null} Marker and the line carrying it
101
+ */
102
+ function findExitReasonMarker(text, accept = () => true) {
103
+ if (!text) {
104
+ return null;
105
+ }
106
+ for (const marker of EXIT_REASON_MARKERS) {
107
+ if (!accept(marker)) {
108
+ continue;
109
+ }
110
+ const match = marker.pattern.exec(text);
111
+ if (match) {
112
+ return { reason: marker.reason, line: extractLineAt(text, match.index) };
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Scan log text for a known fatal marker.
120
+ * @param {string|null|undefined} text - Log text (usually the log tail)
121
+ * @returns {string|null} Detected reason, or null when nothing matched
122
+ */
123
+ function detectExitReason(text) {
124
+ const marker = findExitReasonMarker(text);
125
+ return marker ? marker.reason : null;
126
+ }
127
+
128
+ /**
129
+ * Scan log text for a marker that specifically reports memory exhaustion.
130
+ * @param {string|null|undefined} text - Log text (usually the log tail)
131
+ * @returns {{reason: string, line: string}|null} Marker and the line carrying it
132
+ */
133
+ function detectMemoryMarker(text) {
134
+ return findExitReasonMarker(text, (marker) =>
135
+ marker.reason.startsWith(MEMORY_EXHAUSTION_PREFIX)
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Resolve the memory-exhaustion observation for a finished execution (#165).
141
+ *
142
+ * A runtime that aborts on its own heap limit is invisible to every container
143
+ * signal: it dies below the container limit, so `State.OOMKilled` stays `false`
144
+ * and the cgroup `oom_kill` counter stays `0`. The only evidence is what the
145
+ * runtime printed on its way out, which sits in the very log tail the footer
146
+ * scan already reads.
147
+ *
148
+ * Like `oomKilled` (#151) this is an *observation*, never a verdict: it is only
149
+ * derived for a run that already ended abnormally, so a log that merely quotes
150
+ * a fatal marker (a test fixture, an `rg` dump) cannot turn a clean run into a
151
+ * reported memory failure.
152
+ *
153
+ * @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
154
+ * @returns {{memoryExhausted: true, memoryExhaustedReason: string}|null}
155
+ */
156
+ function resolveMemoryExhaustion(input) {
157
+ if (!input) {
158
+ return null;
159
+ }
160
+ const { exitCode } = input;
161
+ if (
162
+ typeof exitCode !== 'number' ||
163
+ !Number.isFinite(exitCode) ||
164
+ exitCode === 0
165
+ ) {
166
+ return null;
167
+ }
168
+
169
+ const marker = detectMemoryMarker(input.logTail);
170
+ if (marker) {
171
+ return { memoryExhausted: true, memoryExhaustedReason: marker.line };
172
+ }
173
+ if (input.oomKilled === true) {
174
+ return {
175
+ memoryExhausted: true,
176
+ memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Map a shell exit code to the signal name it encodes.
184
+ * @param {number|null|undefined} exitCode - Terminal exit code
185
+ * @returns {string|null} Signal name, or null when the code is not a signal
186
+ */
187
+ function signalNameForExitCode(exitCode) {
188
+ if (typeof exitCode !== 'number' || !Number.isFinite(exitCode)) {
189
+ return null;
190
+ }
191
+ if (exitCode <= 128 || exitCode > 128 + 64) {
192
+ return null;
193
+ }
194
+ return SIGNAL_NAMES[exitCode - 128] || null;
195
+ }
196
+
197
+ /**
198
+ * Resolve the best available hint for why an execution ended.
199
+ *
200
+ * Precedence: the log marker (evidence written by the command itself), then the
201
+ * cgroup OOM observation, then the signal encoded in the exit code.
202
+ *
203
+ * @param {{exitCode?: number|null, logTail?: string|null, oomKilled?: boolean}|null} input
204
+ * @returns {string|null} Exit reason hint, or null when nothing is known
205
+ */
206
+ function resolveExitReason(input) {
207
+ if (!input) {
208
+ return null;
209
+ }
210
+
211
+ const fromLog = detectExitReason(input.logTail);
212
+ if (fromLog) {
213
+ return fromLog;
214
+ }
215
+
216
+ if (input.oomKilled === true) {
217
+ return 'memory-exhaustion (cgroup-oom-killer)';
218
+ }
219
+
220
+ const signalName = signalNameForExitCode(input.exitCode);
221
+ return signalName ? `signal (${signalName})` : null;
222
+ }
223
+
224
+ module.exports = {
225
+ EXIT_REASON_MARKERS,
226
+ MEMORY_EXHAUSTION_PREFIX,
227
+ SIGNAL_NAMES,
228
+ detectExitReason,
229
+ detectMemoryMarker,
230
+ findExitReasonMarker,
231
+ resolveMemoryExhaustion,
232
+ resolveExitReason,
233
+ signalNameForExitCode,
234
+ };
@@ -4,7 +4,7 @@
4
4
  * Handles command failures - detects repository, uploads logs, creates issues
5
5
  */
6
6
 
7
- const { execSync } = require('child_process');
7
+ const { execFileSync } = require('child_process');
8
8
  const os = require('os');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
@@ -97,7 +97,7 @@ function detectRepository(cmdName) {
97
97
  let cmdPath;
98
98
 
99
99
  try {
100
- cmdPath = execSync(`${whichCmd} ${cmdName}`, {
100
+ cmdPath = execFileSync(whichCmd, [cmdName], {
101
101
  encoding: 'utf8',
102
102
  stdio: ['pipe', 'pipe', 'pipe'],
103
103
  }).trim();
@@ -117,7 +117,7 @@ function detectRepository(cmdName) {
117
117
  // Check if it's in npm global modules
118
118
  let npmGlobalPath;
119
119
  try {
120
- npmGlobalPath = execSync('npm root -g', {
120
+ npmGlobalPath = execFileSync('npm', ['root', '-g'], {
121
121
  encoding: 'utf8',
122
122
  stdio: ['pipe', 'pipe', 'pipe'],
123
123
  }).trim();
@@ -183,42 +183,28 @@ function detectRepository(cmdName) {
183
183
  packageName = cmdName;
184
184
  }
185
185
 
186
- // Try to get repository URL from npm
187
- try {
188
- const npmInfo = execSync(
189
- `npm view ${packageName} repository.url 2>/dev/null`,
190
- {
186
+ // Try the repository URL, then the bugs URL, from the npm registry.
187
+ // execFileSync, not execSync: packageName comes from a path or from the
188
+ // failing command's own name, and a shell would run anything it carries
189
+ // (CodeQL js/shell-command-injection-from-environment).
190
+ for (const field of ['repository.url', 'bugs.url']) {
191
+ let url;
192
+ try {
193
+ url = execFileSync('npm', ['view', packageName, field], {
191
194
  encoding: 'utf8',
192
- stdio: ['pipe', 'pipe', 'pipe'],
193
- }
194
- ).trim();
195
-
196
- if (npmInfo) {
197
- // Parse git URL to extract owner and repo
198
- const parsed = parseGitUrl(npmInfo);
199
- if (parsed) {
200
- return parsed;
201
- }
195
+ stdio: ['pipe', 'pipe', 'ignore'],
196
+ }).trim();
197
+ } catch {
198
+ // npm view failed: package might not exist or have no such field.
199
+ continue;
202
200
  }
203
- } catch {
204
- // npm view failed, package might not exist or have no repository
205
- }
206
201
 
207
- // Try to get homepage or bugs URL as fallback
208
- try {
209
- const bugsUrl = execSync(`npm view ${packageName} bugs.url 2>/dev/null`, {
210
- encoding: 'utf8',
211
- stdio: ['pipe', 'pipe', 'pipe'],
212
- }).trim();
213
-
214
- if (bugsUrl && bugsUrl.includes('github.com')) {
215
- const parsed = parseGitUrl(bugsUrl);
216
- if (parsed) {
217
- return parsed;
218
- }
202
+ // parseGitUrl anchors on the github.com host itself, so no separate
203
+ // (and substring-only) host check is needed here.
204
+ const parsed = parseGitUrl(url);
205
+ if (parsed) {
206
+ return parsed;
219
207
  }
220
- } catch {
221
- // Fallback also failed
222
208
  }
223
209
 
224
210
  return null;
@@ -258,7 +244,7 @@ function parseGitUrl(url) {
258
244
  */
259
245
  function isGhAuthenticated() {
260
246
  try {
261
- execSync('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] });
247
+ execFileSync('gh', ['auth', 'status'], { stdio: ['pipe', 'pipe', 'pipe'] });
262
248
  return true;
263
249
  } catch {
264
250
  return false;
@@ -273,7 +259,9 @@ function isGhUploadLogAvailable() {
273
259
  const isWindows = process.platform === 'win32';
274
260
  try {
275
261
  const whichCmd = isWindows ? 'where' : 'which';
276
- execSync(`${whichCmd} gh-upload-log`, { stdio: ['pipe', 'pipe', 'pipe'] });
262
+ execFileSync(whichCmd, ['gh-upload-log'], {
263
+ stdio: ['pipe', 'pipe', 'pipe'],
264
+ });
277
265
  return true;
278
266
  } catch {
279
267
  return false;
@@ -287,7 +275,7 @@ function isGhUploadLogAvailable() {
287
275
  */
288
276
  function uploadLog(logPath) {
289
277
  try {
290
- const result = execSync(`gh-upload-log "${logPath}" --public`, {
278
+ const result = execFileSync('gh-upload-log', [logPath, '--public'], {
291
279
  encoding: 'utf8',
292
280
  stdio: ['pipe', 'pipe', 'pipe'],
293
281
  });
@@ -320,7 +308,7 @@ function uploadLog(logPath) {
320
308
  function canCreateIssue(owner, repo) {
321
309
  try {
322
310
  // Check if the repository exists and we have access
323
- execSync(`gh repo view ${owner}/${repo} --json name`, {
311
+ execFileSync('gh', ['repo', 'view', `${owner}/${repo}`, '--json', 'name'], {
324
312
  stdio: ['pipe', 'pipe', 'pipe'],
325
313
  });
326
314
  return true;
@@ -364,8 +352,22 @@ function createIssue(repoInfo, fullCommand, exitCode, logUrl) {
364
352
  body += `---\n`;
365
353
  body += `*This issue was automatically created by [start-command](https://github.com/link-foundation/start)*\n`;
366
354
 
367
- const result = execSync(
368
- `gh issue create --repo ${repoInfo.owner}/${repoInfo.repo} --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`,
355
+ // execFileSync passes the title and body as argv entries, so no quoting is
356
+ // involved: the failing command they quote reached the shell verbatim
357
+ // before, and its newlines survived only as the two characters "\n"
358
+ // (CodeQL js/incomplete-sanitization, issue #168).
359
+ const result = execFileSync(
360
+ 'gh',
361
+ [
362
+ 'issue',
363
+ 'create',
364
+ '--repo',
365
+ `${repoInfo.owner}/${repoInfo.repo}`,
366
+ '--title',
367
+ title,
368
+ '--body',
369
+ body,
370
+ ],
369
371
  {
370
372
  encoding: 'utf8',
371
373
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -204,7 +204,65 @@ function runAsIsolatedUser(cmd, username) {
204
204
  });
205
205
  }
206
206
 
207
+ /**
208
+ * Number of trailing bytes scanned for the terminal log footer. The footer is
209
+ * always appended at the very end of the log, so there is no reason to read
210
+ * (potentially megabytes of) command output on every `--status` call.
211
+ */
212
+ const LOG_TAIL_BYTES = 16 * 1024;
213
+
214
+ /**
215
+ * Number of trailing bytes scanned for a fatal marker (issue #165).
216
+ *
217
+ * Wider than the footer window because a dying runtime keeps printing after the
218
+ * marker: V8 emits a full native stack trace between `FATAL ERROR: Reached heap
219
+ * limit ...` and the end of the log, which can push the marker several tens of
220
+ * kilobytes away from EOF.
221
+ */
222
+ const FATAL_MARKER_TAIL_BYTES = 64 * 1024;
223
+
224
+ /**
225
+ * Read the last `bytes` bytes of a file as UTF-8 text.
226
+ *
227
+ * A partial first line is dropped: the slice can start in the middle of a line,
228
+ * and that fragment must not be treated as the beginning of a line by the
229
+ * anchored footer pattern.
230
+ *
231
+ * @param {string} logPath - Path to the log file
232
+ * @param {number} [bytes] - Maximum number of trailing bytes to read
233
+ * @returns {string|null} Tail content, or null when the file cannot be read
234
+ */
235
+ function readLogTail(logPath, bytes = LOG_TAIL_BYTES) {
236
+ let fd;
237
+ try {
238
+ fd = fs.openSync(logPath, 'r');
239
+ const size = fs.fstatSync(fd).size;
240
+ const length = Math.min(size, bytes);
241
+ const buffer = Buffer.alloc(length);
242
+ fs.readSync(fd, buffer, 0, length, size - length);
243
+ const tail = buffer.toString('utf8');
244
+ if (size <= bytes) {
245
+ return tail;
246
+ }
247
+ const firstNewline = tail.indexOf('\n');
248
+ return firstNewline === -1 ? '' : tail.slice(firstNewline + 1);
249
+ } catch {
250
+ return null;
251
+ } finally {
252
+ if (fd !== undefined) {
253
+ try {
254
+ fs.closeSync(fd);
255
+ } catch {
256
+ // ignore
257
+ }
258
+ }
259
+ }
260
+ }
261
+
207
262
  module.exports = {
263
+ LOG_TAIL_BYTES,
264
+ FATAL_MARKER_TAIL_BYTES,
265
+ readLogTail,
208
266
  getTempRoot,
209
267
  ensureDirectory,
210
268
  ensureParentDirectory,
@@ -27,6 +27,7 @@ const {
27
27
  wrapCommandWithLogFooter,
28
28
  } = require('./isolation-log-utils');
29
29
  const {
30
+ buildAttachedDockerKeptMessage,
30
31
  DOCKER_CONTAINER_CLEANUP_POLICY,
31
32
  getDockerContainerCleanupPolicy,
32
33
  shouldCleanupDockerContainer,
@@ -168,6 +169,7 @@ const {
168
169
  isShellInvocationWithArgs,
169
170
  buildShellWithArgsCmdArgs,
170
171
  buildDisplayCommand,
172
+ toShellWords,
171
173
  } = require('./shell-utils');
172
174
 
173
175
  /** Returns true if the current process has a TTY attached. */
@@ -452,7 +454,7 @@ function runInSsh(command, options = {}) {
452
454
  } else {
453
455
  const extraFlags = shellInteractiveFlag ? [shellInteractiveFlag] : [];
454
456
  const sshArgs = isInteractiveShellCommand(command)
455
- ? [sshTarget, ...command.trim().split(/\s+/)]
457
+ ? [sshTarget, ...toShellWords(command)]
456
458
  : useExplicitShell
457
459
  ? [sshTarget, useExplicitShell, ...extraFlags, '-c', command]
458
460
  : [sshTarget, command];
@@ -677,7 +679,7 @@ function runInDocker(command, options = {}) {
677
679
  ? [shellToUse, shellInteractiveFlag]
678
680
  : [shellToUse];
679
681
  const cmdArgs = isBareShell
680
- ? command.trim().split(/\s+/)
682
+ ? toShellWords(command)
681
683
  : isShellInvocationWithArgs(command)
682
684
  ? buildShellWithArgsCmdArgs(effectiveCommand)
683
685
  : [...shellArgs, '-c', effectiveCommand];
@@ -747,7 +749,7 @@ function runInDocker(command, options = {}) {
747
749
  containerName,
748
750
  cleanupPolicy
749
751
  );
750
- message += `\nAttach with: docker attach ${containerName}`;
752
+ message += `\nAttach with: $ --attach ${containerName} (or docker attach ${containerName})`;
751
753
  message += `\nView logs: docker logs ${containerName}`;
752
754
  if (options.logPath) {
753
755
  message += `\nLive log: ${options.logPath}`;
@@ -777,7 +779,7 @@ function runInDocker(command, options = {}) {
777
779
  // Shell with -c: pass directly as argv (avoids double-wrapping and quote-stripping, issue #91).
778
780
  let attachedCmdArgs;
779
781
  if (isBareShell) {
780
- const parts = command.trim().split(/\s+/);
782
+ const parts = toShellWords(command);
781
783
  const bareFlag = getShellInteractiveFlag(parts[0]);
782
784
  attachedCmdArgs =
783
785
  bareFlag && !parts.includes(bareFlag)
@@ -819,7 +821,7 @@ function runInDocker(command, options = {}) {
819
821
  let message = `Docker container "${containerName}" exited with code ${exitCode}`;
820
822
  // Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
821
823
  if (isBareShell && exitCode !== 0 && durationMs < 3000) {
822
- const shell0 = command.trim().split(/\s+/)[0];
824
+ const shell0 = toShellWords(command)[0];
823
825
  // prettier-ignore
824
826
  const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
825
827
  const hint = `Hint: The shell exited immediately — its startup file (.bashrc/.zshrc) may have errors.\nTry skipping startup files: ${shell0} ${norc}`;
@@ -853,11 +855,12 @@ function runInDocker(command, options = {}) {
853
855
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL ||
854
856
  cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT
855
857
  ) {
856
- message +=
857
- oomKilled === true
858
- ? `\nContainer kept because Docker reports it was OOM-killed.`
859
- : `\nContainer kept because the command failed.`;
860
- message += `\nRemove when done: docker rm -f ${containerName}`;
858
+ message += buildAttachedDockerKeptMessage({
859
+ containerName,
860
+ exitCode,
861
+ oomKilled,
862
+ logPath: options.logPath,
863
+ });
861
864
  }
862
865
 
863
866
  resolve({