start-command 0.31.0 → 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,14 @@
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
+
3
12
  ## 0.31.0
4
13
 
5
14
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.31.0",
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": {
@@ -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,8 +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)
18
- * --network <name> Connect docker container to a named network (docker only)
19
- * --network-alias <alias> Add a network-scoped alias (repeatable, 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)
20
19
  * --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
21
20
  * --isolated-user, -u [username] Create isolated user with same permissions (auto-generated name if not specified)
22
21
  * --keep-user Keep isolated user after command completes (don't delete)
@@ -181,7 +180,8 @@ function parseArgs(args) {
181
180
  mounts: [], // Docker --mount specs, applied to docker levels
182
181
  env: [], // Docker environment variables (-e/--env, KEY=VALUE), applied to docker levels
183
182
  privileged: false, // Run docker container in privileged mode
184
- network: null, // Docker network name
183
+ network: null, // First Docker network name (compatibility accessor)
184
+ networks: [], // Ordered Docker network names
185
185
  networkAliases: [], // Docker network-scoped aliases
186
186
  endpoint: null, // SSH endpoint (current level, e.g., user@host)
187
187
  endpointStack: null, // SSH endpoints for each level (with nulls for non-ssh levels)
@@ -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
+ };
@@ -8,14 +8,17 @@ function parseDockerNetworkOption(args, index, options) {
8
8
  throw new Error(`Option ${arg} requires a ${value} argument`);
9
9
  }
10
10
  if (arg === '--network') {
11
- options.network = args[index + 1];
11
+ options.networks.push(args[index + 1]);
12
+ options.network ??= args[index + 1];
12
13
  } else {
13
14
  options.networkAliases.push(args[index + 1]);
14
15
  }
15
16
  return 2;
16
17
  }
17
18
  if (arg.startsWith('--network=')) {
18
- options.network = arg.slice('--network='.length);
19
+ const network = arg.slice('--network='.length);
20
+ options.networks.push(network);
21
+ options.network ??= network;
19
22
  return 1;
20
23
  }
21
24
  if (arg.startsWith('--network-alias=')) {
@@ -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 =
@@ -519,8 +524,9 @@ function buildDockerRuntimeArgs(options = {}) {
519
524
  for (const mount of options.mounts || []) {
520
525
  args.push('--mount', mount);
521
526
  }
522
- if (options.network) {
523
- args.push('--network', options.network);
527
+ const [firstNetwork] = getDockerNetworks(options);
528
+ if (firstNetwork) {
529
+ args.push('--network', firstNetwork);
524
530
  }
525
531
  for (const alias of options.networkAliases || []) {
526
532
  args.push('--network-alias', alias);
@@ -532,7 +538,7 @@ function buildDockerRuntimeArgs(options = {}) {
532
538
  * Build the human-readable `[Isolation]` status lines for docker runtime
533
539
  * options (volumes, mounts, env, privileged). Empty collections and a falsy
534
540
  * privileged flag contribute no lines.
535
- * @param {object} options - Options (volumes, mounts, env, privileged)
541
+ * @param {object} options - Options (volumes, mounts, env, privileged, network, networks, networkAliases)
536
542
  * @returns {string[]} Status lines for the start block / log header
537
543
  */
538
544
  function buildDockerRuntimeStatusLines(options = {}) {
@@ -549,8 +555,12 @@ function buildDockerRuntimeStatusLines(options = {}) {
549
555
  if (options.privileged) {
550
556
  lines.push(`[Isolation] Privileged: true`);
551
557
  }
552
- if (options.network) {
553
- lines.push(`[Isolation] Network: ${options.network}`);
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(', ')}`);
554
564
  }
555
565
  if (options.networkAliases && options.networkAliases.length > 0) {
556
566
  lines.push(
@@ -563,17 +573,19 @@ function buildDockerRuntimeStatusLines(options = {}) {
563
573
  /**
564
574
  * Build the execution-record metadata for docker runtime options, normalizing
565
575
  * empty collections and a falsy privileged flag to `null`.
566
- * @param {object} options - Options (volumes, mounts, env, privileged)
567
- * @returns {{volumes: ?string[], mounts: ?string[], env: ?string[], privileged: ?boolean, network: ?string, networkAliases: ?string[]}}
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[]}}
568
578
  */
569
579
  function buildDockerRuntimeMetadata(options = {}) {
580
+ const networks = getDockerNetworks(options);
570
581
  return {
571
582
  volumes:
572
583
  options.volumes && options.volumes.length > 0 ? options.volumes : null,
573
584
  mounts: options.mounts && options.mounts.length > 0 ? options.mounts : null,
574
585
  env: options.env && options.env.length > 0 ? options.env : null,
575
586
  privileged: options.privileged || null,
576
- network: options.network || null,
587
+ network: networks[0] || null,
588
+ networks: networks.length > 0 ? networks : null,
577
589
  networkAliases:
578
590
  options.networkAliases && options.networkAliases.length > 0
579
591
  ? options.networkAliases
@@ -644,8 +656,9 @@ function runInDocker(command, options = {}) {
644
656
  console.log();
645
657
 
646
658
  try {
659
+ const needsNetworkSetup = getDockerNetworks(options).length > 1;
647
660
  if (options.detached) {
648
- const dockerArgs = ['run', '-d'];
661
+ const dockerArgs = needsNetworkSetup ? ['create'] : ['run', '-d'];
649
662
  if (options.keepAlive) {
650
663
  dockerArgs.push('-i', '-t');
651
664
  }
@@ -704,6 +717,18 @@ function runInDocker(command, options = {}) {
704
717
 
705
718
  const containerId = dockerResult.stdout.trim();
706
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
+
707
732
  startDetachedDockerCompletionWatcher(
708
733
  containerName,
709
734
  cleanupPolicy,
@@ -735,7 +760,7 @@ function runInDocker(command, options = {}) {
735
760
  message,
736
761
  });
737
762
  } else {
738
- const dockerArgs = ['run'];
763
+ const dockerArgs = [needsNetworkSetup ? 'create' : 'run'];
739
764
  dockerArgs.push(hasTTY() ? '-it' : '-i');
740
765
  dockerArgs.push('--name', containerName);
741
766
  if (options.user) {
@@ -769,9 +794,24 @@ function runInDocker(command, options = {}) {
769
794
  console.log(`[DEBUG] Running: docker ${dockerArgs.join(' ')}`);
770
795
  }
771
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
+
772
809
  return new Promise((resolve) => {
773
810
  const startTime = Date.now();
774
- const child = spawnAttachedDocker(dockerArgs, options.logPath);
811
+ const launchArgs = needsNetworkSetup
812
+ ? ['start', '-a', '-i', containerName]
813
+ : dockerArgs;
814
+ const child = spawnAttachedDocker(launchArgs, options.logPath);
775
815
 
776
816
  child.on('exit', (code) => {
777
817
  const durationMs = Date.now() - startTime;
package/src/lib/usage.js CHANGED
@@ -14,8 +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
+ --network <name> Connect to a named network (repeatable, docker only)
18
+ --network-alias <alias> Add alias to the first network (repeatable, docker only)
19
19
  --endpoint <endpoint> SSH endpoint (required for ssh isolation, e.g., user@host)
20
20
  --isolated-user, -u [name] Create isolated user with same permissions
21
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
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- /** Real-daemon integration coverage for Docker network isolation (issue #154). */
2
+ /** Real-daemon integration coverage for Docker network isolation (issues #154, #156, #158). */
3
3
 
4
4
  const { after, before, describe, it } = require('node:test');
5
5
  const assert = require('assert');
@@ -7,9 +7,15 @@ const { spawnSync } = require('child_process');
7
7
  const { randomUUID } = require('crypto');
8
8
  const { runInDocker } = require('../src/lib/isolation');
9
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
+
10
14
  const suffix = randomUUID().slice(0, 8);
11
15
  const network = `start-network-${suffix}`;
16
+ const secondNetwork = `start-network-b-${suffix}`;
12
17
  const sidecar = `start-sidecar-${suffix}`;
18
+ const secondSidecar = `start-sidecar-b-${suffix}`;
13
19
  const connected = `start-connected-${suffix}`;
14
20
  const control = `start-control-${suffix}`;
15
21
  const missing = `start-missing-${suffix}`;
@@ -20,7 +26,36 @@ function docker(args) {
20
26
  return spawnSync('docker', args, { encoding: 'utf8' });
21
27
  }
22
28
 
23
- describe('Docker named network integration', { timeout: 60000 }, () => {
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', () => {
24
59
  before(() => {
25
60
  dockerAvailable =
26
61
  process.platform === 'linux' && docker(['info']).status === 0;
@@ -28,93 +63,119 @@ describe('Docker named network integration', { timeout: 60000 }, () => {
28
63
  return;
29
64
  }
30
65
 
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);
66
+ createInternalNetworkWithSidecar(network, sidecar, 'formal-ai');
67
+ createInternalNetworkWithSidecar(secondNetwork, secondSidecar, 'formal-db');
47
68
  });
48
69
 
49
70
  after(() => {
50
71
  if (!dockerAvailable) {
51
72
  return;
52
73
  }
53
- docker(['rm', '-f', sidecar, connected, control, missing, missingAttached]);
54
- docker(['network', 'rm', network]);
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]);
55
85
  });
56
86
 
57
- it('resolves an alias only from containers joined to the internal network', async () => {
58
- if (!dockerAvailable) {
59
- return;
60
- }
87
+ it(
88
+ 'reaches a private sidecar on each of two networks',
89
+ { timeout: TEST_TIMEOUT },
90
+ async () => {
91
+ if (!dockerAvailable) {
92
+ return;
93
+ }
61
94
 
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
- });
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
+ );
84
119
 
85
- it('fails for a missing network without orphaning a container', async () => {
86
- if (!dockerAvailable) {
87
- return;
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);
88
127
  }
128
+ );
89
129
 
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
- });
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
+ );
120
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
+ });
@@ -91,12 +91,13 @@ describe('Docker runtime options parsing', () => {
91
91
  assert.strictEqual(result.wrapperOptions.privileged, true);
92
92
  });
93
93
 
94
- it('should parse --network value and equals forms without consuming child args', () => {
94
+ it('should parse repeatable --network forms without consuming child args', () => {
95
95
  const spaced = parseArgs([
96
96
  '-i',
97
97
  'docker',
98
98
  '--network',
99
99
  'hive-formal-ai',
100
+ '--network=public-egress',
100
101
  '--',
101
102
  '--network',
102
103
  'child-network',
@@ -111,8 +112,13 @@ describe('Docker runtime options parsing', () => {
111
112
  ]);
112
113
 
113
114
  assert.strictEqual(spaced.wrapperOptions.network, 'hive-formal-ai');
115
+ assert.deepStrictEqual(spaced.wrapperOptions.networks, [
116
+ 'hive-formal-ai',
117
+ 'public-egress',
118
+ ]);
114
119
  assert.deepStrictEqual(spaced.rawCommand, ['--network', 'child-network']);
115
120
  assert.strictEqual(equals.wrapperOptions.network, 'hive-formal-ai');
121
+ assert.deepStrictEqual(equals.wrapperOptions.networks, ['hive-formal-ai']);
116
122
  });
117
123
 
118
124
  it('should parse repeatable --network-alias value and equals forms', () => {
@@ -142,6 +148,7 @@ describe('Docker runtime options parsing', () => {
142
148
  assert.deepStrictEqual(result.wrapperOptions.env, []);
143
149
  assert.strictEqual(result.wrapperOptions.privileged, false);
144
150
  assert.strictEqual(result.wrapperOptions.network, null);
151
+ assert.deepStrictEqual(result.wrapperOptions.networks, []);
145
152
  assert.deepStrictEqual(result.wrapperOptions.networkAliases, []);
146
153
  });
147
154
 
@@ -228,6 +235,7 @@ describe('buildDockerRuntimeArgs', () => {
228
235
  volumes: ['/h/a:/c/a', '/h/b:/c/b:ro'],
229
236
  mounts: ['type=bind,src=/h,dst=/c'],
230
237
  network: 'hive-formal-ai',
238
+ networks: ['hive-formal-ai', 'public-egress'],
231
239
  networkAliases: ['formal-ai', 'checker'],
232
240
  });
233
241
  assert.deepStrictEqual(args, [
@@ -264,6 +272,7 @@ describe('buildDockerRuntimeStatusLines', () => {
264
272
  env: ['FOO=bar'],
265
273
  privileged: true,
266
274
  network: 'hive-formal-ai',
275
+ networks: ['hive-formal-ai', 'public-egress'],
267
276
  networkAliases: ['formal-ai', 'checker'],
268
277
  });
269
278
  assert.deepStrictEqual(lines, [
@@ -272,6 +281,7 @@ describe('buildDockerRuntimeStatusLines', () => {
272
281
  '[Isolation] Env: FOO=bar',
273
282
  '[Isolation] Privileged: true',
274
283
  '[Isolation] Network: hive-formal-ai',
284
+ '[Isolation] Networks: hive-formal-ai, public-egress',
275
285
  '[Isolation] Network aliases: formal-ai, checker',
276
286
  ]);
277
287
  });
@@ -292,6 +302,7 @@ describe('buildDockerRuntimeMetadata', () => {
292
302
  env: null,
293
303
  privileged: null,
294
304
  network: null,
305
+ networks: null,
295
306
  networkAliases: null,
296
307
  });
297
308
  });
@@ -304,6 +315,7 @@ describe('buildDockerRuntimeMetadata', () => {
304
315
  env: ['FOO=bar'],
305
316
  privileged: true,
306
317
  network: 'hive-formal-ai',
318
+ networks: ['hive-formal-ai', 'public-egress'],
307
319
  networkAliases: ['formal-ai', 'checker'],
308
320
  }),
309
321
  {
@@ -312,6 +324,7 @@ describe('buildDockerRuntimeMetadata', () => {
312
324
  env: ['FOO=bar'],
313
325
  privileged: true,
314
326
  network: 'hive-formal-ai',
327
+ networks: ['hive-formal-ai', 'public-egress'],
315
328
  networkAliases: ['formal-ai', 'checker'],
316
329
  }
317
330
  );