iterate-harness 2.2.6 → 2.2.7
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 +4 -1
- package/README.zh-CN.md +2 -0
- package/lib/bootstrap.js +132 -2
- package/lib/ui.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,10 @@ delegation logic used at install time and at runtime is the same:
|
|
|
37
37
|
unreachable or its TLS cert fails to verify. If PyPI is unavailable, the
|
|
38
38
|
wrapper falls back to the pre-built **wheel** on the GitHub release
|
|
39
39
|
(frontend assets baked in, like iterate-skill-installer ships pre-wrapped
|
|
40
|
-
assets), then the pinned **source archive** as a final resort.
|
|
40
|
+
assets), then the pinned **source archive** as a final resort. Every artifact
|
|
41
|
+
the wrapper downloads itself is hashed with SHA256 and verified against the
|
|
42
|
+
release's `.sha256` sidecar before pip installs it — a tampered or
|
|
43
|
+
corrupted download is refused, never installed.
|
|
41
44
|
4. Delegates to the venv's real `ih` executable with argv, stdio, signals and
|
|
42
45
|
exit codes forwarded.
|
|
43
46
|
|
package/README.zh-CN.md
CHANGED
|
@@ -32,6 +32,8 @@ harness 本身是**一个 Python 包**。这个 npm 包是一个薄分发包装
|
|
|
32
32
|
解析 `iterate-harness==X.Y.Z`,即使 GitHub 不可达或 TLS 证书校验失败也能安装。
|
|
33
33
|
若 PyPI 不可用,再兑底 GitHub release 上的**预构建 wheel**(已内置编译好的前端
|
|
34
34
|
资源——与 iterate-skill-installer 分发预打包资源一致),最后才用锁定的**源码归档**。
|
|
35
|
+
本包装器自行下载的每个产物都会在 pip 安装前用 SHA256 与 release 发布的 `.sha256`
|
|
36
|
+
校验文件做完整性校验——被篡改或损坏的下载会被拒绝,绝不安装。
|
|
35
37
|
4. 代理调用 venv 里真正的 `ih` 可执行文件,转发 argv、stdio、信号与退出码。
|
|
36
38
|
|
|
37
39
|
若 `npm install` 期间无法完成安装(无 Python、无网络,或
|
package/lib/bootstrap.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"use strict";
|
|
32
32
|
|
|
33
33
|
const { spawn, spawnSync } = require("child_process");
|
|
34
|
+
const crypto = require("crypto");
|
|
34
35
|
const fs = require("fs");
|
|
35
36
|
const http = require("http");
|
|
36
37
|
const https = require("https");
|
|
@@ -52,6 +53,12 @@ const MAX_DOWNLOAD_REDIRECTS = 5;
|
|
|
52
53
|
const DOWNLOAD_TIMEOUT_MS = 120000;
|
|
53
54
|
const CURL_MAX_TIME_SECONDS = 240;
|
|
54
55
|
|
|
56
|
+
// Integrity: every release publishes a `<artifact>.sha256` sidecar next to the
|
|
57
|
+
// artifact. Any bytes this wrapper downloads itself are hashed and compared
|
|
58
|
+
// against that sidecar before pip is allowed to install them.
|
|
59
|
+
const CHECKSUM_ASSET_SUFFIX = ".sha256";
|
|
60
|
+
const MAX_CHECKSUM_BYTES = 4096;
|
|
61
|
+
|
|
55
62
|
const PYTHON_ENV_VAR = "ITERATE_HARNESS_PYTHON";
|
|
56
63
|
const HOME_ENV_VAR = "ITERATE_HARNESS_NPM_HOME";
|
|
57
64
|
const INSTALL_URL_ENV_VAR = "ITERATE_HARNESS_INSTALL_URL";
|
|
@@ -167,6 +174,103 @@ function isRemoteHttpUrl(target) {
|
|
|
167
174
|
return /^https?:\/\//i.test(String(target || ""));
|
|
168
175
|
}
|
|
169
176
|
|
|
177
|
+
function checksumAssetUrl(url) {
|
|
178
|
+
return `${url}${CHECKSUM_ASSET_SUFFIX}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A `.sha256` sidecar is either a bare 64-char hex digest or the common
|
|
182
|
+
// `<hex> <filename>` line emitted by `sha256sum` / `shasum -a 256`.
|
|
183
|
+
function parseChecksum(content) {
|
|
184
|
+
const match = /([0-9a-fA-F]{64})/.exec(String(content || ""));
|
|
185
|
+
return match ? match[1].toLowerCase() : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sha256File(filePath) {
|
|
189
|
+
const hash = crypto.createHash("sha256");
|
|
190
|
+
hash.update(fs.readFileSync(filePath));
|
|
191
|
+
return hash.digest("hex");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Small text fetch with the same redirect/timeout discipline as downloadFile.
|
|
195
|
+
function fetchChecksumText(url, redirectBudget) {
|
|
196
|
+
const budget = redirectBudget === undefined ? MAX_DOWNLOAD_REDIRECTS : redirectBudget;
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
if (budget < 0) {
|
|
199
|
+
reject(new BootstrapError(`too many redirects while fetching ${url}`));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let parsed;
|
|
203
|
+
try {
|
|
204
|
+
parsed = new URL(url);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
reject(new BootstrapError(`invalid checksum URL ${url}: ${error.message}`));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const client = parsed.protocol === "http:" ? http : https;
|
|
210
|
+
const request = client.get(url, { timeout: DOWNLOAD_TIMEOUT_MS }, (response) => {
|
|
211
|
+
const status = response.statusCode || 0;
|
|
212
|
+
if (status >= 300 && status < 400 && response.headers.location) {
|
|
213
|
+
response.resume();
|
|
214
|
+
resolve(fetchChecksumText(new URL(response.headers.location, url).toString(), budget - 1));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (status < 200 || status >= 300) {
|
|
218
|
+
response.resume();
|
|
219
|
+
reject(new BootstrapError(`fetching ${url} failed with HTTP ${status}`));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const chunks = [];
|
|
223
|
+
let size = 0;
|
|
224
|
+
response.on("data", (chunk) => {
|
|
225
|
+
size += chunk.length;
|
|
226
|
+
if (size > MAX_CHECKSUM_BYTES) {
|
|
227
|
+
request.destroy(new BootstrapError(`checksum file ${url} is unexpectedly large`));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
chunks.push(chunk);
|
|
231
|
+
});
|
|
232
|
+
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
233
|
+
response.on("error", (responseError) => reject(responseError));
|
|
234
|
+
});
|
|
235
|
+
request.on("timeout", () => {
|
|
236
|
+
request.destroy(new BootstrapError(`fetching ${url} timed out`));
|
|
237
|
+
});
|
|
238
|
+
request.on("error", (requestError) => reject(requestError));
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Verify a self-downloaded artifact against its published `.sha256` sidecar.
|
|
243
|
+
// Returns true when a checksum was present and matched, false when none is
|
|
244
|
+
// published (pre-2.2.7 releases predate the sidecar), and throws on mismatch:
|
|
245
|
+
// corrupted or tampered bytes must never reach pip.
|
|
246
|
+
async function verifyDownloadedArtifact(url, filePath, fetchChecksum) {
|
|
247
|
+
let content;
|
|
248
|
+
try {
|
|
249
|
+
content = await fetchChecksum(checksumAssetUrl(url));
|
|
250
|
+
} catch (error) {
|
|
251
|
+
process.stderr.write(
|
|
252
|
+
`[iterate-harness] warning: no SHA256 checksum published for ${url} ` +
|
|
253
|
+
`(${error.message}); skipping integrity check\n`
|
|
254
|
+
);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const expected = parseChecksum(content);
|
|
258
|
+
if (!expected) {
|
|
259
|
+
process.stderr.write(
|
|
260
|
+
`[iterate-harness] warning: unreadable SHA256 checksum for ${url}; skipping integrity check\n`
|
|
261
|
+
);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
const actual = sha256File(filePath);
|
|
265
|
+
if (actual !== expected) {
|
|
266
|
+
fs.rmSync(filePath, { force: true });
|
|
267
|
+
throw new BootstrapError(
|
|
268
|
+
`SHA256 mismatch for ${url}: expected ${expected}, got ${actual}; refusing to install`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
|
|
170
274
|
// Infer the on-disk extension (for the cache file) from a download URL so
|
|
171
275
|
// pip-installing the local copy keeps a correct .whl / .tar.gz suffix.
|
|
172
276
|
function artifactExtensionFor(url) {
|
|
@@ -442,6 +546,7 @@ async function installHarness(options) {
|
|
|
442
546
|
const candidates = options.candidates;
|
|
443
547
|
const runStepFn = options.runStepFn || runStep;
|
|
444
548
|
const downloader = options.downloader || downloadTarballTo;
|
|
549
|
+
const checksumFetcher = options.checksumFetcher || fetchChecksumText;
|
|
445
550
|
|
|
446
551
|
if (!candidates || candidates.length === 0) {
|
|
447
552
|
throw new BootstrapError("no install candidate URLs were provided");
|
|
@@ -453,7 +558,15 @@ async function installHarness(options) {
|
|
|
453
558
|
if (isRemoteHttpUrl(candidate)) {
|
|
454
559
|
// HTTP(S) candidate: `pip` first, then node/curl download + local pip
|
|
455
560
|
// (survives broken Python TLS trust stores on macOS python.org builds).
|
|
456
|
-
await installRemoteArtifact(
|
|
561
|
+
await installRemoteArtifact(
|
|
562
|
+
python,
|
|
563
|
+
candidate,
|
|
564
|
+
homeDir,
|
|
565
|
+
version,
|
|
566
|
+
runStepFn,
|
|
567
|
+
downloader,
|
|
568
|
+
checksumFetcher
|
|
569
|
+
);
|
|
457
570
|
} else {
|
|
458
571
|
// Non-URL candidate (e.g. `iterate-harness==1.12.9` resolved by pip
|
|
459
572
|
// against the user's mirror): no artifact to download, just pip it.
|
|
@@ -474,7 +587,15 @@ async function installHarness(options) {
|
|
|
474
587
|
|
|
475
588
|
// pip-install a downloadable HTTP artifact, retrying with a node/curl download
|
|
476
589
|
// of the raw file + local pip install when pip's TLS trust store is broken.
|
|
477
|
-
async function installRemoteArtifact(
|
|
590
|
+
async function installRemoteArtifact(
|
|
591
|
+
python,
|
|
592
|
+
url,
|
|
593
|
+
homeDir,
|
|
594
|
+
version,
|
|
595
|
+
runStepFn,
|
|
596
|
+
downloader,
|
|
597
|
+
checksumFetcher
|
|
598
|
+
) {
|
|
478
599
|
try {
|
|
479
600
|
runStepFn(python, pipInstallArgs(url));
|
|
480
601
|
return;
|
|
@@ -485,6 +606,9 @@ async function installRemoteArtifact(python, url, homeDir, version, runStepFn, d
|
|
|
485
606
|
}
|
|
486
607
|
const cachePath = downloadCachePath(homeDir, version, artifactExtensionFor(url));
|
|
487
608
|
await downloader(url, cachePath);
|
|
609
|
+
// These are bytes we fetched ourselves, so we own their integrity: check the
|
|
610
|
+
// published SHA256 sidecar before handing the file to pip.
|
|
611
|
+
await verifyDownloadedArtifact(url, cachePath, checksumFetcher);
|
|
488
612
|
runStepFn(python, pipInstallArgs(cachePath));
|
|
489
613
|
}
|
|
490
614
|
|
|
@@ -655,12 +779,18 @@ module.exports = {
|
|
|
655
779
|
INSTALL_URL_ENV_VAR,
|
|
656
780
|
SKIP_INSTALL_ENV_VAR,
|
|
657
781
|
WHEEL_ASSET_SUFFIX,
|
|
782
|
+
CHECKSUM_ASSET_SUFFIX,
|
|
658
783
|
artifactExtensionFor,
|
|
784
|
+
checksumAssetUrl,
|
|
659
785
|
detectPython,
|
|
660
786
|
curlDownload,
|
|
661
787
|
downloadCachePath,
|
|
662
788
|
downloadFile,
|
|
663
789
|
downloadTarballTo,
|
|
790
|
+
fetchChecksumText,
|
|
791
|
+
parseChecksum,
|
|
792
|
+
sha256File,
|
|
793
|
+
verifyDownloadedArtifact,
|
|
664
794
|
ensureRuntime,
|
|
665
795
|
installCandidates,
|
|
666
796
|
installHarness,
|
package/lib/ui.js
CHANGED
|
@@ -87,7 +87,7 @@ const DEFAULT_PROMPT_TIMEOUT_MS = 120000;
|
|
|
87
87
|
|
|
88
88
|
function askYesNo(question, defaultNo = false, options = {}) {
|
|
89
89
|
const input = options.input || process.stdin;
|
|
90
|
-
const output = options.output || process.
|
|
90
|
+
const output = options.output || process.stderr;
|
|
91
91
|
const timeoutMs =
|
|
92
92
|
options.timeoutMs === undefined ? DEFAULT_PROMPT_TIMEOUT_MS : options.timeoutMs;
|
|
93
93
|
return new Promise((resolve) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-harness",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.7",
|
|
4
4
|
"description": "npm distribution wrapper for iterate-harness — installs the Python harness (ih CLI) into a managed venv on install and exposes it as a global command",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|