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
@@ -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);
@@ -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). */
package/src/lib/usage.js CHANGED
@@ -3,6 +3,7 @@ function printUsage() {
3
3
  console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--running] [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id> | $ --attach <id> [--read-only] | $ --resume <id> [-- <command>] | $ --resume-all
4
4
 
5
5
  Options:
6
+ --help, -h Show this usage and exit successfully
6
7
  --isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
7
8
  --attached, -a Run in attached mode (foreground)
8
9
  --detached, -d Run in detached mode (background)
@@ -13,6 +14,7 @@ Options:
13
14
  --volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
14
15
  --mount <spec> Docker --mount spec (repeatable, docker only)
15
16
  --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
17
+ --label <KEY=VALUE> Container attribution (repeatable, docker only; start-command.* reserved)
16
18
  --privileged Run docker container in privileged mode (docker only)
17
19
  --network <name> Connect to a named network (repeatable, docker only)
18
20
  --network-alias <alias> Add alias to the first network (repeatable, docker only)
@@ -43,13 +45,15 @@ Options:
43
45
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
44
46
  --list List all tracked executions (--output-format: links-notation|json|text)
45
47
  --running With --list, only report executions that are still running
46
- --upload-log <id> Upload the stored log for an execution UUID or session name
48
+ --upload-log <id> Upload a sanitized copy of the stored log privately
49
+ --no-sanitize With --upload-log, explicitly upload without secret redaction
47
50
  --stop <id> Ask a detached isolated execution to stop gracefully
48
51
  --terminate <id> Terminate a detached isolated execution immediately
49
52
  --attach <id> Attach to a running detached isolated execution
50
53
  --read-only With --attach, follow output without sending input
51
54
  --resume <id> Restart a stopped detached execution in the same environment
52
55
  (append -- <command> to run a different command there)
56
+ --remove-original With legacy snapshot resume, remove the stopped original after launch
53
57
  --resume-all Re-attach or reconcile every execution still marked running
54
58
  --cleanup Clean up stale "executing" records (crashed/killed processes)
55
59
  --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
@@ -156,6 +156,10 @@ describe('CI workflow invariants', () => {
156
156
  it('gives every job a timeout', () => {
157
157
  for (const name of workflows) {
158
158
  for (const job of parseJobs(readWorkflow(name))) {
159
+ if (jobKey(job.body, 'uses')) {
160
+ // Reusable-workflow callers cannot declare a timeout; their jobs do.
161
+ continue;
162
+ }
159
163
  assert.ok(
160
164
  jobKey(job.body, 'timeout-minutes'),
161
165
  `${name}: job "${job.name}" has no timeout-minutes`
@@ -186,7 +190,7 @@ describe('CI workflow invariants', () => {
186
190
  }
187
191
  });
188
192
 
189
- it('makes writers non-cancellable and read-only checks cancellable', () => {
193
+ it('protects main and the gated Rust stage from cancellation', () => {
190
194
  for (const name of workflows) {
191
195
  for (const job of parseJobs(readWorkflow(name))) {
192
196
  const group = concurrencyKey(job.body, 'group');
@@ -212,8 +216,10 @@ describe('CI workflow invariants', () => {
212
216
  );
213
217
  assert.strictEqual(
214
218
  cancel,
215
- 'true',
216
- `${name}: superseded check "${job.name}" should be cancelled`
219
+ name === 'rust.yml' || job.name === 'rust-stage'
220
+ ? 'false'
221
+ : "${{ github.ref != 'refs/heads/main' }}",
222
+ `${name}: checks must preserve main and the gated Rust stage`
217
223
  );
218
224
  }
219
225
  }
@@ -223,7 +229,7 @@ describe('CI workflow invariants', () => {
223
229
  it('uses !cancelled() rather than always() outside the status job', () => {
224
230
  for (const name of workflows) {
225
231
  for (const job of parseJobs(readWorkflow(name))) {
226
- if (job.name === 'pipeline-status') {
232
+ if (job.name === 'pipeline-status' || job.name === 'rust-stage') {
227
233
  continue;
228
234
  }
229
235
  assert.doesNotMatch(
@@ -246,7 +252,7 @@ describe('CI workflow invariants', () => {
246
252
  `${name}: pipeline-status must run even when jobs are cancelled`
247
253
  );
248
254
  for (const job of jobs) {
249
- if (job.name === 'pipeline-status') {
255
+ if (job.name === 'pipeline-status' || job.name === 'rust-stage') {
250
256
  continue;
251
257
  }
252
258
  assert.match(
@@ -395,15 +401,10 @@ describe('CI workflow invariants', () => {
395
401
 
396
402
  it('audits both dependency graphs for advisories', () => {
397
403
  const security = readWorkflow('security.yml');
398
- const jobs = parseJobs(security).map((job) => job.name);
399
- for (const job of ['cargo-audit', 'npm-audit']) {
400
- assert.ok(jobs.includes(job), `security.yml has no ${job} job`);
401
- }
402
- assert.match(
403
- security,
404
- /cargo audit/,
405
- 'cargo-audit job must run cargo audit'
406
- );
404
+ const rust = readWorkflow('rust.yml');
405
+ assert.ok(parseJobs(security).some((job) => job.name === 'npm-audit'));
406
+ assert.ok(parseJobs(rust).some((job) => job.name === 'cargo-audit'));
407
+ assert.match(rust, /cargo audit/, 'cargo-audit job must run cargo audit');
407
408
  assert.match(security, /npm audit/, 'npm-audit job must run npm audit');
408
409
  });
409
410
 
@@ -0,0 +1,107 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+ const {
8
+ buildCommandHandoffArgs,
9
+ buildCommandHandoffScript,
10
+ commandHandoffPath,
11
+ writeCommandHandoff,
12
+ } = require('../src/lib/docker-command-handoff');
13
+ const { buildResumePlan, ResumeMode } = require('../src/lib/execution-resume');
14
+ const { SessionState } = require('../src/lib/session-probe');
15
+
16
+ test('new stopped containers choose a copy-free command handoff', () => {
17
+ const plan = buildResumePlan(
18
+ {
19
+ uuid: 'uuid',
20
+ command: 'old-command',
21
+ options: {
22
+ isolated: 'docker',
23
+ isolationMode: 'detached',
24
+ sessionName: 'box',
25
+ commandHandoff: true,
26
+ },
27
+ },
28
+ 'printf new-command',
29
+ { state: SessionState.STOPPED, alive: false }
30
+ );
31
+ assert.equal(plan.mode, ResumeMode.DOCKER_START);
32
+ assert.equal(plan.handoffCommand, 'printf new-command');
33
+ assert.deepEqual(
34
+ plan.steps.map((step) => step.args),
35
+ [['start', 'box']]
36
+ );
37
+ assert.equal(plan.snapshotImage, undefined);
38
+ });
39
+
40
+ test('handoff preserves argv and accepts a replacement without executing it on the host', () => {
41
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'handoff-test-'));
42
+ const marker = path.join(dir, 'command');
43
+ try {
44
+ const args = buildCommandHandoffArgs(
45
+ ['sh', '-c', 'printf original'],
46
+ 'command'
47
+ );
48
+ const shellOptions = { cwd: dir, encoding: 'utf8' };
49
+ assert.equal(
50
+ spawnSync(args[0], args.slice(1), shellOptions).stdout,
51
+ 'original'
52
+ );
53
+ let script;
54
+ const copied = writeCommandHandoff(
55
+ 'box',
56
+ "printf '%s' 'new $value'",
57
+ (_bin, argv) => {
58
+ assert.equal(argv[0], 'cp');
59
+ assert.equal(argv[2], `box:${commandHandoffPath('box')}`);
60
+ script = fs.readFileSync(argv[1], 'utf8');
61
+ fs.copyFileSync(argv[1], marker);
62
+ fs.accessSync(argv[1], fs.constants.R_OK);
63
+ // Windows chmod supports write permission, not Unix mode bits.
64
+ if (process.platform !== 'win32') {
65
+ assert.equal(fs.statSync(argv[1]).mode & 0o777, 0o644);
66
+ }
67
+ return { success: true };
68
+ },
69
+ { shell: 'sh' }
70
+ );
71
+ assert.equal(copied.success, true);
72
+ assert.ok(script.includes('new $value'));
73
+ assert.equal(
74
+ spawnSync(args[0], args.slice(1), shellOptions).stdout,
75
+ 'new $value'
76
+ );
77
+ // docker start without another replacement runs the current stored command.
78
+ assert.equal(
79
+ spawnSync(args[0], args.slice(1), shellOptions).stdout,
80
+ 'new $value'
81
+ );
82
+ } finally {
83
+ fs.rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ test('handoff preserves direct shell positional argv and evaluates compound commands as scripts', () => {
88
+ for (const [command, expected] of [
89
+ ['sh -c \'printf "%s:%s" "$0" "$1"\' tag \'two words\'', 'tag:two words'],
90
+ ["sh -c 'exit 7'; printf 'after%s' \"$?\"", 'after7'],
91
+ ]) {
92
+ const script = buildCommandHandoffScript(command, { shell: 'sh' });
93
+ const result = spawnSync('sh', ['-c', script], { encoding: 'utf8' });
94
+ assert.equal(result.status, 0, result.stderr);
95
+ assert.equal(result.stdout, expected);
96
+ }
97
+ });
98
+
99
+ test('auto handoff chooses an installed shell inside the existing container', () => {
100
+ const script = buildCommandHandoffScript('printf auto');
101
+ assert.ok(
102
+ script.includes('command -v bash || command -v zsh || command -v sh')
103
+ );
104
+ const result = spawnSync('sh', ['-c', script], { encoding: 'utf8' });
105
+ assert.equal(result.status, 0, result.stderr);
106
+ assert.equal(result.stdout, 'auto');
107
+ });
@@ -300,6 +300,8 @@ describe('buildDockerRuntimeMetadata', () => {
300
300
  volumes: null,
301
301
  mounts: null,
302
302
  env: null,
303
+ labels: null,
304
+ commandHandoff: null,
303
305
  privileged: null,
304
306
  network: null,
305
307
  networks: null,
@@ -334,6 +336,8 @@ describe('buildDockerRuntimeMetadata', () => {
334
336
  networks: ['hive-formal-ai', 'public-egress'],
335
337
  networkAliases: ['formal-ai', 'checker'],
336
338
  resourceLimits: null,
339
+ labels: null,
340
+ commandHandoff: null,
337
341
  resolvedLimits: null,
338
342
  resourceLimitSpecs: null,
339
343
  onKillResumeMemory: null,
@@ -0,0 +1,106 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+ const {
8
+ preflightSnapshot,
9
+ acquireSnapshotLock,
10
+ containerdRoot,
11
+ } = require('../src/lib/docker-snapshot-safety');
12
+
13
+ const GiB = 1024 ** 3;
14
+ function runner(size, containerd = false) {
15
+ return (_bin, args) => ({
16
+ success: true,
17
+ stdout:
18
+ args[0] === 'info'
19
+ ? JSON.stringify({
20
+ DockerRootDir: '/docker',
21
+ DriverStatus: containerd
22
+ ? [['driver-type', 'io.containerd.snapshotter.v1']]
23
+ : [],
24
+ })
25
+ : String(size),
26
+ });
27
+ }
28
+
29
+ test('preflight refuses insufficient space before any snapshot', () => {
30
+ assert.throws(
31
+ () =>
32
+ preflightSnapshot('box', runner(12 * GiB), {
33
+ freeBytes: () => 20 * GiB,
34
+ }),
35
+ /Insufficient disk.*34\.00 GiB/
36
+ );
37
+ });
38
+
39
+ test('containerd image store checks Docker and containerd filesystems separately', () => {
40
+ const checked = [];
41
+ assert.throws(
42
+ () =>
43
+ preflightSnapshot('box', runner(8 * GiB, true), {
44
+ containerdRoot: '/containerd',
45
+ freeBytes: (root) => {
46
+ checked.push(root);
47
+ return root === '/docker' ? 100 * GiB : 25 * GiB;
48
+ },
49
+ }),
50
+ /\/containerd/
51
+ );
52
+ assert.deepEqual(checked, ['/docker', '/containerd']);
53
+ });
54
+
55
+ test('preflight logs the layer size and rejects unknown sizes or roots', () => {
56
+ assert.match(
57
+ preflightSnapshot('box', runner(GiB), { freeBytes: () => 100 * GiB })
58
+ .message,
59
+ /snapshotting 1\.00 GiB/
60
+ );
61
+ assert.throws(
62
+ () => preflightSnapshot('box', runner('unknown')),
63
+ /writable layer/
64
+ );
65
+ assert.equal(
66
+ containerdRoot('version = 2\nroot = "/custom"\n[plugins]\nroot = "/wrong"'),
67
+ '/custom'
68
+ );
69
+ });
70
+
71
+ test('snapshot lock is separate from store locks and excludes concurrent processes', () => {
72
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-lock-'));
73
+ const lockPath = path.join(dir, 'snapshot.lock');
74
+ try {
75
+ const lock = acquireSnapshotLock({ lockPath });
76
+ assert.throws(
77
+ () => acquireSnapshotLock({ lockPath }),
78
+ /snapshot.*in progress/
79
+ );
80
+ const child = spawnSync(
81
+ process.execPath,
82
+ [
83
+ '-e',
84
+ `
85
+ try {
86
+ require(${JSON.stringify(require.resolve('../src/lib/docker-snapshot-safety'))}).acquireSnapshotLock();
87
+ process.exitCode = 3;
88
+ } catch (error) {
89
+ console.log(error.message);
90
+ process.exitCode = 2;
91
+ }
92
+ `,
93
+ ],
94
+ {
95
+ encoding: 'utf8',
96
+ env: { ...process.env, START_SNAPSHOT_LOCK: lockPath },
97
+ }
98
+ );
99
+ assert.equal(child.status, 2, child.stderr);
100
+ assert.match(child.stdout, /snapshot.*in progress/);
101
+ lock.release();
102
+ acquireSnapshotLock({ lockPath }).release();
103
+ } finally {
104
+ fs.rmSync(dir, { recursive: true, force: true });
105
+ }
106
+ });
@@ -101,12 +101,8 @@ describe('buildResumePlan', () => {
101
101
  '--name',
102
102
  'box-resume-1',
103
103
  ]);
104
- assert.deepStrictEqual(runArgs.slice(-4), [
105
- 'start-command-resume/box:1',
106
- 'sh',
107
- '-c',
108
- 'npm run build',
109
- ]);
104
+ assert.ok(runArgs.includes('start-command-resume/box:1'));
105
+ assert.deepStrictEqual(runArgs.slice(-3), ['sh', '-c', 'npm run build']);
110
106
  });
111
107
 
112
108
  test('carries stored docker runtime options into the snapshot run', () => {
@@ -289,9 +285,15 @@ describe('resumeExecution', () => {
289
285
  const result = await resumeExecution(store, 'box', {
290
286
  command: 'npm run build',
291
287
  probe: () => probeWith(SessionState.STOPPED, 'exited'),
292
- runner: () => ({
288
+ snapshotOptions: { freeBytes: () => 100 * 1024 ** 3 },
289
+ runner: (_bin, args) => ({
293
290
  success: true,
294
- stdout: 'deadbeef\n',
291
+ stdout:
292
+ args[0] === 'info'
293
+ ? JSON.stringify({ DockerRootDir: '/docker' })
294
+ : args.includes('--size')
295
+ ? '1024'
296
+ : 'deadbeef\n',
295
297
  stderr: '',
296
298
  status: 0,
297
299
  }),
@@ -175,6 +175,17 @@ describe('failure-handler', () => {
175
175
  assert.ok(!body.includes('\\"'), 'body must not contain escaped quotes');
176
176
  });
177
177
 
178
+ it('sanitizes credentials in the issue title and body', () => {
179
+ if (process.platform === 'win32') {
180
+ return;
181
+ }
182
+ const token = `gh${'p_'}${'a'.repeat(36)}`;
183
+ const { argv } = createIssueWithFakeGh(`echo ${token}`);
184
+ assert.ok(!argv[5].includes(token));
185
+ assert.ok(!argv[7].includes(token));
186
+ assert.ok(argv[7].includes('[REDACTED]'));
187
+ });
188
+
178
189
  it('writes real newlines into the issue body', () => {
179
190
  if (process.platform === 'win32') {
180
191
  return;