riciplay 1.9.1 → 1.9.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/README.md CHANGED
@@ -18,7 +18,7 @@ npm install -g riciplay
18
18
  pip install riciplay-cli
19
19
  ```
20
20
 
21
- Requires Python 3.10+ (installed automatically when possible). Chrome and mitmproxy integrations are pulled in as Python dependencies.
21
+ Requires Node.js 18+ and an existing Python 3.12+ installation; this wrapper does not install Python itself. Chrome and proxy integrations are pulled in as Python dependencies. The 1.9.3 candidate uses `riciplay-mitmproxy==12.3.1.1` and MiniProxy 0.9.7+. It rejects coexistence with upstream `mitmproxy`, refuses automatic shared-package removal, and requires a fresh venv for migration. Publication remains held until the complete fork chain and public installation/audit gates pass.
22
22
 
23
23
  ## Usage
24
24
 
@@ -29,15 +29,32 @@ riciplay chat # start an agent session
29
29
 
30
30
  ## The npm wrapper
31
31
 
32
- `bin/riciplay` resolves the pip-installed console script via `PATH` and falls back to invoking `riciplay_cli.main:app` through `python3`. It explicitly excludes its own path as a candidate, so an npm global install (which symlinks this wrapper onto `PATH`) can never re-exec itself.
32
+ `bin/riciplay` resolves the pip-installed console script via `PATH` and falls back to invoking `riciplay_cli.main:app` through a detected Python 3.12+ interpreter. It explicitly excludes its own path as a candidate, so an npm global install (which symlinks this wrapper onto `PATH`) can never re-exec itself.
33
33
 
34
- `scripts/postinstall.js` pip-installs `riciplay-cli` **pinned to this launcher's exact version** (`riciplay-cli==<package.json version>`), and compares the *installed* version rather than merely checking that the module imports — so an out-of-date install is upgraded instead of being silently kept. It degrades to a printed manual command when it can't (no network, `--ignore-scripts`, managed Python).
34
+ `scripts/postinstall.js` selects Python 3.12+ and runs that interpreter's `-m pip` to install `riciplay-cli` **pinned to this launcher's exact version** (`riciplay-cli==<package.json version>`). It checks the installed version both before and after installation, so an out-of-date install is upgraded instead of being silently kept. It degrades to a printed manual command when it can't (no network, `--ignore-scripts`, managed Python).
35
+
36
+ Before dispatching user commands, both PATH-console and module-fallback routes
37
+ require the exact CLI version, Python 3.12+, Tornado 6.6+, and
38
+ `riciplay-mitmproxy==12.3.1.1` via a local metadata-only check. A stale PATH entry fails closed;
39
+ it is not silently run after a failed installation. This is a version guard,
40
+ not a substitute for the release dependency audit or a guarantee against a
41
+ malicious executable placed on your PATH.
35
42
 
36
43
  ### The two versions are locked together
37
44
 
38
45
  The launcher and the Python package must be released at the same version, because postinstall pins the pair. That used to be an unenforced convention and it drifted (npm `1.8.95` against PyPI `1.8.96`). Three things now enforce it:
39
46
 
40
47
  - `npm test` / `npm publish` run `scripts/check-version-sync.js` — `prepublishOnly` **refuses to publish** a mismatched pair
48
+ - `npm test`, CI, and `npm publish` execute the real launcher regression. Publish
49
+ also enforces the version-bound `release-readiness.json` hold and independently
50
+ fetches exact non-yanked PyPI metadata, resolves `riciplay-cli==<version>` in a
51
+ fresh Python 3.12 venv, rejects upstream/fork overlap, runs `pip check`, and
52
+ requires a strict zero-finding `pip-audit`. A mutable readiness file alone is
53
+ never release authorization. Release automation also supplies a new absolute
54
+ `RICIPLAY_PUBLIC_GATE_EVIDENCE` path; prepublish records public artifact hashes
55
+ there with create-only private permissions after all checks succeed. It also
56
+ requires `RICIPLAY_RELEASE_PYTHON` to be the absolute validated Python 3.12
57
+ interpreter, avoiding PATH-dependent release resolution.
41
58
  - `pytest tests/test_version_sync.py` fails the suite on drift, catching it at PR time
42
59
  - `python scripts/bump_version.py <version>` moves both files together, so the correct action is also the easy one (`--check` to verify, `--show` to inspect)
43
60
 
package/bin/riciplay CHANGED
@@ -18,6 +18,9 @@ const { spawn } = require('child_process');
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { EOL } = require('os');
21
+ const { minimumPython, resolvePython } = require('../scripts/python');
22
+ const { checkRuntime } = require('../scripts/runtime');
23
+ const npmVersion = require('../package.json').version;
21
24
 
22
25
  function realpathSafe(p) {
23
26
  try {
@@ -93,7 +96,7 @@ function fail(msg) {
93
96
  function run(cmd, argv) {
94
97
  const proc = spawn(cmd, argv, { stdio: 'inherit' });
95
98
  proc.on('error', (err) => fail(err.code === 'ENOENT'
96
- ? 'python3 not found. Riciplay is a Python tool — install Python 3.10+ first.'
99
+ ? `Selected executable not found. Install Python ${minimumPython}+ and the matching riciplay-cli package.`
97
100
  : err.message));
98
101
  proc.on('close', (code) => process.exit(code ?? 1));
99
102
  }
@@ -102,17 +105,30 @@ function main(argv = process.argv.slice(2)) {
102
105
  // Prefer an installed console script (same PATH resolution the OS used).
103
106
  const candidate = resolveConsoleScript();
104
107
  if (candidate) {
108
+ if (!checkRuntime(candidate, [], npmVersion)) {
109
+ fail(`PATH riciplay is incompatible. Require riciplay-cli ${npmVersion}, Python ${minimumPython}+, Tornado 6.6+, and riciplay-mitmproxy 12.3.1.1 without upstream mitmproxy. Migrate to a fresh venv or remove the stale PATH entry.`);
110
+ return;
111
+ }
105
112
  run(candidate, argv);
106
113
  return;
107
114
  }
108
115
 
109
116
  // Module fallback — app() reads sys.argv; normalize argv[0] so usage
110
117
  // strings and version output match the real console script.
111
- run('python3', [
118
+ const python = resolvePython();
119
+ if (!python) {
120
+ fail(`No supported Python found. Riciplay requires Python ${minimumPython}+.`);
121
+ return;
122
+ }
123
+ const prefix = [
112
124
  '-c',
113
125
  "import sys; sys.argv[0] = 'riciplay'; from riciplay_cli.main import app; app()",
114
- ...argv,
115
- ]);
126
+ ];
127
+ if (!checkRuntime(python, prefix, npmVersion)) {
128
+ fail(`Install matching riciplay-cli ${npmVersion} with its supported dependencies in the selected Python ${minimumPython}+ environment.`);
129
+ return;
130
+ }
131
+ run(python, [...prefix, ...argv]);
116
132
  }
117
133
 
118
134
  if (require.main === module) {
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "riciplay",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "AI-powered bug bounty reconnaissance and penetration testing agent \u2014 browser, proxy interception, recon, and exploit workflows",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "riciplay": "./bin/riciplay"
8
8
  },
9
9
  "scripts": {
10
- "test": "node scripts/check-version-sync.js",
11
- "prepublishOnly": "node scripts/check-version-sync.js || (echo 'Refusing to publish: npm and PyPI versions disagree.' && exit 1)",
10
+ "test": "node scripts/check-version-sync.js && node tests/npm_launcher_test.js",
11
+ "prepublishOnly": "npm test && node scripts/check-publish-ready.js",
12
12
  "postinstall": "node scripts/postinstall.js"
13
13
  },
14
14
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "homepage": "https://github.com/Zaidux/Riciplay#readme",
35
35
  "engines": {
36
- "node": ">=14.0.0"
36
+ "node": ">=18.0.0"
37
37
  },
38
38
  "os": [
39
39
  "darwin",
@@ -42,6 +42,13 @@
42
42
  "files": [
43
43
  "bin/",
44
44
  "scripts/postinstall.js",
45
+ "scripts/python.js",
46
+ "scripts/runtime.js",
47
+ "scripts/public-release-gate.js",
48
+ "tests/npm_launcher_test.js",
49
+ "scripts/check-publish-ready.js",
50
+ "scripts/check-version-sync.js",
51
+ "release-readiness.json",
45
52
  "README.md"
46
53
  ],
47
54
  "dependencies": {},
@@ -0,0 +1,5 @@
1
+ {
2
+ "version": "1.9.3",
3
+ "blocked": false,
4
+ "reason": "Complete CLI/MiniProxy regressions, validated artifacts and installed smokes; exact public PyPI versions/hashes, fresh resolution and strict complete audit verified before npm publication."
5
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ // An explicit, version-bound release hold; builds/local packs remain allowed.
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const version = require('../package.json').version;
6
+ const { verifyPublicRelease } = require('./public-release-gate');
7
+ const readinessFile = path.resolve(__dirname, '..', 'release-readiness.json');
8
+
9
+ function checkReadiness(file = readinessFile, target = version) {
10
+ try {
11
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
12
+ if (state.version !== target || typeof state.blocked !== 'boolean' ||
13
+ typeof state.reason !== 'string' || !state.reason.trim()) {
14
+ return { ready: false, reason: 'Missing or mismatched release-readiness evidence.' };
15
+ }
16
+ return { ready: state.blocked === false, reason: state.reason };
17
+ } catch {
18
+ return { ready: false, reason: 'Missing or invalid release-readiness file.' };
19
+ }
20
+ }
21
+
22
+ async function main() {
23
+ const state = checkReadiness();
24
+ if (!state.ready) {
25
+ throw new Error(state.reason);
26
+ }
27
+ const evidenceFile = process.env.RICIPLAY_PUBLIC_GATE_EVIDENCE;
28
+ if (!evidenceFile || !path.isAbsolute(evidenceFile)) {
29
+ throw new Error('RICIPLAY_PUBLIC_GATE_EVIDENCE must name a new absolute private evidence file');
30
+ }
31
+ const verified = await verifyPublicRelease();
32
+ const { writeEvidence } = require('./public-release-gate');
33
+ await writeEvidence(evidenceFile, verified.metadata);
34
+ return true;
35
+ }
36
+
37
+ if (require.main === module) main().catch(error => {
38
+ console.error(`Refusing to publish riciplay ${version}: ${error.message}`);
39
+ process.exitCode = 1;
40
+ });
41
+ module.exports = { checkReadiness, main };
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ // Release guard: refuse to publish when the npm launcher and the Python
3
+ // package disagree on version.
4
+ //
5
+ // The npm package pins `riciplay-cli==<its own version>` in postinstall, so a
6
+ // published pair with different versions either installs the wrong
7
+ // implementation or fails outright. This ran as an unenforced convention once
8
+ // already, and drifted (npm 1.8.95 vs PyPI 1.8.96).
9
+ //
10
+ // Run by `prepublishOnly` (so `npm publish` fails closed) and by the test
11
+ // suite. Deliberately reads both files directly rather than importing the
12
+ // Python helper: this must work with only node available.
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ const NPM_DIR = path.resolve(__dirname, '..');
18
+ const CLI_DIR = path.resolve(NPM_DIR, '..');
19
+ const PYPROJECT = path.join(CLI_DIR, 'pyproject.toml');
20
+ const NPM_PACKAGE = path.join(NPM_DIR, 'package.json');
21
+
22
+ /**
23
+ * First bare `version = "x.y.z"` in pyproject.toml — the [project] version.
24
+ * Dependency pins never start a line, so the first match is the package's.
25
+ */
26
+ function readPyprojectVersion(file = PYPROJECT) {
27
+ const text = fs.readFileSync(file, 'utf8');
28
+ const m = text.match(/^version\s*=\s*"([^"]+)"/m);
29
+ if (!m) throw new Error(`no version found in ${file}`);
30
+ return m[1];
31
+ }
32
+
33
+ function readNpmVersion(file = NPM_PACKAGE) {
34
+ return JSON.parse(fs.readFileSync(file, 'utf8')).version;
35
+ }
36
+
37
+ function compareVersions(pythonFile = process.env.RICIPLAY_RELEASE_PYPROJECT || PYPROJECT,
38
+ npmFile = NPM_PACKAGE) {
39
+ const py = readPyprojectVersion(pythonFile);
40
+ const npm = readNpmVersion(npmFile);
41
+ return { py, npm, match: py === npm };
42
+ }
43
+
44
+ function main() {
45
+ const { py, npm, match } = compareVersions();
46
+ if (match) {
47
+ console.log(`✓ version sync OK: riciplay-cli ${py} == npm riciplay ${npm}`);
48
+ return 0;
49
+ }
50
+ console.error(
51
+ `error: version mismatch — pyproject.toml=${py} npm/package.json=${npm}\n` +
52
+ ' The npm launcher pins riciplay-cli==<its own version>, so these\n' +
53
+ ' must match. Fix with: python scripts/bump_version.py <version>'
54
+ );
55
+ return 1;
56
+ }
57
+
58
+ if (require.main === module) {
59
+ process.exit(main());
60
+ }
61
+
62
+ module.exports = { readPyprojectVersion, readNpmVersion, compareVersions, main };
@@ -18,15 +18,16 @@
18
18
  // --ignore-scripts, without network, or managing Python manually — print the
19
19
  // manual command instead of failing the install.
20
20
 
21
- const { execSync, spawnSync } = require('child_process');
21
+ const { spawnSync } = require('child_process');
22
22
  const os = require('os');
23
23
  const path = require('path');
24
+ const { minimumPython, resolvePython } = require('./python');
25
+ const { upstreamAbsent, checkRuntime } = require('./runtime');
24
26
 
25
27
  const npmVersion = require(path.join(__dirname, '..', 'package.json')).version;
26
28
 
27
29
  const isWin = os.platform() === 'win32';
28
30
  const python = isWin ? 'python' : 'python3';
29
- const pip = isWin ? 'pip' : 'pip3';
30
31
 
31
32
  /**
32
33
  * The version of `riciplay_cli` importable right now, or null if it is not
@@ -55,17 +56,38 @@ function pipSpec(target = npmVersion) {
55
56
  return `riciplay-cli==${target}`;
56
57
  }
57
58
 
58
- function main() {
59
+ function quotePython(command, platform = process.platform) {
60
+ return platform === 'win32'
61
+ ? `"${command.replace(/"/g, '""')}"`
62
+ : `'${command.replace(/'/g, "'\\''")}'`;
63
+ }
64
+
65
+ function main({ run = spawnSync } = {}) {
59
66
  console.log('\n🕷️ Installing Riciplay (bug bounty AI agent)...\n');
60
67
 
68
+ const selectedPython = resolvePython({ run });
69
+ if (!selectedPython) {
70
+ console.warn(
71
+ `⚠ Riciplay needs Python ${minimumPython}+. Install it first, then run:\n` +
72
+ ` python3.12 -m pip install ${pipSpec()}\n`
73
+ );
74
+ return;
75
+ }
76
+
61
77
  let current = null;
62
78
  try {
63
- current = installedVersion();
79
+ current = installedVersion(selectedPython, run);
64
80
  } catch {
65
81
  current = null; // treated as "not installed" below
66
82
  }
67
83
 
68
- if (!needsInstall(current)) {
84
+ // Shared files cannot be safely repaired by uninstalling one distribution.
85
+ // Refuse mutation and leave captures/credentials/old environments untouched.
86
+ if (!upstreamAbsent(selectedPython, run)) {
87
+ console.warn('⚠ Upstream mitmproxy is installed or could not be checked. Riciplay 1.9.3 requires a fresh Python 3.12+ venv without upstream mitmproxy. No packages were removed. See cli/README.md.');
88
+ return;
89
+ }
90
+ if (!needsInstall(current) && checkRuntime(selectedPython, ['-m', 'riciplay_cli.main'], npmVersion, run)) {
69
91
  console.log(`✓ riciplay-cli ${npmVersion} already installed.`);
70
92
  } else {
71
93
  if (current) {
@@ -80,13 +102,24 @@ function main() {
80
102
  );
81
103
  }
82
104
  try {
83
- execSync(`${pip} install --upgrade ${pipSpec()}`, { stdio: 'inherit' });
105
+ const result = run(selectedPython, ['-m', 'pip', 'install', '--upgrade', pipSpec()], {
106
+ stdio: 'inherit',
107
+ });
108
+ if (result.error || result.status !== 0) {
109
+ throw new Error('pip installation failed');
110
+ }
111
+ if (needsInstall(installedVersion(selectedPython, run))) {
112
+ throw new Error('the matching CLI version is still not importable');
113
+ }
114
+ if (!checkRuntime(selectedPython, ['-m', 'riciplay_cli.main'], npmVersion, run)) {
115
+ throw new Error('proxy distribution/version/overlap verification failed');
116
+ }
84
117
  console.log(`✓ Installed riciplay-cli ${npmVersion}.`);
85
118
  } catch (err) {
86
119
  console.warn(
87
120
  `\n⚠ Could not install the Python package automatically (${err.message}).\n` +
88
- ' Riciplay needs Python 3.10+. Install the matching version manually with:\n' +
89
- ` pip install ${pipSpec()}\n`
121
+ ` Riciplay needs Python ${minimumPython}+. Install the matching version manually with:\n` +
122
+ ` ${quotePython(selectedPython)} -m pip install ${pipSpec()}\n`
90
123
  );
91
124
  }
92
125
  }
@@ -98,4 +131,4 @@ if (require.main === module) {
98
131
  main();
99
132
  }
100
133
 
101
- module.exports = { npmVersion, installedVersion, needsInstall, pipSpec, main };
134
+ module.exports = { npmVersion, installedVersion, needsInstall, pipSpec, quotePython, main };
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /* Fail-closed public dependency metadata, resolution and vulnerability audit. */
3
+ const { spawnSync } = require('child_process');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const required = Object.freeze({
9
+ 'riciplay-cli': require('../package.json').version,
10
+ 'riciplay-miniproxy': '0.9.7',
11
+ 'riciplay-mitmproxy': '12.3.1.1',
12
+ });
13
+
14
+ async function publicMetadata(name, version, fetcher = fetch) {
15
+ const response = await fetcher(`https://pypi.org/pypi/${name}/${version}/json`, {
16
+ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20000),
17
+ });
18
+ if (!response.ok) throw new Error(`${name} ${version} is not publicly available (HTTP ${response.status})`);
19
+ const data = await response.json();
20
+ if (data.info.version !== version || !Array.isArray(data.urls) || !data.urls.some(item => item.yanked === false)) {
21
+ throw new Error(`${name} ${version} has no public non-yanked artifact`);
22
+ }
23
+ return data;
24
+ }
25
+
26
+ async function releaseHashes(name, version, fetcher = fetch) {
27
+ const metadata = await publicMetadata(name, version, fetcher);
28
+ const artifacts = metadata.urls.map(item => ({ filename: item.filename, sha256: item.digests && item.digests.sha256 }))
29
+ .filter(item => item.filename && /^[a-f0-9]{64}$/.test(item.sha256 || ''));
30
+ if (!artifacts.length) throw new Error(`${name} ${version} has no public SHA-256 artifact hashes`);
31
+ return artifacts;
32
+ }
33
+
34
+ function exactRequirement(requirements, name, version) {
35
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ return requirements.some(value => new RegExp(`^${escaped}\\s*==\\s*${version}(?:\\s*;.*)?$`, 'i').test(value));
37
+ }
38
+
39
+ function hasRangeRequirement(requirements, name, minimum, maximum) {
40
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ return requirements.some(value => {
42
+ const match = String(value).match(new RegExp(`^${escaped}\\s*(?:\\(([^)]*)\\)|([^;]*))(?:;.*)?$`, 'i'));
43
+ const spec = match && (match[1] || match[2] || '');
44
+ return Boolean(spec && new RegExp(`(?:^|,)\\s*>=\\s*${minimum}(?:\\s*,|$)`).test(spec) &&
45
+ new RegExp(`(?:^|,)\\s*<\\s*${maximum}(?:\\s*,|$)`).test(spec));
46
+ });
47
+ }
48
+
49
+ function hasCompatibleTornado(requirements) {
50
+ return requirements.some(value => {
51
+ const match = String(value).match(/^tornado\s*(?:\(([^)]*)\)|([^;]*))(?:;.*)?$/i);
52
+ const spec = match && (match[1] || match[2] || '');
53
+ return Boolean(spec && /(?:^|,)\s*>=\s*6\.6(?:\s*,|$)/.test(spec) &&
54
+ (/(?:^|,)\s*<\s*7(?:\.0*)?(?:\s*,|$)/.test(spec) ||
55
+ /(?:^|,)\s*<=\s*6\.6(?:\.0*)?(?:\s*,|$)/.test(spec) ||
56
+ /(?:^|,)\s*==\s*6\.6(?:\.0*)?(?:\s*,|$)/.test(spec)));
57
+ });
58
+ }
59
+
60
+ function runChecked(run, command, args, options) {
61
+ const result = run(command, args, options);
62
+ if (result.error || result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed`);
63
+ }
64
+
65
+ async function verifyPublicRelease({ fetcher = fetch, run = spawnSync, tempRoot = os.tmpdir(), metadata = null } = {}) {
66
+ const [cli, mini, fork] = metadata || await Promise.all(Object.entries(required).map(([name, version]) => publicMetadata(name, version, fetcher)));
67
+ const cliReq = cli.info.requires_dist || [];
68
+ const miniReq = mini.info.requires_dist || [];
69
+ if (!exactRequirement(cliReq, 'riciplay-mitmproxy', required['riciplay-mitmproxy']) ||
70
+ !hasRangeRequirement(cliReq, 'riciplay-miniproxy', '0\\.9\\.7', '2')) {
71
+ throw new Error('Public CLI metadata does not require the validated dependency releases');
72
+ }
73
+ if (!exactRequirement(miniReq, 'riciplay-mitmproxy', required['riciplay-mitmproxy'])) {
74
+ throw new Error('Public MiniProxy metadata does not require the validated fork');
75
+ }
76
+ if ((fork.info.requires_dist || []).some(value => /^mitmproxy\b/i.test(value))) {
77
+ throw new Error('Fork metadata overlaps the upstream mitmproxy distribution');
78
+ }
79
+ if (!hasCompatibleTornado(fork.info.requires_dist || [])) {
80
+ throw new Error('Public fork metadata does not enforce Tornado 6.6 compatibility');
81
+ }
82
+
83
+ const directory = fs.mkdtempSync(path.join(tempRoot, 'riciplay-public-gate-'));
84
+ try {
85
+ const python = process.env.RICIPLAY_RELEASE_PYTHON;
86
+ if (!python || !path.isAbsolute(python)) {
87
+ throw new Error('RICIPLAY_RELEASE_PYTHON must be the absolute validated Python 3.12 interpreter');
88
+ }
89
+ const venv = path.join(directory, 'venv');
90
+ runChecked(run, python, ['-m', 'venv', venv], { stdio: 'inherit', timeout: 120000 });
91
+ const py = path.join(venv, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
92
+ runChecked(run, py, ['-m', 'pip', 'install', '--upgrade', 'pip', 'pip-audit'], { stdio: 'inherit', timeout: 300000 });
93
+ runChecked(run, py, ['-m', 'pip', 'install', '--only-binary=:all:', '--no-cache-dir', '--force-reinstall',
94
+ `riciplay-cli==${required['riciplay-cli']}`], { stdio: 'inherit', timeout: 600000 });
95
+ runChecked(run, py, ['-m', 'pip', 'check'], { stdio: 'inherit', timeout: 120000 });
96
+ runChecked(run, py, ['-c', [
97
+ 'from importlib import metadata',
98
+ `expected=${JSON.stringify(required)}`,
99
+ 'assert all(metadata.version(name)==version for name,version in expected.items())',
100
+ 'try:',
101
+ ' metadata.version("mitmproxy")',
102
+ 'except metadata.PackageNotFoundError:',
103
+ ' pass',
104
+ 'else:',
105
+ ' raise SystemExit("upstream mitmproxy overlap")',
106
+ ].join('\n')], { stdio: 'inherit', timeout: 120000 });
107
+ runChecked(run, py, ['-m', 'pip_audit', '--strict', '--progress-spinner', 'off'], { stdio: 'inherit', timeout: 300000 });
108
+ } finally {
109
+ fs.rmSync(directory, { recursive: true, force: true });
110
+ }
111
+ return { metadata: [cli, mini, fork] };
112
+ }
113
+
114
+ async function writeEvidence(file, metadata) {
115
+ const releases = {};
116
+ let index = 0;
117
+ for (const [name, version] of Object.entries(required)) {
118
+ const artifacts = metadata[index++].urls.map(item => ({ filename: item.filename, sha256: item.digests && item.digests.sha256 }))
119
+ .filter(item => item.filename && /^[a-f0-9]{64}$/.test(item.sha256 || ''));
120
+ if (!artifacts.length) throw new Error(`${name} ${version} has no public SHA-256 artifact hashes`);
121
+ releases[name] = { version, artifacts };
122
+ }
123
+ const evidence = { schema: 1, created_at: new Date().toISOString(), releases };
124
+ fs.writeFileSync(file, JSON.stringify(evidence, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
125
+ return evidence;
126
+ }
127
+
128
+ if (require.main === module) verifyPublicRelease().catch(error => {
129
+ console.error(`Public release gate failed: ${error.message}`);
130
+ process.exitCode = 1;
131
+ });
132
+ module.exports = { required, publicMetadata, releaseHashes, writeEvidence, exactRequirement,
133
+ hasRangeRequirement, hasCompatibleTornado, verifyPublicRelease };
@@ -0,0 +1,27 @@
1
+ // Share interpreter selection between postinstall and the module fallback.
2
+ // Prefer the active venv's generic command; skip Python <3.12 rather than
3
+ // installing into an unrelated pip or running an unsupported interpreter.
4
+ const { spawnSync } = require('child_process');
5
+
6
+ const minimumPython = '3.12';
7
+ const probe =
8
+ "import sys; sys.exit(1) if sys.version_info < (3, 12) or sys.version_info.major != 3 else None; print(sys.executable)";
9
+
10
+ function resolvePython({ platform = process.platform, run = spawnSync } = {}) {
11
+ const candidates = platform === 'win32'
12
+ ? ['python', 'python3', 'python3.12', 'python3.13', 'python3.14', 'python3.15']
13
+ : ['python3', 'python3.12', 'python3.13', 'python3.14', 'python3.15', 'python'];
14
+ for (const command of candidates) {
15
+ try {
16
+ const result = run(command, ['-c', probe], { encoding: 'utf8', timeout: 10000 });
17
+ if (result.status === 0 && result.stdout && String(result.stdout).trim()) {
18
+ return String(result.stdout).trim();
19
+ }
20
+ } catch {
21
+ // Missing/broken interpreter: try the next candidate.
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+
27
+ module.exports = { minimumPython, resolvePython };
@@ -0,0 +1,39 @@
1
+ const { spawnSync } = require('child_process');
2
+
3
+ function runtimeReady(info, target) {
4
+ if (!info || info.cli_version !== target) return false;
5
+ const python = info.python_version;
6
+ if (!Array.isArray(python) || python.length !== 3 ||
7
+ !python.every(n => Number.isSafeInteger(n) && n >= 0) ||
8
+ python[0] !== 3 || python[1] < 12) return false;
9
+ // The same Python process evaluates the declared floors with packaging's
10
+ // PEP 440 semantics. Do not substitute a JavaScript semver parser: valid
11
+ // post/local releases are not npm semver, and prereleases must stay rejected.
12
+ return typeof info.tornado_version === 'string' &&
13
+ typeof info.mitmproxy_version === 'string' &&
14
+ info.proxy_distribution === 'riciplay-mitmproxy' &&
15
+ info.upstream_mitmproxy_version === null &&
16
+ info.proxy_dependencies_supported === true;
17
+ }
18
+
19
+ function upstreamAbsent(python, run = spawnSync) {
20
+ try {
21
+ const result = run(python, ['-c',
22
+ 'from importlib.metadata import version,PackageNotFoundError\ntry: version("mitmproxy")\nexcept PackageNotFoundError: raise SystemExit(0)\nraise SystemExit(1)'],
23
+ { encoding: 'utf8', timeout: 10000, maxBuffer: 65536 });
24
+ return result.status === 0;
25
+ } catch { return false; }
26
+ }
27
+
28
+ function checkRuntime(command, prefix, target, run = spawnSync) {
29
+ try {
30
+ const result = run(command, [...prefix, 'launcher-info'], {
31
+ encoding: 'utf8', timeout: 20000, maxBuffer: 65536,
32
+ });
33
+ return result.status === 0 && runtimeReady(JSON.parse(result.stdout), target);
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ module.exports = { runtimeReady, checkRuntime, upstreamAbsent };
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+ // Regression test for npm/bin/riciplay launcher path resolution.
3
+ //
4
+ // Fails on the pre-fix launcher (a hardcoded /usr/local/bin, /usr/bin,
5
+ // ~/.local/bin scan that matched the npm wrapper's own symlink → infinite
6
+ // recursion, and that ignored a venv/pipx console script → wrong interpreter)
7
+ // and passes once resolution goes through PATH while explicitly skipping the
8
+ // wrapper's own file.
9
+
10
+ const assert = require('assert');
11
+ const { spawnSync } = require('child_process');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const REPO_CLI = path.resolve(__dirname, '..', '..');
17
+ const WRAPPER = path.join(REPO_CLI, 'npm', 'bin', 'riciplay');
18
+
19
+ let failures = 0;
20
+ function test(name, fn) {
21
+ try {
22
+ const result = fn();
23
+ if (result && typeof result.then === 'function') {
24
+ throw new Error('async tests must use testAsync');
25
+ }
26
+ console.log(`ok - ${name}`);
27
+ } catch (err) {
28
+ failures += 1;
29
+ console.error(`FAIL - ${name}`);
30
+ console.error(` ${err && err.message}`);
31
+ }
32
+ }
33
+
34
+ const pending = [];
35
+ function testAsync(name, fn) {
36
+ pending.push(Promise.resolve().then(fn).then(
37
+ () => console.log(`ok - ${name}`),
38
+ err => { failures += 1; console.error(`FAIL - ${name}`); console.error(` ${err && err.message}`); },
39
+ ));
40
+ }
41
+
42
+ function tmpdir() {
43
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'riciplay-launcher-'));
44
+ }
45
+
46
+ function writeFakeConsoleScript(dir) {
47
+ const target = path.join(dir, 'riciplay');
48
+ const info = JSON.stringify(safeRuntime());
49
+ fs.writeFileSync(target, `#!/bin/sh\nif [ "$1" = "launcher-info" ]; then\n echo '${info}'\nelse\n echo RICIPLAY_FAKE_CONSOLE_SCRIPT "$@"\nfi\n`);
50
+ fs.chmodSync(target, 0o755);
51
+ return target;
52
+ }
53
+
54
+ const launcher = require(WRAPPER);
55
+ const { resolvePython, minimumPython } = require('../scripts/python');
56
+ const postinstall = require('../scripts/postinstall');
57
+ const { runtimeReady, checkRuntime } = require('../scripts/runtime');
58
+ const { checkReadiness } = require('../scripts/check-publish-ready');
59
+ const publicGate = require('../scripts/public-release-gate');
60
+ assert.strictEqual(typeof launcher.resolveConsoleScript, 'function');
61
+
62
+ function safeRuntime(overrides = {}) {
63
+ return {
64
+ cli_version: postinstall.npmVersion, python_version: [3, 12, 3],
65
+ tornado_version: '6.6', mitmproxy_version: '12.3.1.1',
66
+ proxy_distribution: 'riciplay-mitmproxy', upstream_mitmproxy_version: null,
67
+ proxy_dependencies_supported: true, ...overrides,
68
+ };
69
+ }
70
+
71
+ test('runtime gate requires exact CLI, supported Python and both dependency floors', () => {
72
+ assert.strictEqual(runtimeReady(safeRuntime(), postinstall.npmVersion), true);
73
+ for (const info of [
74
+ null, {}, safeRuntime({ cli_version: '1.9.2' }),
75
+ safeRuntime({ python_version: [3, 11, 9] }), safeRuntime({ python_version: [4, 0, 0] }),
76
+ safeRuntime({ python_version: ['3', 12, 3] }),
77
+ safeRuntime({ proxy_dependencies_supported: false }),
78
+ safeRuntime({ proxy_dependencies_supported: undefined }),
79
+ safeRuntime({ proxy_dependencies_supported: 'true' }),
80
+ safeRuntime({ tornado_version: null }), safeRuntime({ mitmproxy_version: null }),
81
+ safeRuntime({ proxy_distribution: 'mitmproxy' }), safeRuntime({ upstream_mitmproxy_version: '12.3.1' }),
82
+ ]) assert.strictEqual(runtimeReady(info, postinstall.npmVersion), false, JSON.stringify(info));
83
+ assert.strictEqual(runtimeReady(safeRuntime({
84
+ tornado_version: '6.6.post1', mitmproxy_version: '12.3.1.1+local',
85
+ }), postinstall.npmVersion), true);
86
+ });
87
+
88
+ test('runtime probe fails closed on old commands, malformed output and timeouts', () => {
89
+ for (const run of [
90
+ () => ({ status: 1, stdout: JSON.stringify(safeRuntime()) }),
91
+ () => ({ status: 0, stdout: 'riciplay-cli 1.9.2' }),
92
+ () => { throw new Error('timed out'); },
93
+ ]) assert.strictEqual(checkRuntime('/candidate', [], postinstall.npmVersion, run), false);
94
+ assert.strictEqual(checkRuntime('/candidate', [], postinstall.npmVersion, (cmd, argv, options) => {
95
+ assert.deepStrictEqual(argv, ['launcher-info']);
96
+ assert.strictEqual(options.timeout, 20000);
97
+ return { status: 0, stdout: JSON.stringify(safeRuntime()) };
98
+ }), true);
99
+ });
100
+
101
+ test('recovery command safely quotes the selected POSIX interpreter', () => {
102
+ const python = "/venv with 'quotes' and $(false)/python";
103
+ const result = spawnSync('/bin/sh', ['-c', `printf '%s' ${postinstall.quotePython(python, 'linux')}`], {
104
+ encoding: 'utf8', timeout: 10000,
105
+ });
106
+ assert.strictEqual(result.status, 0, result.stderr);
107
+ assert.strictEqual(result.stdout, python);
108
+ });
109
+
110
+ test('publish hold rejects missing, malformed, mismatched, and blocked states', () => {
111
+ const root = tmpdir();
112
+ const file = path.join(root, 'readiness.json');
113
+ assert.strictEqual(checkReadiness(file).ready, false);
114
+ fs.writeFileSync(file, '{');
115
+ assert.strictEqual(checkReadiness(file).ready, false);
116
+ for (const state of [
117
+ { version: '0.0.1', blocked: false, reason: 'verified' },
118
+ { version: postinstall.npmVersion, blocked: true, reason: 'dependency hold' },
119
+ { version: postinstall.npmVersion, blocked: 'false', reason: 'invalid' },
120
+ { version: postinstall.npmVersion, blocked: false },
121
+ ]) {
122
+ fs.writeFileSync(file, JSON.stringify(state));
123
+ assert.strictEqual(checkReadiness(file).ready, false);
124
+ }
125
+ fs.writeFileSync(file, JSON.stringify({ version: postinstall.npmVersion, blocked: false, reason: 'verified gates' }));
126
+ assert.strictEqual(checkReadiness(file).ready, true);
127
+ });
128
+
129
+ testAsync('public release gate requires exact non-yanked dependency metadata', async () => {
130
+ const records = {
131
+ 'riciplay-cli': { requires_dist: ['riciplay-miniproxy>=0.9.7,<2', 'riciplay-mitmproxy==12.3.1.1'] },
132
+ 'riciplay-miniproxy': { requires_dist: ['riciplay-mitmproxy==12.3.1.1'] },
133
+ 'riciplay-mitmproxy': { requires_dist: ['tornado>=6.6,<7'] },
134
+ };
135
+ for (const [name, version] of Object.entries(publicGate.required)) {
136
+ const data = await publicGate.publicMetadata(name, version, async url => ({
137
+ ok: true, status: 200, json: async () => ({ info: { version, ...records[name] },
138
+ urls: [{ yanked: false, filename: `${name}.whl`, digests: { sha256: 'a'.repeat(64) } }] }),
139
+ }));
140
+ assert.strictEqual(data.info.version, version);
141
+ }
142
+ assert.strictEqual(publicGate.exactRequirement(records['riciplay-cli'].requires_dist, 'riciplay-mitmproxy', '12.3.1.1'), true);
143
+ assert.strictEqual((await publicGate.releaseHashes('riciplay-cli', '1.9.3', async () => ({
144
+ ok: true, status: 200, json: async () => ({ info: { version: '1.9.3' },
145
+ urls: [{ yanked: false, filename: 'cli.whl', digests: { sha256: 'b'.repeat(64) } }] }),
146
+ })))[0].sha256, 'b'.repeat(64));
147
+ assert.strictEqual(publicGate.hasRangeRequirement(records['riciplay-cli'].requires_dist, 'riciplay-miniproxy', '0\\.9\\.7', '2'), true);
148
+ assert.strictEqual(publicGate.hasCompatibleTornado(['tornado<=6.6,>=6.6']), true);
149
+ assert.strictEqual(publicGate.hasCompatibleTornado(['tornado>=6.5,<7']), false);
150
+ assert.strictEqual(publicGate.exactRequirement(['riciplay-mitmproxy>=12.3.1'], 'riciplay-mitmproxy', '12.3.1.1'), false);
151
+ await assert.rejects(() => publicGate.publicMetadata('riciplay-cli', '1.9.3', async () => ({
152
+ ok: true, status: 200, json: async () => ({ info: { version: '1.9.3' }, urls: [{ yanked: true }] }),
153
+ })), /non-yanked/);
154
+ });
155
+
156
+ testAsync('public gate force-reinstalls exact public CLI and audits the resolved environment', async () => {
157
+ const metadata = Object.entries(publicGate.required).map(([name, version]) => ({
158
+ info: { version, requires_dist: name === 'riciplay-cli'
159
+ ? ['riciplay-miniproxy>=0.9.7,<2', 'riciplay-mitmproxy==12.3.1.1']
160
+ : name === 'riciplay-miniproxy' ? ['riciplay-mitmproxy==12.3.1.1'] : ['tornado>=6.6,<7'] },
161
+ urls: [{ yanked: false, filename: `${name}.whl`, digests: { sha256: 'c'.repeat(64) } }],
162
+ }));
163
+ const calls = [];
164
+ const run = (command, args) => {
165
+ calls.push([command, args]);
166
+ if (args[1] === 'venv') {
167
+ const py = path.join(args[2], process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
168
+ fs.mkdirSync(path.dirname(py), { recursive: true });
169
+ fs.writeFileSync(py, '');
170
+ }
171
+ return { status: 0 };
172
+ };
173
+ const previousPython = process.env.RICIPLAY_RELEASE_PYTHON;
174
+ process.env.RICIPLAY_RELEASE_PYTHON = process.platform === 'win32'
175
+ ? 'C:\\Python312\\python.exe' : '/usr/bin/python3.12';
176
+ try {
177
+ await publicGate.verifyPublicRelease({ metadata, run, tempRoot: tmpdir() });
178
+ } finally {
179
+ if (previousPython === undefined) delete process.env.RICIPLAY_RELEASE_PYTHON;
180
+ else process.env.RICIPLAY_RELEASE_PYTHON = previousPython;
181
+ }
182
+ const install = calls.find(([, args]) => args.includes('riciplay-cli==1.9.3'))[1];
183
+ assert(install.includes('--only-binary=:all:') && install.includes('--force-reinstall'));
184
+ assert(calls.some(([, args]) => args.includes('check')));
185
+ assert(calls.some(([, args]) => args.includes('pip_audit') && args.includes('--strict')));
186
+ });
187
+
188
+ testAsync('public gate rejects PATH-relative release interpreters before running commands', async () => {
189
+ const previous = process.env.RICIPLAY_RELEASE_PYTHON;
190
+ process.env.RICIPLAY_RELEASE_PYTHON = 'python3.12';
191
+ const metadata = [
192
+ { info: { requires_dist: ['riciplay-miniproxy>=0.9.7,<2', 'riciplay-mitmproxy==12.3.1.1'] }, urls: [] },
193
+ { info: { requires_dist: ['riciplay-mitmproxy==12.3.1.1'] }, urls: [] },
194
+ { info: { requires_dist: ['tornado>=6.6,<7'] }, urls: [] },
195
+ ];
196
+ try {
197
+ await assert.rejects(() => publicGate.verifyPublicRelease({ metadata,
198
+ run: () => { throw new Error('must not run'); } }),
199
+ /absolute validated Python/);
200
+ } finally {
201
+ if (previous === undefined) delete process.env.RICIPLAY_RELEASE_PYTHON;
202
+ else process.env.RICIPLAY_RELEASE_PYTHON = previous;
203
+ }
204
+ });
205
+
206
+ test('Python selection preserves a supported active venv', () => {
207
+ const calls = [];
208
+ const python = resolvePython({ platform: 'linux', run: (cmd, argv, options) => {
209
+ calls.push(cmd);
210
+ assert.match(argv[1], /sys.version_info < \(3, 12\)/);
211
+ assert.match(argv[1], /sys.version_info.major != 3/);
212
+ assert.strictEqual(options.timeout, 10000);
213
+ return { status: 0, stdout: '/active venv/bin/python\n' };
214
+ } });
215
+ assert.strictEqual(python, '/active venv/bin/python');
216
+ assert.deepStrictEqual(calls, ['python3']);
217
+ assert.strictEqual(minimumPython, '3.12');
218
+ });
219
+
220
+ test('Python selection skips an unsupported generic Python', () => {
221
+ const calls = [];
222
+ const python = resolvePython({ platform: 'linux', run: (cmd) => {
223
+ calls.push(cmd);
224
+ return cmd === 'python3.12'
225
+ ? { status: 0, stdout: '/python312/bin/python\n' }
226
+ : { status: 1, stdout: '' };
227
+ } });
228
+ assert.strictEqual(python, '/python312/bin/python');
229
+ assert.deepStrictEqual(calls, ['python3', 'python3.12']);
230
+ });
231
+
232
+ test('Python selection fails closed for missing, broken, or empty probes', () => {
233
+ assert.strictEqual(resolvePython({ run: () => ({ status: 1 }) }), null);
234
+ assert.strictEqual(resolvePython({ run: () => ({ status: 0, stdout: ' \n' }) }), null);
235
+ assert.strictEqual(resolvePython({ run: () => { throw new Error('not found'); } }), null);
236
+ });
237
+
238
+ test('Windows Python selection probes python first', () => {
239
+ const calls = [];
240
+ assert.strictEqual(resolvePython({ platform: 'win32', run: (cmd) => {
241
+ calls.push(cmd);
242
+ return { status: 0, stdout: 'C:\\Python312\\python.exe\n' };
243
+ } }), 'C:\\Python312\\python.exe');
244
+ assert.deepStrictEqual(calls, ['python']);
245
+ });
246
+
247
+ function capturePostinstall(run) {
248
+ const logs = [], warnings = [];
249
+ const log = console.log, warn = console.warn;
250
+ try {
251
+ console.log = (...args) => logs.push(args.join(' '));
252
+ console.warn = (...args) => warnings.push(args.join(' '));
253
+ postinstall.main({ run });
254
+ } finally {
255
+ console.log = log;
256
+ console.warn = warn;
257
+ }
258
+ return { logs: logs.join('\n'), warnings: warnings.join('\n') };
259
+ }
260
+
261
+ test('postinstall uses the selected interpreter pip and verifies the exact version', () => {
262
+ const calls = [];
263
+ let installed = false;
264
+ const output = capturePostinstall((cmd, argv) => {
265
+ calls.push([cmd, argv]);
266
+ if (argv[0] === '-c' && argv[1].includes('sys.version_info')) {
267
+ return { status: 0, stdout: '/venv with spaces/bin/python\n' };
268
+ }
269
+ assert.strictEqual(cmd, '/venv with spaces/bin/python');
270
+ if (argv[0] === '-c' && argv[1].includes('PackageNotFoundError')) return { status: 0 };
271
+ if (argv[0] === '-m') {
272
+ if (argv[1] === 'riciplay_cli.main') return { status: 0, stdout: JSON.stringify(safeRuntime()) };
273
+ assert.deepStrictEqual(argv, ['-m', 'pip', 'install', '--upgrade', postinstall.pipSpec()]);
274
+ installed = true;
275
+ return { status: 0 };
276
+ }
277
+ return installed
278
+ ? { status: 0, stdout: `${postinstall.npmVersion}\n` }
279
+ : { status: 1 };
280
+ });
281
+ assert.strictEqual(calls.length, 6); // Python, version, overlap, install, version, runtime
282
+ assert.match(output.logs, /✓ Installed riciplay-cli/);
283
+ assert.strictEqual(output.warnings, '');
284
+ });
285
+
286
+ test('postinstall never invokes pip without a supported Python', () => {
287
+ const output = capturePostinstall((cmd, argv) => {
288
+ assert.strictEqual(argv[0], '-c');
289
+ return { status: 1 };
290
+ });
291
+ assert.match(output.warnings, /needs Python 3\.12\+/);
292
+ assert.doesNotMatch(output.logs, /✓ Installed/);
293
+ });
294
+
295
+ test('postinstall rejects pip failure and a successful install with stale readback', () => {
296
+ for (const pipStatus of [0, 1]) {
297
+ const output = capturePostinstall((cmd, argv) => {
298
+ if (argv[0] === '-c' && argv[1].includes('sys.version_info')) {
299
+ return { status: 0, stdout: '/venv/bin/python\n' };
300
+ }
301
+ if (argv[0] === '-c' && argv[1].includes('PackageNotFoundError')) return { status: 0 };
302
+ if (argv[0] === '-m') return { status: pipStatus };
303
+ return { status: 0, stdout: '0.0.1\n' };
304
+ });
305
+ assert.match(output.warnings, /Could not install/);
306
+ assert.match(output.warnings, /'\/venv\/bin\/python' -m pip install/);
307
+ assert.doesNotMatch(output.logs, /✓ Installed/);
308
+ }
309
+ });
310
+
311
+ test('postinstall keeps the exact already-installed version without invoking pip', () => {
312
+ const output = capturePostinstall((cmd, argv) => {
313
+ if (argv[0] === '-m') {
314
+ assert.strictEqual(argv[1], 'riciplay_cli.main');
315
+ return { status: 0, stdout: JSON.stringify(safeRuntime()) };
316
+ }
317
+ assert.strictEqual(argv[0], '-c');
318
+ return {
319
+ status: 0,
320
+ stdout: argv[1].includes('sys.version_info') ? '/venv/bin/python\n' : `${postinstall.npmVersion}\n`,
321
+ };
322
+ });
323
+ assert.match(output.logs, /already installed/);
324
+ assert.strictEqual(output.warnings, '');
325
+ });
326
+
327
+ test('postinstall refuses overlapping upstream installs without mutation', () => {
328
+ const output = capturePostinstall((cmd, argv) => {
329
+ assert.strictEqual(argv[0], '-c');
330
+ if (argv[1].includes('sys.version_info')) return { status: 0, stdout: '/venv/bin/python\n' };
331
+ if (argv[1].includes('PackageNotFoundError')) return { status: 1 };
332
+ return { status: 0, stdout: '1.9.2\n' };
333
+ });
334
+ assert.match(output.warnings, /fresh Python 3\.12\+ venv/);
335
+ assert.doesNotMatch(output.logs, /✓ Installed/);
336
+ });
337
+
338
+ test('resolveConsoleScript skips its own npm symlink and picks the PATH script', () => {
339
+ const root = tmpdir();
340
+ const npmbin = path.join(root, 'npmbin');
341
+ const venvbin = path.join(root, 'venv', 'bin');
342
+ fs.mkdirSync(npmbin);
343
+ fs.mkdirSync(venvbin, { recursive: true });
344
+ const real = writeFakeConsoleScript(venvbin);
345
+ const selfLink = path.join(npmbin, 'riciplay');
346
+ fs.symlinkSync(WRAPPER, selfLink);
347
+
348
+ const resolved = launcher.resolveConsoleScript({
349
+ platform: 'linux',
350
+ env: { PATH: `${npmbin}:${venvbin}` },
351
+ argv1: selfLink,
352
+ filename: WRAPPER,
353
+ });
354
+ assert.strictEqual(resolved, real, `expected ${real}, got ${resolved}`);
355
+ });
356
+
357
+ test('venv/pipx console script is found when PATH only contains it (bug 3b)', () => {
358
+ const root = tmpdir();
359
+ const venvbin = path.join(root, 'venv', 'bin');
360
+ fs.mkdirSync(venvbin, { recursive: true });
361
+ const real = writeFakeConsoleScript(venvbin);
362
+
363
+ const resolved = launcher.resolveConsoleScript({
364
+ platform: 'linux',
365
+ env: { PATH: venvbin },
366
+ argv1: path.join(root, 'npmbin', 'riciplay'),
367
+ filename: WRAPPER,
368
+ });
369
+ assert.strictEqual(resolved, real);
370
+ });
371
+
372
+ test('returns null when the only riciplay on PATH is the wrapper itself', () => {
373
+ const root = tmpdir();
374
+ const npmbin = path.join(root, 'npmbin');
375
+ fs.mkdirSync(npmbin);
376
+ const selfLink = path.join(npmbin, 'riciplay');
377
+ fs.symlinkSync(WRAPPER, selfLink);
378
+
379
+ const resolved = launcher.resolveConsoleScript({
380
+ platform: 'linux',
381
+ env: { PATH: npmbin },
382
+ argv1: selfLink,
383
+ filename: WRAPPER,
384
+ });
385
+ assert.strictEqual(resolved, null);
386
+ });
387
+
388
+ test('end-to-end: wrapper invoked via npm symlink runs the real script, no recursion', () => {
389
+ if (process.platform === 'win32') return; // shebang console scripts are POSIX-only
390
+ const root = tmpdir();
391
+ const npmbin = path.join(root, 'npmbin');
392
+ const venvbin = path.join(root, 'venv', 'bin');
393
+ fs.mkdirSync(npmbin);
394
+ fs.mkdirSync(venvbin, { recursive: true });
395
+ writeFakeConsoleScript(venvbin);
396
+ const selfLink = path.join(npmbin, 'riciplay');
397
+ fs.symlinkSync(WRAPPER, selfLink);
398
+
399
+ const proc = spawnSync(process.execPath, [selfLink, '--version'], {
400
+ encoding: 'utf8',
401
+ timeout: 20000,
402
+ env: { PATH: `${npmbin}:${venvbin}`, HOME: root },
403
+ });
404
+ assert.strictEqual(
405
+ proc.status, 0,
406
+ `launcher exited ${proc.status}; stdout=${proc.stdout} stderr=${proc.stderr}`,
407
+ );
408
+ assert.match(proc.stdout, /RICIPLAY_FAKE_CONSOLE_SCRIPT/);
409
+ });
410
+
411
+ test('module fallback skips unsupported python3 and uses python3.12', () => {
412
+ if (process.platform === 'win32') return;
413
+ const root = tmpdir();
414
+ const oldPython = path.join(root, 'python3');
415
+ const newPython = path.join(root, 'python3.12');
416
+ fs.writeFileSync(oldPython, '#!/bin/sh\nexit 1\n');
417
+ fs.writeFileSync(newPython, `#!/bin/sh\ncase "$2" in\n *sys.version_info*) echo "$0";;\n *) if [ "$3" = "launcher-info" ]; then echo '${JSON.stringify(safeRuntime())}'; else echo RICIPLAY_PY312 "$@"; fi;;\nesac\n`);
418
+ fs.chmodSync(oldPython, 0o755);
419
+ fs.chmodSync(newPython, 0o755);
420
+ const proc = spawnSync(process.execPath, [WRAPPER, '--version'], {
421
+ encoding: 'utf8', timeout: 20000, env: { PATH: root, HOME: root },
422
+ });
423
+ assert.strictEqual(proc.status, 0, proc.stderr);
424
+ assert.match(proc.stdout, /RICIPLAY_PY312.*--version/);
425
+ });
426
+
427
+ test('PATH candidate cannot dispatch user commands with stale versions or unsafe runtimes', () => {
428
+ if (process.platform === 'win32') return;
429
+ for (const info of [
430
+ safeRuntime({ cli_version: '1.9.2' }), safeRuntime({ python_version: [3, 10, 9] }),
431
+ safeRuntime({ tornado_version: '6.5.10', proxy_dependencies_supported: false }),
432
+ safeRuntime({ mitmproxy_version: '10.3.0', proxy_dependencies_supported: false }),
433
+ ]) {
434
+ const root = tmpdir();
435
+ const script = path.join(root, 'riciplay');
436
+ fs.writeFileSync(script, `#!/bin/sh\nif [ "$1" = "launcher-info" ]; then echo '${JSON.stringify(info)}'; else echo UNSAFE_DISPATCH; fi\n`);
437
+ fs.chmodSync(script, 0o755);
438
+ const proc = spawnSync(process.execPath, [WRAPPER, 'chat'], {
439
+ encoding: 'utf8', timeout: 20000, env: { PATH: root, HOME: root },
440
+ });
441
+ assert.strictEqual(proc.status, 1, proc.stderr);
442
+ assert.match(proc.stderr, /PATH riciplay is incompatible/);
443
+ assert.doesNotMatch(proc.stdout, /UNSAFE_DISPATCH/);
444
+ }
445
+ });
446
+
447
+ test('module fallback reports Python 3.12 minimum when no interpreter exists', () => {
448
+ const root = tmpdir();
449
+ const proc = spawnSync(process.execPath, [WRAPPER, '--version'], {
450
+ encoding: 'utf8', timeout: 20000, env: { PATH: root, HOME: root },
451
+ });
452
+ assert.strictEqual(proc.status, 1, proc.stderr);
453
+ assert.match(proc.stderr, /requires Python 3\.12\+/);
454
+ });
455
+
456
+ Promise.all(pending).then(() => {
457
+ if (failures > 0) {
458
+ console.error(`\n${failures} launcher test(s) failed`);
459
+ process.exit(1);
460
+ }
461
+ console.log('\nall launcher tests passed');
462
+ });