start-command 0.30.5 → 0.31.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,11 @@
1
1
  # start-command
2
2
 
3
+ ## 0.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b14f11c: Add `--network` and repeatable `--network-alias` options for Docker-isolated commands.
8
+
3
9
  ## 0.30.5
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.30.5",
3
+ "version": "0.31.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
@@ -581,10 +581,7 @@ async function runWithIsolation(
581
581
  alwaysCleanupContainer: options.alwaysCleanupContainer,
582
582
  keepContainer: options.keepContainer,
583
583
  keepContainerOnFail: options.keepContainerOnFail,
584
- volumes: options.volumes,
585
- mounts: options.mounts,
586
- env: options.env,
587
- privileged: options.privileged,
584
+ ...buildDockerRuntimeMetadata(options),
588
585
  shell: options.shell,
589
586
  logPath: logFilePath,
590
587
  });
@@ -15,6 +15,8 @@
15
15
  * --mount <mount-spec> Docker --mount spec (repeatable, docker only)
16
16
  * --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
17
17
  * --privileged Run docker container in privileged mode (docker only)
18
+ * --network <name> Connect docker container to a named network (docker only)
19
+ * --network-alias <alias> Add a network-scoped alias (repeatable, docker only)
18
20
  * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
19
21
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
20
22
  * --keep-user Keep isolated user after command completes (don't delete)
@@ -37,6 +39,7 @@
37
39
  */
38
40
 
39
41
  const { getDefaultDockerImage } = require('./docker-utils');
42
+ const dockerNetworkOptions = require('./docker-network-options');
40
43
  const { parseSequence, isSequence } = require('./sequence-parser');
41
44
 
42
45
  // Debug mode from environment
@@ -178,6 +181,8 @@ function parseArgs(args) {
178
181
  mounts: [], // Docker --mount specs, applied to docker levels
179
182
  env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
180
183
  privileged: false, // Run docker container in privileged mode
184
+ network: null, // Docker network name
185
+ networkAliases: [], // Docker network-scoped aliases
181
186
  endpoint: null, // SSH endpoint (current level, e.g., user@host)
182
187
  endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
183
188
  user: false, // Create isolated user
@@ -396,6 +401,11 @@ function parseOption(args, index, options) {
396
401
  return 1;
397
402
  }
398
403
 
404
+ const networkOption = dockerNetworkOptions.parse(args, index, options);
405
+ if (networkOption) {
406
+ return networkOption;
407
+ }
408
+
399
409
  // --endpoint (for ssh) - supports sequence for stacked isolation
400
410
  if (arg === '--endpoint') {
401
411
  if (index + 1 < args.length && !args[index + 1].startsWith('-')) {
@@ -645,8 +655,7 @@ function parseOption(args, index, options) {
645
655
  }
646
656
 
647
657
  /**
648
- * Throw if docker runtime options (--volume, --mount, --env, --privileged)
649
- * are present but the isolation configuration does not include docker.
658
+ * Throw if docker runtime options are present without docker isolation.
650
659
  * @param {object} options - Parsed options
651
660
  * @throws {Error} If a docker-only option is set without docker isolation
652
661
  */
@@ -671,6 +680,7 @@ function validateDockerRuntimeOptionsRequireDocker(options) {
671
680
  '--privileged option is only valid when isolation stack includes docker'
672
681
  );
673
682
  }
683
+ dockerNetworkOptions.validateRequireDocker(options);
674
684
  }
675
685
 
676
686
  function validateDockerCleanupOptions(options, hasDocker) {
@@ -109,6 +109,22 @@ function readDockerContainerOomKilled(containerName) {
109
109
  return null;
110
110
  }
111
111
 
112
+ function readDockerContainerStatus(containerName) {
113
+ const result = spawnSync(
114
+ getDockerCommand(),
115
+ ['inspect', '-f', '{{.State.Status}}', containerName],
116
+ getDockerSpawnOptions({
117
+ encoding: 'utf8',
118
+ env: process.env,
119
+ stdio: ['pipe', 'pipe', 'pipe'],
120
+ })
121
+ );
122
+ if (result.error || result.status !== 0) {
123
+ return null;
124
+ }
125
+ return String(result.stdout || '').trim() || null;
126
+ }
127
+
112
128
  function removeDockerContainer(containerName, logPath = null) {
113
129
  const result = spawnSync(
114
130
  getDockerCommand(),
@@ -237,6 +253,7 @@ module.exports = {
237
253
  getDockerContainerCleanupInstructions,
238
254
  appendDockerContainerCleanupPolicyMessage,
239
255
  readDockerContainerOomKilled,
256
+ readDockerContainerStatus,
240
257
  removeDockerContainer,
241
258
  buildDetachedDockerCompletionScript,
242
259
  startDetachedDockerCompletionWatcher,
@@ -0,0 +1,44 @@
1
+ /** Parse and validate Docker network wrapper options. */
2
+
3
+ function parseDockerNetworkOption(args, index, options) {
4
+ const arg = args[index];
5
+ if (arg === '--network' || arg === '--network-alias') {
6
+ if (index + 1 >= args.length || args[index + 1].startsWith('-')) {
7
+ const value = arg === '--network' ? 'network name' : 'alias';
8
+ throw new Error(`Option ${arg} requires a ${value} argument`);
9
+ }
10
+ if (arg === '--network') {
11
+ options.network = args[index + 1];
12
+ } else {
13
+ options.networkAliases.push(args[index + 1]);
14
+ }
15
+ return 2;
16
+ }
17
+ if (arg.startsWith('--network=')) {
18
+ options.network = arg.slice('--network='.length);
19
+ return 1;
20
+ }
21
+ if (arg.startsWith('--network-alias=')) {
22
+ options.networkAliases.push(arg.slice('--network-alias='.length));
23
+ return 1;
24
+ }
25
+ return 0;
26
+ }
27
+
28
+ function validateDockerNetworkOptionsRequireDocker(options) {
29
+ if (options.network) {
30
+ throw new Error(
31
+ '--network option is only valid when isolation stack includes docker'
32
+ );
33
+ }
34
+ if (options.networkAliases?.length > 0) {
35
+ throw new Error(
36
+ '--network-alias option is only valid when isolation stack includes docker'
37
+ );
38
+ }
39
+ }
40
+
41
+ module.exports = {
42
+ parse: parseDockerNetworkOption,
43
+ validateRequireDocker: validateDockerNetworkOptionsRequireDocker,
44
+ };
@@ -28,6 +28,7 @@ const {
28
28
  getDockerContainerCleanupInstructions,
29
29
  appendDockerContainerCleanupPolicyMessage,
30
30
  readDockerContainerOomKilled,
31
+ readDockerContainerStatus,
31
32
  removeDockerContainer,
32
33
  startDetachedDockerCompletionWatcher,
33
34
  spawnAttachedDocker,
@@ -499,9 +500,9 @@ const {
499
500
  /**
500
501
  * Build the docker run runtime argument list contributed by configurable
501
502
  * container options: privileged mode, environment variables, volumes/bind
502
- * mounts, and --mount specs. Returned in a stable order so they can be spliced
503
+ * mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced
503
504
  * into the `docker run` argv before the image name.
504
- * @param {object} options - Options (privileged, env, volumes, mounts)
505
+ * @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases)
505
506
  * @returns {string[]} Docker CLI arguments
506
507
  */
507
508
  function buildDockerRuntimeArgs(options = {}) {
@@ -518,6 +519,12 @@ function buildDockerRuntimeArgs(options = {}) {
518
519
  for (const mount of options.mounts || []) {
519
520
  args.push('--mount', mount);
520
521
  }
522
+ if (options.network) {
523
+ args.push('--network', options.network);
524
+ }
525
+ for (const alias of options.networkAliases || []) {
526
+ args.push('--network-alias', alias);
527
+ }
521
528
  return args;
522
529
  }
523
530
 
@@ -542,6 +549,14 @@ function buildDockerRuntimeStatusLines(options = {}) {
542
549
  if (options.privileged) {
543
550
  lines.push(`[Isolation] Privileged: true`);
544
551
  }
552
+ if (options.network) {
553
+ lines.push(`[Isolation] Network: ${options.network}`);
554
+ }
555
+ if (options.networkAliases && options.networkAliases.length > 0) {
556
+ lines.push(
557
+ `[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
558
+ );
559
+ }
545
560
  return lines;
546
561
  }
547
562
 
@@ -549,7 +564,7 @@ function buildDockerRuntimeStatusLines(options = {}) {
549
564
  * Build the execution-record metadata for docker runtime options, normalizing
550
565
  * empty collections and a falsy privileged flag to `null`.
551
566
  * @param {object} options - Options (volumes, mounts, env, privileged)
552
- * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
567
+ * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networkAliases: ?string[]}}
553
568
  */
554
569
  function buildDockerRuntimeMetadata(options = {}) {
555
570
  return {
@@ -558,6 +573,11 @@ function buildDockerRuntimeMetadata(options = {}) {
558
573
  mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
559
574
  env: options.env && options.env.length > 0 ? options.env : null,
560
575
  privileged: options.privileged || null,
576
+ network: options.network || null,
577
+ networkAliases:
578
+ options.networkAliases && options.networkAliases.length > 0
579
+ ? options.networkAliases
580
+ : null,
561
581
  };
562
582
  }
563
583
 
@@ -597,6 +617,8 @@ function runInDocker(command, options = {}) {
597
617
  }
598
618
 
599
619
  const containerName = options.session || generateSessionName('docker');
620
+ const containerExistedBeforeLaunch =
621
+ readDockerContainerStatus(containerName) !== null;
600
622
  const cleanupPolicy = getDockerContainerCleanupPolicy(options);
601
623
  if (!dockerImageExists(options.image)) {
602
624
  // Pass logPath so the image-preparation phase (docker pull) is recorded in
@@ -671,6 +693,12 @@ function runInDocker(command, options = {}) {
671
693
  dockerResult.stderr.trim() ||
672
694
  dockerResult.stdout.trim() ||
673
695
  `docker exited with code ${dockerResult.status}`;
696
+ if (
697
+ !containerExistedBeforeLaunch &&
698
+ readDockerContainerStatus(containerName) === 'created'
699
+ ) {
700
+ removeDockerContainer(containerName, options.logPath);
701
+ }
674
702
  throw new Error(dockerError);
675
703
  }
676
704
 
@@ -760,8 +788,17 @@ function runInDocker(command, options = {}) {
760
788
  }
761
789
 
762
790
  const oomKilled = readDockerContainerOomKilled(containerName);
791
+ const launchFailed =
792
+ !containerExistedBeforeLaunch &&
793
+ readDockerContainerStatus(containerName) === 'created';
763
794
 
764
- if (
795
+ if (launchFailed) {
796
+ if (removeDockerContainer(containerName, options.logPath)) {
797
+ message += `\nContainer removed after launch failure.`;
798
+ } else {
799
+ message += `\nWarning: failed to remove container after launch failure.`;
800
+ }
801
+ } else if (
765
802
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
766
803
  ) {
767
804
  if (removeDockerContainer(containerName, options.logPath)) {
package/src/lib/usage.js CHANGED
@@ -14,6 +14,8 @@ Options:
14
14
  --mount <spec> Docker --mount spec (repeatable, docker only)
15
15
  --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
16
16
  --privileged Run docker container in privileged mode (docker only)
17
+ --network <name> Connect docker container to a named network (docker only)
18
+ --network-alias <alias> Add network-scoped alias (repeatable, docker only)
17
19
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
18
20
  --isolated-user, -u [name] Create isolated user with same permissions
19
21
  --keep-user Keep isolated user after command completes
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bun
2
+ /** Real-daemon integration coverage for Docker network isolation (issue #154). */
3
+
4
+ const { after, before, describe, it } = require('node:test');
5
+ const assert = require('assert');
6
+ const { spawnSync } = require('child_process');
7
+ const { randomUUID } = require('crypto');
8
+ const { runInDocker } = require('../src/lib/isolation');
9
+
10
+ const suffix = randomUUID().slice(0, 8);
11
+ const network = `start-network-${suffix}`;
12
+ const sidecar = `start-sidecar-${suffix}`;
13
+ const connected = `start-connected-${suffix}`;
14
+ const control = `start-control-${suffix}`;
15
+ const missing = `start-missing-${suffix}`;
16
+ const missingAttached = `start-missing-attached-${suffix}`;
17
+ let dockerAvailable = false;
18
+
19
+ function docker(args) {
20
+ return spawnSync('docker', args, { encoding: 'utf8' });
21
+ }
22
+
23
+ describe('Docker named network integration', { timeout: 60000 }, () => {
24
+ before(() => {
25
+ dockerAvailable =
26
+ process.platform === 'linux' && docker(['info']).status === 0;
27
+ if (!dockerAvailable) {
28
+ return;
29
+ }
30
+
31
+ const created = docker(['network', 'create', '--internal', network]);
32
+ assert.strictEqual(created.status, 0, created.stderr);
33
+ const started = docker([
34
+ 'run',
35
+ '-d',
36
+ '--name',
37
+ sidecar,
38
+ '--network',
39
+ network,
40
+ '--network-alias',
41
+ 'formal-ai',
42
+ 'alpine:3.23',
43
+ 'sleep',
44
+ '60',
45
+ ]);
46
+ assert.strictEqual(started.status, 0, started.stderr);
47
+ });
48
+
49
+ after(() => {
50
+ if (!dockerAvailable) {
51
+ return;
52
+ }
53
+ docker(['rm', '-f', sidecar, connected, control, missing, missingAttached]);
54
+ docker(['network', 'rm', network]);
55
+ });
56
+
57
+ it('resolves an alias only from containers joined to the internal network', async () => {
58
+ if (!dockerAvailable) {
59
+ return;
60
+ }
61
+
62
+ const joined = await runInDocker('ping -c 1 formal-ai', {
63
+ image: 'alpine:3.23',
64
+ session: connected,
65
+ network,
66
+ networkAliases: ['task'],
67
+ detached: true,
68
+ shell: 'sh',
69
+ keepContainer: true,
70
+ });
71
+ assert.strictEqual(joined.success, true, joined.message);
72
+ const joinedExit = docker(['wait', connected]);
73
+ assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
74
+ assert.strictEqual(joinedExit.stdout.trim(), '0');
75
+
76
+ const unconnected = await runInDocker('ping -c 1 formal-ai', {
77
+ image: 'alpine:3.23',
78
+ session: control,
79
+ shell: 'sh',
80
+ alwaysCleanupContainer: true,
81
+ });
82
+ assert.strictEqual(unconnected.success, false);
83
+ });
84
+
85
+ it('fails for a missing network without orphaning a container', async () => {
86
+ if (!dockerAvailable) {
87
+ return;
88
+ }
89
+
90
+ const result = await runInDocker('echo should-not-run', {
91
+ image: 'alpine:3.23',
92
+ session: missing,
93
+ network: `${network}-absent`,
94
+ detached: true,
95
+ shell: 'sh',
96
+ });
97
+ assert.strictEqual(result.success, false);
98
+ assert.match(result.message, /network .* not found/i);
99
+ assert.notStrictEqual(docker(['inspect', missing]).status, 0);
100
+
101
+ const attachedResult = await runInDocker('echo should-not-run', {
102
+ image: 'alpine:3.23',
103
+ session: missingAttached,
104
+ network: `${network}-absent`,
105
+ shell: 'sh',
106
+ });
107
+ assert.strictEqual(attachedResult.success, false);
108
+ assert.notStrictEqual(docker(['inspect', missingAttached]).status, 0);
109
+
110
+ const conflict = await runInDocker('echo should-not-run', {
111
+ image: 'alpine:3.23',
112
+ session: sidecar,
113
+ network: `${network}-absent`,
114
+ detached: true,
115
+ shell: 'sh',
116
+ });
117
+ assert.strictEqual(conflict.success, false);
118
+ assert.strictEqual(docker(['inspect', sidecar]).status, 0);
119
+ });
120
+ });
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * Tests for Docker runtime options: --volume/-v, --mount, --env/-e, --privileged
3
+ * Tests for Docker runtime options: --volume/-v, --mount, --env/-e,
4
+ * --privileged, --network, and --network-alias
4
5
  *
5
6
  * Reproduces issue #132: callers need to configure bind mounts, volumes,
6
7
  * environment variables, and privileged mode for the docker isolation backend
@@ -90,12 +91,58 @@ describe('Docker runtime options parsing', () => {
90
91
  assert.strictEqual(result.wrapperOptions.privileged, true);
91
92
  });
92
93
 
94
+ it('should parse --network value and equals forms without consuming child args', () => {
95
+ const spaced = parseArgs([
96
+ '-i',
97
+ 'docker',
98
+ '--network',
99
+ 'hive-formal-ai',
100
+ '--',
101
+ '--network',
102
+ 'child-network',
103
+ ]);
104
+ const equals = parseArgs([
105
+ '-i',
106
+ 'docker',
107
+ '--network=hive-formal-ai',
108
+ '--',
109
+ 'echo',
110
+ 'ok',
111
+ ]);
112
+
113
+ assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai');
114
+ assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']);
115
+ assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai');
116
+ });
117
+
118
+ it('should parse repeatable --network-alias value and equals forms', () => {
119
+ const result = parseArgs([
120
+ '-i',
121
+ 'docker',
122
+ '--network',
123
+ 'hive-formal-ai',
124
+ '--network-alias',
125
+ 'formal-ai',
126
+ '--network-alias=checker',
127
+ '--',
128
+ 'echo',
129
+ 'ok',
130
+ ]);
131
+
132
+ assert.deepStrictEqual(result.wrapperOptions.networkAliases, [
133
+ 'formal-ai',
134
+ 'checker',
135
+ ]);
136
+ });
137
+
93
138
  it('should default runtime options to empty/false', () => {
94
139
  const result = parseArgs(['-i', 'docker', '--', 'ls']);
95
140
  assert.deepStrictEqual(result.wrapperOptions.volumes, []);
96
141
  assert.deepStrictEqual(result.wrapperOptions.mounts, []);
97
142
  assert.deepStrictEqual(result.wrapperOptions.env, []);
98
143
  assert.strictEqual(result.wrapperOptions.privileged, false);
144
+ assert.strictEqual(result.wrapperOptions.network, null);
145
+ assert.deepStrictEqual(result.wrapperOptions.networkAliases, []);
99
146
  });
100
147
 
101
148
  it('should throw when --volume requires an argument', () => {
@@ -136,6 +183,15 @@ describe('Docker runtime options validation', () => {
136
183
  }, /--privileged option is only valid when isolation stack includes docker/);
137
184
  });
138
185
 
186
+ it('should reject network options without docker', () => {
187
+ assert.throws(() => {
188
+ parseArgs(['-i', 'tmux', '--network', 'private', '--', 'ls']);
189
+ }, /--network option is only valid when isolation stack includes docker/);
190
+ assert.throws(() => {
191
+ parseArgs(['--network-alias', 'service', '--', 'ls']);
192
+ }, /--network-alias option is only valid when isolation stack includes docker/);
193
+ });
194
+
139
195
  it('should accept runtime options when stack includes docker', () => {
140
196
  const result = parseArgs([
141
197
  '-i',
@@ -171,6 +227,8 @@ describe('buildDockerRuntimeArgs', () => {
171
227
  env: ['FOO=bar', 'GH_TOKEN=secret'],
172
228
  volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
173
229
  mounts: ['type=bind,src=/h,dst=/c'],
230
+ network: 'hive-formal-ai',
231
+ networkAliases: ['formal-ai', 'checker'],
174
232
  });
175
233
  assert.deepStrictEqual(args, [
176
234
  '--privileged',
@@ -184,6 +242,12 @@ describe('buildDockerRuntimeArgs', () => {
184
242
  '/h/b:/c/b:ro',
185
243
  '--mount',
186
244
  'type=bind,src=/h,dst=/c',
245
+ '--network',
246
+ 'hive-formal-ai',
247
+ '--network-alias',
248
+ 'formal-ai',
249
+ '--network-alias',
250
+ 'checker',
187
251
  ]);
188
252
  });
189
253
  });
@@ -199,12 +263,16 @@ describe('buildDockerRuntimeStatusLines', () => {
199
263
  mounts: ['type=bind,src=/h,dst=/c'],
200
264
  env: ['FOO=bar'],
201
265
  privileged: true,
266
+ network: 'hive-formal-ai',
267
+ networkAliases: ['formal-ai', 'checker'],
202
268
  });
203
269
  assert.deepStrictEqual(lines, [
204
270
  '[Isolation] Volumes: /h:/c:ro',
205
271
  '[Isolation] Mounts: type=bind,src=/h,dst=/c',
206
272
  '[Isolation] Env: FOO=bar',
207
273
  '[Isolation] Privileged: true',
274
+ '[Isolation] Network: hive-formal-ai',
275
+ '[Isolation] Network aliases: formal-ai, checker',
208
276
  ]);
209
277
  });
210
278
 
@@ -223,6 +291,8 @@ describe('buildDockerRuntimeMetadata', () => {
223
291
  mounts: null,
224
292
  env: null,
225
293
  privileged: null,
294
+ network: null,
295
+ networkAliases: null,
226
296
  });
227
297
  });
228
298
 
@@ -233,12 +303,16 @@ describe('buildDockerRuntimeMetadata', () => {
233
303
  mounts: ['type=bind,src=/h,dst=/c'],
234
304
  env: ['FOO=bar'],
235
305
  privileged: true,
306
+ network: 'hive-formal-ai',
307
+ networkAliases: ['formal-ai', 'checker'],
236
308
  }),
237
309
  {
238
310
  volumes: ['/h:/c'],
239
311
  mounts: ['type=bind,src=/h,dst=/c'],
240
312
  env: ['FOO=bar'],
241
313
  privileged: true,
314
+ network: 'hive-formal-ai',
315
+ networkAliases: ['formal-ai', 'checker'],
242
316
  }
243
317
  );
244
318
  });