start-command 0.30.5 → 0.32.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,20 @@
1
1
  # start-command
2
2
 
3
+ ## 0.32.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Allow Docker isolation to join multiple networks before starting the command.
8
+
9
+ Make the Docker multi-network integration test hermetic and add opt-in
10
+ `START_DEBUG` tracing to the Docker network lifecycle helpers.
11
+
12
+ ## 0.31.0
13
+
14
+ ### Minor Changes
15
+
16
+ - b14f11c: Add `--network` and repeatable `--network-alias` options for Docker-isolated commands.
17
+
3
18
  ## 0.30.5
4
19
 
5
20
  ### 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.32.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
  });
@@ -4,7 +4,6 @@
4
4
  * Supports two syntax patterns:
5
5
  * 1. $ [wrapper-options] -- [command-options]
6
6
  * 2. $ [wrapper-options] command [command-options]
7
- *
8
7
  * Wrapper Options:
9
8
  * --isolated, --isolation, -i <backend> Run in isolated environment (screen, tmux, docker, ssh)
10
9
  * --attached, -a Run in attached mode (foreground)
@@ -15,6 +14,8 @@
15
14
  * --mount <mount-spec> Docker --mount spec (repeatable, docker only)
16
15
  * --env, -e <KEY=VALUE> Environment variable for docker container (repeatable, docker only)
17
16
  * --privileged Run docker container in privileged mode (docker only)
17
+ * --network <name> Connect docker container to a named network (repeatable, docker only)
18
+ * --network-alias <alias> Add an alias to the first network (repeatable, docker only)
18
19
  * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
19
20
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
20
21
  * --keep-user Keep isolated user after command completes (don't delete)
@@ -37,6 +38,7 @@
37
38
  */
38
39
 
39
40
  const { getDefaultDockerImage } = require('./docker-utils');
41
+ const dockerNetworkOptions = require('./docker-network-options');
40
42
  const { parseSequence, isSequence } = require('./sequence-parser');
41
43
 
42
44
  // Debug mode from environment
@@ -178,6 +180,9 @@ function parseArgs(args) {
178
180
  mounts: [], // Docker --mount specs, applied to docker levels
179
181
  env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
180
182
  privileged: false, // Run docker container in privileged mode
183
+ network: null, // First Docker network name (compatibility accessor)
184
+ networks: [], // Ordered Docker network names
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,68 @@
1
+ /** Docker multi-network lifecycle helpers. */
2
+
3
+ const { spawnSync } = require('child_process');
4
+
5
+ // Opt-in tracing, off by default: the multi-network attachment sequence
6
+ // (`docker create` -> `docker network connect` -> `docker start`) previously
7
+ // failed in CI with nothing but an exit code to go on (issue #158). Enable with
8
+ // START_DEBUG=1 to see every docker invocation, its status and its output.
9
+ const DEBUG =
10
+ process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
11
+
12
+ function debugLog(message) {
13
+ if (DEBUG) {
14
+ console.error(`[docker-network] ${message}`);
15
+ }
16
+ }
17
+
18
+ function getDockerNetworks(options = {}) {
19
+ return options.networks?.length
20
+ ? options.networks
21
+ : options.network
22
+ ? [options.network]
23
+ : [];
24
+ }
25
+
26
+ function runDockerCommand(args) {
27
+ debugLog(`$ docker ${args.join(' ')}`);
28
+ const result = spawnSync('docker', args, {
29
+ encoding: 'utf8',
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ if (result.error) {
33
+ debugLog(`spawn failed: ${result.error.message}`);
34
+ throw result.error;
35
+ }
36
+ debugLog(
37
+ `exit=${result.status} stdout=${JSON.stringify(
38
+ (result.stdout || '').trim()
39
+ )} stderr=${JSON.stringify((result.stderr || '').trim())}`
40
+ );
41
+ if (result.status !== 0) {
42
+ throw new Error(
43
+ result.stderr.trim() ||
44
+ result.stdout.trim() ||
45
+ `docker ${args[0]} exited with code ${result.status}`
46
+ );
47
+ }
48
+ return result.stdout.trim();
49
+ }
50
+
51
+ function connectAdditionalDockerNetworks(containerName, options = {}) {
52
+ const networks = getDockerNetworks(options);
53
+ debugLog(
54
+ `connecting ${containerName} to additional networks: ${
55
+ networks.slice(1).join(', ') || '<none>'
56
+ }`
57
+ );
58
+ for (const network of networks.slice(1)) {
59
+ runDockerCommand(['network', 'connect', network, containerName]);
60
+ }
61
+ }
62
+
63
+ module.exports = {
64
+ connectAdditionalDockerNetworks,
65
+ debugLog,
66
+ getDockerNetworks,
67
+ runDockerCommand,
68
+ };
@@ -0,0 +1,47 @@
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.networks.push(args[index + 1]);
12
+ options.network ??= args[index + 1];
13
+ } else {
14
+ options.networkAliases.push(args[index + 1]);
15
+ }
16
+ return 2;
17
+ }
18
+ if (arg.startsWith('--network=')) {
19
+ const network = arg.slice('--network='.length);
20
+ options.networks.push(network);
21
+ options.network ??= network;
22
+ return 1;
23
+ }
24
+ if (arg.startsWith('--network-alias=')) {
25
+ options.networkAliases.push(arg.slice('--network-alias='.length));
26
+ return 1;
27
+ }
28
+ return 0;
29
+ }
30
+
31
+ function validateDockerNetworkOptionsRequireDocker(options) {
32
+ if (options.network) {
33
+ throw new Error(
34
+ '--network option is only valid when isolation stack includes docker'
35
+ );
36
+ }
37
+ if (options.networkAliases?.length > 0) {
38
+ throw new Error(
39
+ '--network-alias option is only valid when isolation stack includes docker'
40
+ );
41
+ }
42
+ }
43
+
44
+ module.exports = {
45
+ parse: parseDockerNetworkOption,
46
+ validateRequireDocker: validateDockerNetworkOptionsRequireDocker,
47
+ };
@@ -4,6 +4,11 @@ const { execSync, spawn, spawnSync } = require('child_process');
4
4
  const path = require('path');
5
5
  const { generateSessionName } = require('./args-parser');
6
6
  const outputBlocks = require('./output-blocks');
7
+ const {
8
+ connectAdditionalDockerNetworks,
9
+ getDockerNetworks,
10
+ runDockerCommand,
11
+ } = require('./docker-network-lifecycle');
7
12
 
8
13
  // Debug mode from environment
9
14
  const DEBUG =
@@ -28,6 +33,7 @@ const {
28
33
  getDockerContainerCleanupInstructions,
29
34
  appendDockerContainerCleanupPolicyMessage,
30
35
  readDockerContainerOomKilled,
36
+ readDockerContainerStatus,
31
37
  removeDockerContainer,
32
38
  startDetachedDockerCompletionWatcher,
33
39
  spawnAttachedDocker,
@@ -499,9 +505,9 @@ const {
499
505
  /**
500
506
  * Build the docker run runtime argument list contributed by configurable
501
507
  * container options: privileged mode, environment variables, volumes/bind
502
- * mounts, and --mount specs. Returned in a stable order so they can be spliced
508
+ * mounts, --mount specs, and network configuration. Returned in a stable order so they can be spliced
503
509
  * into the `docker run` argv before the image name.
504
- * @param {object} options - Options (privileged, env, volumes, mounts)
510
+ * @param {object} options - Options (privileged, env, volumes, mounts, network, networkAliases)
505
511
  * @returns {string[]} Docker CLI arguments
506
512
  */
507
513
  function buildDockerRuntimeArgs(options = {}) {
@@ -518,6 +524,13 @@ function buildDockerRuntimeArgs(options = {}) {
518
524
  for (const mount of options.mounts || []) {
519
525
  args.push('--mount', mount);
520
526
  }
527
+ const [firstNetwork] = getDockerNetworks(options);
528
+ if (firstNetwork) {
529
+ args.push('--network', firstNetwork);
530
+ }
531
+ for (const alias of options.networkAliases || []) {
532
+ args.push('--network-alias', alias);
533
+ }
521
534
  return args;
522
535
  }
523
536
 
@@ -525,7 +538,7 @@ function buildDockerRuntimeArgs(options = {}) {
525
538
  * Build the human-readable `[Isolation]` status lines for docker runtime
526
539
  * options (volumes, mounts, env, privileged). Empty collections and a falsy
527
540
  * privileged flag contribute no lines.
528
- * @param {object} options - Options (volumes, mounts, env, privileged)
541
+ * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
529
542
  * @returns {string[]} Status lines for the start block / log header
530
543
  */
531
544
  function buildDockerRuntimeStatusLines(options = {}) {
@@ -542,22 +555,41 @@ function buildDockerRuntimeStatusLines(options = {}) {
542
555
  if (options.privileged) {
543
556
  lines.push(`[Isolation] Privileged: true`);
544
557
  }
558
+ const networks = getDockerNetworks(options);
559
+ if (networks.length > 0) {
560
+ lines.push(`[Isolation] Network: ${networks[0]}`);
561
+ }
562
+ if (networks.length > 1) {
563
+ lines.push(`[Isolation] Networks: ${networks.join(', ')}`);
564
+ }
565
+ if (options.networkAliases && options.networkAliases.length > 0) {
566
+ lines.push(
567
+ `[Isolation] Network aliases: ${options.networkAliases.join(', ')}`
568
+ );
569
+ }
545
570
  return lines;
546
571
  }
547
572
 
548
573
  /**
549
574
  * Build the execution-record metadata for docker runtime options, normalizing
550
575
  * empty collections and a falsy privileged flag to `null`.
551
- * @param {object} options - Options (volumes, mounts, env, privileged)
552
- * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean}}
576
+ * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
577
+ * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networks: ?string[], networkAliases: ?string[]}}
553
578
  */
554
579
  function buildDockerRuntimeMetadata(options = {}) {
580
+ const networks = getDockerNetworks(options);
555
581
  return {
556
582
  volumes:
557
583
  options.volumes && options.volumes.length > 0 ? options.volumes : null,
558
584
  mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
559
585
  env: options.env && options.env.length > 0 ? options.env : null,
560
586
  privileged: options.privileged || null,
587
+ network: networks[0] || null,
588
+ networks: networks.length > 0 ? networks : null,
589
+ networkAliases:
590
+ options.networkAliases && options.networkAliases.length > 0
591
+ ? options.networkAliases
592
+ : null,
561
593
  };
562
594
  }
563
595
 
@@ -597,6 +629,8 @@ function runInDocker(command, options = {}) {
597
629
  }
598
630
 
599
631
  const containerName = options.session || generateSessionName('docker');
632
+ const containerExistedBeforeLaunch =
633
+ readDockerContainerStatus(containerName) !== null;
600
634
  const cleanupPolicy = getDockerContainerCleanupPolicy(options);
601
635
  if (!dockerImageExists(options.image)) {
602
636
  // Pass logPath so the image-preparation phase (docker pull) is recorded in
@@ -622,8 +656,9 @@ function runInDocker(command, options = {}) {
622
656
  console.log();
623
657
 
624
658
  try {
659
+ const needsNetworkSetup = getDockerNetworks(options).length > 1;
625
660
  if (options.detached) {
626
- const dockerArgs = ['run', '-d'];
661
+ const dockerArgs = needsNetworkSetup ? ['create'] : ['run', '-d'];
627
662
  if (options.keepAlive) {
628
663
  dockerArgs.push('-i', '-t');
629
664
  }
@@ -671,11 +706,29 @@ function runInDocker(command, options = {}) {
671
706
  dockerResult.stderr.trim() ||
672
707
  dockerResult.stdout.trim() ||
673
708
  `docker exited with code ${dockerResult.status}`;
709
+ if (
710
+ !containerExistedBeforeLaunch &&
711
+ readDockerContainerStatus(containerName) === 'created'
712
+ ) {
713
+ removeDockerContainer(containerName, options.logPath);
714
+ }
674
715
  throw new Error(dockerError);
675
716
  }
676
717
 
677
718
  const containerId = dockerResult.stdout.trim();
678
719
 
720
+ if (needsNetworkSetup) {
721
+ try {
722
+ connectAdditionalDockerNetworks(containerName, options);
723
+ runDockerCommand(['start', containerName]);
724
+ } catch (error) {
725
+ if (!containerExistedBeforeLaunch) {
726
+ removeDockerContainer(containerName, options.logPath);
727
+ }
728
+ throw error;
729
+ }
730
+ }
731
+
679
732
  startDetachedDockerCompletionWatcher(
680
733
  containerName,
681
734
  cleanupPolicy,
@@ -707,7 +760,7 @@ function runInDocker(command, options = {}) {
707
760
  message,
708
761
  });
709
762
  } else {
710
- const dockerArgs = ['run'];
763
+ const dockerArgs = [needsNetworkSetup ? 'create' : 'run'];
711
764
  dockerArgs.push(hasTTY() ? '-it' : '-i');
712
765
  dockerArgs.push('--name', containerName);
713
766
  if (options.user) {
@@ -741,9 +794,24 @@ function runInDocker(command, options = {}) {
741
794
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
742
795
  }
743
796
 
797
+ if (needsNetworkSetup) {
798
+ try {
799
+ runDockerCommand(dockerArgs);
800
+ connectAdditionalDockerNetworks(containerName, options);
801
+ } catch (error) {
802
+ if (!containerExistedBeforeLaunch) {
803
+ removeDockerContainer(containerName, options.logPath);
804
+ }
805
+ throw error;
806
+ }
807
+ }
808
+
744
809
  return new Promise((resolve) => {
745
810
  const startTime = Date.now();
746
- const child = spawnAttachedDocker(dockerArgs, options.logPath);
811
+ const launchArgs = needsNetworkSetup
812
+ ? ['start', '-a', '-i', containerName]
813
+ : dockerArgs;
814
+ const child = spawnAttachedDocker(launchArgs, options.logPath);
747
815
 
748
816
  child.on('exit', (code) => {
749
817
  const durationMs = Date.now() - startTime;
@@ -760,8 +828,17 @@ function runInDocker(command, options = {}) {
760
828
  }
761
829
 
762
830
  const oomKilled = readDockerContainerOomKilled(containerName);
831
+ const launchFailed =
832
+ !containerExistedBeforeLaunch &&
833
+ readDockerContainerStatus(containerName) === 'created';
763
834
 
764
- if (
835
+ if (launchFailed) {
836
+ if (removeDockerContainer(containerName, options.logPath)) {
837
+ message += `\nContainer removed after launch failure.`;
838
+ } else {
839
+ message += `\nWarning: failed to remove container after launch failure.`;
840
+ }
841
+ } else if (
765
842
  shouldCleanupDockerContainer(cleanupPolicy, exitCode, oomKilled)
766
843
  ) {
767
844
  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 to a named network (repeatable, docker only)
18
+ --network-alias <alias> Add alias to the first network (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,61 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ checkCoverage,
5
+ parseLineCoverage,
6
+ } from '../../scripts/check-js-coverage.mjs';
7
+
8
+ // Verbatim Bun 1.3 text reporter output: the totals row carries no `%` suffix,
9
+ // which is why the previous CI grep never matched (issue #158).
10
+ const BUN_REPORT = `bun test v1.3.14 (0d9b296a)
11
+ -----------------|---------|---------|-------------------
12
+ File | % Funcs | % Lines | Uncovered Line #s
13
+ -----------------|---------|---------|-------------------
14
+ All files | 100.00 | 83.33 |
15
+ test/version.js | 100.00 | 83.33 | 38-43,103,105-108
16
+ -----------------|---------|---------|-------------------
17
+
18
+ 14 pass
19
+ 0 fail
20
+ `;
21
+
22
+ describe('check-js-coverage', () => {
23
+ it('parses line coverage from a real Bun text report', () => {
24
+ expect(parseLineCoverage(BUN_REPORT)).toBe(83.33);
25
+ });
26
+
27
+ it('reads the line column, not the function column', () => {
28
+ const report = 'All files | 92.60 | 88.09 |';
29
+ expect(parseLineCoverage(report)).toBe(88.09);
30
+ });
31
+
32
+ it('ignores the uncovered-lines column when picking numbers', () => {
33
+ const report = 'All files | 10.00 | 20.00 | 30-40,50';
34
+ expect(parseLineCoverage(report)).toBe(20);
35
+ });
36
+
37
+ it('returns null when no totals row is present', () => {
38
+ expect(parseLineCoverage('bun test v1.3.14\n 0 pass\n')).toBeNull();
39
+ expect(parseLineCoverage('')).toBeNull();
40
+ expect(parseLineCoverage(undefined)).toBeNull();
41
+ });
42
+
43
+ it('fails instead of skipping when coverage cannot be determined', () => {
44
+ const result = checkCoverage('no table here', 45);
45
+ expect(result.ok).toBe(false);
46
+ expect(result.coverage).toBeNull();
47
+ expect(result.message).toContain('Could not determine coverage');
48
+ });
49
+
50
+ it('passes when coverage meets the threshold', () => {
51
+ const result = checkCoverage(BUN_REPORT, 45);
52
+ expect(result.ok).toBe(true);
53
+ expect(result.coverage).toBe(83.33);
54
+ });
55
+
56
+ it('fails when coverage is below the threshold', () => {
57
+ const result = checkCoverage(BUN_REPORT, 90);
58
+ expect(result.ok).toBe(false);
59
+ expect(result.message).toContain('below the 90% threshold');
60
+ });
61
+ });
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Structural invariants for every GitHub Actions workflow (issue #158).
4
+ *
5
+ * These guard the CI/CD properties that silently regressed before: a job with
6
+ * no timeout, a workflow-level `concurrency` block that cancels a started
7
+ * release, a bare `always()` that keeps work running after cancellation, a
8
+ * missing aggregate status job that lets a red run look green, and coverage
9
+ * gates that swallow their own failures.
10
+ *
11
+ * The Rust suite mirrors these checks in `rust/tests/ci_workflow_invariants.rs`.
12
+ */
13
+
14
+ const { describe, it } = require('node:test');
15
+ const assert = require('assert');
16
+ const { readdirSync, readFileSync } = require('fs');
17
+ const { join, resolve } = require('path');
18
+
19
+ const workflowDir = resolve(__dirname, '..', '..', '.github', 'workflows');
20
+ const repoRoot = resolve(__dirname, '..', '..');
21
+
22
+ // Jobs that push commits, tags, releases or packages. They must share one
23
+ // repository-wide concurrency group so two writers never run at once.
24
+ const WRITER_JOBS = new Set([
25
+ 'release',
26
+ 'instant-release',
27
+ 'changeset-pr',
28
+ 'auto-release',
29
+ 'manual-release',
30
+ ]);
31
+ const MAIN_WRITER_GROUP = 'main-writer-${{ github.repository }}-main';
32
+
33
+ function listWorkflows() {
34
+ return readdirSync(workflowDir)
35
+ .filter((name) => /\.ya?ml$/.test(name))
36
+ .sort();
37
+ }
38
+
39
+ function readWorkflow(name) {
40
+ return readFileSync(join(workflowDir, name), 'utf8').replaceAll('\r\n', '\n');
41
+ }
42
+
43
+ /** Split a workflow into `{ name, body }` job blocks using indentation. */
44
+ function parseJobs(workflow) {
45
+ const lines = workflow.split('\n');
46
+ const jobsIndex = lines.findIndex((line) => line === 'jobs:');
47
+ assert.notStrictEqual(jobsIndex, -1, 'workflow has no jobs: block');
48
+
49
+ const jobs = [];
50
+ for (let index = jobsIndex + 1; index < lines.length; index++) {
51
+ const header = lines[index].match(/^ {2}([a-zA-Z0-9_-]+):\s*$/);
52
+ if (!header) {
53
+ continue;
54
+ }
55
+ const body = [];
56
+ for (let cursor = index + 1; cursor < lines.length; cursor++) {
57
+ const line = lines[cursor];
58
+ if (/^ {2}[a-zA-Z0-9_-]+:\s*$/.test(line) || /^[a-zA-Z]/.test(line)) {
59
+ break;
60
+ }
61
+ body.push(line);
62
+ }
63
+ jobs.push({ name: header[1], body: body.join('\n') });
64
+ }
65
+ return jobs;
66
+ }
67
+
68
+ /** Value of a key inside a job body, at any indentation depth. */
69
+ function jobKey(body, key, indent = 4) {
70
+ const match = body.match(new RegExp(`^ {${indent}}${key}:[ \\t]*(.*)$`, 'm'));
71
+ return match ? match[1].trim() : null;
72
+ }
73
+
74
+ /** Value of a key inside the job's `concurrency:` block. */
75
+ function concurrencyKey(body, key) {
76
+ return jobKey(body, key, 6);
77
+ }
78
+
79
+ const workflows = listWorkflows();
80
+
81
+ describe('CI workflow invariants', () => {
82
+ it('finds every workflow file', () => {
83
+ assert.ok(workflows.length >= 4, `only found ${workflows.join(', ')}`);
84
+ for (const expected of [
85
+ 'js.yml',
86
+ 'rust.yml',
87
+ 'security.yml',
88
+ 'links.yml',
89
+ ]) {
90
+ assert.ok(workflows.includes(expected), `missing ${expected}`);
91
+ }
92
+ });
93
+
94
+ it('declares a least-privilege default permission set', () => {
95
+ for (const name of workflows) {
96
+ const workflow = readWorkflow(name);
97
+ assert.match(
98
+ workflow,
99
+ /^permissions:\n {2}contents: read$/m,
100
+ `${name} must default to read-only contents permission`
101
+ );
102
+ }
103
+ });
104
+
105
+ it('gives every job a timeout', () => {
106
+ for (const name of workflows) {
107
+ for (const job of parseJobs(readWorkflow(name))) {
108
+ assert.ok(
109
+ jobKey(job.body, 'timeout-minutes'),
110
+ `${name}: job "${job.name}" has no timeout-minutes`
111
+ );
112
+ }
113
+ }
114
+ });
115
+
116
+ it('never puts concurrency at workflow level', () => {
117
+ for (const name of workflows) {
118
+ assert.doesNotMatch(
119
+ readWorkflow(name),
120
+ /^concurrency:$/m,
121
+ `${name}: workflow-level concurrency would also cancel started writers`
122
+ );
123
+ }
124
+ });
125
+
126
+ it('gives every job its own concurrency group', () => {
127
+ for (const name of workflows) {
128
+ for (const job of parseJobs(readWorkflow(name))) {
129
+ if (job.name === 'pipeline-status') {
130
+ continue;
131
+ }
132
+ const group = concurrencyKey(job.body, 'group');
133
+ assert.ok(group, `${name}: job "${job.name}" has no concurrency group`);
134
+ }
135
+ }
136
+ });
137
+
138
+ it('makes writers non-cancellable and read-only checks cancellable', () => {
139
+ for (const name of workflows) {
140
+ for (const job of parseJobs(readWorkflow(name))) {
141
+ const group = concurrencyKey(job.body, 'group');
142
+ if (!group) {
143
+ continue;
144
+ }
145
+ const cancel = concurrencyKey(job.body, 'cancel-in-progress');
146
+ if (WRITER_JOBS.has(job.name)) {
147
+ assert.strictEqual(
148
+ group,
149
+ MAIN_WRITER_GROUP,
150
+ `${name}: writer "${job.name}" must use the shared main-writer group`
151
+ );
152
+ assert.strictEqual(
153
+ cancel,
154
+ 'false',
155
+ `${name}: writer "${job.name}" must not be cancellable`
156
+ );
157
+ } else {
158
+ assert.ok(
159
+ group.startsWith('check-'),
160
+ `${name}: check "${job.name}" must use a check-* group, got ${group}`
161
+ );
162
+ assert.strictEqual(
163
+ cancel,
164
+ 'true',
165
+ `${name}: superseded check "${job.name}" should be cancelled`
166
+ );
167
+ }
168
+ }
169
+ }
170
+ });
171
+
172
+ it('uses !cancelled() rather than always() outside the status job', () => {
173
+ for (const name of workflows) {
174
+ for (const job of parseJobs(readWorkflow(name))) {
175
+ if (job.name === 'pipeline-status') {
176
+ continue;
177
+ }
178
+ assert.doesNotMatch(
179
+ job.body,
180
+ /always\(\)/,
181
+ `${name}: job "${job.name}" uses always(); use !cancelled() so cancellation propagates`
182
+ );
183
+ }
184
+ }
185
+ });
186
+
187
+ it('aggregates every job into a pipeline-status gate', () => {
188
+ for (const name of workflows) {
189
+ const jobs = parseJobs(readWorkflow(name));
190
+ const status = jobs.find((job) => job.name === 'pipeline-status');
191
+ assert.ok(status, `${name}: no pipeline-status job`);
192
+ assert.match(
193
+ status.body,
194
+ /if: always\(\)/,
195
+ `${name}: pipeline-status must run even when jobs are cancelled`
196
+ );
197
+ for (const job of jobs) {
198
+ if (job.name === 'pipeline-status') {
199
+ continue;
200
+ }
201
+ assert.match(
202
+ status.body,
203
+ new RegExp(`(^|[\\s\\[,])${job.name}([\\s\\],]|$)`, 'm'),
204
+ `${name}: pipeline-status does not depend on "${job.name}"`
205
+ );
206
+ }
207
+ }
208
+ });
209
+
210
+ it('configures git before checkout so no init hints are printed', () => {
211
+ for (const name of workflows) {
212
+ const workflow = readWorkflow(name);
213
+ assert.match(workflow, /GIT_CONFIG_COUNT: '1'/, `${name}`);
214
+ assert.match(
215
+ workflow,
216
+ /GIT_CONFIG_KEY_0: init\.defaultBranch/,
217
+ `${name}`
218
+ );
219
+ assert.match(workflow, /GIT_CONFIG_VALUE_0: main/, `${name}`);
220
+ }
221
+ });
222
+
223
+ it('never swallows a failing command in a quality gate', () => {
224
+ for (const name of workflows) {
225
+ const workflow = readWorkflow(name);
226
+ for (const [index, line] of workflow.split('\n').entries()) {
227
+ // `|| true` on a `grep` that is allowed to find nothing is fine; the
228
+ // regression was `... | tee coverage.txt || true`, which hid failing
229
+ // tests from the coverage job.
230
+ const hidesAGate = /\btee\b|\bbun run\b|\bcargo\b|\bnpm run\b/.test(
231
+ line
232
+ );
233
+ if (
234
+ line.includes('|| true') &&
235
+ hidesAGate &&
236
+ !line.trimStart().startsWith('#')
237
+ ) {
238
+ assert.fail(
239
+ `${name}:${index + 1} uses "|| true", which hides failures: ${line.trim()}`
240
+ );
241
+ }
242
+ }
243
+ }
244
+ });
245
+
246
+ it('references only helper scripts that exist', () => {
247
+ const pattern = /(?:bash|node|bun)\s+(scripts\/[A-Za-z0-9._/-]+)/g;
248
+ for (const name of workflows) {
249
+ const workflow = readWorkflow(name);
250
+ for (const match of workflow.matchAll(pattern)) {
251
+ const scriptPath = join(repoRoot, match[1]);
252
+ assert.doesNotThrow(
253
+ () => readFileSync(scriptPath),
254
+ `${name} references missing ${match[1]}`
255
+ );
256
+ }
257
+ }
258
+ });
259
+
260
+ it('parses coverage through the tested helper, not an inline grep', () => {
261
+ const js = readWorkflow('js.yml');
262
+ assert.doesNotMatch(
263
+ js,
264
+ /grep -oP '\\d\+\\\.\\d\+\(\?=%\)'/,
265
+ 'the inline coverage grep never matched Bun output (issue #158)'
266
+ );
267
+ assert.match(js, /scripts\/check-js-coverage\.mjs/);
268
+ assert.doesNotMatch(
269
+ js,
270
+ /Could not determine coverage, skipping check/,
271
+ 'an unparsable coverage report must fail, not skip'
272
+ );
273
+ });
274
+ });
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env bun
2
+ /** Real-daemon integration coverage for Docker network isolation (issues #154, #156, #158). */
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
+ // Bun's `node:test` shim ignores a `describe`-level timeout, so every `it`
11
+ // declares its own budget explicitly (issue #158).
12
+ const TEST_TIMEOUT = 120000;
13
+
14
+ const suffix = randomUUID().slice(0, 8);
15
+ const network = `start-network-${suffix}`;
16
+ const secondNetwork = `start-network-b-${suffix}`;
17
+ const sidecar = `start-sidecar-${suffix}`;
18
+ const secondSidecar = `start-sidecar-b-${suffix}`;
19
+ const connected = `start-connected-${suffix}`;
20
+ const control = `start-control-${suffix}`;
21
+ const missing = `start-missing-${suffix}`;
22
+ const missingAttached = `start-missing-attached-${suffix}`;
23
+ let dockerAvailable = false;
24
+
25
+ function docker(args) {
26
+ return spawnSync('docker', args, { encoding: 'utf8' });
27
+ }
28
+
29
+ /**
30
+ * Container output, folded into assertion messages so a CI failure carries the
31
+ * evidence needed to diagnose it instead of only reporting the exit code.
32
+ */
33
+ function containerDiagnostics(name) {
34
+ const logs = docker(['logs', name]);
35
+ const output = `${logs.stdout || ''}${logs.stderr || ''}`.trim();
36
+ return `\n--- docker logs ${name} ---\n${output || '<no output>'}\n---`;
37
+ }
38
+
39
+ function createInternalNetworkWithSidecar(networkName, containerName, alias) {
40
+ const created = docker(['network', 'create', '--internal', networkName]);
41
+ assert.strictEqual(created.status, 0, created.stderr);
42
+ const started = docker([
43
+ 'run',
44
+ '-d',
45
+ '--name',
46
+ containerName,
47
+ '--network',
48
+ networkName,
49
+ '--network-alias',
50
+ alias,
51
+ 'alpine:3.23',
52
+ 'sleep',
53
+ '300',
54
+ ]);
55
+ assert.strictEqual(started.status, 0, started.stderr);
56
+ }
57
+
58
+ describe('Docker named network integration', () => {
59
+ before(() => {
60
+ dockerAvailable =
61
+ process.platform === 'linux' && docker(['info']).status === 0;
62
+ if (!dockerAvailable) {
63
+ return;
64
+ }
65
+
66
+ createInternalNetworkWithSidecar(network, sidecar, 'formal-ai');
67
+ createInternalNetworkWithSidecar(secondNetwork, secondSidecar, 'formal-db');
68
+ });
69
+
70
+ after(() => {
71
+ if (!dockerAvailable) {
72
+ return;
73
+ }
74
+ docker([
75
+ 'rm',
76
+ '-f',
77
+ sidecar,
78
+ secondSidecar,
79
+ connected,
80
+ control,
81
+ missing,
82
+ missingAttached,
83
+ ]);
84
+ docker(['network', 'rm', network, secondNetwork]);
85
+ });
86
+
87
+ it(
88
+ 'reaches a private sidecar on each of two networks',
89
+ { timeout: TEST_TIMEOUT },
90
+ async () => {
91
+ if (!dockerAvailable) {
92
+ return;
93
+ }
94
+
95
+ // Both endpoints are local, user-defined networks on purpose: probing a
96
+ // public endpoint (previously `https://api.github.com`) made this test
97
+ // fail whenever the shared runner IP hit the 60 requests/hour
98
+ // unauthenticated GitHub API rate limit (issue #158).
99
+ const joined = await runInDocker(
100
+ 'ping -c 1 formal-ai && ping -c 1 formal-db',
101
+ {
102
+ image: 'alpine:3.23',
103
+ session: connected,
104
+ network,
105
+ networks: [network, secondNetwork],
106
+ detached: true,
107
+ shell: 'sh',
108
+ keepContainer: true,
109
+ }
110
+ );
111
+ assert.strictEqual(joined.success, true, joined.message);
112
+ const joinedExit = docker(['wait', connected]);
113
+ assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
114
+ assert.strictEqual(
115
+ joinedExit.stdout.trim(),
116
+ '0',
117
+ `container did not exit cleanly${containerDiagnostics(connected)}`
118
+ );
119
+
120
+ const unconnected = await runInDocker('ping -c 1 formal-ai', {
121
+ image: 'alpine:3.23',
122
+ session: control,
123
+ shell: 'sh',
124
+ alwaysCleanupContainer: true,
125
+ });
126
+ assert.strictEqual(unconnected.success, false, unconnected.message);
127
+ }
128
+ );
129
+
130
+ it(
131
+ 'fails for a missing network without orphaning a container',
132
+ { timeout: TEST_TIMEOUT },
133
+ async () => {
134
+ if (!dockerAvailable) {
135
+ return;
136
+ }
137
+
138
+ const result = await runInDocker('echo should-not-run', {
139
+ image: 'alpine:3.23',
140
+ session: missing,
141
+ network: `${network}-absent`,
142
+ detached: true,
143
+ shell: 'sh',
144
+ });
145
+ assert.strictEqual(result.success, false);
146
+ assert.match(result.message, /network .* not found/i);
147
+ assert.notStrictEqual(docker(['inspect', missing]).status, 0);
148
+
149
+ const missingSecond = await runInDocker('echo should-not-run', {
150
+ image: 'alpine:3.23',
151
+ session: missing,
152
+ network,
153
+ networks: [network, `${network}-absent`],
154
+ detached: true,
155
+ shell: 'sh',
156
+ });
157
+ assert.strictEqual(missingSecond.success, false);
158
+ assert.match(missingSecond.message, /network .* not found/i);
159
+ assert.notStrictEqual(docker(['inspect', missing]).status, 0);
160
+
161
+ const attachedResult = await runInDocker('echo should-not-run', {
162
+ image: 'alpine:3.23',
163
+ session: missingAttached,
164
+ network: `${network}-absent`,
165
+ shell: 'sh',
166
+ });
167
+ assert.strictEqual(attachedResult.success, false);
168
+ assert.notStrictEqual(docker(['inspect', missingAttached]).status, 0);
169
+
170
+ const conflict = await runInDocker('echo should-not-run', {
171
+ image: 'alpine:3.23',
172
+ session: sidecar,
173
+ network: `${network}-absent`,
174
+ detached: true,
175
+ shell: 'sh',
176
+ });
177
+ assert.strictEqual(conflict.success, false);
178
+ assert.strictEqual(docker(['inspect', sidecar]).status, 0);
179
+ }
180
+ );
181
+ });
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unit coverage for the Docker multi-network lifecycle helpers (issue #158).
4
+ *
5
+ * The verbose mode added here exists because the failing CI run gave nothing
6
+ * but an exit code for the `docker create` -> `network connect` -> `start`
7
+ * sequence. It must stay OFF unless START_DEBUG is explicitly set.
8
+ */
9
+
10
+ const { describe, it } = require('node:test');
11
+ const assert = require('assert');
12
+ const {
13
+ debugLog,
14
+ getDockerNetworks,
15
+ } = require('../src/lib/docker-network-lifecycle');
16
+
17
+ /** Capture everything written to console.error while `run` executes. */
18
+ function captureStderr(run) {
19
+ const original = console.error;
20
+ const lines = [];
21
+ console.error = (...args) => lines.push(args.join(' '));
22
+ try {
23
+ run();
24
+ } finally {
25
+ console.error = original;
26
+ }
27
+ return lines;
28
+ }
29
+
30
+ describe('docker network lifecycle helpers', () => {
31
+ it('returns an empty list when no network is requested', () => {
32
+ assert.deepStrictEqual(getDockerNetworks({}), []);
33
+ assert.deepStrictEqual(getDockerNetworks(), []);
34
+ });
35
+
36
+ it('wraps a single network into a list', () => {
37
+ assert.deepStrictEqual(getDockerNetworks({ network: 'bridge' }), [
38
+ 'bridge',
39
+ ]);
40
+ });
41
+
42
+ it('prefers the explicit networks list over the single network', () => {
43
+ assert.deepStrictEqual(
44
+ getDockerNetworks({ network: 'bridge', networks: ['a', 'b'] }),
45
+ ['a', 'b']
46
+ );
47
+ });
48
+
49
+ it('ignores an empty networks list and falls back to network', () => {
50
+ assert.deepStrictEqual(
51
+ getDockerNetworks({ network: 'bridge', networks: [] }),
52
+ ['bridge']
53
+ );
54
+ });
55
+
56
+ it('keeps verbose output switched off by default', () => {
57
+ const previous = process.env.START_DEBUG;
58
+ delete process.env.START_DEBUG;
59
+ try {
60
+ // The module reads START_DEBUG once at load time, and the test process
61
+ // never sets it, so nothing may be printed.
62
+ assert.deepStrictEqual(
63
+ captureStderr(() => debugLog('should not appear')),
64
+ []
65
+ );
66
+ } finally {
67
+ if (previous !== undefined) {
68
+ process.env.START_DEBUG = previous;
69
+ }
70
+ }
71
+ });
72
+ });
@@ -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,65 @@ describe('Docker runtime options parsing', () => {
90
91
  assert.strictEqual(result.wrapperOptions.privileged, true);
91
92
  });
92
93
 
94
+ it('should parse repeatable --network forms without consuming child args', () => {
95
+ const spaced = parseArgs([
96
+ '-i',
97
+ 'docker',
98
+ '--network',
99
+ 'hive-formal-ai',
100
+ '--network=public-egress',
101
+ '--',
102
+ '--network',
103
+ 'child-network',
104
+ ]);
105
+ const equals = parseArgs([
106
+ '-i',
107
+ 'docker',
108
+ '--network=hive-formal-ai',
109
+ '--',
110
+ 'echo',
111
+ 'ok',
112
+ ]);
113
+
114
+ assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai');
115
+ assert.deepStrictEqual(spaced.wrapperOptions.networks, [
116
+ 'hive-formal-ai',
117
+ 'public-egress',
118
+ ]);
119
+ assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']);
120
+ assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai');
121
+ assert.deepStrictEqual(equals.wrapperOptions.networks, ['hive-formal-ai']);
122
+ });
123
+
124
+ it('should parse repeatable --network-alias value and equals forms', () => {
125
+ const result = parseArgs([
126
+ '-i',
127
+ 'docker',
128
+ '--network',
129
+ 'hive-formal-ai',
130
+ '--network-alias',
131
+ 'formal-ai',
132
+ '--network-alias=checker',
133
+ '--',
134
+ 'echo',
135
+ 'ok',
136
+ ]);
137
+
138
+ assert.deepStrictEqual(result.wrapperOptions.networkAliases, [
139
+ 'formal-ai',
140
+ 'checker',
141
+ ]);
142
+ });
143
+
93
144
  it('should default runtime options to empty/false', () => {
94
145
  const result = parseArgs(['-i', 'docker', '--', 'ls']);
95
146
  assert.deepStrictEqual(result.wrapperOptions.volumes, []);
96
147
  assert.deepStrictEqual(result.wrapperOptions.mounts, []);
97
148
  assert.deepStrictEqual(result.wrapperOptions.env, []);
98
149
  assert.strictEqual(result.wrapperOptions.privileged, false);
150
+ assert.strictEqual(result.wrapperOptions.network, null);
151
+ assert.deepStrictEqual(result.wrapperOptions.networks, []);
152
+ assert.deepStrictEqual(result.wrapperOptions.networkAliases, []);
99
153
  });
100
154
 
101
155
  it('should throw when --volume requires an argument', () => {
@@ -136,6 +190,15 @@ describe('Docker runtime options validation', () => {
136
190
  }, /--privileged option is only valid when isolation stack includes docker/);
137
191
  });
138
192
 
193
+ it('should reject network options without docker', () => {
194
+ assert.throws(() => {
195
+ parseArgs(['-i', 'tmux', '--network', 'private', '--', 'ls']);
196
+ }, /--network option is only valid when isolation stack includes docker/);
197
+ assert.throws(() => {
198
+ parseArgs(['--network-alias', 'service', '--', 'ls']);
199
+ }, /--network-alias option is only valid when isolation stack includes docker/);
200
+ });
201
+
139
202
  it('should accept runtime options when stack includes docker', () => {
140
203
  const result = parseArgs([
141
204
  '-i',
@@ -171,6 +234,9 @@ describe('buildDockerRuntimeArgs', () => {
171
234
  env: ['FOO=bar', 'GH_TOKEN=secret'],
172
235
  volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
173
236
  mounts: ['type=bind,src=/h,dst=/c'],
237
+ network: 'hive-formal-ai',
238
+ networks: ['hive-formal-ai', 'public-egress'],
239
+ networkAliases: ['formal-ai', 'checker'],
174
240
  });
175
241
  assert.deepStrictEqual(args, [
176
242
  '--privileged',
@@ -184,6 +250,12 @@ describe('buildDockerRuntimeArgs', () => {
184
250
  '/h/b:/c/b:ro',
185
251
  '--mount',
186
252
  'type=bind,src=/h,dst=/c',
253
+ '--network',
254
+ 'hive-formal-ai',
255
+ '--network-alias',
256
+ 'formal-ai',
257
+ '--network-alias',
258
+ 'checker',
187
259
  ]);
188
260
  });
189
261
  });
@@ -199,12 +271,18 @@ describe('buildDockerRuntimeStatusLines', () => {
199
271
  mounts: ['type=bind,src=/h,dst=/c'],
200
272
  env: ['FOO=bar'],
201
273
  privileged: true,
274
+ network: 'hive-formal-ai',
275
+ networks: ['hive-formal-ai', 'public-egress'],
276
+ networkAliases: ['formal-ai', 'checker'],
202
277
  });
203
278
  assert.deepStrictEqual(lines, [
204
279
  '[Isolation] Volumes: /h:/c:ro',
205
280
  '[Isolation] Mounts: type=bind,src=/h,dst=/c',
206
281
  '[Isolation] Env: FOO=bar',
207
282
  '[Isolation] Privileged: true',
283
+ '[Isolation] Network: hive-formal-ai',
284
+ '[Isolation] Networks: hive-formal-ai, public-egress',
285
+ '[Isolation] Network aliases: formal-ai, checker',
208
286
  ]);
209
287
  });
210
288
 
@@ -223,6 +301,9 @@ describe('buildDockerRuntimeMetadata', () => {
223
301
  mounts: null,
224
302
  env: null,
225
303
  privileged: null,
304
+ network: null,
305
+ networks: null,
306
+ networkAliases: null,
226
307
  });
227
308
  });
228
309
 
@@ -233,12 +314,18 @@ describe('buildDockerRuntimeMetadata', () => {
233
314
  mounts: ['type=bind,src=/h,dst=/c'],
234
315
  env: ['FOO=bar'],
235
316
  privileged: true,
317
+ network: 'hive-formal-ai',
318
+ networks: ['hive-formal-ai', 'public-egress'],
319
+ networkAliases: ['formal-ai', 'checker'],
236
320
  }),
237
321
  {
238
322
  volumes: ['/h:/c'],
239
323
  mounts: ['type=bind,src=/h,dst=/c'],
240
324
  env: ['FOO=bar'],
241
325
  privileged: true,
326
+ network: 'hive-formal-ai',
327
+ networks: ['hive-formal-ai', 'public-egress'],
328
+ networkAliases: ['formal-ai', 'checker'],
242
329
  }
243
330
  );
244
331
  });