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 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(python, candidate, homeDir, version, runStepFn, downloader);
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(python, url, homeDir, version, runStepFn, downloader) {
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.stdout;
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.6",
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",