start-command 0.28.0 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # start-command
2
2
 
3
+ ## 0.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add Docker isolation runtime controls: `--volume`/`-v`, `--mount`, `--env`/`-e`, and `--privileged`. These are threaded into the underlying `docker run` invocation and recorded in `--status`/`--list` metadata, allowing callers to mount tool credentials, pass environment variables, and run Docker-in-Docker images without wrapping `docker run` themselves.
8
+
9
+ Add `--isolation` as an alias for `--isolated` and fail fast on unknown wrapper options.
10
+
3
11
  ## 0.28.0
4
12
 
5
13
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
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
@@ -22,6 +22,8 @@ const {
22
22
  appendLogFile,
23
23
  createLogPath,
24
24
  getDefaultDockerImage,
25
+ buildDockerRuntimeStatusLines,
26
+ buildDockerRuntimeMetadata,
25
27
  } = require('../lib/isolation');
26
28
  const {
27
29
  createIsolatedUser,
@@ -470,6 +472,7 @@ async function runWithIsolation(
470
472
  if (effectiveImage) {
471
473
  extraLines.push(`[Isolation] Image: ${effectiveImage}`);
472
474
  }
475
+ extraLines.push(...buildDockerRuntimeStatusLines(options));
473
476
  if (options.endpoint) {
474
477
  extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
475
478
  }
@@ -496,6 +499,7 @@ async function runWithIsolation(
496
499
  isolationMode: mode,
497
500
  sessionName,
498
501
  image: effectiveImage,
502
+ ...buildDockerRuntimeMetadata(options),
499
503
  endpoint: options.endpoint,
500
504
  user: options.user,
501
505
  keepAlive: options.keepAlive,
@@ -570,6 +574,10 @@ async function runWithIsolation(
570
574
  user: createdUser,
571
575
  keepAlive: options.keepAlive,
572
576
  autoRemoveDockerContainer: options.autoRemoveDockerContainer,
577
+ volumes: options.volumes,
578
+ mounts: options.mounts,
579
+ env: options.env,
580
+ privileged: options.privileged,
573
581
  shell: options.shell,
574
582
  logPath: logFilePath,
575
583
  });
@@ -6,11 +6,15 @@
6
6
  * 2. $ [wrapper-options] command [command-options]
7
7
  *
8
8
  * Wrapper Options:
9
- * --isolated, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
9
+ * --isolated, --isolation, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
10
10
  * --attached, -a Run in attached mode (foreground)
11
11
  * --detached, -d Run in detached mode (background)
12
12
  * --session, -s <name> Session name for isolation
13
13
  * --image <image> Docker image (optional, defaults to OS-matched image)
14
+ * --volume, -v <host:container[:mode]> Docker bind mount/volume (repeatable, docker only)
15
+ * --mount <mount-spec> Docker --mount spec (repeatable, docker only)
16
+ * --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
17
+ * --privileged Run docker container in privileged mode (docker only)
14
18
  * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
15
19
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
16
20
  * --keep-user Keep isolated user after command completes (don't delete)
@@ -167,6 +171,10 @@ function parseArgs(args) {
167
171
  sessionId: null, // Session ID (UUID) for tracking - auto-generated if not provided
168
172
  image: null, // Docker image (current level)
169
173
  imageStack: null, // Docker images for each level (with nulls for non-docker levels)
174
+ volumes: [], // Docker bind mounts/volumes (-v/--volume), applied to docker levels
175
+ mounts: [], // Docker --mount specs, applied to docker levels
176
+ env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
177
+ privileged: false, // Run docker container in privileged mode
170
178
  endpoint: null, // SSH endpoint (current level, e.g., user@host)
171
179
  endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
172
180
  user: false, // Create isolated user
@@ -206,9 +214,7 @@ function parseArgs(args) {
206
214
  if (arg.startsWith('-')) {
207
215
  const consumed = parseOption(args, i, wrapperOptions);
208
216
  if (consumed === 0) {
209
- // Unknown option, treat rest as command
210
- commandArgs = args.slice(i);
211
- break;
217
+ throw new Error(`Unknown wrapper option: ${arg}`);
212
218
  }
213
219
  i += consumed;
214
220
  } else {
@@ -239,6 +245,9 @@ function parseWrapperArgs(args, options) {
239
245
  while (i < args.length) {
240
246
  const consumed = parseOption(args, i, options);
241
247
  if (consumed === 0) {
248
+ if (args[i].startsWith('-')) {
249
+ throw new Error(`Unknown wrapper option: ${args[i]}`);
250
+ }
242
251
  if (DEBUG) {
243
252
  console.warn(`Unknown wrapper option: ${args[i]}`);
244
253
  }
@@ -259,8 +268,8 @@ function parseWrapperArgs(args, options) {
259
268
  function parseOption(args, index, options) {
260
269
  const arg = args[index];
261
270
 
262
- // --isolated or -i
263
- if (arg === '--isolated' || arg === '-i') {
271
+ // --isolated, --isolation, or -i
272
+ if (arg === '--isolated' || arg === '--isolation' || arg === '-i') {
264
273
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
265
274
  const value = args[index + 1];
266
275
  parseIsolatedValue(value, options);
@@ -272,8 +281,8 @@ function parseOption(args, index, options) {
272
281
  }
273
282
  }
274
283
 
275
- // --isolated=<value>
276
- if (arg.startsWith('--isolated=')) {
284
+ // --isolated=<value> or --isolation=<value>
285
+ if (arg.startsWith('--isolated=') || arg.startsWith('--isolation=')) {
277
286
  const value = arg.split('=')[1];
278
287
  parseIsolatedValue(value, options);
279
288
  return 1;
@@ -325,6 +334,62 @@ function parseOption(args, index, options) {
325
334
  return 1;
326
335
  }
327
336
 
337
+ // --volume or -v (for docker) - repeatable bind mount / volume
338
+ if (arg === '--volume' || arg === '-v') {
339
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
340
+ options.volumes.push(args[index + 1]);
341
+ return 2;
342
+ } else {
343
+ throw new Error(
344
+ `Option ${arg} requires a volume argument (host:container[:mode])`
345
+ );
346
+ }
347
+ }
348
+
349
+ // --volume=<value> or -v=<value>
350
+ if (arg.startsWith('--volume=') || arg.startsWith('-v=')) {
351
+ options.volumes.push(arg.slice(arg.indexOf('=') + 1));
352
+ return 1;
353
+ }
354
+
355
+ // --mount (for docker) - repeatable mount spec
356
+ if (arg === '--mount') {
357
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
358
+ options.mounts.push(args[index + 1]);
359
+ return 2;
360
+ } else {
361
+ throw new Error(`Option ${arg} requires a mount spec argument`);
362
+ }
363
+ }
364
+
365
+ // --mount=<value>
366
+ if (arg.startsWith('--mount=')) {
367
+ options.mounts.push(arg.slice('--mount='.length));
368
+ return 1;
369
+ }
370
+
371
+ // --env or -e (for docker) - repeatable environment variable
372
+ if (arg === '--env' || arg === '-e') {
373
+ if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
374
+ options.env.push(args[index + 1]);
375
+ return 2;
376
+ } else {
377
+ throw new Error(`Option ${arg} requires a KEY=VALUE argument`);
378
+ }
379
+ }
380
+
381
+ // --env=<value> or -e=<value>
382
+ if (arg.startsWith('--env=') || arg.startsWith('-e=')) {
383
+ options.env.push(arg.slice(arg.indexOf('=') + 1));
384
+ return 1;
385
+ }
386
+
387
+ // --privileged (for docker)
388
+ if (arg === '--privileged') {
389
+ options.privileged = true;
390
+ return 1;
391
+ }
392
+
328
393
  // --endpoint (for ssh) - supports sequence for stacked isolation
329
394
  if (arg === '--endpoint') {
330
395
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -555,6 +620,35 @@ function parseOption(args, index, options) {
555
620
  return 0;
556
621
  }
557
622
 
623
+ /**
624
+ * Throw if docker runtime options (--volume, --mount, --env, --privileged)
625
+ * are present but the isolation configuration does not include docker.
626
+ * @param {object} options - Parsed options
627
+ * @throws {Error} If a docker-only option is set without docker isolation
628
+ */
629
+ function validateDockerRuntimeOptionsRequireDocker(options) {
630
+ if (options.volumes && options.volumes.length > 0) {
631
+ throw new Error(
632
+ '--volume option is only valid when isolation stack includes docker'
633
+ );
634
+ }
635
+ if (options.mounts && options.mounts.length > 0) {
636
+ throw new Error(
637
+ '--mount option is only valid when isolation stack includes docker'
638
+ );
639
+ }
640
+ if (options.env && options.env.length > 0) {
641
+ throw new Error(
642
+ '--env option is only valid when isolation stack includes docker'
643
+ );
644
+ }
645
+ if (options.privileged) {
646
+ throw new Error(
647
+ '--privileged option is only valid when isolation stack includes docker'
648
+ );
649
+ }
650
+ }
651
+
558
652
  /**
559
653
  * Validate parsed options
560
654
  * @param {object} options - Parsed options
@@ -677,6 +771,11 @@ function validateOptions(options) {
677
771
  );
678
772
  }
679
773
 
774
+ // Docker runtime options (--volume, --mount, --env, --privileged) require docker
775
+ if (!stack.includes('docker')) {
776
+ validateDockerRuntimeOptionsRequireDocker(options);
777
+ }
778
+
680
779
  // User isolation is not supported with Docker as first level
681
780
  if (options.user && currentBackend === 'docker') {
682
781
  throw new Error(
@@ -701,6 +800,7 @@ function validateOptions(options) {
701
800
  '--endpoint option is only valid when isolation stack includes ssh'
702
801
  );
703
802
  }
803
+ validateDockerRuntimeOptionsRequireDocker(options);
704
804
  }
705
805
 
706
806
  // Session name is only valid with isolation
@@ -54,6 +54,23 @@ function buildNextLevelCommand(options, command) {
54
54
  }
55
55
  }
56
56
 
57
+ // Docker runtime options are flat (not per-level); forward them only when a
58
+ // remaining level still uses docker so the nested $ invocation can apply them.
59
+ if (remainingStack.includes('docker')) {
60
+ for (const volume of options.volumes || []) {
61
+ parts.push(`--volume "${volume}"`);
62
+ }
63
+ for (const mount of options.mounts || []) {
64
+ parts.push(`--mount "${mount}"`);
65
+ }
66
+ for (const envVar of options.env || []) {
67
+ parts.push(`--env "${envVar}"`);
68
+ }
69
+ if (options.privileged) {
70
+ parts.push('--privileged');
71
+ }
72
+ }
73
+
57
74
  // Pass through global flags
58
75
  if (options.detached) {
59
76
  parts.push('--detached');
@@ -486,10 +486,75 @@ const {
486
486
  canRunLinuxDockerImages,
487
487
  } = require('./docker-utils');
488
488
 
489
+ /**
490
+ * Build the docker run runtime argument list contributed by configurable
491
+ * container options: privileged mode, environment variables, volumes/bind
492
+ * mounts, and --mount specs. Returned in a stable order so they can be spliced
493
+ * into the `docker run` argv before the image name.
494
+ * @param {object} options - Options (privileged, env, volumes, mounts)
495
+ * @returns {string[]} Docker CLI arguments
496
+ */
497
+ function buildDockerRuntimeArgs(options = {}) {
498
+ const args = [];
499
+ if (options.privileged) {
500
+ args.push('--privileged');
501
+ }
502
+ for (const envVar of options.env || []) {
503
+ args.push('-e', envVar);
504
+ }
505
+ for (const volume of options.volumes || []) {
506
+ args.push('-v', volume);
507
+ }
508
+ for (const mount of options.mounts || []) {
509
+ args.push('--mount', mount);
510
+ }
511
+ return args;
512
+ }
513
+
514
+ /**
515
+ * Build the human-readable `[Isolation]` status lines for docker runtime
516
+ * options (volumes, mounts, env, privileged). Empty collections and a falsy
517
+ * privileged flag contribute no lines.
518
+ * @param {object} options - Options (volumes, mounts, env, privileged)
519
+ * @returns {string[]} Status lines for the start block / log header
520
+ */
521
+ function buildDockerRuntimeStatusLines(options = {}) {
522
+ const lines = [];
523
+ if (options.volumes && options.volumes.length > 0) {
524
+ lines.push(`[Isolation] Volumes: ${options.volumes.join(', ')}`);
525
+ }
526
+ if (options.mounts && options.mounts.length > 0) {
527
+ lines.push(`[Isolation] Mounts: ${options.mounts.join(', ')}`);
528
+ }
529
+ if (options.env && options.env.length > 0) {
530
+ lines.push(`[Isolation] Env: ${options.env.join(', ')}`);
531
+ }
532
+ if (options.privileged) {
533
+ lines.push(`[Isolation] Privileged: true`);
534
+ }
535
+ return lines;
536
+ }
537
+
538
+ /**
539
+ * Build the execution-record metadata for docker runtime options, normalizing
540
+ * empty collections and a falsy privileged flag to `null`.
541
+ * @param {object} options - Options (volumes, mounts, env, privileged)
542
+ * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
543
+ */
544
+ function buildDockerRuntimeMetadata(options = {}) {
545
+ return {
546
+ volumes:
547
+ options.volumes && options.volumes.length > 0 ? options.volumes : null,
548
+ mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
549
+ env: options.env && options.env.length > 0 ? options.env : null,
550
+ privileged: options.privileged || null,
551
+ };
552
+ }
553
+
489
554
  /**
490
555
  * Run command in Docker container
491
556
  * @param {string} command - Command to execute
492
- * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer)
557
+ * @param {object} options - Options (image, session/name, detached, user, keepAlive, autoRemoveDockerContainer, volumes, mounts, env, privileged)
493
558
  * @returns {Promise<{success: boolean, containerName: string, message: string}>}
494
559
  */
495
560
  function runInDocker(command, options = {}) {
@@ -558,6 +623,8 @@ function runInDocker(command, options = {}) {
558
623
  dockerArgs.push('--user', options.user);
559
624
  }
560
625
 
626
+ dockerArgs.push(...buildDockerRuntimeArgs(options));
627
+
561
628
  const effectiveCommand = options.keepAlive
562
629
  ? `${command}; exec ${shellToUse}`
563
630
  : command;
@@ -640,6 +707,7 @@ function runInDocker(command, options = {}) {
640
707
  if (options.user) {
641
708
  dockerArgs.push('--user', options.user);
642
709
  }
710
+ dockerArgs.push(...buildDockerRuntimeArgs(options));
643
711
  if (DEBUG) {
644
712
  console.log(`[DEBUG] shell: ${shellToUse}`);
645
713
  }
@@ -784,6 +852,9 @@ module.exports = {
784
852
  runInScreen,
785
853
  runInTmux,
786
854
  runInDocker,
855
+ buildDockerRuntimeArgs,
856
+ buildDockerRuntimeStatusLines,
857
+ buildDockerRuntimeMetadata,
787
858
  runInSsh,
788
859
  runIsolated,
789
860
  runAsIsolatedUser,
package/src/lib/usage.js CHANGED
@@ -3,13 +3,17 @@ function printUsage() {
3
3
  console.log(`Usage: $ [options] [--] <command> | $ --status <uuid> [--output-format <fmt>] | $ --list [--output-format <fmt>] | $ --upload-log <id> | $ --stop <id> | $ --terminate <id>
4
4
 
5
5
  Options:
6
- --isolated, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
6
+ --isolated, --isolation, -i <env> Run in isolated environment (screen, tmux, docker, ssh)
7
7
  --attached, -a Run in attached mode (foreground)
8
8
  --detached, -d Run in detached mode (background)
9
9
  --session, -s <name> Session name for isolation
10
10
  --session-id <uuid> Session UUID for tracking (auto-generated if not provided)
11
11
  --session-name <uuid> Alias for --session-id
12
12
  --image <image> Docker image (optional, defaults to OS-matched image)
13
+ --volume, -v <spec> Docker bind mount/volume host:container[:mode] (repeatable, docker only)
14
+ --mount <spec> Docker --mount spec (repeatable, docker only)
15
+ --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
16
+ --privileged Run docker container in privileged mode (docker only)
13
17
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
14
18
  --isolated-user, -u [name] Create isolated user with same permissions
15
19
  --keep-user Keep isolated user after command completes
@@ -33,6 +37,8 @@ Examples:
33
37
  $ -i screen -d bun start
34
38
  $ --isolated docker -- echo "hi" # uses OS-matched default image
35
39
  $ --isolated docker --image ghcr.io/link-foundation/box-js:latest -- bun --version
40
+ $ -i docker -v ~/.config/gh:/root/.config/gh -e TOKEN=abc -- gh repo list
41
+ $ -i docker --image konard/hive-mind-dind:latest --privileged -- solve ...
36
42
  $ --isolated ssh --endpoint user@remote.server -- ls -la
37
43
  $ --isolated-user -- npm test # Create isolated user
38
44
  $ -u myuser -- npm start # Custom username
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Regression tests for --isolation alias handling and unknown wrapper options.
4
+ */
5
+
6
+ const { describe, it } = require('node:test');
7
+ const assert = require('assert');
8
+ const { parseArgs } = require('../src/lib/args-parser');
9
+
10
+ describe('parseArgs --isolation alias', () => {
11
+ it('should parse --isolation alias with value', () => {
12
+ const result = parseArgs(['--isolation', 'docker', '--', 'echo', 'hi']);
13
+ assert.strictEqual(result.wrapperOptions.isolated, 'docker');
14
+ assert.strictEqual(result.command, 'echo hi');
15
+ });
16
+
17
+ it('should parse --isolation=value alias format', () => {
18
+ const result = parseArgs(['--isolation=screen', '--', 'ls', '-la']);
19
+ assert.strictEqual(result.wrapperOptions.isolated, 'screen');
20
+ });
21
+
22
+ it('should parse --isolation alias before command without separator', () => {
23
+ const result = parseArgs(['--isolation', 'docker', 'echo', 'hi']);
24
+ assert.strictEqual(result.wrapperOptions.isolated, 'docker');
25
+ assert.strictEqual(result.command, 'echo hi');
26
+ });
27
+ });
28
+
29
+ describe('parseArgs unknown wrapper options', () => {
30
+ it('should throw for unknown wrapper option before separator', () => {
31
+ assert.throws(() => {
32
+ parseArgs(['--unknown-wrapper', 'value', '--', 'echo', 'hi']);
33
+ }, /Unknown wrapper option: --unknown-wrapper/);
34
+ });
35
+
36
+ it('should throw for unknown wrapper option without separator', () => {
37
+ assert.throws(() => {
38
+ parseArgs(['--unknown-wrapper', 'value', 'echo', 'hi']);
39
+ }, /Unknown wrapper option: --unknown-wrapper/);
40
+ });
41
+ });
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Tests for Docker runtime options: --volume/-v, --mount, --env/-e, --privileged
4
+ *
5
+ * Reproduces issue #132: callers need to configure bind mounts, volumes,
6
+ * environment variables, and privileged mode for the docker isolation backend
7
+ * so they can mount tool credentials and run Docker-in-Docker images without
8
+ * wrapping `docker run` themselves.
9
+ */
10
+
11
+ const { describe, it } = require('node:test');
12
+ const assert = require('assert');
13
+ const { parseArgs } = require('../src/lib/args-parser');
14
+ const {
15
+ buildDockerRuntimeArgs,
16
+ buildDockerRuntimeStatusLines,
17
+ buildDockerRuntimeMetadata,
18
+ } = require('../src/lib/isolation');
19
+
20
+ describe('Docker runtime options parsing', () => {
21
+ it('should parse repeatable --volume and -v', () => {
22
+ const result = parseArgs([
23
+ '--isolated',
24
+ 'docker',
25
+ '--volume',
26
+ '/host/a:/container/a',
27
+ '-v',
28
+ '/host/b:/container/b:ro',
29
+ '--',
30
+ 'ls',
31
+ ]);
32
+ assert.deepStrictEqual(result.wrapperOptions.volumes, [
33
+ '/host/a:/container/a',
34
+ '/host/b:/container/b:ro',
35
+ ]);
36
+ });
37
+
38
+ it('should parse --volume=value and -v=value', () => {
39
+ const result = parseArgs([
40
+ '-i',
41
+ 'docker',
42
+ '--volume=/host/a:/c/a',
43
+ '-v=/host/b:/c/b',
44
+ '--',
45
+ 'ls',
46
+ ]);
47
+ assert.deepStrictEqual(result.wrapperOptions.volumes, [
48
+ '/host/a:/c/a',
49
+ '/host/b:/c/b',
50
+ ]);
51
+ });
52
+
53
+ it('should parse repeatable --mount', () => {
54
+ const result = parseArgs([
55
+ '-i',
56
+ 'docker',
57
+ '--mount',
58
+ 'type=bind,src=/h,dst=/c',
59
+ '--mount=type=volume,src=vol,dst=/data',
60
+ '--',
61
+ 'ls',
62
+ ]);
63
+ assert.deepStrictEqual(result.wrapperOptions.mounts, [
64
+ 'type=bind,src=/h,dst=/c',
65
+ 'type=volume,src=vol,dst=/data',
66
+ ]);
67
+ });
68
+
69
+ it('should parse repeatable --env and -e', () => {
70
+ const result = parseArgs([
71
+ '-i',
72
+ 'docker',
73
+ '--env',
74
+ 'FOO=bar',
75
+ '-e',
76
+ 'GH_TOKEN=secret',
77
+ '--env=BAZ=qux',
78
+ '--',
79
+ 'env',
80
+ ]);
81
+ assert.deepStrictEqual(result.wrapperOptions.env, [
82
+ 'FOO=bar',
83
+ 'GH_TOKEN=secret',
84
+ 'BAZ=qux',
85
+ ]);
86
+ });
87
+
88
+ it('should parse --privileged', () => {
89
+ const result = parseArgs(['-i', 'docker', '--privileged', '--', 'ls']);
90
+ assert.strictEqual(result.wrapperOptions.privileged, true);
91
+ });
92
+
93
+ it('should default runtime options to empty/false', () => {
94
+ const result = parseArgs(['-i', 'docker', '--', 'ls']);
95
+ assert.deepStrictEqual(result.wrapperOptions.volumes, []);
96
+ assert.deepStrictEqual(result.wrapperOptions.mounts, []);
97
+ assert.deepStrictEqual(result.wrapperOptions.env, []);
98
+ assert.strictEqual(result.wrapperOptions.privileged, false);
99
+ });
100
+
101
+ it('should throw when --volume requires an argument', () => {
102
+ assert.throws(() => {
103
+ parseArgs(['-i', 'docker', '--volume', '--', 'ls']);
104
+ }, /requires a volume argument/);
105
+ });
106
+
107
+ it('should throw when --env requires an argument', () => {
108
+ assert.throws(() => {
109
+ parseArgs(['-i', 'docker', '--env', '--', 'ls']);
110
+ }, /requires a KEY=VALUE argument/);
111
+ });
112
+ });
113
+
114
+ describe('Docker runtime options validation', () => {
115
+ it('should reject --volume with non-docker backend', () => {
116
+ assert.throws(() => {
117
+ parseArgs(['-i', 'tmux', '-v', '/a:/b', '--', 'ls']);
118
+ }, /--volume option is only valid when isolation stack includes docker/);
119
+ });
120
+
121
+ it('should reject --mount without isolation', () => {
122
+ assert.throws(() => {
123
+ parseArgs(['--mount', 'type=bind,src=/a,dst=/b', '--', 'ls']);
124
+ }, /--mount option is only valid when isolation stack includes docker/);
125
+ });
126
+
127
+ it('should reject --env with non-docker backend', () => {
128
+ assert.throws(() => {
129
+ parseArgs(['-i', 'ssh', '--endpoint', 'u@h', '-e', 'A=1', '--', 'ls']);
130
+ }, /--env option is only valid when isolation stack includes docker/);
131
+ });
132
+
133
+ it('should reject --privileged without docker', () => {
134
+ assert.throws(() => {
135
+ parseArgs(['--privileged', '--', 'ls']);
136
+ }, /--privileged option is only valid when isolation stack includes docker/);
137
+ });
138
+
139
+ it('should accept runtime options when stack includes docker', () => {
140
+ const result = parseArgs([
141
+ '-i',
142
+ 'screen docker',
143
+ '-v',
144
+ '/a:/b',
145
+ '-e',
146
+ 'A=1',
147
+ '--privileged',
148
+ '--',
149
+ 'ls',
150
+ ]);
151
+ assert.deepStrictEqual(result.wrapperOptions.volumes, ['/a:/b']);
152
+ assert.deepStrictEqual(result.wrapperOptions.env, ['A=1']);
153
+ assert.strictEqual(result.wrapperOptions.privileged, true);
154
+ });
155
+ });
156
+
157
+ describe('buildDockerRuntimeArgs', () => {
158
+ it('should build empty args by default', () => {
159
+ assert.deepStrictEqual(buildDockerRuntimeArgs({}), []);
160
+ });
161
+
162
+ it('should add --privileged first', () => {
163
+ assert.deepStrictEqual(buildDockerRuntimeArgs({ privileged: true }), [
164
+ '--privileged',
165
+ ]);
166
+ });
167
+
168
+ it('should expand env, volumes, and mounts in order', () => {
169
+ const args = buildDockerRuntimeArgs({
170
+ privileged: true,
171
+ env: ['FOO=bar', 'GH_TOKEN=secret'],
172
+ volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
173
+ mounts: ['type=bind,src=/h,dst=/c'],
174
+ });
175
+ assert.deepStrictEqual(args, [
176
+ '--privileged',
177
+ '-e',
178
+ 'FOO=bar',
179
+ '-e',
180
+ 'GH_TOKEN=secret',
181
+ '-v',
182
+ '/h/a:/c/a',
183
+ '-v',
184
+ '/h/b:/c/b:ro',
185
+ '--mount',
186
+ 'type=bind,src=/h,dst=/c',
187
+ ]);
188
+ });
189
+ });
190
+
191
+ describe('buildDockerRuntimeStatusLines', () => {
192
+ it('should return no lines for empty options', () => {
193
+ assert.deepStrictEqual(buildDockerRuntimeStatusLines({}), []);
194
+ });
195
+
196
+ it('should format volumes, mounts, env, and privileged lines', () => {
197
+ const lines = buildDockerRuntimeStatusLines({
198
+ volumes: ['/h:/c:ro'],
199
+ mounts: ['type=bind,src=/h,dst=/c'],
200
+ env: ['FOO=bar'],
201
+ privileged: true,
202
+ });
203
+ assert.deepStrictEqual(lines, [
204
+ '[Isolation] Volumes: /h:/c:ro',
205
+ '[Isolation] Mounts: type=bind,src=/h,dst=/c',
206
+ '[Isolation] Env: FOO=bar',
207
+ '[Isolation] Privileged: true',
208
+ ]);
209
+ });
210
+
211
+ it('should join multiple volumes with a comma', () => {
212
+ assert.deepStrictEqual(
213
+ buildDockerRuntimeStatusLines({ volumes: ['/a:/a', '/b:/b'] }),
214
+ ['[Isolation] Volumes: /a:/a, /b:/b']
215
+ );
216
+ });
217
+ });
218
+
219
+ describe('buildDockerRuntimeMetadata', () => {
220
+ it('should null out empty collections and falsy privileged', () => {
221
+ assert.deepStrictEqual(buildDockerRuntimeMetadata({}), {
222
+ volumes: null,
223
+ mounts: null,
224
+ env: null,
225
+ privileged: null,
226
+ });
227
+ });
228
+
229
+ it('should pass through populated collections and privileged', () => {
230
+ assert.deepStrictEqual(
231
+ buildDockerRuntimeMetadata({
232
+ volumes: ['/h:/c'],
233
+ mounts: ['type=bind,src=/h,dst=/c'],
234
+ env: ['FOO=bar'],
235
+ privileged: true,
236
+ }),
237
+ {
238
+ volumes: ['/h:/c'],
239
+ mounts: ['type=bind,src=/h,dst=/c'],
240
+ env: ['FOO=bar'],
241
+ privileged: true,
242
+ }
243
+ );
244
+ });
245
+ });