start-command 0.29.2 → 0.30.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # start-command
2
2
 
3
+ ## 0.30.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1a040d6: Use `docker stop` for detached Docker `--stop` control so containers follow Docker's graceful stop lifecycle instead of receiving a raw `SIGINT`.
8
+
9
+ ## 0.30.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 3d7fe09: Clean up Docker isolation containers by default after completion, preserve host log files, and add explicit `--keep-container`, `--always-cleanup-container`, and `--keep-container-on-fail` cleanup policy flags.
14
+
3
15
  ## 0.29.2
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.29.2",
3
+ "version": "0.30.1",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -503,6 +503,10 @@ async function runWithIsolation(
503
503
  endpoint: options.endpoint,
504
504
  user: options.user,
505
505
  keepAlive: options.keepAlive,
506
+ autoRemoveDockerContainer: options.autoRemoveDockerContainer,
507
+ alwaysCleanupContainer: options.alwaysCleanupContainer,
508
+ keepContainer: options.keepContainer,
509
+ keepContainerOnFail: options.keepContainerOnFail,
506
510
  useCommandStream,
507
511
  },
508
512
  });
@@ -574,6 +578,9 @@ async function runWithIsolation(
574
578
  user: createdUser,
575
579
  keepAlive: options.keepAlive,
576
580
  autoRemoveDockerContainer: options.autoRemoveDockerContainer,
581
+ alwaysCleanupContainer: options.alwaysCleanupContainer,
582
+ keepContainer: options.keepContainer,
583
+ keepContainerOnFail: options.keepContainerOnFail,
577
584
  volumes: options.volumes,
578
585
  mounts: options.mounts,
579
586
  env: options.env,
@@ -19,7 +19,10 @@
19
19
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
20
20
  * --keep-user Keep isolated user after command completes (don't delete)
21
21
  * --keep-alive, -k Keep isolation environment alive after command exits
22
- * --auto-remove-docker-container Automatically remove docker container after exit (disabled by default)
22
+ * --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
23
+ * --always-cleanup-container Always remove docker container after exit (default)
24
+ * --keep-container Keep docker container filesystem after exit
25
+ * --keep-container-on-fail Remove successful docker containers, keep failed ones
23
26
  * --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
24
27
  * --use-command-stream Use command-stream library for command execution (experimental)
25
28
  * --verbose Enable verbose/debug output (sets START_VERBOSE=1)
@@ -27,7 +30,7 @@
27
30
  * --list List all tracked command executions
28
31
  * --upload-log <uuid-or-session> Upload the stored log for a tracked execution
29
32
  * --output-format <format> Output format for status/list (links-notation, json, text)
30
- * --stop <uuid-or-session-name> Send CTRL+C/SIGINT to a detached execution
33
+ * --stop <uuid-or-session-name> Ask a detached execution to stop gracefully
31
34
  * --terminate <uuid-or-session-name> Terminate a detached execution immediately
32
35
  * --cleanup Clean up stale "executing" records (processes that crashed or were killed)
33
36
  * --cleanup-dry-run Show stale records that would be cleaned up (without actually cleaning)
@@ -181,7 +184,10 @@ function parseArgs(args) {
181
184
  userName: null, // Optional custom username for isolated user
182
185
  keepUser: false, // Keep isolated user after command completes (don't delete)
183
186
  keepAlive: false, // Keep environment alive after command exits
184
- autoRemoveDockerContainer: false, // Auto-remove docker container after exit
187
+ autoRemoveDockerContainer: false, // Always remove docker container after exit (compatibility alias)
188
+ alwaysCleanupContainer: false, // Explicitly request default always-cleanup docker policy
189
+ keepContainer: false, // Keep docker container filesystem after exit
190
+ keepContainerOnFail: false, // Keep docker container filesystem only when command fails
185
191
  shell: 'auto', // Shell to use in isolation environments: auto, bash, zsh, sh
186
192
  useCommandStream: false, // Use command-stream library for command execution
187
193
  status: null, // UUID to show status for
@@ -449,6 +455,24 @@ function parseOption(args, index, options) {
449
455
  return 1;
450
456
  }
451
457
 
458
+ // --always-cleanup-container
459
+ if (arg === '--always-cleanup-container') {
460
+ options.alwaysCleanupContainer = true;
461
+ return 1;
462
+ }
463
+
464
+ // --keep-container
465
+ if (arg === '--keep-container') {
466
+ options.keepContainer = true;
467
+ return 1;
468
+ }
469
+
470
+ // --keep-container-on-fail
471
+ if (arg === '--keep-container-on-fail') {
472
+ options.keepContainerOnFail = true;
473
+ return 1;
474
+ }
475
+
452
476
  // --shell <shell>
453
477
  if (arg === '--shell') {
454
478
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -649,6 +673,35 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
649
673
  }
650
674
  }
651
675
 
676
+ function validateDockerCleanupOptions(options, hasDocker) {
677
+ const cleanupFlags = [
678
+ ['--auto-remove-docker-container', options.autoRemoveDockerContainer],
679
+ ['--always-cleanup-container', options.alwaysCleanupContainer],
680
+ ['--keep-container', options.keepContainer],
681
+ ['--keep-container-on-fail', options.keepContainerOnFail],
682
+ ];
683
+
684
+ for (const [flag, enabled] of cleanupFlags) {
685
+ if (enabled && !hasDocker) {
686
+ throw new Error(
687
+ `${flag} option is only valid when isolation stack includes docker`
688
+ );
689
+ }
690
+ }
691
+
692
+ const selectedPolicies = [
693
+ options.autoRemoveDockerContainer || options.alwaysCleanupContainer,
694
+ options.keepContainer,
695
+ options.keepContainerOnFail,
696
+ ].filter(Boolean).length;
697
+
698
+ if (selectedPolicies > 1) {
699
+ throw new Error(
700
+ 'Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail.'
701
+ );
702
+ }
703
+ }
704
+
652
705
  /**
653
706
  * Validate parsed options
654
707
  * @param {object} options - Parsed options
@@ -764,12 +817,7 @@ function validateOptions(options) {
764
817
  );
765
818
  }
766
819
 
767
- // Auto-remove-docker-container is only valid with docker in stack
768
- if (options.autoRemoveDockerContainer && !stack.includes('docker')) {
769
- throw new Error(
770
- '--auto-remove-docker-container option is only valid when isolation stack includes docker'
771
- );
772
- }
820
+ validateDockerCleanupOptions(options, stack.includes('docker'));
773
821
 
774
822
  // Docker runtime options (--volume, --mount, --env, --privileged) require docker
775
823
  if (!stack.includes('docker')) {
@@ -785,11 +833,7 @@ function validateOptions(options) {
785
833
  }
786
834
  } else {
787
835
  // Validate options that require isolation when no isolation is specified
788
- if (options.autoRemoveDockerContainer) {
789
- throw new Error(
790
- '--auto-remove-docker-container option is only valid when isolation stack includes docker'
791
- );
792
- }
836
+ validateDockerCleanupOptions(options, false);
793
837
  if (options.image) {
794
838
  throw new Error(
795
839
  '--image option is only valid when isolation stack includes docker'
@@ -84,8 +84,19 @@ function buildNextLevelCommand(options, command) {
84
84
  parts.push(`--session-id ${options.sessionId}`);
85
85
  }
86
86
 
87
- if (options.autoRemoveDockerContainer) {
88
- parts.push('--auto-remove-docker-container');
87
+ if (remainingStack.includes('docker')) {
88
+ if (options.autoRemoveDockerContainer) {
89
+ parts.push('--auto-remove-docker-container');
90
+ }
91
+ if (options.alwaysCleanupContainer) {
92
+ parts.push('--always-cleanup-container');
93
+ }
94
+ if (options.keepContainer) {
95
+ parts.push('--keep-container');
96
+ }
97
+ if (options.keepContainerOnFail) {
98
+ parts.push('--keep-container-on-fail');
99
+ }
89
100
  }
90
101
 
91
102
  if (options.shell && options.shell !== 'auto') {
@@ -0,0 +1,145 @@
1
+ const { spawn, spawnSync } = require('child_process');
2
+ const {
3
+ appendLogFile,
4
+ createShellLogFooterSnippet,
5
+ shellQuote,
6
+ } = require('./isolation-log-utils');
7
+
8
+ const DOCKER_CONTAINER_CLEANUP_POLICY = {
9
+ ALWAYS: 'always',
10
+ KEEP: 'keep',
11
+ KEEP_ON_FAIL: 'keep-on-fail',
12
+ };
13
+
14
+ function getDockerContainerCleanupPolicy(options = {}) {
15
+ if (options.keepContainer) {
16
+ return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP;
17
+ }
18
+ if (options.keepContainerOnFail) {
19
+ return DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL;
20
+ }
21
+ return DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS;
22
+ }
23
+
24
+ function shouldCleanupDockerContainer(policy, exitCode) {
25
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
26
+ return true;
27
+ }
28
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
29
+ return exitCode === 0;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function getDockerContainerCleanupInstructions(containerName) {
35
+ return [
36
+ `Container kept for investigation: ${containerName}`,
37
+ `Inspect: docker exec -it ${containerName} sh (if running) or docker start -ai ${containerName}`,
38
+ `Remove when done: docker rm -f ${containerName}`,
39
+ ].join('\n');
40
+ }
41
+
42
+ function appendDockerContainerCleanupPolicyMessage(
43
+ message,
44
+ containerName,
45
+ policy
46
+ ) {
47
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
48
+ return `${message}\n${getDockerContainerCleanupInstructions(containerName)}`;
49
+ }
50
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
51
+ return (
52
+ `${message}\nContainer will be removed after successful completion.` +
53
+ `\nContainer will be kept if the command fails.` +
54
+ `\nRemove when done: docker rm -f ${containerName}`
55
+ );
56
+ }
57
+ return `${message}\nContainer will be removed after command completes.`;
58
+ }
59
+
60
+ function removeDockerContainer(containerName, logPath = null) {
61
+ const result = spawnSync('docker', ['rm', '-f', containerName], {
62
+ encoding: 'utf8',
63
+ stdio: ['pipe', 'pipe', 'pipe'],
64
+ });
65
+ const output = `${result.stdout || ''}${result.stderr || ''}`;
66
+ if (logPath && output) {
67
+ appendLogFile(logPath, output.endsWith('\n') ? output : `${output}\n`);
68
+ }
69
+ return !result.error && result.status === 0;
70
+ }
71
+
72
+ function buildDetachedDockerCompletionScript(containerName, policy, logPath) {
73
+ const quotedName = shellQuote(containerName);
74
+ const parts = [];
75
+
76
+ if (logPath) {
77
+ const quotedLogPath = shellQuote(logPath);
78
+ parts.push(`docker logs -f ${quotedName} >> ${quotedLogPath} 2>&1`);
79
+ parts.push(
80
+ `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
81
+ );
82
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
83
+ parts.push(`docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true`);
84
+ } else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
85
+ parts.push(
86
+ `if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >> ${quotedLogPath} 2>&1 || true; fi`
87
+ );
88
+ }
89
+ parts.push(`${createShellLogFooterSnippet()} >> ${quotedLogPath}`);
90
+ } else {
91
+ parts.push(`docker wait ${quotedName} >/dev/null 2>&1`);
92
+ parts.push(
93
+ `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${quotedName} 2>/dev/null || printf '%s' '-1')`
94
+ );
95
+ if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS) {
96
+ parts.push(`docker rm -f ${quotedName} >/dev/null 2>&1 || true`);
97
+ } else if (policy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL) {
98
+ parts.push(
99
+ `if [ "$__start_command_exit" -eq 0 ] 2>/dev/null; then docker rm -f ${quotedName} >/dev/null 2>&1 || true; fi`
100
+ );
101
+ }
102
+ }
103
+
104
+ return parts.join('; ');
105
+ }
106
+
107
+ function startDetachedDockerCompletionWatcher(containerName, policy, logPath) {
108
+ const watcher = spawn(
109
+ 'sh',
110
+ ['-c', buildDetachedDockerCompletionScript(containerName, policy, logPath)],
111
+ {
112
+ detached: true,
113
+ stdio: 'ignore',
114
+ }
115
+ );
116
+ watcher.unref();
117
+ }
118
+
119
+ function spawnAttachedDocker(dockerArgs, logPath) {
120
+ if (!logPath) {
121
+ return spawn('docker', dockerArgs, { stdio: 'inherit' });
122
+ }
123
+
124
+ const child = spawn('docker', dockerArgs, {
125
+ stdio: ['inherit', 'pipe', 'pipe'],
126
+ });
127
+ const tee = (chunk, stream) => {
128
+ stream.write(chunk);
129
+ appendLogFile(logPath, chunk.toString());
130
+ };
131
+ child.stdout.on('data', (chunk) => tee(chunk, process.stdout));
132
+ child.stderr.on('data', (chunk) => tee(chunk, process.stderr));
133
+ return child;
134
+ }
135
+
136
+ module.exports = {
137
+ DOCKER_CONTAINER_CLEANUP_POLICY,
138
+ getDockerContainerCleanupPolicy,
139
+ shouldCleanupDockerContainer,
140
+ getDockerContainerCleanupInstructions,
141
+ appendDockerContainerCleanupPolicyMessage,
142
+ removeDockerContainer,
143
+ startDetachedDockerCompletionWatcher,
144
+ spawnAttachedDocker,
145
+ };
@@ -197,9 +197,9 @@ function getControlCommand(record, action) {
197
197
  case 'docker':
198
198
  return {
199
199
  command: 'docker',
200
- args: ['kill', '--signal=SIGINT', sessionName],
201
- method: 'SIGINT',
202
- message: `Sent SIGINT to detached docker container: ${sessionName}`,
200
+ args: ['stop', sessionName],
201
+ method: 'DOCKER_STOP',
202
+ message: `Requested graceful stop for detached docker container: ${sessionName}`,
203
203
  };
204
204
  default:
205
205
  return {
@@ -18,10 +18,19 @@ const {
18
18
  } = require('./screen-isolation');
19
19
  const {
20
20
  appendLogFile,
21
- createShellLogFooterSnippet,
22
21
  shellQuote,
23
22
  wrapCommandWithLogFooter,
24
23
  } = require('./isolation-log-utils');
24
+ const {
25
+ DOCKER_CONTAINER_CLEANUP_POLICY,
26
+ getDockerContainerCleanupPolicy,
27
+ shouldCleanupDockerContainer,
28
+ getDockerContainerCleanupInstructions,
29
+ appendDockerContainerCleanupPolicyMessage,
30
+ removeDockerContainer,
31
+ startDetachedDockerCompletionWatcher,
32
+ spawnAttachedDocker,
33
+ } = require('./docker-cleanup');
25
34
 
26
35
  /**
27
36
  * Check if a command is available on the system
@@ -554,7 +563,7 @@ function buildDockerRuntimeMetadata(options = {}) {
554
563
  /**
555
564
  * Run command in Docker container
556
565
  * @param {string} command - Command to execute
557
- * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer, volumes, mounts, env, privileged)
566
+ * @param {object} options - Options (image, session/name, detached, user, keepAlive, container cleanup policy, volumes, mounts, env, privileged)
558
567
  * @returns {Promise<{success: boolean, containerName: string, message: string}>}
559
568
  */
560
569
  function runInDocker(command, options = {}) {
@@ -587,6 +596,7 @@ function runInDocker(command, options = {}) {
587
596
  }
588
597
 
589
598
  const containerName = options.session || generateSessionName('docker');
599
+ const cleanupPolicy = getDockerContainerCleanupPolicy(options);
590
600
  if (!dockerImageExists(options.image)) {
591
601
  // Pass logPath so the image-preparation phase (docker pull) is recorded in
592
602
  // the session log, keeping it a gap-free record of the run (issue #138).
@@ -617,9 +627,6 @@ function runInDocker(command, options = {}) {
617
627
  dockerArgs.push('-i', '-t');
618
628
  }
619
629
  dockerArgs.push('--name', containerName);
620
- if (options.autoRemoveDockerContainer) {
621
- dockerArgs.push('--rm');
622
- }
623
630
 
624
631
  if (options.user) {
625
632
  dockerArgs.push('--user', options.user);
@@ -647,6 +654,7 @@ function runInDocker(command, options = {}) {
647
654
  console.log(
648
655
  `[DEBUG] autoRemoveDockerContainer: ${options.autoRemoveDockerContainer || false}`
649
656
  );
657
+ console.log(`[DEBUG] docker cleanup policy: ${cleanupPolicy}`);
650
658
  }
651
659
 
652
660
  const dockerResult = spawnSync('docker', dockerArgs, {
@@ -667,18 +675,11 @@ function runInDocker(command, options = {}) {
667
675
 
668
676
  const containerId = dockerResult.stdout.trim();
669
677
 
670
- if (options.logPath) {
671
- const loggerScript = [
672
- `docker logs -f ${shellQuote(containerName)} >> ${shellQuote(options.logPath)} 2>&1`,
673
- `__start_command_exit=$(docker inspect -f '{{.State.ExitCode}}' ${shellQuote(containerName)} 2>/dev/null || printf '%s' '-1')`,
674
- `${createShellLogFooterSnippet()} >> ${shellQuote(options.logPath)}`,
675
- ].join('; ');
676
- const logger = spawn('sh', ['-c', loggerScript], {
677
- detached: true,
678
- stdio: 'ignore',
679
- });
680
- logger.unref();
681
- }
678
+ startDetachedDockerCompletionWatcher(
679
+ containerName,
680
+ cleanupPolicy,
681
+ options.logPath
682
+ );
682
683
 
683
684
  let message = `Command started in detached docker container: ${containerName}`;
684
685
  message += `\nContainer ID: ${containerId.substring(0, 12)}`;
@@ -687,11 +688,11 @@ function runInDocker(command, options = {}) {
687
688
  } else {
688
689
  message += `\nContainer will exit automatically after command completes.`;
689
690
  }
690
- if (options.autoRemoveDockerContainer) {
691
- message += `\nContainer will be automatically removed after exit.`;
692
- } else {
693
- message += `\nContainer filesystem will be preserved after exit.`;
694
- }
691
+ message = appendDockerContainerCleanupPolicyMessage(
692
+ message,
693
+ containerName,
694
+ cleanupPolicy
695
+ );
695
696
  message += `\nAttach with: docker attach ${containerName}`;
696
697
  message += `\nView logs: docker logs ${containerName}`;
697
698
  if (options.logPath) {
@@ -705,7 +706,9 @@ function runInDocker(command, options = {}) {
705
706
  message,
706
707
  });
707
708
  } else {
708
- const dockerArgs = ['run', '-it', '--rm', '--name', containerName];
709
+ const dockerArgs = ['run'];
710
+ dockerArgs.push(hasTTY() ? '-it' : '-i');
711
+ dockerArgs.push('--name', containerName);
709
712
  if (options.user) {
710
713
  dockerArgs.push('--user', options.user);
711
714
  }
@@ -739,13 +742,14 @@ function runInDocker(command, options = {}) {
739
742
 
740
743
  return new Promise((resolve) => {
741
744
  const startTime = Date.now();
742
- const child = spawn('docker', dockerArgs, { stdio: 'inherit' });
745
+ const child = spawnAttachedDocker(dockerArgs, options.logPath);
743
746
 
744
747
  child.on('exit', (code) => {
745
748
  const durationMs = Date.now() - startTime;
746
- let message = `Docker container "${containerName}" exited with code ${code}`;
749
+ const exitCode = code ?? 1;
750
+ let message = `Docker container "${containerName}" exited with code ${exitCode}`;
747
751
  // Bare shell exited non-zero quickly → startup file error; suggest --norc (issue #84).
748
- if (isBareShell && code !== 0 && durationMs < 3000) {
752
+ if (isBareShell && exitCode !== 0 && durationMs < 3000) {
749
753
  const shell0 = command.trim().split(/\s+/)[0];
750
754
  // prettier-ignore
751
755
  const norc = path.basename(shell0) === 'zsh' ? '--no-rcs' : '--norc';
@@ -753,11 +757,28 @@ function runInDocker(command, options = {}) {
753
757
  console.log(hint);
754
758
  message += `\n${hint}`;
755
759
  }
760
+
761
+ if (shouldCleanupDockerContainer(cleanupPolicy, exitCode)) {
762
+ if (removeDockerContainer(containerName, options.logPath)) {
763
+ message += `\nContainer removed after completion.`;
764
+ } else {
765
+ message += `\nWarning: failed to remove container automatically.`;
766
+ message += `\nRemove when done: docker rm -f ${containerName}`;
767
+ }
768
+ } else if (cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP) {
769
+ message += `\n${getDockerContainerCleanupInstructions(containerName)}`;
770
+ } else if (
771
+ cleanupPolicy === DOCKER_CONTAINER_CLEANUP_POLICY.KEEP_ON_FAIL
772
+ ) {
773
+ message += `\nContainer kept because the command failed.`;
774
+ message += `\nRemove when done: docker rm -f ${containerName}`;
775
+ }
776
+
756
777
  resolve({
757
- success: code === 0,
778
+ success: exitCode === 0,
758
779
  containerName,
759
780
  message,
760
- exitCode: code,
781
+ exitCode,
761
782
  });
762
783
  });
763
784
 
package/src/lib/usage.js CHANGED
@@ -18,13 +18,16 @@ Options:
18
18
  --isolated-user, -u [name] Create isolated user with same permissions
19
19
  --keep-user Keep isolated user after command completes
20
20
  --keep-alive, -k Keep isolation environment alive after command exits
21
- --auto-remove-docker-container Auto-remove docker container after exit
21
+ --auto-remove-docker-container Always remove docker container after exit (compatibility alias)
22
+ --always-cleanup-container Always remove docker container after exit (default)
23
+ --keep-container Keep docker container filesystem after exit
24
+ --keep-container-on-fail Remove successful docker containers, keep failed ones
22
25
  --shell <shell> Shell to use in isolation environments: auto, bash, zsh, sh (default: auto)
23
26
  --use-command-stream Use command-stream library for execution (experimental)
24
27
  --status <id> Show status of execution by UUID or session name (--output-format: links-notation|json|text)
25
28
  --list List all tracked executions (--output-format: links-notation|json|text)
26
29
  --upload-log <id> Upload the stored log for an execution UUID or session name
27
- --stop <id> Send CTRL+C/SIGINT to a detached isolated execution
30
+ --stop <id> Ask a detached isolated execution to stop gracefully
28
31
  --terminate <id> Terminate a detached isolated execution immediately
29
32
  --cleanup Clean up stale "executing" records (crashed/killed processes)
30
33
  --cleanup-dry-run Show stale records that would be cleaned up (without cleaning)
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('assert');
5
+ const { parseArgs } = require('../src/lib/args-parser');
6
+
7
+ function parseDocker(args = []) {
8
+ return parseArgs([
9
+ '--isolated',
10
+ 'docker',
11
+ '--image',
12
+ 'alpine',
13
+ ...args,
14
+ '--',
15
+ 'npm',
16
+ 'test',
17
+ ]);
18
+ }
19
+
20
+ describe('docker container cleanup options', () => {
21
+ it('should parse --auto-remove-docker-container flag', () => {
22
+ const result = parseDocker(['--auto-remove-docker-container']);
23
+ assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
24
+ });
25
+
26
+ it('should parse --always-cleanup-container flag', () => {
27
+ const result = parseDocker(['--always-cleanup-container']);
28
+ assert.strictEqual(result.wrapperOptions.alwaysCleanupContainer, true);
29
+ });
30
+
31
+ it('should parse --keep-container flag', () => {
32
+ const result = parseDocker(['--keep-container']);
33
+ assert.strictEqual(result.wrapperOptions.keepContainer, true);
34
+ });
35
+
36
+ it('should parse --keep-container-on-fail flag', () => {
37
+ const result = parseDocker(['--keep-container-on-fail']);
38
+ assert.strictEqual(result.wrapperOptions.keepContainerOnFail, true);
39
+ });
40
+
41
+ it('should default cleanup flags to false', () => {
42
+ const result = parseDocker();
43
+ assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, false);
44
+ assert.strictEqual(result.wrapperOptions.alwaysCleanupContainer, false);
45
+ assert.strictEqual(result.wrapperOptions.keepContainer, false);
46
+ assert.strictEqual(result.wrapperOptions.keepContainerOnFail, false);
47
+ });
48
+
49
+ it('should throw error for docker cleanup options without docker isolation', () => {
50
+ assert.throws(() => {
51
+ parseArgs([
52
+ '-i',
53
+ 'tmux',
54
+ '--auto-remove-docker-container',
55
+ '--',
56
+ 'npm',
57
+ 'test',
58
+ ]);
59
+ }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
60
+ assert.throws(() => {
61
+ parseArgs([
62
+ '-i',
63
+ 'tmux',
64
+ '--always-cleanup-container',
65
+ '--',
66
+ 'npm',
67
+ 'test',
68
+ ]);
69
+ }, /--always-cleanup-container option is only valid when isolation stack includes docker/);
70
+ assert.throws(() => {
71
+ parseArgs(['-i', 'tmux', '--keep-container', '--', 'npm', 'test']);
72
+ }, /--keep-container option is only valid when isolation stack includes docker/);
73
+ assert.throws(() => {
74
+ parseArgs([
75
+ '-i',
76
+ 'tmux',
77
+ '--keep-container-on-fail',
78
+ '--',
79
+ 'npm',
80
+ 'test',
81
+ ]);
82
+ }, /--keep-container-on-fail option is only valid when isolation stack includes docker/);
83
+ });
84
+
85
+ it('should throw error for docker cleanup options without isolation', () => {
86
+ assert.throws(() => {
87
+ parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
88
+ }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
89
+ assert.throws(() => {
90
+ parseArgs(['--always-cleanup-container', '--', 'npm', 'test']);
91
+ }, /--always-cleanup-container option is only valid when isolation stack includes docker/);
92
+ assert.throws(() => {
93
+ parseArgs(['--keep-container', '--', 'npm', 'test']);
94
+ }, /--keep-container option is only valid when isolation stack includes docker/);
95
+ assert.throws(() => {
96
+ parseArgs(['--keep-container-on-fail', '--', 'npm', 'test']);
97
+ }, /--keep-container-on-fail option is only valid when isolation stack includes docker/);
98
+ });
99
+
100
+ it('should work with keep-alive and auto-remove-docker-container', () => {
101
+ const result = parseArgs([
102
+ '-i',
103
+ 'docker',
104
+ '--image',
105
+ 'node:20',
106
+ '-k',
107
+ '--auto-remove-docker-container',
108
+ '--',
109
+ 'npm',
110
+ 'test',
111
+ ]);
112
+ assert.strictEqual(result.wrapperOptions.isolated, 'docker');
113
+ assert.strictEqual(result.wrapperOptions.image, 'node:20');
114
+ assert.strictEqual(result.wrapperOptions.keepAlive, true);
115
+ assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
116
+ });
117
+
118
+ it('should reject conflicting docker cleanup options', () => {
119
+ assert.throws(() => {
120
+ parseArgs([
121
+ '-i',
122
+ 'docker',
123
+ '--keep-container',
124
+ '--always-cleanup-container',
125
+ '--',
126
+ 'npm',
127
+ 'test',
128
+ ]);
129
+ }, /Cannot combine docker container cleanup policies/);
130
+
131
+ assert.throws(() => {
132
+ parseArgs([
133
+ '-i',
134
+ 'docker',
135
+ '--keep-container',
136
+ '--keep-container-on-fail',
137
+ '--',
138
+ 'npm',
139
+ 'test',
140
+ ]);
141
+ }, /Cannot combine docker container cleanup policies/);
142
+ });
143
+ });
@@ -343,75 +343,6 @@ describe('parseArgs', () => {
343
343
  });
344
344
  });
345
345
 
346
- describe('auto-remove-docker-container option', () => {
347
- it('should parse --auto-remove-docker-container flag', () => {
348
- const result = parseArgs([
349
- '--isolated',
350
- 'docker',
351
- '--image',
352
- 'alpine',
353
- '--auto-remove-docker-container',
354
- '--',
355
- 'npm',
356
- 'test',
357
- ]);
358
- assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
359
- });
360
-
361
- it('should default autoRemoveDockerContainer to false', () => {
362
- const result = parseArgs([
363
- '-i',
364
- 'docker',
365
- '--image',
366
- 'alpine',
367
- '--',
368
- 'npm',
369
- 'test',
370
- ]);
371
- assert.strictEqual(
372
- result.wrapperOptions.autoRemoveDockerContainer,
373
- false
374
- );
375
- });
376
-
377
- it('should throw error for auto-remove-docker-container without docker isolation', () => {
378
- assert.throws(() => {
379
- parseArgs([
380
- '-i',
381
- 'tmux',
382
- '--auto-remove-docker-container',
383
- '--',
384
- 'npm',
385
- 'test',
386
- ]);
387
- }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
388
- });
389
-
390
- it('should throw error for auto-remove-docker-container without isolation', () => {
391
- assert.throws(() => {
392
- parseArgs(['--auto-remove-docker-container', '--', 'npm', 'test']);
393
- }, /--auto-remove-docker-container option is only valid when isolation stack includes docker/);
394
- });
395
-
396
- it('should work with keep-alive and auto-remove-docker-container', () => {
397
- const result = parseArgs([
398
- '-i',
399
- 'docker',
400
- '--image',
401
- 'node:20',
402
- '-k',
403
- '--auto-remove-docker-container',
404
- '--',
405
- 'npm',
406
- 'test',
407
- ]);
408
- assert.strictEqual(result.wrapperOptions.isolated, 'docker');
409
- assert.strictEqual(result.wrapperOptions.image, 'node:20');
410
- assert.strictEqual(result.wrapperOptions.keepAlive, true);
411
- assert.strictEqual(result.wrapperOptions.autoRemoveDockerContainer, true);
412
- });
413
- });
414
-
415
346
  describe('command without separator', () => {
416
347
  it('should parse command after options without separator', () => {
417
348
  const result = parseArgs(['-i', 'tmux', '-d', 'npm', 'start']);
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bun
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('assert');
5
+ const { buildNextLevelCommand } = require('../src/lib/command-builder');
6
+
7
+ describe('command-builder docker cleanup forwarding', () => {
8
+ it('forwards cleanup policy flags when a remaining level uses docker', () => {
9
+ const command = buildNextLevelCommand(
10
+ {
11
+ isolatedStack: ['tmux', 'docker'],
12
+ imageStack: [null, 'alpine'],
13
+ keepContainerOnFail: true,
14
+ },
15
+ 'npm test'
16
+ );
17
+
18
+ assert.match(command, /--isolated "docker"/);
19
+ assert.match(command, /--keep-container-on-fail/);
20
+ });
21
+
22
+ it('does not forward docker cleanup flags after the docker level is consumed', () => {
23
+ const command = buildNextLevelCommand(
24
+ {
25
+ isolatedStack: ['docker', 'tmux'],
26
+ imageStack: ['alpine', null],
27
+ keepContainer: true,
28
+ },
29
+ 'npm test'
30
+ );
31
+
32
+ assert.match(command, /--isolated "tmux"/);
33
+ assert.doesNotMatch(command, /--keep-container/);
34
+ });
35
+ });
@@ -1,10 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Tests for Docker auto-remove container feature
3
+ * Tests for Docker container cleanup behavior
4
4
  */
5
5
 
6
6
  const { describe, it } = require('node:test');
7
7
  const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
8
11
  const { isCommandAvailable } = require('../src/lib/isolation');
9
12
  const { runInDocker } = require('../src/lib/isolation');
10
13
  const { execSync } = require('child_process');
@@ -26,9 +29,9 @@ async function waitFor(conditionFn, timeout = 5000, interval = 100) {
26
29
  const { canRunLinuxDockerImages } = require('../src/lib/isolation');
27
30
  const DOCKER_TEST_TIMEOUT = 20000;
28
31
 
29
- describe('Docker Auto-Remove Container Feature', () => {
30
- // These tests verify the --auto-remove-docker-container option
31
- // which automatically removes the container after exit (disabled by default)
32
+ describe('Docker Container Cleanup Policy', () => {
33
+ // These tests verify that docker isolation removes finished containers by
34
+ // default while still providing explicit flags to keep them for investigation.
32
35
 
33
36
  describe('auto-remove enabled', () => {
34
37
  it(
@@ -55,8 +58,8 @@ describe('Docker Auto-Remove Container Feature', () => {
55
58
 
56
59
  assert.strictEqual(result.success, true);
57
60
  assert.ok(
58
- result.message.includes('automatically removed'),
59
- 'Message should indicate auto-removal'
61
+ result.message.includes('will be removed'),
62
+ 'Message should indicate cleanup'
60
63
  );
61
64
 
62
65
  // Wait for container to finish and be removed
@@ -99,9 +102,9 @@ describe('Docker Auto-Remove Container Feature', () => {
99
102
  );
100
103
  });
101
104
 
102
- describe('auto-remove disabled (default)', () => {
105
+ describe('default cleanup', () => {
103
106
  it(
104
- 'should preserve container filesystem by default (without autoRemoveDockerContainer)',
107
+ 'should remove container filesystem by default',
105
108
  { timeout: DOCKER_TEST_TIMEOUT },
106
109
  async () => {
107
110
  if (!canRunLinuxDockerImages()) {
@@ -111,9 +114,10 @@ describe('Docker Auto-Remove Container Feature', () => {
111
114
  return;
112
115
  }
113
116
 
114
- const containerName = `test-preserve-${Date.now()}`;
117
+ const containerName = `test-default-cleanup-${Date.now()}`;
115
118
 
116
- // Run command without autoRemoveDockerContainer
119
+ // Run command without any cleanup flag. The default should still remove
120
+ // the container after the command finishes.
117
121
  const result = await runInDocker('echo "test" && sleep 0.1', {
118
122
  image: 'alpine:latest',
119
123
  session: containerName,
@@ -124,12 +128,76 @@ describe('Docker Auto-Remove Container Feature', () => {
124
128
 
125
129
  assert.strictEqual(result.success, true);
126
130
  assert.ok(
127
- result.message.includes('filesystem will be preserved'),
128
- 'Message should indicate filesystem preservation'
131
+ result.message.includes('will be removed'),
132
+ 'Message should indicate default cleanup'
129
133
  );
130
134
 
131
- // Wait for container to exit
132
- await waitFor(() => {
135
+ const containerRemoved = await waitFor(() => {
136
+ try {
137
+ execSync(`docker inspect -f '{{.State.Status}}' ${containerName}`, {
138
+ encoding: 'utf8',
139
+ stdio: ['pipe', 'pipe', 'pipe'],
140
+ });
141
+ return false;
142
+ } catch {
143
+ return true;
144
+ }
145
+ }, 10000);
146
+
147
+ assert.ok(
148
+ containerRemoved,
149
+ 'Container should be removed after exit by default'
150
+ );
151
+
152
+ try {
153
+ const allContainers = execSync('docker ps -a', {
154
+ encoding: 'utf8',
155
+ stdio: ['pipe', 'pipe', 'pipe'],
156
+ });
157
+ assert.ok(
158
+ !allContainers.includes(containerName),
159
+ 'Container should NOT appear in docker ps -a after default cleanup'
160
+ );
161
+ } catch (err) {
162
+ assert.fail(`Failed to verify container cleanup: ${err.message}`);
163
+ }
164
+ }
165
+ );
166
+ });
167
+
168
+ describe('keep-container opt-out', () => {
169
+ it(
170
+ 'should preserve container filesystem when keepContainer is true',
171
+ { timeout: DOCKER_TEST_TIMEOUT },
172
+ async () => {
173
+ if (!canRunLinuxDockerImages()) {
174
+ console.log(
175
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
176
+ );
177
+ return;
178
+ }
179
+
180
+ const containerName = `test-keep-container-${Date.now()}`;
181
+
182
+ const result = await runInDocker('echo "test" && sleep 0.1', {
183
+ image: 'alpine:latest',
184
+ session: containerName,
185
+ detached: true,
186
+ keepAlive: false,
187
+ keepContainer: true,
188
+ });
189
+
190
+ assert.strictEqual(result.success, true);
191
+ assert.ok(
192
+ result.message.includes('Container kept for investigation'),
193
+ 'Message should explain that the container is kept'
194
+ );
195
+ assert.ok(
196
+ result.message.includes(`docker rm -f ${containerName}`),
197
+ 'Message should include cleanup command'
198
+ );
199
+
200
+ const containerExited = await waitFor(() => {
133
201
  try {
134
202
  const status = execSync(
135
203
  `docker inspect -f '{{.State.Status}}' ${containerName}`,
@@ -144,26 +212,66 @@ describe('Docker Auto-Remove Container Feature', () => {
144
212
  }
145
213
  }, 10000);
146
214
 
147
- // Container should still exist (in exited state)
215
+ assert.ok(containerExited, 'Container should remain in exited state');
216
+
148
217
  try {
149
- const allContainers = execSync('docker ps -a', {
150
- encoding: 'utf8',
151
- stdio: ['pipe', 'pipe', 'pipe'],
152
- });
153
- assert.ok(
154
- allContainers.includes(containerName),
155
- 'Container should appear in docker ps -a (filesystem preserved)'
156
- );
218
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
219
+ } catch {
220
+ // Ignore cleanup errors
221
+ }
222
+ }
223
+ );
224
+
225
+ it(
226
+ 'should preserve failed containers when keepContainerOnFail is true',
227
+ { timeout: DOCKER_TEST_TIMEOUT },
228
+ async () => {
229
+ if (!canRunLinuxDockerImages()) {
157
230
  console.log(
158
- ' Docker container filesystem preserved by default (can be re-entered)'
159
- );
160
- } catch (err) {
161
- assert.fail(
162
- `Failed to verify container preservation: ${err.message}`
231
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
163
232
  );
233
+ return;
164
234
  }
165
235
 
166
- // Clean up
236
+ const containerName = `test-keep-on-fail-${Date.now()}`;
237
+
238
+ const result = await runInDocker('echo "test" && exit 7', {
239
+ image: 'alpine:latest',
240
+ session: containerName,
241
+ detached: true,
242
+ keepAlive: false,
243
+ keepContainerOnFail: true,
244
+ });
245
+
246
+ assert.strictEqual(result.success, true);
247
+ assert.ok(
248
+ result.message.includes(
249
+ 'Container will be kept if the command fails'
250
+ ),
251
+ 'Message should describe failure retention'
252
+ );
253
+ assert.ok(
254
+ result.message.includes(`docker rm -f ${containerName}`),
255
+ 'Message should include cleanup command'
256
+ );
257
+
258
+ const containerExited = await waitFor(() => {
259
+ try {
260
+ const status = execSync(
261
+ `docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${containerName}`,
262
+ {
263
+ encoding: 'utf8',
264
+ stdio: ['pipe', 'pipe', 'pipe'],
265
+ }
266
+ ).trim();
267
+ return status === 'exited 7';
268
+ } catch {
269
+ return false;
270
+ }
271
+ }, 10000);
272
+
273
+ assert.ok(containerExited, 'Failed container should be preserved');
274
+
167
275
  try {
168
276
  execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
169
277
  } catch {
@@ -172,4 +280,71 @@ describe('Docker Auto-Remove Container Feature', () => {
172
280
  }
173
281
  );
174
282
  });
283
+
284
+ describe('attached mode logging', () => {
285
+ it(
286
+ 'should write attached docker output to the provided host log path',
287
+ { timeout: DOCKER_TEST_TIMEOUT },
288
+ async () => {
289
+ if (!canRunLinuxDockerImages()) {
290
+ console.log(
291
+ ' Skipping: docker not available, daemon not running, or Linux containers not supported'
292
+ );
293
+ return;
294
+ }
295
+
296
+ const containerName = `test-attached-log-${Date.now()}`;
297
+ const logPath = path.join(
298
+ os.tmpdir(),
299
+ `start-attached-docker-${process.pid}-${Date.now()}.log`
300
+ );
301
+ fs.writeFileSync(logPath, '=== test log header ===\n');
302
+
303
+ try {
304
+ const result = await runInDocker("printf 'attached-log-line\\n'", {
305
+ image: 'alpine:latest',
306
+ session: containerName,
307
+ detached: false,
308
+ keepAlive: false,
309
+ logPath,
310
+ });
311
+
312
+ assert.strictEqual(result.success, true);
313
+ assert.ok(
314
+ result.message.includes('Container removed after completion'),
315
+ 'Attached container should be removed after completion by default'
316
+ );
317
+
318
+ const contents = fs.readFileSync(logPath, 'utf8');
319
+ assert.ok(
320
+ contents.includes('attached-log-line'),
321
+ `Attached docker output should be written to host log, got:\n${contents}`
322
+ );
323
+
324
+ const containerRemoved = await waitFor(() => {
325
+ try {
326
+ execSync(
327
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
328
+ {
329
+ encoding: 'utf8',
330
+ stdio: ['pipe', 'pipe', 'pipe'],
331
+ }
332
+ );
333
+ return false;
334
+ } catch {
335
+ return true;
336
+ }
337
+ }, 10000);
338
+ assert.ok(containerRemoved, 'Attached container should be removed');
339
+ } finally {
340
+ try {
341
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
342
+ } catch {
343
+ // Ignore cleanup errors
344
+ }
345
+ fs.rmSync(logPath, { force: true });
346
+ }
347
+ }
348
+ );
349
+ });
175
350
  });
@@ -136,6 +136,46 @@ describe('execution control', () => {
136
136
  });
137
137
  });
138
138
 
139
+ it('should stop a detached docker container with docker stop', () => {
140
+ const store = createStore();
141
+ store.save(
142
+ createDetachedRecord({
143
+ uuid: 'docker-control-uuid',
144
+ options: {
145
+ isolated: 'docker',
146
+ isolationMode: 'detached',
147
+ sessionName: 'docker-session',
148
+ containerId: 'abc123',
149
+ },
150
+ })
151
+ );
152
+ const runner = createRunner({
153
+ 'docker inspect -f {{.Id}} {{.State.Pid}} docker-session': {
154
+ success: true,
155
+ stdout: 'abcdef 0\n',
156
+ stderr: '',
157
+ status: 0,
158
+ error: null,
159
+ },
160
+ });
161
+
162
+ const result = controlExecution(
163
+ store,
164
+ 'docker-control-uuid',
165
+ ControlAction.STOP,
166
+ runner
167
+ );
168
+
169
+ assert.strictEqual(result.success, true);
170
+ assert.match(result.output, /action stop/);
171
+ assert.match(result.output, /method DOCKER_STOP/);
172
+ assert.match(result.output, /containerId abcdef/);
173
+ assert.deepStrictEqual(runner.calls[0], {
174
+ command: 'docker',
175
+ args: ['stop', 'docker-session'],
176
+ });
177
+ });
178
+
139
179
  it('should send docker terminate through docker kill', () => {
140
180
  const store = createStore();
141
181
  store.save(