browser-commander 0.31.2 → 0.32.1

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.
@@ -90,6 +90,7 @@ function environment(overrides) {
90
90
  NPM_REGISTRY: `${stub.origin}/npm`,
91
91
  CRATES_API: `${stub.origin}/crates`,
92
92
  NPM_PACKAGE: '@link-foundation/probe',
93
+ CRATES_PACKAGE: 'probe-crate',
93
94
  CARGO_REGISTRY_TOKEN: '',
94
95
  CARGO_TOKEN: '',
95
96
  GITHUB_STEP_SUMMARY: '',
@@ -133,6 +134,13 @@ const EXCHANGE =
133
134
  'POST /npm/-/npm/v1/oidc/token/exchange/package/@link-foundation%2Fprobe';
134
135
  const CRATES_MINT = 'POST /crates/api/v1/trusted_publishing/tokens';
135
136
  const CRATES_REVOKE = 'DELETE /crates/api/v1/trusted_publishing/tokens';
137
+ const CRATES_PUBLISH = 'PUT /crates/api/v1/crates/new';
138
+ // What crates.io answers to an authorised publish that omits the archive.
139
+ const TOKEN_ACCEPTED = [
140
+ 400,
141
+ { errors: [{ detail: 'invalid tarball length' }] },
142
+ ];
143
+ const TOKEN_REJECTED = [403, { errors: [{ detail: 'authentication failed' }] }];
136
144
  const INVALID_PUBLISHER = [
137
145
  422,
138
146
  {
@@ -157,6 +165,33 @@ function probeCrates(answer, overrides = {}) {
157
165
  return preflight({ PREFLIGHT_REGISTRIES: 'crates', ...overrides });
158
166
  }
159
167
 
168
+ /**
169
+ * Refuse the crates.io OIDC exchange and answer the token publish probe with
170
+ * `answer`, so the outcome rests on the token fallback.
171
+ */
172
+ function probeFallback(answer, overrides = {}) {
173
+ stub.routes[CRATES_PUBLISH] = answer;
174
+ return probeCrates([403, {}], { CARGO_TOKEN: CARGO_SECRET, ...overrides });
175
+ }
176
+
177
+ /** Run the crates.io probe in a job without `id-token: write`. */
178
+ function preflightWithoutOidc(overrides = {}) {
179
+ return preflight({
180
+ PREFLIGHT_REGISTRIES: 'crates',
181
+ ACTIONS_ID_TOKEN_REQUEST_URL: '',
182
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
183
+ ...overrides,
184
+ });
185
+ }
186
+
187
+ /** The metadata of a token publish probe: a u32 length, then JSON. */
188
+ function probeMetadata(body) {
189
+ const bytes = Buffer.from(body, 'latin1');
190
+ const length = bytes.readUInt32LE(0);
191
+ assert.equal(bytes.length, 4 + length, 'the probe must carry no archive');
192
+ return JSON.parse(bytes.subarray(4).toString('utf8'));
193
+ }
194
+
160
195
  describe('scripts/preflight-credentials.sh', scriptOptions, () => {
161
196
  before(async () => {
162
197
  stub.server = createServer(respond);
@@ -349,11 +384,11 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
349
384
  assertNoSecrets(output);
350
385
  });
351
386
 
352
- it('fails a release when no trusted publisher matches, even with a legacy secret', async () => {
353
- const { status, output } = await probeCrates(
354
- [403, { errors: [{ detail: 'no matching publisher' }] }],
355
- { CARGO_TOKEN: CARGO_SECRET }
356
- );
387
+ it('fails a release when no trusted publisher matches and no token is set', async () => {
388
+ const { status, output } = await probeCrates([
389
+ 403,
390
+ { errors: [{ detail: 'no matching publisher' }] },
391
+ ]);
357
392
 
358
393
  assert.equal(status, 1, output);
359
394
  assert.match(
@@ -362,18 +397,80 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
362
397
  );
363
398
  assert.match(output, /rust\.yml/);
364
399
  assert.match(output, /docs\/PUBLISHING\.md/);
400
+ assert.match(output, /neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN/);
365
401
  assert.equal(stub.requests.length, 2);
402
+ });
403
+
404
+ it('passes a release on the token fallback when no trusted publisher matches', async () => {
405
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED);
406
+
407
+ assert.equal(status, 0, output);
408
+ assert.match(output, /1 verified, 0 failed, 0 unknown/);
409
+ assert.match(
410
+ output,
411
+ /PASS: crates.io accepted the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN fallback for probe-crate/
412
+ );
413
+ assert.match(
414
+ output,
415
+ /::warning::release-preflight: crates.io refused the OIDC token exchange \(403\).*falls back to the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN secret/
416
+ );
417
+ assert.doesNotMatch(output, /::error::/);
418
+ const publish = stub.requests.at(-1);
419
+ assert.equal(publish.route, CRATES_PUBLISH);
420
+ assert.equal(publish.authorization, CARGO_SECRET);
421
+ assert.match(publish.userAgent, /^release-preflight /);
422
+ assert.deepEqual(probeMetadata(publish.body), {
423
+ name: 'probe-crate',
424
+ vers: '0.0.0-preflight',
425
+ });
426
+ assert.equal(stub.requests.length, 3);
366
427
  assertNoSecrets(output);
367
428
  });
368
429
 
369
- it('fails when the job has no OIDC permission, with no secret fallback', async () => {
370
- const { status, output } = await preflight({
371
- PREFLIGHT_REGISTRIES: 'crates',
372
- ACTIONS_ID_TOKEN_REQUEST_URL: '',
373
- ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
430
+ it('prefers CARGO_REGISTRY_TOKEN over CARGO_TOKEN for the fallback', async () => {
431
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
432
+ CARGO_REGISTRY_TOKEN: CARGO_SECRET,
433
+ CARGO_TOKEN: 'cio-other-token-must-not-be-used',
434
+ });
435
+
436
+ assert.equal(status, 0, output);
437
+ assert.equal(stub.requests.at(-1).authorization, CARGO_SECRET);
438
+ });
439
+
440
+ it('fails a release when trusted publishing and the token fallback are both refused', async () => {
441
+ const { status, output } = await probeFallback(TOKEN_REJECTED);
442
+
443
+ assert.equal(status, 1, output);
444
+ assert.match(output, /0 verified, 1 failed, 0 unknown/);
445
+ assert.match(
446
+ output,
447
+ /::error::release-preflight: crates.io refused the OIDC token exchange \(403\).*; crates.io rejected the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN fallback for probe-crate \(403\)/
448
+ );
449
+ assert.equal(stub.requests.length, 3);
450
+ assertNoSecrets(output);
451
+ });
452
+
453
+ it('falls back to the token when the job has no OIDC permission', async () => {
454
+ stub.routes[CRATES_PUBLISH] = TOKEN_ACCEPTED;
455
+ const { status, output } = await preflightWithoutOidc({
374
456
  CARGO_REGISTRY_TOKEN: CARGO_SECRET,
375
457
  });
376
458
 
459
+ assert.equal(status, 0, output);
460
+ assert.match(
461
+ output,
462
+ /::warning::release-preflight: crates.io: no OIDC token available: .*id-token: write/
463
+ );
464
+ assert.deepEqual(
465
+ stub.requests.map(({ route }) => route),
466
+ [CRATES_PUBLISH]
467
+ );
468
+ assertNoSecrets(output);
469
+ });
470
+
471
+ it('fails when the job has no OIDC permission and no token is set', async () => {
472
+ const { status, output } = await preflightWithoutOidc();
473
+
377
474
  assert.equal(status, 1, output);
378
475
  assert.match(
379
476
  output,
@@ -382,6 +479,54 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
382
479
  assert.equal(stub.requests.length, 0);
383
480
  });
384
481
 
482
+ it('treats an unproven token fallback as unknown, never as a pass', async () => {
483
+ for (const answer of [
484
+ [400, { errors: [{ detail: 'invalid upload request' }] }],
485
+ [429, {}],
486
+ [500, { token: CARGO_SECRET }],
487
+ ]) {
488
+ const { status, output } = await probeFallback(answer);
489
+ assert.equal(status, 1, output);
490
+ assert.match(output, /0 verified, 0 failed, 1 unknown/);
491
+ assert.match(output, /verified nothing/);
492
+ assertNoSecrets(output);
493
+ }
494
+ });
495
+
496
+ it('reads the crate name from Cargo.toml when CRATES_PACKAGE is unset', async () => {
497
+ writeFileSync(
498
+ path.join(workDir, 'Cargo.toml'),
499
+ [
500
+ '[package]',
501
+ 'name = "from-manifest"',
502
+ 'version = "1.2.3"',
503
+ '',
504
+ '[dependencies.serde]',
505
+ 'name = "not-this-one"',
506
+ '',
507
+ ].join('\n')
508
+ );
509
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
510
+ CRATES_PACKAGE: '',
511
+ });
512
+
513
+ assert.equal(status, 0, output);
514
+ assert.equal(
515
+ probeMetadata(stub.requests.at(-1).body).name,
516
+ 'from-manifest'
517
+ );
518
+ });
519
+
520
+ it('cannot probe the token without a crate name', async () => {
521
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
522
+ CRATES_PACKAGE: '',
523
+ });
524
+
525
+ assert.equal(status, 1, output);
526
+ assert.match(output, /cannot tell which crate to probe/);
527
+ assert.equal(stub.requests.length, 2);
528
+ });
529
+
385
530
  it('keeps missing publishers advisory on pull requests', async () => {
386
531
  const { status, output } = await probeCrates([403, {}], {
387
532
  PREFLIGHT_MODE: 'report',
@@ -429,6 +574,7 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
429
574
  stub.routes[MINT] = INVALID_PUBLISHER;
430
575
  stub.routes[EXCHANGE] = [403, { message: 'forbidden' }];
431
576
  stub.routes[CRATES_MINT] = [401, { errors: [{ detail: 'bad token' }] }];
577
+ stub.routes[CRATES_PUBLISH] = TOKEN_REJECTED;
432
578
  const summary = path.join(workDir, 'summary.md');
433
579
  writeFileSync(summary, '');
434
580
 
@@ -7,8 +7,13 @@ import { readRepoText } from '../../helpers/repo.js';
7
7
  describe('trusted publishing configuration', () => {
8
8
  const rust = parse(readRepoText('.github/workflows/rust.yml'));
9
9
 
10
+ // Organization policy: trusted publishing (OIDC) first, the token secret
11
+ // only when OIDC is unavailable.
12
+ const FALLBACK =
13
+ '${{ steps.crates_auth.outputs.token || secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}';
14
+
10
15
  for (const jobId of ['auto-release', 'manual-release']) {
11
- it(`${jobId} uses a fresh OIDC token only when publishing`, () => {
16
+ it(`${jobId} publishes with a fresh OIDC token, falling back to the token secret`, () => {
12
17
  const job = rust.jobs[jobId];
13
18
  assert.equal(job.permissions['id-token'], 'write');
14
19
  const auth = job.steps.find((step) => step.id === 'crates_auth');
@@ -20,9 +25,11 @@ describe('trusted publishing configuration', () => {
20
25
  );
21
26
  assert.equal(auth.if, publish.if);
22
27
  assert.equal(
23
- publish.env.CARGO_REGISTRY_TOKEN,
24
- '${{ steps.crates_auth.outputs.token }}'
28
+ auth['continue-on-error'],
29
+ true,
30
+ 'a trusted publishing failure must leave the token fallback reachable'
25
31
  );
32
+ assert.equal(publish.env.CARGO_REGISTRY_TOKEN, FALLBACK);
26
33
  assert.ok(job.steps.indexOf(auth) < job.steps.indexOf(publish));
27
34
  assert.ok(
28
35
  job.steps.indexOf(auth) >
@@ -31,13 +38,28 @@ describe('trusted publishing configuration', () => {
31
38
  });
32
39
  }
33
40
 
34
- it('never reads the long-lived Cargo secret in the Rust workflow', () => {
35
- assert.doesNotMatch(
36
- readRepoText('.github/workflows/rust.yml'),
37
- /secrets\.CARGO_TOKEN/
41
+ it('gives the release preflight the same token fallback', () => {
42
+ const probe = rust.jobs['release-preflight'].steps.find(
43
+ (step) => step.name === 'Probe crates.io trusted publishing'
44
+ );
45
+ assert.equal(
46
+ probe.env.CARGO_REGISTRY_TOKEN,
47
+ '${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}'
38
48
  );
39
49
  });
40
50
 
51
+ it('reads the Cargo secrets only as the trusted publishing fallback', () => {
52
+ const uses = readRepoText('.github/workflows/rust.yml')
53
+ .split('\n')
54
+ .filter((line) => /secrets\.CARGO_/.test(line))
55
+ .map((line) => line.trim());
56
+ assert.deepEqual(uses, [
57
+ 'CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}',
58
+ `CARGO_REGISTRY_TOKEN: ${FALLBACK}`,
59
+ `CARGO_REGISTRY_TOKEN: ${FALLBACK}`,
60
+ ]);
61
+ });
62
+
41
63
  it('keeps the registered PyPI identity identical across preflight and releases', () => {
42
64
  const python = parse(readRepoText('.github/workflows/python.yml'));
43
65
  for (const jobId of [
@@ -0,0 +1,182 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { tmpdir } from 'node:os';
4
+ import assert from 'node:assert';
5
+ import { describe, it } from 'node:test';
6
+ import {
7
+ interruptionMode,
8
+ startProcess,
9
+ } from '../../../src/utilities/subprocess.js';
10
+ import { repoPath } from '../../helpers/repo.js';
11
+ import { waitForFileText } from '../../helpers/temp-directory.js';
12
+
13
+ // feature-parity: process.interruption@native-typed
14
+
15
+ const node = process.execPath;
16
+ const delayedShutdown = repoPath(
17
+ 'experiments',
18
+ 'issue-172',
19
+ 'delayed-shutdown.mjs'
20
+ );
21
+ const unixOnly = {
22
+ timeout: 15000,
23
+ skip:
24
+ process.platform === 'win32' &&
25
+ 'Command Stream only creates separate process groups on Unix',
26
+ };
27
+
28
+ function isAlive(pid) {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (error) {
33
+ if (error.code === 'ESRCH') {
34
+ return false;
35
+ }
36
+ throw error;
37
+ }
38
+ }
39
+
40
+ function forceKill(pid) {
41
+ if (pid && isAlive(pid)) {
42
+ process.kill(pid, 'SIGKILL');
43
+ }
44
+ }
45
+
46
+ async function tempDirectory(t, prefix) {
47
+ const directory = await fs.mkdtemp(path.join(tmpdir(), prefix));
48
+ t.after(() => fs.rm(directory, { recursive: true, force: true }));
49
+ return directory;
50
+ }
51
+
52
+ /**
53
+ * Run an embedding supervisor that interrupts itself, keeps its child from
54
+ * its own SIGINT handler, and exits. Returns whether the child survived.
55
+ */
56
+ async function childSurvivesSupervisorInterrupt(t, mode) {
57
+ const directory = await tempDirectory(t, 'bc-interrupt-174-');
58
+ const pidFile = path.join(directory, 'pid');
59
+ const supervisor = startProcess(node, [
60
+ repoPath('experiments', 'issue-174', 'sigint-supervisor.mjs'),
61
+ mode,
62
+ pidFile,
63
+ ]);
64
+ assert.equal(await supervisor.exited, 130, supervisor.stderrTail);
65
+ const pid = Number(await fs.readFile(pidFile, 'utf8'));
66
+ t.after(() => forceKill(pid));
67
+ assert.ok(pid > 0);
68
+ await new Promise((resolve) => setTimeout(resolve, 300));
69
+ return isAlive(pid);
70
+ }
71
+
72
+ describe('interruption ownership (issue #174)', unixOnly, () => {
73
+ it('forwards SIGINT to the child by default, before the embedder can keep it', async (t) => {
74
+ assert.equal(await childSurvivesSupervisorInterrupt(t, 'managed'), false);
75
+ assert.equal(
76
+ await childSurvivesSupervisorInterrupt(t, 'detach-in-handler'),
77
+ false
78
+ );
79
+ });
80
+
81
+ it("leaves SIGINT to the embedder's handler with interruption: 'caller'", async (t) => {
82
+ assert.equal(await childSurvivesSupervisorInterrupt(t, 'caller'), true);
83
+ });
84
+
85
+ it('keeps streaming output and reporting the exit code for a caller-owned child', async () => {
86
+ const child = startProcess(
87
+ node,
88
+ [
89
+ '-e',
90
+ 'setTimeout(()=>{process.stderr.write("hello\\n");process.exit(4)},100)',
91
+ ],
92
+ { interruption: 'caller' }
93
+ );
94
+ assert.equal(child.interruption, 'caller');
95
+ let stderr = '';
96
+ child.stderr.on('data', (chunk) => {
97
+ stderr += chunk;
98
+ });
99
+ assert.equal(await child.exited, 4);
100
+ assert.equal(stderr, 'hello\n');
101
+ assert.equal(child.kill(), false);
102
+ });
103
+
104
+ it('kill() still awaits the actual exit of a caller-owned child', async (t) => {
105
+ const directory = await tempDirectory(t, 'bc-caller-kill-174-');
106
+ const marker = path.join(directory, 'flushed');
107
+ const child = startProcess(node, [delayedShutdown, marker], {
108
+ interruption: 'caller',
109
+ killGrace: 1000,
110
+ });
111
+ t.after(() => forceKill(child.pid));
112
+ await new Promise((resolve) => child.stdout.on('data', resolve));
113
+ assert.equal(child.kill(), true);
114
+ assert.equal(await child.exited, 0);
115
+ assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
116
+ assert.equal(isAlive(child.pid), false);
117
+ });
118
+
119
+ it('escalates to SIGKILL when a caller-owned child ignores SIGTERM', async (t) => {
120
+ const child = startProcess(
121
+ node,
122
+ [
123
+ '-e',
124
+ 'process.on("SIGTERM",()=>{});process.stdout.write("ready");setInterval(()=>{},1000)',
125
+ ],
126
+ { interruption: 'caller', killGrace: 200 }
127
+ );
128
+ t.after(() => forceKill(child.pid));
129
+ await new Promise((resolve) => child.stdout.on('data', resolve));
130
+ child.kill();
131
+ assert.equal(await child.exited, 128 + 9);
132
+ assert.equal(child.signalCode, 'SIGKILL');
133
+ });
134
+
135
+ it('detach() lets the supervisor end while the exact child keeps running', async (t) => {
136
+ const directory = await tempDirectory(t, 'bc-detach-174-');
137
+ const marker = path.join(directory, 'survived');
138
+ const launcher = startProcess(node, [
139
+ repoPath('experiments', 'issue-174', 'detach-launcher.mjs'),
140
+ marker,
141
+ ]);
142
+ let output = '';
143
+ launcher.stdout.on('data', (chunk) => {
144
+ output += chunk;
145
+ });
146
+ assert.equal(await launcher.exited, 0, launcher.stderrTail);
147
+ const { pid, detached } = JSON.parse(output);
148
+ t.after(() => forceKill(pid));
149
+ assert.equal(detached, true);
150
+ assert.equal(isAlive(pid), true);
151
+ assert.equal(
152
+ await waitForFileText(marker, 'survived'),
153
+ true,
154
+ 'detached child did not outlive its supervisor'
155
+ );
156
+ });
157
+
158
+ it('detach() is idempotent and a detached child can still be stopped', async () => {
159
+ const child = startProcess(node, ['-e', 'setInterval(()=>{},1000)'], {
160
+ killGrace: 200,
161
+ });
162
+ await new Promise((resolve) => setTimeout(resolve, 100));
163
+ assert.equal(child.detach(), true);
164
+ assert.equal(child.detach(), true);
165
+ assert.equal(child.detached, true);
166
+ assert.equal(child.kill(), true);
167
+ assert.equal(await child.exited, 128 + 15);
168
+ assert.equal(child.detach(), false);
169
+ });
170
+ });
171
+
172
+ describe('interruptionMode', () => {
173
+ it('defaults to managed and rejects unknown modes', () => {
174
+ assert.equal(interruptionMode(), 'managed');
175
+ assert.equal(interruptionMode('caller'), 'caller');
176
+ assert.throws(() => interruptionMode('detached'), TypeError);
177
+ assert.throws(
178
+ () => startProcess(node, [], { interruption: 'none' }),
179
+ /interruption must be one of managed, caller/
180
+ );
181
+ });
182
+ });
@@ -9,6 +9,7 @@ import {
9
9
  startProcess,
10
10
  } from '../../../src/utilities/subprocess.js';
11
11
  import { repoPath } from '../../helpers/repo.js';
12
+ import { waitForFileText } from '../../helpers/temp-directory.js';
12
13
 
13
14
  const node = process.execPath;
14
15
 
@@ -173,19 +174,11 @@ describe(
173
174
  assert.equal(await launcher.exited, 0, launcher.stderrTail);
174
175
  assert.ok(pid > 0);
175
176
  await assert.rejects(fs.readFile(marker), { code: 'ENOENT' });
176
- const deadline = Date.now() + 5000;
177
- while (Date.now() < deadline) {
178
- try {
179
- assert.equal(await fs.readFile(marker, 'utf8'), 'survived');
180
- return;
181
- } catch (error) {
182
- if (error.code !== 'ENOENT') {
183
- throw error;
184
- }
185
- await new Promise((resolve) => setTimeout(resolve, 50));
186
- }
187
- }
188
- assert.fail('detached child did not survive the launcher exit');
177
+ assert.equal(
178
+ await waitForFileText(marker, 'survived'),
179
+ true,
180
+ 'detached child did not survive the launcher exit'
181
+ );
189
182
  });
190
183
  }
191
184
  );