start-command 0.32.0 → 0.33.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/docker-network-integration.js +22 -14
  30. package/test/echo-integration.js +20 -13
  31. package/test/execution-attach.js +221 -0
  32. package/test/execution-resume.js +433 -0
  33. package/test/execution-store.js +52 -0
  34. package/test/exit-reason.js +209 -0
  35. package/test/failure-handler.js +107 -0
  36. package/test/load-command-stream.mjs +83 -0
  37. package/test/regression-162.js +320 -0
  38. package/test/regression-164.js +308 -0
  39. package/test/regression-165.js +310 -0
  40. package/test/regression-84.js +8 -3
  41. package/test/release-name.mjs +57 -0
  42. package/test/session-probe.js +150 -0
  43. package/test/user-manager.js +23 -0
@@ -76,6 +76,57 @@ function concurrencyKey(body, key) {
76
76
  return jobKey(body, key, 6);
77
77
  }
78
78
 
79
+ /** Split a job body into individual `- ...` step blocks. */
80
+ function parseSteps(body) {
81
+ const lines = body.split('\n');
82
+ const steps = [];
83
+ let current = null;
84
+ let indent = 0;
85
+ for (const line of lines) {
86
+ const start = line.match(/^(\s*)-\s/);
87
+ if (start && (current === null || start[1].length === indent)) {
88
+ if (current) {
89
+ steps.push(current.join('\n'));
90
+ }
91
+ current = [line];
92
+ indent = start[1].length;
93
+ continue;
94
+ }
95
+ if (current) {
96
+ current.push(line);
97
+ }
98
+ }
99
+ if (current) {
100
+ steps.push(current.join('\n'));
101
+ }
102
+ return steps;
103
+ }
104
+
105
+ /** True when line `index` of `workflow` sits inside a `run:` block. */
106
+ function inRunBlock(workflow, index) {
107
+ const lines = workflow.split('\n');
108
+ const line = lines[index];
109
+ if (/^\s*(- )?run:/.test(line)) {
110
+ return true;
111
+ }
112
+ const indent = line.length - line.trimStart().length;
113
+ if (!line.trim()) {
114
+ return false;
115
+ }
116
+ for (let cursor = index - 1; cursor >= 0; cursor--) {
117
+ const previous = lines[cursor];
118
+ if (!previous.trim()) {
119
+ continue;
120
+ }
121
+ const previousIndent = previous.length - previous.trimStart().length;
122
+ if (previousIndent >= indent) {
123
+ continue;
124
+ }
125
+ return /^\s*(- )?run:\s*[|>]/.test(previous);
126
+ }
127
+ return false;
128
+ }
129
+
79
130
  const workflows = listWorkflows();
80
131
 
81
132
  describe('CI workflow invariants', () => {
@@ -257,6 +308,146 @@ describe('CI workflow invariants', () => {
257
308
  }
258
309
  });
259
310
 
311
+ // --- issue #168: false negatives the pipeline could not see -------------
312
+
313
+ it('lints and audits the workflows themselves', () => {
314
+ assert.ok(
315
+ workflows.includes('workflows.yml'),
316
+ 'no workflows.yml: nothing ran actionlint or zizmor over .github/workflows'
317
+ );
318
+ const meta = readWorkflow('workflows.yml');
319
+ assert.match(
320
+ meta,
321
+ /rhysd\/actionlint/,
322
+ 'workflows.yml must run actionlint'
323
+ );
324
+ assert.match(meta, /zizmor/, 'workflows.yml must run zizmor');
325
+ for (const config of ['zizmor.yml', 'actionlint.yaml']) {
326
+ assert.doesNotThrow(
327
+ () => readFileSync(join(repoRoot, '.github', config)),
328
+ `missing .github/${config}`
329
+ );
330
+ }
331
+ });
332
+
333
+ it('never interpolates untrusted context into a run: block', () => {
334
+ // actionlint/zizmor call this template-injection: the value is attacker
335
+ // controlled on a fork PR and is pasted into the shell verbatim.
336
+ const untrusted =
337
+ /\$\{\{\s*github\.(head_ref|base_ref|event\.[A-Za-z0-9_.]*(title|body|name|label|ref))/;
338
+ for (const name of workflows) {
339
+ for (const [index, line] of readWorkflow(name).split('\n').entries()) {
340
+ if (line.trimStart().startsWith('#')) {
341
+ continue;
342
+ }
343
+ if (inRunBlock(readWorkflow(name), index) && untrusted.test(line)) {
344
+ assert.fail(
345
+ `${name}:${index + 1} interpolates untrusted context into run:; pass it through env: instead - ${line.trim()}`
346
+ );
347
+ }
348
+ }
349
+ }
350
+ });
351
+
352
+ it('does not persist credentials on read-only checkouts', () => {
353
+ for (const name of workflows) {
354
+ for (const job of parseJobs(readWorkflow(name))) {
355
+ if (WRITER_JOBS.has(job.name)) {
356
+ continue;
357
+ }
358
+ for (const step of parseSteps(job.body)) {
359
+ if (!/uses:\s*actions\/checkout/.test(step)) {
360
+ continue;
361
+ }
362
+ assert.match(
363
+ step,
364
+ /persist-credentials:\s*false/,
365
+ `${name}: read-only job "${job.name}" checks out with the token left in .git/config`
366
+ );
367
+ }
368
+ }
369
+ }
370
+ });
371
+
372
+ it('pins third-party actions to a commit hash', () => {
373
+ // Trusted first-party namespaces may use a moving tag; anything else is a
374
+ // mutable ref in a job that can hold write credentials.
375
+ const trusted =
376
+ /^(actions|github|docker|astral-sh|lycheeverse|zizmorcore|changesets)\//;
377
+ for (const name of workflows) {
378
+ const workflow = readWorkflow(name);
379
+ for (const match of workflow.matchAll(/uses:\s*(\S+)/g)) {
380
+ const reference = match[1];
381
+ if (reference.startsWith('docker://') || reference.startsWith('./')) {
382
+ continue;
383
+ }
384
+ if (trusted.test(reference)) {
385
+ continue;
386
+ }
387
+ assert.match(
388
+ reference,
389
+ /@[0-9a-f]{40}$/,
390
+ `${name}: third-party action ${reference} must be pinned to a commit hash`
391
+ );
392
+ }
393
+ }
394
+ });
395
+
396
+ it('audits both dependency graphs for advisories', () => {
397
+ const security = readWorkflow('security.yml');
398
+ const jobs = parseJobs(security).map((job) => job.name);
399
+ for (const job of ['cargo-audit', 'npm-audit']) {
400
+ assert.ok(jobs.includes(job), `security.yml has no ${job} job`);
401
+ }
402
+ assert.match(
403
+ security,
404
+ /cargo audit/,
405
+ 'cargo-audit job must run cargo audit'
406
+ );
407
+ assert.match(security, /npm audit/, 'npm-audit job must run npm audit');
408
+ });
409
+
410
+ it('lints the repository-level scripts/ directory', () => {
411
+ // js/eslint.config.mjs has js/ as its base path, so `eslint .` run from js/
412
+ // silently skipped scripts/ - the release automation (issue #168).
413
+ assert.doesNotThrow(
414
+ () => readFileSync(join(repoRoot, 'eslint.config.mjs')),
415
+ 'no repository-level eslint.config.mjs: scripts/ would go unlinted'
416
+ );
417
+ const js = readWorkflow('js.yml');
418
+ assert.match(js, /lint:scripts/, 'js.yml must lint scripts/');
419
+ assert.match(
420
+ js,
421
+ /format:check:scripts/,
422
+ 'js.yml must format-check scripts/'
423
+ );
424
+ });
425
+
426
+ it('scopes CodeQL to this project\u2019s own code', () => {
427
+ // Without this, CodeQL extracts the third-party bundles archived under
428
+ // dev/log/ as evidence and fails pull requests on other projects'
429
+ // findings (issue #168: js/redos in the vendored use-m snapshot).
430
+ const security = readWorkflow('security.yml');
431
+ const codeqlJob = parseJobs(security).find((job) => job.name === 'codeql');
432
+ assert.ok(codeqlJob, 'security.yml has no codeql job');
433
+ const configPath = jobKey(codeqlJob.body, 'config-file', 10);
434
+ assert.ok(
435
+ configPath,
436
+ 'the CodeQL init step must pass config-file, or archived evidence is analysed'
437
+ );
438
+ const config = readFileSync(join(repoRoot, configPath), 'utf8');
439
+ assert.match(
440
+ config,
441
+ /^paths-ignore:/m,
442
+ `${configPath} has no paths-ignore`
443
+ );
444
+ assert.match(
445
+ config,
446
+ /^\s*-\s*dev\/log\s*$/m,
447
+ `${configPath} must exclude dev/log`
448
+ );
449
+ });
450
+
260
451
  it('parses coverage through the tested helper, not an inline grep', () => {
261
452
  const js = readWorkflow('js.yml');
262
453
  assert.doesNotMatch(
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- /** Real-daemon integration coverage for Docker network isolation (issues #154, #156, #158). */
2
+ /** Real-daemon integration coverage for Docker network isolation (issues #154, #156, #158, #160). */
3
3
 
4
4
  const { after, before, describe, it } = require('node:test');
5
5
  const assert = require('assert');
@@ -10,6 +10,8 @@ const { runInDocker } = require('../src/lib/isolation');
10
10
  // Bun's `node:test` shim ignores a `describe`-level timeout, so every `it`
11
11
  // declares its own budget explicitly (issue #158).
12
12
  const TEST_TIMEOUT = 120000;
13
+ const MULTI_NETWORK_PROBE =
14
+ "ping -c 1 formal-ai && ping -c 1 formal-db && ip route | grep -q '^default '";
13
15
 
14
16
  const suffix = randomUUID().slice(0, 8);
15
17
  const network = `start-network-${suffix}`;
@@ -84,6 +86,13 @@ describe('Docker named network integration', () => {
84
86
  docker(['network', 'rm', network, secondNetwork]);
85
87
  });
86
88
 
89
+ it('keeps the multi-network probe hermetic', () => {
90
+ assert.doesNotMatch(MULTI_NETWORK_PROBE, /https?:\/\//);
91
+ assert.match(MULTI_NETWORK_PROBE, /formal-ai/);
92
+ assert.match(MULTI_NETWORK_PROBE, /formal-db/);
93
+ assert.match(MULTI_NETWORK_PROBE, /ip route/);
94
+ });
95
+
87
96
  it(
88
97
  'reaches a private sidecar on each of two networks',
89
98
  { timeout: TEST_TIMEOUT },
@@ -95,19 +104,18 @@ describe('Docker named network integration', () => {
95
104
  // Both endpoints are local, user-defined networks on purpose: probing a
96
105
  // public endpoint (previously `https://api.github.com`) made this test
97
106
  // 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
- );
107
+ // unauthenticated GitHub API rate limit. Checking the route table keeps
108
+ // the original assertion that the default bridge route survived without
109
+ // making the test depend on any internet service (issues #158, #160).
110
+ const joined = await runInDocker(MULTI_NETWORK_PROBE, {
111
+ image: 'alpine:3.23',
112
+ session: connected,
113
+ network: 'bridge',
114
+ networks: ['bridge', network, secondNetwork],
115
+ detached: true,
116
+ shell: 'sh',
117
+ keepContainer: true,
118
+ });
111
119
  assert.strictEqual(joined.success, true, joined.message);
112
120
  const joinedExit = docker(['wait', connected]);
113
121
  assert.strictEqual(joinedExit.status, 0, joinedExit.stderr);
@@ -19,22 +19,27 @@
19
19
 
20
20
  const { describe, it } = require('node:test');
21
21
  const assert = require('assert');
22
- const { execSync } = require('child_process');
22
+ const { execSync, spawnSync } = require('child_process');
23
23
  const path = require('path');
24
24
  const {
25
25
  isCommandAvailable,
26
26
  canRunLinuxDockerImages,
27
27
  hasTTY,
28
28
  } = require('../src/lib/isolation');
29
+ const { toShellWords } = require('../src/lib/shell-utils');
29
30
 
30
31
  // Path to the CLI
31
32
  const CLI_PATH = path.join(__dirname, '..', 'src', 'bin', 'cli.js');
32
33
 
33
- // Helper function to run the CLI and capture output
34
+ // Helper function to run the CLI and capture output.
35
+ // The arguments are split here instead of being handed to a host shell, so the
36
+ // CLI receives the same argv on every platform (issue #164).
34
37
  function runCli(args, options = {}) {
35
38
  const timeout = options.timeout || 30000;
36
- try {
37
- const result = execSync(`bun run ${CLI_PATH} ${args}`, {
39
+ const result = spawnSync(
40
+ process.execPath,
41
+ ['run', CLI_PATH, ...toShellWords(args)],
42
+ {
38
43
  encoding: 'utf8',
39
44
  timeout,
40
45
  env: {
@@ -43,16 +48,18 @@ function runCli(args, options = {}) {
43
48
  START_DISABLE_TRACKING: '1',
44
49
  },
45
50
  maxBuffer: 1024 * 1024, // 1MB
46
- });
47
- return { success: true, output: result };
48
- } catch (err) {
49
- return {
50
- success: false,
51
- output: err.stdout || '',
52
- stderr: err.stderr || '',
53
- exitCode: err.status,
54
- };
51
+ }
52
+ );
53
+ const output = result.stdout || '';
54
+ if (result.status === 0) {
55
+ return { success: true, output };
55
56
  }
57
+ return {
58
+ success: false,
59
+ output,
60
+ stderr: result.stderr || '',
61
+ exitCode: result.status,
62
+ };
56
63
  }
57
64
 
58
65
  // Verify output contains expected structure for attached modes (shows finish block)
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Tests for --attach (issue #162)
3
+ */
4
+
5
+ const { test, describe } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const {
8
+ buildAttachPlan,
9
+ attachExecution,
10
+ formatAttachResultAsLinksNotation,
11
+ } = require('../src/lib/execution-attach');
12
+ const { SessionState } = require('../src/lib/session-probe');
13
+
14
+ function makeRecord(options = {}, extra = {}) {
15
+ return {
16
+ uuid: '11111111-2222-3333-4444-555555555555',
17
+ status: 'executing',
18
+ logPath: '/tmp/session.log',
19
+ options: { isolationMode: 'detached', ...options },
20
+ ...extra,
21
+ };
22
+ }
23
+
24
+ const runningProbe = (backend, sessionName) => ({
25
+ backend,
26
+ sessionName,
27
+ state: SessionState.RUNNING,
28
+ alive: true,
29
+ containerStatus: backend === 'docker' ? 'running' : null,
30
+ });
31
+
32
+ describe('buildAttachPlan', () => {
33
+ test('attaches to a running docker container', () => {
34
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
35
+ const plan = buildAttachPlan(record, {
36
+ probe: runningProbe('docker', 'box'),
37
+ });
38
+ assert.strictEqual(plan.error, undefined);
39
+ assert.strictEqual(plan.command, 'docker');
40
+ assert.deepStrictEqual(plan.args, ['attach', 'box']);
41
+ assert.strictEqual(plan.interactive, true);
42
+ assert.strictEqual(plan.method, 'DOCKER_ATTACH');
43
+ });
44
+
45
+ test('follows docker logs in read-only mode', () => {
46
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
47
+ const plan = buildAttachPlan(record, {
48
+ readOnly: true,
49
+ probe: runningProbe('docker', 'box'),
50
+ });
51
+ assert.deepStrictEqual(plan.args, ['logs', '-f', 'box']);
52
+ assert.strictEqual(plan.interactive, false);
53
+ assert.strictEqual(plan.method, 'DOCKER_LOG_FOLLOW');
54
+ });
55
+
56
+ test('attaches to a running screen session', () => {
57
+ const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
58
+ const plan = buildAttachPlan(record, {
59
+ probe: runningProbe('screen', 'work'),
60
+ });
61
+ assert.strictEqual(plan.command, 'screen');
62
+ assert.deepStrictEqual(plan.args, ['-r', 'work']);
63
+ assert.strictEqual(plan.interactive, true);
64
+ });
65
+
66
+ test('follows the stored log for a read-only screen attach', () => {
67
+ const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
68
+ const plan = buildAttachPlan(record, {
69
+ readOnly: true,
70
+ probe: runningProbe('screen', 'work'),
71
+ });
72
+ assert.strictEqual(plan.command, 'tail');
73
+ assert.deepStrictEqual(plan.args, ['-f', '/tmp/session.log']);
74
+ assert.strictEqual(plan.method, 'LOG_FOLLOW');
75
+ });
76
+
77
+ test('uses tmux read-only attach for --read-only', () => {
78
+ const record = makeRecord({ isolated: 'tmux', sessionName: 'work' });
79
+ const plan = buildAttachPlan(record, {
80
+ probe: runningProbe('tmux', 'work'),
81
+ });
82
+ assert.deepStrictEqual(plan.args, ['attach-session', '-t', 'work']);
83
+ const readOnlyPlan = buildAttachPlan(record, {
84
+ readOnly: true,
85
+ probe: runningProbe('tmux', 'work'),
86
+ });
87
+ assert.deepStrictEqual(readOnlyPlan.args, [
88
+ 'attach-session',
89
+ '-r',
90
+ '-t',
91
+ 'work',
92
+ ]);
93
+ assert.strictEqual(readOnlyPlan.interactive, true);
94
+ });
95
+
96
+ test('follows the log for ssh sessions', () => {
97
+ const record = makeRecord({ isolated: 'ssh', sessionName: 'remote' });
98
+ const plan = buildAttachPlan(record, {
99
+ probe: {
100
+ backend: 'ssh',
101
+ sessionName: 'remote',
102
+ state: SessionState.UNKNOWN,
103
+ alive: false,
104
+ },
105
+ });
106
+ assert.strictEqual(plan.command, 'tail');
107
+ assert.strictEqual(plan.method, 'LOG_FOLLOW');
108
+ });
109
+
110
+ test('points at --resume when the session is not running', () => {
111
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
112
+ const plan = buildAttachPlan(record, {
113
+ probe: {
114
+ backend: 'docker',
115
+ sessionName: 'box',
116
+ state: SessionState.STOPPED,
117
+ alive: false,
118
+ containerStatus: 'exited',
119
+ },
120
+ });
121
+ assert.match(plan.error, /is not running/);
122
+ assert.match(plan.error, /--resume/);
123
+ });
124
+
125
+ test('reports a removed container distinctly', () => {
126
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
127
+ const plan = buildAttachPlan(record, {
128
+ probe: {
129
+ backend: 'docker',
130
+ sessionName: 'box',
131
+ state: SessionState.MISSING,
132
+ alive: false,
133
+ },
134
+ });
135
+ assert.match(plan.error, /no longer exists/);
136
+ });
137
+
138
+ test('rejects records without a session name', () => {
139
+ const plan = buildAttachPlan(makeRecord({ isolated: 'docker' }), {
140
+ probe: { state: SessionState.UNKNOWN, alive: false },
141
+ });
142
+ assert.match(plan.error, /does not contain an isolation session name/);
143
+ });
144
+
145
+ test('rejects attached (non-detached) executions', () => {
146
+ const record = makeRecord({
147
+ isolated: 'docker',
148
+ sessionName: 'box',
149
+ isolationMode: 'attached',
150
+ });
151
+ const plan = buildAttachPlan(record, {
152
+ probe: runningProbe('docker', 'box'),
153
+ });
154
+ assert.match(plan.error, /Only detached isolated executions/);
155
+ });
156
+ });
157
+
158
+ describe('attachExecution', () => {
159
+ function makeStore(record) {
160
+ return { get: () => record };
161
+ }
162
+
163
+ test('reports a missing execution', () => {
164
+ const result = attachExecution({ get: () => null }, 'nope', {});
165
+ assert.strictEqual(result.success, false);
166
+ assert.match(result.error, /No execution found/);
167
+ });
168
+
169
+ test('reports disabled tracking', () => {
170
+ const result = attachExecution(null, 'nope', {});
171
+ assert.strictEqual(result.success, false);
172
+ assert.match(result.error, /Execution tracking is disabled/);
173
+ });
174
+
175
+ test('runs the interactive attach command', () => {
176
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
177
+ const calls = [];
178
+ const result = attachExecution(makeStore(record), 'box', {
179
+ probe: () => runningProbe('docker', 'box'),
180
+ interactiveRunner: (command, args) => {
181
+ calls.push([command, ...args]);
182
+ return { success: true, status: 0 };
183
+ },
184
+ });
185
+ assert.strictEqual(result.success, true);
186
+ assert.deepStrictEqual(calls, [['docker', 'attach', 'box']]);
187
+ assert.match(result.output, /attach/);
188
+ });
189
+
190
+ test('surfaces plan errors', () => {
191
+ const record = makeRecord({ isolated: 'docker', sessionName: 'box' });
192
+ const result = attachExecution(makeStore(record), 'box', {
193
+ probe: () => ({
194
+ backend: 'docker',
195
+ sessionName: 'box',
196
+ state: SessionState.STOPPED,
197
+ alive: false,
198
+ }),
199
+ });
200
+ assert.strictEqual(result.success, false);
201
+ assert.match(result.error, /--resume/);
202
+ });
203
+ });
204
+
205
+ describe('formatAttachResultAsLinksNotation', () => {
206
+ test('emits a nested links-notation block', () => {
207
+ const output = formatAttachResultAsLinksNotation({
208
+ identifier: 'box',
209
+ uuid: 'u-1',
210
+ backend: 'docker',
211
+ sessionName: 'box',
212
+ method: 'DOCKER_ATTACH',
213
+ readOnly: false,
214
+ command: 'docker attach box',
215
+ message: 'Attaching to detached docker container: box',
216
+ });
217
+ assert.match(output, /^executionAttach\n/);
218
+ assert.match(output, /\n {2}method DOCKER_ATTACH\n/);
219
+ assert.match(output, /\n {2}readOnly false\n/);
220
+ });
221
+ });