browser-commander 0.26.1 → 0.26.3

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 796ebd8: Accept dedicated macOS application profiles under `~/Library/Application Support` by separating Safari import discovery roots from protected browser data paths. Safari, Technology Preview and Chrome default profiles remain protected.
8
+
9
+ ## 0.26.2
10
+
11
+ ### Patch Changes
12
+
13
+ - e2ef59f: Verify crates.io trusted publishing before releases and mark repository fixture manifests as non-publishable. Document the account setup required for PyPI and crates.io.
14
+
3
15
  ## 0.26.1
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -141,6 +141,19 @@ export function resolveBrowserRoots(name, options = {}) {
141
141
  return resolveTemplates(templates, options);
142
142
  }
143
143
 
144
+ /**
145
+ * Browser-owned paths forbidden as automation profiles. Import discovery may
146
+ * search broader locations; use explicit protection roots when declared.
147
+ */
148
+ export function resolveBrowserProtectionRoots(name, options = {}) {
149
+ const source = normalizeBrowserSource(name);
150
+ const roots = source.protectionRoots ?? source.roots;
151
+ return resolveTemplates(
152
+ roots?.[options.platform ?? process.platform] ?? [],
153
+ options
154
+ );
155
+ }
156
+
144
157
  function resolveTemplates(
145
158
  templates,
146
159
  {
@@ -764,6 +764,13 @@
764
764
  "{home}/Library"
765
765
  ]
766
766
  },
767
+ "protectionRoots": {
768
+ "darwin": [
769
+ "{home}/Library/Safari",
770
+ "{home}/Library/Containers/com.apple.Safari",
771
+ "{home}/Library/Cookies"
772
+ ]
773
+ },
767
774
  "default": {
768
775
  "darwin": ["com.apple.safari"]
769
776
  },
@@ -781,6 +788,12 @@
781
788
  "{home}/Library/Safari Technology Preview"
782
789
  ]
783
790
  },
791
+ "protectionRoots": {
792
+ "darwin": [
793
+ "{home}/Library/Safari Technology Preview",
794
+ "{home}/Library/Containers/com.apple.SafariTechnologyPreview"
795
+ ]
796
+ },
784
797
  "default": {
785
798
  "darwin": ["com.apple.safaritechnologypreview"]
786
799
  },
@@ -2,7 +2,7 @@ import { constants } from 'node:fs';
2
2
  import { physicalPath } from './browser-profile-files.js';
3
3
  import {
4
4
  BROWSER_SOURCES,
5
- resolveBrowserRoots,
5
+ resolveBrowserProtectionRoots,
6
6
  resolveBrowserExecutables,
7
7
  } from './browser-sources.js';
8
8
  import { access } from 'node:fs/promises';
@@ -46,7 +46,7 @@ export function knownDefaultUserDataDirs({
46
46
  environment = process.env,
47
47
  } = {}) {
48
48
  return BROWSER_SOURCES.flatMap((browser) =>
49
- resolveBrowserRoots(browser.id, {
49
+ resolveBrowserProtectionRoots(browser.id, {
50
50
  platform,
51
51
  homeDir,
52
52
  environment,
@@ -6,6 +6,7 @@ import { useTempDirectories } from '../../helpers/temp-directory.js';
6
6
  import {
7
7
  BROWSER_SOURCES,
8
8
  resolveBrowserRoots,
9
+ resolveBrowserProtectionRoots,
9
10
  } from '../../../src/browser/browser-sources.js';
10
11
  import {
11
12
  assertDedicatedUserDataDir,
@@ -13,8 +14,51 @@ import {
13
14
  } from '../../../src/browser/system-browser.js';
14
15
 
15
16
  const temporary = useTempDirectories('bc-catalogue-protection-');
17
+ const macOSOptions = {
18
+ platform: 'darwin',
19
+ homeDir: '/users/test',
20
+ environment: {},
21
+ };
16
22
 
17
23
  describe('catalogue launch and protection (#121)', () => {
24
+ it('accepts dedicated macOS application profiles under Library (#132)', () => {
25
+ const options = macOSOptions;
26
+ for (const directory of [
27
+ 'Library/Application Support/package-registry-manager/browser-profile',
28
+ 'Library/Safari-backup/profile',
29
+ 'Library/Cookies-backup/profile',
30
+ 'Library/Containers/com.apple.Safari-helper/profile',
31
+ ]) {
32
+ assert.doesNotThrow(() =>
33
+ assertDedicatedUserDataDir(
34
+ path.posix.join(options.homeDir, directory),
35
+ options
36
+ )
37
+ );
38
+ }
39
+ });
40
+
41
+ it('protects Safari stores and Chrome profiles on macOS (#132)', () => {
42
+ const options = macOSOptions;
43
+ for (const directory of [
44
+ 'Library/Safari',
45
+ 'Library/Cookies',
46
+ 'Library/Containers/com.apple.Safari',
47
+ 'Library/Safari Technology Preview',
48
+ 'Library/Containers/com.apple.SafariTechnologyPreview',
49
+ 'Library/Application Support/Google/Chrome/Default',
50
+ ]) {
51
+ const root = path.posix.join(options.homeDir, directory);
52
+ for (const requested of [root, path.posix.join(root, 'new-profile')]) {
53
+ assert.throws(
54
+ () => assertDedicatedUserDataDir(requested, options),
55
+ /dedicated.*default profile/,
56
+ requested
57
+ );
58
+ }
59
+ }
60
+ });
61
+
18
62
  it('protects a new profile beneath a symlink to a default root', async () => {
19
63
  const homeDir = await temporary();
20
64
  const options = { homeDir, environment: {} };
@@ -47,7 +91,7 @@ describe('catalogue launch and protection (#121)', () => {
47
91
  }
48
92
  });
49
93
 
50
- it('protects every catalogue root and descendant profile on every OS', () => {
94
+ it('protects every catalogue protection root and descendant profile on every OS', () => {
51
95
  for (const platform of ['linux', 'darwin', 'win32']) {
52
96
  const options = {
53
97
  platform,
@@ -55,7 +99,11 @@ describe('catalogue launch and protection (#121)', () => {
55
99
  environment: {},
56
100
  };
57
101
  for (const browser of BROWSER_SOURCES) {
58
- for (const root of resolveBrowserRoots(browser.id, options)) {
102
+ const roots = resolveBrowserProtectionRoots(browser.id, options);
103
+ if (!browser.protectionRoots) {
104
+ assert.deepEqual(roots, resolveBrowserRoots(browser.id, options));
105
+ }
106
+ for (const root of roots) {
59
107
  assert.throws(
60
108
  () => assertDedicatedUserDataDir(root, options),
61
109
  /dedicated.*default profile/,
@@ -5,8 +5,8 @@
5
5
  * Run 37509328334 built, tested and versioned the Python package on main and
6
6
  * only then failed at "Publish to PyPI" with `invalid-publisher` (issue #128).
7
7
  * `scripts/preflight-credentials.sh` performs the credential half of each
8
- * publish up front -- the OIDC exchange PyPI and npm trusted publishing do, and
9
- * the crates.io token check -- and the `release-preflight` job in each
8
+ * publish up front -- the OIDC exchanges all three registries do -- and the
9
+ * `release-preflight` job in each
10
10
  * language workflow gates the publishing jobs on it. These tests pin the rules
11
11
  * the workflows depend on (principle 16 of the link-foundation pipeline
12
12
  * templates): every failure is reported, `unknown` is never a pass in release
@@ -28,6 +28,7 @@ import { after, before, beforeEach, describe, it } from 'node:test';
28
28
  import { BASH_AVAILABLE, readRepoText, repoPath } from '../../helpers/repo.js';
29
29
 
30
30
  const SCRIPT = repoPath('scripts/preflight-credentials.sh');
31
+ const scriptOptions = { skip: !BASH_AVAILABLE, timeout: 30_000 };
31
32
 
32
33
  // Values a leak would make visible: none of them may reach the output.
33
34
  const REQUEST_TOKEN = 'gh-request-token-must-not-leak';
@@ -130,7 +131,8 @@ const OIDC_OK = [200, { value: OIDC_TOKEN }];
130
131
  const MINT = 'POST /pypi/_/oidc/mint-token';
131
132
  const EXCHANGE =
132
133
  'POST /npm/-/npm/v1/oidc/token/exchange/package/@link-foundation%2Fprobe';
133
- const ME = 'GET /crates/api/v1/me';
134
+ const CRATES_MINT = 'POST /crates/api/v1/trusted_publishing/tokens';
135
+ const CRATES_REVOKE = 'DELETE /crates/api/v1/trusted_publishing/tokens';
134
136
  const INVALID_PUBLISHER = [
135
137
  422,
136
138
  {
@@ -150,7 +152,12 @@ function probePypi(answer, overrides = {}) {
150
152
  return preflight({ PREFLIGHT_REGISTRIES: 'pypi', ...overrides });
151
153
  }
152
154
 
153
- describe('scripts/preflight-credentials.sh', { skip: !BASH_AVAILABLE }, () => {
155
+ function probeCrates(answer, overrides = {}) {
156
+ stub.routes[CRATES_MINT] = answer;
157
+ return preflight({ PREFLIGHT_REGISTRIES: 'crates', ...overrides });
158
+ }
159
+
160
+ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
154
161
  before(async () => {
155
162
  stub.server = createServer(respond);
156
163
  stub.server.listen(0, '127.0.0.1');
@@ -318,81 +325,102 @@ describe('scripts/preflight-credentials.sh', { skip: !BASH_AVAILABLE }, () => {
318
325
  });
319
326
  });
320
327
 
321
- describe('crates.io token', () => {
322
- it('passes a live API token that /api/v1/me refuses by design', async () => {
323
- stub.routes[ME] = [
324
- 403,
325
- {
326
- errors: [
327
- {
328
- detail:
329
- 'this action can only be performed on the crates.io website',
330
- },
331
- ],
332
- },
333
- ];
328
+ describe('crates.io trusted publishing', () => {
329
+ it('exchanges OIDC and revokes the temporary token without using a Cargo secret', async () => {
330
+ stub.routes[CRATES_REVOKE] = [204, ''];
334
331
 
335
- const { status, output } = await preflight({
336
- PREFLIGHT_REGISTRIES: 'crates',
337
- CARGO_REGISTRY_TOKEN: CARGO_SECRET,
338
- });
332
+ const { status, output } = await probeCrates(
333
+ [200, { token: ISSUED_TOKEN }],
334
+ { CARGO_REGISTRY_TOKEN: CARGO_SECRET }
335
+ );
339
336
 
340
337
  assert.equal(status, 0, output);
341
- assert.match(output, /PASS: crates.io authenticated the publish token/);
342
- assert.equal(stub.requests[0].authorization, CARGO_SECRET);
343
- assert.match(stub.requests[0].userAgent, /release-preflight/);
338
+ assert.match(
339
+ output,
340
+ /PASS: crates.io minted a short-lived publish token/
341
+ );
342
+ const [oidc, mint, revoke] = stub.requests;
343
+ assert.match(oidc.url, /&audience=127\.0\.0\.1:\d+\/crates$/);
344
+ assert.deepEqual(JSON.parse(mint.body), { jwt: OIDC_TOKEN });
345
+ assert.equal(mint.authorization, '');
346
+ assert.equal(revoke.route, CRATES_REVOKE);
347
+ assert.equal(revoke.authorization, `Bearer ${ISSUED_TOKEN}`);
348
+ assert.equal(stub.requests.length, 3);
344
349
  assertNoSecrets(output);
345
350
  });
346
351
 
347
- it('fails a release on a token crates.io does not recognise', async () => {
348
- stub.routes[ME] = [
349
- 403,
350
- { errors: [{ detail: 'authentication failed' }] },
351
- ];
352
-
353
- const { status, output } = await preflight({
354
- PREFLIGHT_REGISTRIES: 'crates',
355
- CARGO_TOKEN: CARGO_SECRET,
356
- });
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
+ );
357
357
 
358
358
  assert.equal(status, 1, output);
359
359
  assert.match(
360
360
  output,
361
- /::error::release-preflight: crates.io rejected the publish token \(403, authentication failed\)/
361
+ /::error::release-preflight: crates.io refused the OIDC token exchange \(403\)/
362
362
  );
363
+ assert.match(output, /rust\.yml/);
364
+ assert.match(output, /docs\/PUBLISHING\.md/);
365
+ assert.equal(stub.requests.length, 2);
363
366
  assertNoSecrets(output);
364
367
  });
365
368
 
366
- it('fails when no token is configured', async () => {
369
+ it('fails when the job has no OIDC permission, with no secret fallback', async () => {
367
370
  const { status, output } = await preflight({
368
371
  PREFLIGHT_REGISTRIES: 'crates',
372
+ ACTIONS_ID_TOKEN_REQUEST_URL: '',
373
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
374
+ CARGO_REGISTRY_TOKEN: CARGO_SECRET,
369
375
  });
370
376
 
371
377
  assert.equal(status, 1, output);
372
- assert.match(output, /crates.io has no publish credential/);
378
+ assert.match(
379
+ output,
380
+ /crates.io: no OIDC token available: .*id-token: write/
381
+ );
382
+ assert.equal(stub.requests.length, 0);
373
383
  });
374
384
 
375
- it('checks crate ownership when the token resolves to a login', async () => {
376
- writeFileSync(
377
- path.join(workDir, 'Cargo.toml'),
378
- '[package]\nname = "probe-crate"\n\n[dependencies]\nname = "decoy"\n'
379
- );
380
- stub.routes[ME] = [200, { user: { login: 'Releaser' } }];
381
- const owners = 'GET /crates/api/v1/crates/probe-crate/owners';
382
- const env = {
383
- PREFLIGHT_REGISTRIES: 'crates',
384
- CARGO_REGISTRY_TOKEN: CARGO_SECRET,
385
- };
385
+ it('keeps missing publishers advisory on pull requests', async () => {
386
+ const { status, output } = await probeCrates([403, {}], {
387
+ PREFLIGHT_MODE: 'report',
388
+ });
389
+ assert.equal(status, 0, output);
390
+ assert.match(output, /::warning::release-preflight: crates.io refused/);
391
+ assert.doesNotMatch(output, /::error::/);
392
+ });
386
393
 
387
- stub.routes[owners] = [200, { users: [{ login: 'releaser' }] }];
388
- const owner = await preflight(env);
389
- assert.equal(owner.status, 0, owner.output);
390
- assert.match(owner.output, /PASS: Releaser is an owner of probe-crate/);
394
+ it('treats missing tokens and unavailable registries as unknown', async () => {
395
+ for (const answer of [
396
+ [200, {}],
397
+ [429, {}],
398
+ [503, {}],
399
+ ]) {
400
+ const { status, output } = await probeCrates(answer);
401
+ assert.equal(status, 1, output);
402
+ assert.match(output, /0 verified, 0 failed, 1 unknown/);
403
+ assert.equal(stub.requests.at(-1).route, CRATES_MINT);
404
+ assertNoSecrets(output);
405
+ }
406
+ const port = await closedPort();
407
+ const { status, output } = await preflight({
408
+ PREFLIGHT_REGISTRIES: 'crates',
409
+ CRATES_API: `http://127.0.0.1:${port}`,
410
+ });
411
+ assert.equal(status, 1, output);
412
+ assert.match(output, /UNKNOWN: crates.io was unreachable/);
413
+ });
391
414
 
392
- stub.routes[owners] = [200, { users: [{ login: 'someone-else' }] }];
393
- const stranger = await preflight(env);
394
- assert.equal(stranger.status, 1, stranger.output);
395
- assert.match(stranger.output, /is not an owner of probe-crate/);
415
+ it('reports failed revocation without exposing the token', async () => {
416
+ stub.routes[CRATES_REVOKE] = [403, { token: ISSUED_TOKEN }];
417
+ const { status, output } = await probeCrates([
418
+ 200,
419
+ { token: ISSUED_TOKEN },
420
+ ]);
421
+ assert.equal(status, 1, output);
422
+ assert.match(output, /could not revoke the preflight token/);
423
+ assertNoSecrets(output);
396
424
  });
397
425
  });
398
426
 
@@ -400,7 +428,7 @@ describe('scripts/preflight-credentials.sh', { skip: !BASH_AVAILABLE }, () => {
400
428
  it('reports every failure rather than stopping at the first', async () => {
401
429
  stub.routes[MINT] = INVALID_PUBLISHER;
402
430
  stub.routes[EXCHANGE] = [403, { message: 'forbidden' }];
403
- stub.routes[ME] = [401, { errors: [{ detail: 'bad token' }] }];
431
+ stub.routes[CRATES_MINT] = [401, { errors: [{ detail: 'bad token' }] }];
404
432
  const summary = path.join(workDir, 'summary.md');
405
433
  writeFileSync(summary, '');
406
434
 
@@ -479,7 +507,7 @@ const PREFLIGHT_WORKFLOWS = [
479
507
  {
480
508
  file: 'rust.yml',
481
509
  registry: 'crates',
482
- oidc: false,
510
+ oidc: true,
483
511
  publishers: ['auto-release', 'manual-release'],
484
512
  },
485
513
  ];
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { parse } from 'yaml';
4
+
5
+ import { readRepoText } from '../../helpers/repo.js';
6
+
7
+ describe('trusted publishing configuration', () => {
8
+ const rust = parse(readRepoText('.github/workflows/rust.yml'));
9
+
10
+ for (const jobId of ['auto-release', 'manual-release']) {
11
+ it(`${jobId} uses a fresh OIDC token only when publishing`, () => {
12
+ const job = rust.jobs[jobId];
13
+ assert.equal(job.permissions['id-token'], 'write');
14
+ const auth = job.steps.find((step) => step.id === 'crates_auth');
15
+ const publish = job.steps.find((step) => step.id === 'publish-crate');
16
+ assert.ok(auth, 'missing crates.io authentication step');
17
+ assert.match(
18
+ auth.uses,
19
+ /^rust-lang\/crates-io-auth-action@[a-f0-9]{40}$/
20
+ );
21
+ assert.equal(auth.if, publish.if);
22
+ assert.equal(
23
+ publish.env.CARGO_REGISTRY_TOKEN,
24
+ '${{ steps.crates_auth.outputs.token }}'
25
+ );
26
+ assert.ok(job.steps.indexOf(auth) < job.steps.indexOf(publish));
27
+ assert.ok(
28
+ job.steps.indexOf(auth) >
29
+ job.steps.findIndex((step) => step.name === 'Build release')
30
+ );
31
+ });
32
+ }
33
+
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/
38
+ );
39
+ });
40
+
41
+ it('keeps the registered PyPI identity identical across preflight and releases', () => {
42
+ const python = parse(readRepoText('.github/workflows/python.yml'));
43
+ for (const jobId of [
44
+ 'release-preflight',
45
+ 'auto-release',
46
+ 'manual-release',
47
+ ]) {
48
+ assert.equal(python.jobs[jobId].permissions['id-token'], 'write');
49
+ assert.equal(python.jobs[jobId].environment, undefined);
50
+ }
51
+ });
52
+
53
+ it('marks the template snapshot as private without hiding the real npm package', () => {
54
+ assert.equal(
55
+ JSON.parse(
56
+ readRepoText(
57
+ 'docs/case-studies/issue-55/template-snapshots/js/package.json'
58
+ )
59
+ ).private,
60
+ true
61
+ );
62
+ assert.notEqual(JSON.parse(readRepoText('js/package.json')).private, true);
63
+ });
64
+
65
+ it('disables publishing for the cookie equivalence experiment only', () => {
66
+ const fixture = readRepoText(
67
+ 'experiments/issue-128/cookie-fixture-equivalence/Cargo.toml'
68
+ );
69
+ const packageTable = fixture
70
+ .split(/^\[package\]\s*$/m)[1]
71
+ ?.split(/^\[/m)[0];
72
+ assert.match(packageTable ?? '', /^publish\s*=\s*false\s*$/m);
73
+ assert.doesNotMatch(
74
+ readRepoText('rust/Cargo.toml'),
75
+ /^publish\s*=\s*false\s*$/m
76
+ );
77
+ });
78
+ });