diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index ab1d8239b..acccd8a5a 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -96,6 +96,11 @@ jobs: node scripts/release-artifacts.mjs freeze .artifacts/release npm run build:offline git diff --exit-code HEAD + - name: Setup pinned Bun for real package-manager fixtures + if: steps.restore.outputs.restored != 'true' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: '1.3.11' - name: Install pinned pnpm for real package-manager fixtures if: steps.restore.outputs.restored != 'true' run: npm install --global --ignore-scripts pnpm@10.28.2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53f96990e..0f82ba2c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,11 @@ jobs: - uses: ./.github/actions/setup + - name: Setup pinned Bun for real package-manager fixtures + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: '1.3.11' + - name: Install pinned pnpm for real package-manager fixtures run: npm install --global --ignore-scripts pnpm@10.28.2 diff --git a/.github/workflows/daytona-behaviors.yml b/.github/workflows/daytona-behaviors.yml index 45d196fbd..fbb0de4f6 100644 --- a/.github/workflows/daytona-behaviors.yml +++ b/.github/workflows/daytona-behaviors.yml @@ -28,9 +28,13 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '22.19.0' - - name: Install pinned npm and locked dependencies + - name: Setup pinned Bun for installation harness tests + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: '1.3.11' + - name: Install pinned npm, pnpm and locked dependencies run: | - npm install -g npm@11.16.0 --ignore-scripts + npm install -g npm@11.16.0 pnpm@10.28.2 --ignore-scripts npm ci --ignore-scripts - name: Freeze, build offline, prepare and verify exact candidate bytes run: | @@ -48,16 +52,18 @@ jobs: "$RUNNER_TEMP/behavior-test-venv/bin/python" -m pip install -r scripts/daytona-behaviors/requirements.txt "$RUNNER_TEMP/behavior-test-venv/bin/python" -m unittest discover -s scripts/daytona-behaviors -p 'test_*.py' "$RUNNER_TEMP/behavior-test-venv/bin/python" -m unittest discover -s scripts -p 'test_verify_candidate_artifacts.py' - node --test scripts/artifact-installation.test.mjs scripts/artifact-acceptance.test.mjs scripts/daytona-behaviors-workflow.test.mjs scripts/daytona-behaviors/fixture.test.mjs scripts/daytona-behaviors/live-guard.test.mjs + node --test scripts/artifact-installation.test.mjs scripts/artifact-acceptance.test.mjs scripts/daytona-behaviors-workflow.test.mjs scripts/daytona-behaviors/fixture.test.mjs scripts/daytona-behaviors/live-guard.test.mjs scripts/daytona-behaviors/npm-global-install.test.mjs - name: Bundle checked behavior harness without rebuilding the candidate run: | mkdir -p .artifacts/daytona-bundle tar -czf .artifacts/daytona-bundle/bundle.tar.gz \ scripts/artifact-installation.mjs \ + scripts/npm-lifecycle-registry.mjs \ scripts/verify-candidate-artifacts.py \ scripts/fixtures/artifact-handoff.mjs \ scripts/daytona-behaviors/installed.py \ scripts/daytona-behaviors/install.mjs \ + scripts/daytona-behaviors/npm-global-install.mjs \ scripts/daytona-behaviors/fixture.mjs \ scripts/daytona-behaviors/live-guard.mjs \ scripts/daytona-behaviors/baselines @@ -301,7 +307,7 @@ jobs: failed=1 fi if [[ "$mode" == deterministic ]]; then - expected='["artifact-integrity","npm-package-integrity","bun-package-integrity","npm-installation","npm-print","npm-builtin-auth-transport","npm-terminal-tools-cancel-resume","npm-artifact-terminal","bun-installation","bun-print","bun-builtin-auth-transport","bun-terminal-tools-cancel-resume","bun-artifact-terminal"]' + expected='["artifact-integrity","npm-global-installation","npm-package-integrity","bun-package-integrity","npm-installation","npm-print","npm-builtin-auth-transport","npm-terminal-tools-cancel-resume","npm-artifact-terminal","bun-installation","bun-print","bun-builtin-auth-transport","bun-terminal-tools-cancel-resume","bun-artifact-terminal"]' else expected='["artifact-integrity","npm-package-integrity","npm-live-openai"]' fi diff --git a/README.md b/README.md index 35e97ac07..1457e9da0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Diffex is a lightweight, self-evolving coding agent that lives in your CLI. Ask Requires Node.js 22.19 or newer and npm or pnpm. ```bash -npm install -g --ignore-scripts @diffexai/diffex +npm i -g @diffexai/diffex cd your-project diffex ``` diff --git a/docs/daytona-behaviors.md b/docs/daytona-behaviors.md index 0503e74fd..25eda3ad0 100644 --- a/docs/daytona-behaviors.md +++ b/docs/daytona-behaviors.md @@ -12,6 +12,8 @@ The [2026-10-27 qualification record](daytona-behaviors-qualification.md) docume This is a standalone acceptance gate for the packaged Diffex CLI, not the hosted product's runtime image, a release publisher or a replacement for native artifact acceptance. It installs the exact reviewed candidate tarballs into isolated consumers outside the repository. The deterministic mode controls inference while exercising installed behavior; the live mode uses a bounded real OpenAI request path. +Npm behavior consumers explicitly enable dependency lifecycle scripts; Bun consumers retain the scripts-disabled path. +A separate deterministic `npm-global-installation` scenario checks normal global npm installation of both the exact candidate tarball and the currently published CLI, each in its own fresh isolated prefix and cache. A passing deterministic run is not live-provider evidence. The snapshot is toolchain-only; each run uploads its candidate artifacts and checked harness rather than baking repository source or credentials into a snapshot. Version 1 covers the Linux installed CLI only; hosted-runtime behavior and subscription/OAuth login qualification are out of scope. @@ -47,10 +49,12 @@ The workflow never sets its own qualification variable. ```text scripts/artifact-installation.mjs + scripts/npm-lifecycle-registry.mjs scripts/verify-candidate-artifacts.py scripts/fixtures/artifact-handoff.mjs scripts/daytona-behaviors/installed.py scripts/daytona-behaviors/install.mjs + scripts/daytona-behaviors/npm-global-install.mjs scripts/daytona-behaviors/fixture.mjs scripts/daytona-behaviors/live-guard.mjs scripts/daytona-behaviors/baselines/ @@ -92,6 +96,26 @@ The release and bundle artifacts contain no credentials; per-mode and aggregate They are workflow artifacts, not npm or release assets; that distinction does not make them private. Never rely on GitHub log masking or artifact access controls to protect keys. +## Normal npm installation validation + +The `npm-global-installation` scenario runs the advertised `npm i -g @diffexai/diffex` command without `--ignore-scripts`, after verifying that isolated npm configuration has `ignore-scripts=false`. +The separate candidate installation serves its retained tarball through a loopback-only registry, not an unpublished public registry version. +The fixture advertises npm's `_hasShrinkwrap` metadata only after validating the embedded lock. This matches published-package resolution: npm 10 can ignore a nested shrinkwrap when the dependency is supplied directly as a local tarball. +Synchronous local npm consumers seed their root installation lock from the same verified candidate shrinkwrap, including exact archive integrity, rather than letting a file-based install re-resolve reviewed dependency ranges. +The public installation uses the independently verified public npm archive: a candidate and the public package may share a version while having different bytes, so their inventories must never be interchanged. +Public metadata and tarball fetches reject redirects, enforce bounded reads, and verify SHA-512 registry integrity before installation; the public latest identity must remain unchanged across the run. +Each installation checks its own expected version, help, models, executable resolution through PATH, and installed runtime file hashes against its own pre-install archive inventory. +The report records script policy, cold-cache state, package identities, command outcomes, successful execution of both exact reviewed dependency hooks, and confirmed temporary-prefix cleanup. +Hook evidence is extracted from isolated npm debug logs as reviewed package/version/hook/exit tuples; raw logs, hook output, auth files and configuration are not copied into the report. +The global validator shares a 600-second deadline across downloads and commands, terminates active command process groups on timeout or cancellation, and cleans prefixes before returning. +Its Python supervisor allows 630 seconds, then sends SIGTERM to permit validator cleanup before a bounded SIGKILL fallback; a supervisory timeout remains a failure. +Missing entries, disabled scripts, hash mismatches or failed cleanup fail the installed scenario and aggregate. +The complete deterministic/live behavior suite still exercises the candidate rather than substituting public release behavior for candidate coverage. + +This is validation infrastructure, not a record that the changed workflow has passed remotely. +Public installation guidance uses the ordinary npm command, with scripts-disabled installation retained as an option. Local command validation is not remote Daytona qualification: do not enable automatic runs until the qualification below is complete. +Preparation, publishing and self-updates continue to disable lifecycle scripts. Native and Daytona npm candidate consumers explicitly opt into reviewed hooks; pnpm and Bun consumers remain scripts-disabled. This does not authorize enabling scripts throughout CI. + ## Configure access and a snapshot A repository administrator and a Daytona account owner must perform this setup. diff --git a/docs/release/artifact-acceptance.md b/docs/release/artifact-acceptance.md index 1f2bb50c3..3d77b2be6 100644 --- a/docs/release/artifact-acceptance.md +++ b/docs/release/artifact-acceptance.md @@ -6,7 +6,9 @@ Consumers live outside the repository in paths containing spaces, with separate npm and Bun start with an empty registry cache shared only among consumers in that acceptance run, and Bun registry concurrency is bounded. pnpm consumers use isolated stores, homes, configuration and global executable directories; no user store or credentials are inherited. Installed file identities are rechecked in every consumer even when downloaded dependencies are cached. -Lifecycle scripts are disabled and the Diffex registry scope points to an unavailable loopback endpoint, so a missing local override fails instead of fetching an older Diffex package. +Native candidate npm consumers explicitly enable dependency lifecycle scripts; pnpm and Bun consumers keep scripts disabled. +The shared `installConsumer` helper still disables scripts by default; acceptance opts npm into the reviewed script-enabled path. +The Diffex registry scope points to an unavailable loopback endpoint, so a missing local override fails instead of fetching an older Diffex package. External npm dependencies still resolve from the public registry, except for the reviewed Mistral provider bundle. Only that provider implementation absorbs its required pure-JavaScript SDK code; other implementation paths, declarations, lazy loading, extensions, native bindings and WASM remain unchanged. Bundling checks exact approved npm versions/licenses, records input hashes and registry integrity, preserves complete license/NOTICE text, and rehashes the final staged runtime inventory. @@ -38,6 +40,7 @@ This validates the npm installation with controlled system helpers; it does not The acceptance command also runs `scripts/npm-lifecycle.mjs` against the exact reviewed CLI tarball. It installs globally into an isolated prefix, checks PATH, version, help, and models, then invokes the installed self-updater with real npm subprocesses. Only discovery and the Diffex-scoped registry are controlled fixtures; dependency downloads use npmjs.org. +This updater/recovery harness intentionally retains scripts-disabled installation and self-update behavior; the candidate consumer and public fresh-install checks separately cover script-enabled npm installation. An isolated version-metadata predecessor exercises upgrade to the reviewed candidate without publishing a fake release. Fresh caches require actual target downloads. Malformed and foreign metadata, redirects, outages, no release, and checksum mismatches are rejected without changing the installed prefix, including force. @@ -75,7 +78,7 @@ node scripts/artifact-acceptance.mjs /path/to/artifacts /path/to/private-evidenc ``` The command accepts both the local manifest/tarballs layout and the frozen release artifact layout. -`acceptance.json` records the source commit, OS, architecture, Node and package-manager versions, each package's imports and operation result, exact tarball checksums, process outcomes, and cleanup. +`acceptance.json` records the source commit, OS, architecture, Node and package-manager versions, each package's imports and operation result, lifecycle script modes for package and process consumers, exact tarball checksums, process outcomes, and cleanup. A failed run records failure and the completed subset; it cannot produce a passing overall status. Terminal transcripts and smoke events are retained beside it in private evidence storage. Every test-owned consumer, session, cache, configuration directory, and server process is removed, including on failure. @@ -91,6 +94,11 @@ Regression coverage alters archive bytes and confirms verification fails before The release workflow retains the source snapshot and manifests privately, while npm publication supplies registry integrity through the existing publisher. Trusted publishing authenticates the private source workflow, but npm does not generate provenance for private repositories. Public acceptance must compare the actual downloaded npm bytes with these retained identities after separately authorized publication. +Its initial predecessor install and fresh release reinstall require npm's effective `ignore-scripts` configuration to be `false` before each install and omit the `--ignore-scripts` flag. +These public lifecycle installs use exact versions so the predecessor and release identities remain deterministic; the separate Daytona `npm-global-installation` scenario checks the unversioned `npm i -g @diffexai/diffex` command. +Public pnpm and Bun installs continue to disable scripts, and the installed self-updater intentionally remains scripts-disabled for every manager. +Public evidence records each initial/fresh installation's lifecycle mode and npm configuration result separately from the updater mode. +Changed Daytona scenarios remain a pending remote execution and evidence-review gate; local tests do not establish that result. The baseline CLI-only acceptance passed on Darwin arm64 but emitted no per-package operation or hosted-server result. The previous expanded acceptance covered eleven packages and the hosted server lifecycle before the owner reduced the public inventory on 2026-09-13. diff --git a/docs/release/npm-install-scripts-review.md b/docs/release/npm-install-scripts-review.md new file mode 100644 index 000000000..10a079fb5 --- /dev/null +++ b/docs/release/npm-install-scripts-review.md @@ -0,0 +1,61 @@ +# Normal npm installation review + +## Status + +The public npm installation command is `npm i -g @diffexai/diffex`. +Static dependency review and isolated script-enabled installation checks support that command; scripts-disabled installation remains supported. +The modified Daytona scenario still requires explicit remote qualification and evidence review before automatic behavior runs are enabled or the remote path is treated as qualified. Its workflow is manual or qualified post-merge, not an automatic pull-request check; environment secrets remain restricted to `main`. +This record does not claim that GitHub Actions or Daytona has executed the changed tests. + +The normal-install follow-up changes instructions and acceptance coverage, not dependency versions, lockfiles, self-update behavior or publishing safeguards. +The generated npm README now shows the ordinary command; no install or postinstall hook is added to Diffex itself. + +## Reviewed scope + +The review compared the public `@diffexai/diffex@0.4.0` archive, the current CLI shrinkwrap, and the generated installer lock. +The public archive has 228 dependency entries; the current candidate graph has 140 because the reviewed Mistral provider implementation is bundled. +These are different artifacts even though they currently share version `0.4.0`. +The public archive must not be checked against the candidate's runtime inventory or source commit. + +Read-only metadata and tarball inspection covered 225 unique dependency identities across these graphs, including optional platform packages and required peer dependencies. +Each downloaded tarball matched its registry SHA-512 integrity. +No implicit `binding.gyp` installation or additional preinstall/install/postinstall hook was found. +The package staging code removes the CLI's and bundled workspaces' own scripts. +Build/prepare/publishing hooks declared in registry dependencies are not consumer-install hooks. + +Public CLI archive: + +```text +https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.4.0.tgz +sha512-l76f89GQRAv4M2DOVc6CuT6RakqvK3d1bDwdtol9ciPJ8Lbi9p97pMDy7uuA3VDUUdR+8OlWk0pfEIkhapEZUw== +``` + +Only two install-time hooks were found: + +| Package | Hook | Reviewed behavior | +| --- | --- | --- | +| `@google/genai@2.31.0` | `preinstall`: `echo 'preinstall: no-op'` | Prints a message; no dependency build or download. | +| `protobufjs@7.6.5` | `postinstall`: `node scripts/postinstall` | Reads package metadata and may print a version-scheme warning; no network, subprocess, compilation or filesystem writes. | + +The published protobufjs `scripts/postinstall.js` SHA-256 is `5af8463b97ee8e309b4a2111f9479bacdf0c180de0ca0155527679b1fc6d9e6c`. +The existing exact-version allowlists in `scripts/generate-coding-agent-shrinkwrap.mjs` and `scripts/generate-coding-agent-install-lock.mjs` match these two packages. +An allowlist comment alone is not a replacement for reviewing the actual published hook bytes. +Re-review lifecycle behavior when dependency versions, peers, optional dependencies or the published package change. +This inspection does not prove that arbitrary future package versions or unrelated dependencies are safe. + +## Validation contract + +- The reusable artifact installer continues to disable lifecycle scripts by default. +- Native candidate acceptance explicitly enables scripts for npm package/process consumers and records that mode. pnpm and Bun acceptance remain scripts-disabled. +- Public-release npm initial and fresh installations require `ignore-scripts=false` and omit the flag from the install command; self-updates intentionally remain scripts-disabled. +- The explicit npm-only consumer option enables scripts and rejects opt-in for other package managers. +- Local audited fixtures prove that enabled npm hooks actually run, inherited credentials/configuration do not enter them, and scripts-disabled consumers remain disabled. +- Daytona's candidate npm consumers enable scripts and retain the full installed CLI behavior tests; Bun remains on its existing scripts-disabled path. +- The separate `npm-global-installation` scenario tests the retained candidate and independently verified public latest package in separate cold prefixes. +- Candidate registry metadata and seeded consumer installation locks preserve the reviewed shrinkwrap. npm 10 file/tarball transport can otherwise ignore a nested shrinkwrap and re-resolve transitive ranges; a real check caught unreviewed `protobufjs@7.6.6` before this correction. +- Both global installs require positive debug-log evidence that the exact two reviewed hook commands started and completed successfully. Configuration alone is not execution evidence. Raw npm logs are not retained. +- The public path executes exactly `npm i -g @diffexai/diffex`, with no `--ignore-scripts` flag and verified default configuration of `false`. +- Both global paths require executable resolution through PATH, expected version/help/models, exact installed runtime inventories and positive cleanup. +- Missing evidence, public latest movement, hash mismatches, scripts-disabled configuration or cleanup failure remain failures. + +See [the Daytona operator guide](../daytona-behaviors.md) for workflow authorization, execution, evidence and cleanup requirements. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 402d441f5..fee845b7e 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -47,10 +47,10 @@ Diffex runs in four modes: interactive, print or JSON, RPC for process integrati ## Quick Start ```bash -npm install -g --ignore-scripts @diffexai/diffex +npm i -g @diffexai/diffex ``` -`--ignore-scripts` disables dependency lifecycle scripts during install. Diffex does not require install scripts for normal npm installs. +Requires Node.js 22.19.0 or newer. Diffex does not require install scripts; its pinned dependency hooks are reviewed and normal npm installation is covered by acceptance tests. Add `--ignore-scripts` if your environment requires scripts-disabled installation. Authenticate with an API key: diff --git a/packages/coding-agent/docs/installation.md b/packages/coding-agent/docs/installation.md index fd9dd6d4b..1c93e107c 100644 --- a/packages/coding-agent/docs/installation.md +++ b/packages/coding-agent/docs/installation.md @@ -10,15 +10,17 @@ The hosted runtime image is for the cloud service. ```sh node --version npm --version -npm install -g --ignore-scripts --registry=https://registry.npmjs.org @diffexai/diffex +npm i -g @diffexai/diffex diffex --version diffex --help diffex --list-models ``` Only the CLI package is needed; its internal Diffex modules are included. -`--ignore-scripts` disables dependency installation scripts. +Normal npm installation enables dependency lifecycle scripts. The pinned dependency hooks are reviewed; Diffex itself does not require an install script or download a runtime during installation. Npm verifies downloaded package bytes against registry integrity metadata. +If your policy disables lifecycle scripts, `npm i -g --ignore-scripts @diffexai/diffex` remains supported. +If your configured registry or mirror does not carry Diffex, select the canonical registry with `--registry=https://registry.npmjs.org`. For a reproducible installation, replace the package argument with `@diffexai/diffex@`, using an exact version you have checked on npmjs.org. The executable directory is `$(npm prefix -g)/bin` on supported platforms. @@ -54,7 +56,7 @@ Use `pnpm bin -g` to locate the executable directory and ensure it is on PATH. Use pnpm for subsequent upgrades, repairs, and removal of this installation; do not mix npm and pnpm in the same installation directory. For a reproducible installation, specify `@diffexai/diffex@` instead of the unversioned package. -npm and pnpm own the initial download display. Diffex does not execute install scripts or print a second installer banner. +npm and pnpm own the initial download display. Diffex's package has no install script or second installer banner; npm can run the reviewed dependency hooks. `diffex update --self` shows a compact activity indicator in terminals, retains warnings and failure diagnostics, and streams package-manager output when redirected to a log. The indicator shows activity, not an estimated download percentage. @@ -66,6 +68,7 @@ diffex --version ``` Self-update validates the response from `https://api.diffex.ai/api/latest-version` and installs exactly `@diffexai/diffex@` using the detected package manager. +The updater intentionally disables lifecycle scripts; this stricter update policy is independent of the ordinary npm installation command. It rejects malformed metadata, foreign package names, redirects, and unavailable discovery before starting installation. `--force` permits reinstalling a verified release; it does not bypass identity checks. The discovery service can report no generally announced release even when a bootstrap package exists on npm. @@ -73,7 +76,7 @@ The discovery service can report no generally announced release even when a boot To install a reviewed npm version directly, including when discovery has no release: ```sh -npm install -g --ignore-scripts --registry=https://registry.npmjs.org '@diffexai/diffex@' +npm i -g --registry=https://registry.npmjs.org '@diffexai/diffex@' diffex --version ``` @@ -125,7 +128,7 @@ Use a separate backup directory and retry: DIFFEX_RECOVERY_FROM='/exact/affected/CLI/installation/or/backup' DIFFEX_RECOVERY_DIR="$(mktemp -d)" mv "$DIFFEX_RECOVERY_FROM" "$DIFFEX_RECOVERY_DIR/" -npm install -g --ignore-scripts --registry=https://registry.npmjs.org '@diffexai/diffex@' +npm i -g --registry=https://registry.npmjs.org '@diffexai/diffex@' diffex --version diffex --help ``` diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 6c9849602..779ddf986 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -8,7 +8,7 @@ Use Node.js 22.19.0 or newer on macOS or Linux, on arm64 or x64. Install the canonical package from npmjs.org: ```bash -npm install -g --ignore-scripts --registry=https://registry.npmjs.org @diffexai/diffex +npm i -g @diffexai/diffex diffex --version ``` diff --git a/scripts/artifact-acceptance.mjs b/scripts/artifact-acceptance.mjs index 7b972424a..3e2f910d0 100644 --- a/scripts/artifact-acceptance.mjs +++ b/scripts/artifact-acceptance.mjs @@ -22,6 +22,11 @@ import { getPublicWorkspacePackages } from "./release-packages.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const readJson = (file) => JSON.parse(readFileSync(file, "utf8")); +export function candidateInstallationOptions(manager) { + assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); + return { lifecycleScripts: manager === "npm" ? "enabled" : "disabled" }; +} + export function packageClosure(name, packages) { const result = []; const visiting = new Set(); @@ -133,13 +138,14 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = }; try { for (const manager of managers) { + const installationOptions = candidateInstallationOptions(manager); const cacheDirectory = join(temporary, manager, "registry-cache"); for (const pkg of packages) { assert(operations[pkg.name], `Missing representative operation: ${pkg.name}`); const consumer = join(temporary, manager, pkg.name.split("/").pop()); const selected = packageClosure(pkg.name, packages); const env = installConsumer(consumer, manager, selected, records, tarballDirectory, - manager === "pnpm" ? join(consumer, "cache") : cacheDirectory); + manager === "pnpm" ? join(consumer, "cache") : cacheDirectory, installationOptions); // createRequire preserves an artificial filename's lexical path; use pnpm's real package root. const packageRoot = realpathSync(join(consumer, "node_modules", pkg.name)); const imports = declaredEntrypoints(pkg, packageRoot); @@ -166,6 +172,7 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = evidence.packages.push({ manager, managerVersion: execFileSync(manager, ["--version"], { cwd: consumer, env, encoding: "utf8" }).trim(), + lifecycleScripts: installationOptions.lifecycleScripts, name: pkg.name, version: pkg.version, entrypoints: imports, @@ -176,7 +183,7 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = } const consumer = join(temporary, manager, "process acceptance"); const env = installConsumer(consumer, manager, packages, records, tarballDirectory, - manager === "pnpm" ? join(consumer, "cache") : cacheDirectory); + manager === "pnpm" ? join(consumer, "cache") : cacheDirectory, installationOptions); const project = join(consumer, "project"); writeFileSync(join(project, "artifact helper.txt"), "artifact-helper-confirmation\n"); const helperOptions = { cwd: project, env, encoding: "utf8", timeout: 10000 }; @@ -207,6 +214,7 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = } evidence.processes.push({ manager, + lifecycleScripts: installationOptions.lifecycleScripts, server: "Excluded from CLI distribution; hosted runtime remains private", terminal: "passed", helpers, diff --git a/scripts/artifact-acceptance.test.mjs b/scripts/artifact-acceptance.test.mjs index fd87ac846..10c4e21e0 100644 --- a/scripts/artifact-acceptance.test.mjs +++ b/scripts/artifact-acceptance.test.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { test } from "node:test"; import { assertContained, + candidateInstallationOptions, declaredEntrypoints, isolatedEnvironment, operations, @@ -15,6 +16,13 @@ import { import { cliDistribution } from "./cli-distribution.mjs"; import { getPublicWorkspacePackages } from "./release-packages.mjs"; +test("native candidate consumers explicitly enable npm hooks and keep pnpm and Bun disabled", () => { + assert.deepEqual(candidateInstallationOptions("npm"), { lifecycleScripts: "enabled" }); + for (const manager of ["pnpm", "bun"]) + assert.deepEqual(candidateInstallationOptions(manager), { lifecycleScripts: "disabled" }); + assert.throws(() => candidateInstallationOptions("unknown"), /Unsupported package manager/); +}); + test("every public package has an operation and a dependency-first local closure", () => { const packages = getPublicWorkspacePackages().map((pkg) => cliDistribution().manifest); assert.equal(packages.length, 1); diff --git a/scripts/artifact-installation.mjs b/scripts/artifact-installation.mjs index 8e7354e44..ff8519981 100644 --- a/scripts/artifact-installation.mjs +++ b/scripts/artifact-installation.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -108,11 +108,21 @@ export function installConsumer( records, tarballDirectory, cacheDirectory = join(directory, "cache"), + options = {}, ) { + assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); + assert(options !== null && typeof options === "object" && !Array.isArray(options), "Invalid installation options"); + assert(Object.keys(options).every((key) => ["lifecycleScripts", "timeoutMilliseconds"].includes(key)), "Unsupported installation option"); + const lifecycleScripts = options.lifecycleScripts === undefined ? "disabled" : options.lifecycleScripts; + assert(["disabled", "enabled"].includes(lifecycleScripts), "Invalid lifecycle scripts mode"); + assert(lifecycleScripts === "disabled" || manager === "npm", "Lifecycle scripts opt-in is supported only for npm"); + const timeoutMilliseconds = options.timeoutMilliseconds === undefined ? 600_000 : options.timeoutMilliseconds; + assert(Number.isSafeInteger(timeoutMilliseconds) && timeoutMilliseconds > 0 && timeoutMilliseconds <= 600_000, "Invalid installation timeoutMilliseconds"); + assert(options.timeoutMilliseconds === undefined || lifecycleScripts === "enabled", "Custom timeout requires script-enabled npm"); + assert(lifecycleScripts === "disabled" || process.platform !== "win32", "Script-enabled npm requires POSIX process groups"); directory = resolveArtifactDirectory(directory); cacheDirectory = resolveArtifactDirectory(cacheDirectory); tarballDirectory = resolveArtifactDirectory(tarballDirectory); - assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); const command = manager; assert(selected.every((pkg) => typeof pkg.name === "string" && /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(pkg.name)), "Invalid package name"); mkdirSync(directory, { recursive: true }); @@ -136,15 +146,78 @@ export function installConsumer( join(directory, "package.json"), JSON.stringify({ private: true, type: "module", dependencies, overrides: dependencies, pnpm: { overrides: dependencies } }), ); - execFileSync( - command, - manager === "npm" - ? ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"] - : manager === "pnpm" - ? ["install", "--prod", "--ignore-scripts", "--no-frozen-lockfile"] - : ["install", "--production", "--ignore-scripts", "--network-concurrency=8"], - { cwd: directory, env, stdio: "inherit", timeout: 600_000 }, - ); + if (manager === "npm") { + const selectedRecords = selected.map((pkg) => records.find((record) => record.name === pkg.name)); + verifyTarballs(tarballDirectory, selectedRecords, selected); + const packages = { "": { dependencies } }; + let shrinkwrapped = false; + for (const pkg of selected) { + const archive = dependencies[pkg.name].slice("file:".length); + const archiveOptions = { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 30000 }; + const paths = execFileSync("tar", ["-tzf", archive], archiveOptions).trim().split("\n"); + if (!paths.includes("package/npm-shrinkwrap.json")) continue; + assert.equal(paths.filter((path) => path === "package/npm-shrinkwrap.json").length, 1, "Duplicate candidate shrinkwrap"); + assert.equal(paths.filter((path) => path === "package/package.json").length, 1, "Duplicate candidate manifest"); + const manifest = JSON.parse(execFileSync("tar", ["-xOf", archive, "package/package.json"], archiveOptions)); + const lock = JSON.parse(execFileSync("tar", ["-xOf", archive, "package/npm-shrinkwrap.json"], archiveOptions)); + assert.equal(manifest.name, pkg.name, "Candidate manifest name mismatch"); + assert.equal(manifest.version, pkg.version, "Candidate manifest version mismatch"); + assert.equal(lock.name, manifest.name, "Candidate shrinkwrap name mismatch"); + assert.equal(lock.version, manifest.version, "Candidate shrinkwrap version mismatch"); + assert.equal(lock.lockfileVersion, 3, "Candidate shrinkwrap must use lockfileVersion 3"); + assert(lock.packages && lock.packages[""], "Candidate shrinkwrap has no root entry"); + assert.equal(lock.packages[""].name, manifest.name, "Candidate shrinkwrap root name mismatch"); + assert.equal(lock.packages[""].version, manifest.version, "Candidate shrinkwrap root version mismatch"); + for (const field of ["dependencies", "optionalDependencies", "peerDependencies", "peerDependenciesMeta"]) + assert.deepEqual(lock.packages[""][field] ?? {}, manifest[field] ?? {}, `Candidate shrinkwrap ${field} mismatch`); + const target = `node_modules/${pkg.name}`; + packages[target] = { + ...lock.packages[""], resolved: dependencies[pkg.name], + integrity: `sha512-${createHash("sha512").update(readFileSync(archive)).digest("base64")}`, + hasShrinkwrap: true, + }; + for (const [path, entry] of Object.entries(lock.packages)) { + if (!path) continue; + assert(path.startsWith("node_modules/") && !path.includes("\\") && !path.split("/").some((part) => ["", ".", ".."].includes(part)), "Invalid candidate shrinkwrap path"); + assert(entry && !entry.link, "Candidate shrinkwrap must not contain links"); + packages[`${target}/${path}`] = entry; + } + shrinkwrapped = true; + } + // Local tarball manifests do not expose the registry's _hasShrinkwrap flag. + // Seed it in the consumer lock, not in the immutable published artifact, so + // npm inflates the reviewed graph instead of resolving transitive ranges. + if (shrinkwrapped) + writeFileSync(join(directory, "package-lock.json"), JSON.stringify({ lockfileVersion: 3, requires: true, packages })); + } + const args = manager === "npm" + // Explicit false defeats npm's built-in config as well as inherited config; + // only this audited npm consumer path may enable dependency lifecycle hooks. + ? ["install", "--omit=dev", lifecycleScripts === "enabled" ? "--ignore-scripts=false" : "--ignore-scripts", "--no-audit", "--no-fund"] + : manager === "pnpm" + ? ["install", "--prod", "--ignore-scripts", "--no-frozen-lockfile"] + : ["install", "--production", "--ignore-scripts", "--network-concurrency=8"]; + if (lifecycleScripts === "enabled") { + // spawnSync reaps npm, including on timeout. Inherited stdio cannot keep + // its pipes open; SIGKILL bounds a hook that ignores npm's termination. + const result = spawnSync(command, args, { + cwd: directory, env, stdio: "inherit", detached: true, + timeout: timeoutMilliseconds, killSignal: "SIGKILL", + }); + if (result.pid) { + try { + process.kill(-result.pid, "SIGTERM"); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000); + process.kill(-result.pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + } + if (result.error) throw result.error; + assert.equal(result.status, 0, `npm installation exited with ${result.status ?? result.signal}`); + } else { + execFileSync(command, args, { cwd: directory, env, stdio: "inherit", timeout: 600_000 }); + } verifyInstalledConsumer(directory, selected, records, tarballDirectory); return env; } diff --git a/scripts/artifact-installation.test.mjs b/scripts/artifact-installation.test.mjs index 5a44c3eb5..ed16ade01 100644 --- a/scripts/artifact-installation.test.mjs +++ b/scripts/artifact-installation.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; @@ -7,6 +8,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; import { ACCEPTANCE_MANAGERS, assertPnpmVersion, globalManagerLayout, installConsumer, isolatedEnvironment, PNPM_VERSION, resolveArtifactDirectory, verifyInstalledConsumer, verifyTarballs } from "./artifact-installation.mjs"; test("artifact directories stay within trusted roots and reject symlink components", () => { @@ -43,11 +45,15 @@ test("isolated installation environment copies literal non-secret allowlisted fi process.env.XDG_DATA_HOME = "/private/data"; process.env.COREPACK_NPM_TOKEN = "synthetic-private-corepack-token"; process.env.npm_config_registry = "https://private.invalid"; + process.env.npm_config_ignore_scripts = "true"; + process.env.NPM_CONFIG_IGNORE_SCRIPTS = "true"; const environment = isolatedEnvironment(root); assert.equal(environment.PATH, original.PATH); assert.equal(environment.OPENAI_API_KEY, undefined); assert.equal(environment.DAYTONA_API_KEY, undefined); assert.equal(environment.NODE_OPTIONS, undefined); + assert.equal(environment.npm_config_ignore_scripts, undefined); + assert.equal(environment.NPM_CONFIG_IGNORE_SCRIPTS, undefined); assert.equal(environment.HOME, join(realpathSync(root), "home")); assert.equal(environment.NPM_TOKEN, undefined); assert.equal(environment.COREPACK_NPM_TOKEN, undefined); @@ -128,6 +134,238 @@ test("real pnpm installs dependency-first local tarballs with private-scope regi } }); +test("only opted-in npm runs the audited local dependency postinstall and preserves installed inventories", { timeout: 60000 }, () => { + const root = mkdtempSync(join(tmpdir(), "diffex-lifecycle-consumer-")); + const original = { ...process.env }; + try { + // This local fixture is the entire lifecycle audit: it only writes a marker + // beside its own package files and reports isolation state, never credentials. + const postinstall = [ + "const { writeFileSync } = require('node:fs');", + "writeFileSync('postinstall-marker.json', JSON.stringify({", + " scriptsDisabled: process.env.npm_config_ignore_scripts === 'true',", + " home: process.env.HOME,", + " userconfig: process.env.npm_config_userconfig,", + " inheritedUppercaseIgnoreScripts: Object.hasOwn(process.env, 'NPM_CONFIG_IGNORE_SCRIPTS'),", + " inheritedCredentials: ['NPM_TOKEN', 'NODE_AUTH_TOKEN', 'OPENAI_API_KEY', 'DAYTONA_API_KEY', 'COREPACK_NPM_TOKEN'].some(key => Object.hasOwn(process.env, key)),", + "}));", + ].join("\n"); + const packages = [ + { name: "@diffexai/lifecycle-fixture", version: "1.2.3", scripts: { postinstall: "node postinstall.cjs" } }, + { name: "@diffexai/diffex", version: "1.2.3", dependencies: { "@diffexai/lifecycle-fixture": "1.2.3" } }, + ]; + const records = packages.map((pkg) => { + const stage = join(root, pkg.name.split("/").pop(), "package"); + mkdirSync(stage, { recursive: true }); + const files = { "package.json": JSON.stringify(pkg), "postinstall.cjs": postinstall }; + const inventory = { files: Object.entries(files).map(([path, bytes]) => ({ path, sha256: createHash("sha256").update(bytes).digest("hex") })) }; + for (const [path, bytes] of Object.entries({ ...files, "distribution-files.json": JSON.stringify(inventory) })) { + writeFileSync(join(stage, path), bytes); + } + const filename = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`; + execFileSync("tar", ["-czf", join(root, filename), "-C", dirname(stage), "package"]); + return { ...pkg, filename, sha256: createHash("sha256").update(readFileSync(join(root, filename))).digest("hex") }; + }); + verifyTarballs(root, records, packages); + process.env.npm_config_ignore_scripts = "true"; + process.env.NPM_CONFIG_IGNORE_SCRIPTS = "true"; + process.env.npm_config_userconfig = "/private/credential.npmrc"; + for (const key of ["NPM_TOKEN", "NODE_AUTH_TOKEN", "OPENAI_API_KEY", "DAYTONA_API_KEY", "COREPACK_NPM_TOKEN"]) + process.env[key] = "synthetic-private-credential"; + for (const [manager, enabled] of [["npm", false], ["npm", true], ["pnpm", false], ["bun", false]]) { + const consumer = join(root, `${manager}-${enabled ? "enabled" : "default"}`); + const env = enabled + ? installConsumer(consumer, manager, packages, records, root, join(consumer, "cache"), { lifecycleScripts: "enabled" }) + : installConsumer(consumer, manager, packages, records, root); + const installed = join(consumer, "node_modules", packages[0].name); + const marker = join(installed, "postinstall-marker.json"); + assert.equal(existsSync(marker), enabled, `${manager} dependency lifecycle execution must match the opt-in`); + assert.equal(env.npm_config_ignore_scripts, undefined); + assert.equal(env.NPM_CONFIG_IGNORE_SCRIPTS, undefined); + if (enabled) { + assert.deepEqual(JSON.parse(readFileSync(marker, "utf8")), { + scriptsDisabled: false, + home: env.HOME, + userconfig: env.npm_config_userconfig, + inheritedUppercaseIgnoreScripts: false, + inheritedCredentials: false, + }); + writeFileSync(join(installed, "postinstall.cjs"), "tampered"); + assert.throws(() => verifyInstalledConsumer(consumer, packages, records, root), /Installed bytes differ/); + writeFileSync(join(installed, "postinstall.cjs"), postinstall); + } + verifyInstalledConsumer(consumer, packages, records, root); + } + verifyTarballs(root, records, packages); + } finally { + process.env = original; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("npm seeds the reviewed shrinkwrap graph instead of resolving a newer transitive version", { timeout: 30000 }, async () => { + const root = mkdtempSync(join(tmpdir(), "diffex-seeded-consumer-")); + let server; + try { + const pack = (manifest, extra = {}) => { + const stage = join(root, `stage-${manifest.name.replaceAll("/", "-")}-${manifest.version}`, "package"); + mkdirSync(stage, { recursive: true }); + const files = { "package.json": JSON.stringify(manifest), ...extra }; + const inventory = { files: Object.entries(files).map(([path, bytes]) => ({ path, sha256: createHash("sha256").update(bytes).digest("hex") })) }; + for (const [path, bytes] of Object.entries({ ...files, "distribution-files.json": JSON.stringify(inventory) })) writeFileSync(join(stage, path), bytes); + const filename = `${manifest.name.replace("@", "").replace("/", "-")}-${manifest.version}.tgz`; + execFileSync("tar", ["-czf", join(root, filename), "-C", dirname(stage), "package"]); + const bytes = readFileSync(join(root, filename)); + return { ...manifest, filename, sha256: createHash("sha256").update(bytes).digest("hex"), integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}` }; + }; + const reviewed = pack({ name: "@fixture/reviewed", version: "1.0.0", scripts: { postinstall: "node -e \"require('node:fs').writeFileSync('hook-ran', '')\"" } }); + const newer = pack({ name: "@fixture/reviewed", version: "1.1.0" }); + writeFileSync(join(root, "registry.cjs"), [ + "const fs = require('node:fs'), http = require('node:http');", + `const reviewed = ${JSON.stringify(reviewed)}, newer = ${JSON.stringify(newer)};`, + "const server = http.createServer((req, res) => {", + " const base = 'http://127.0.0.1:' + server.address().port;", + " const entry = [reviewed, newer].find(pkg => req.url === '/' + pkg.filename);", + " if (entry) return res.end(fs.readFileSync(entry.filename));", + " if (decodeURIComponent(req.url) !== '/@fixture/reviewed') return res.writeHead(404).end();", + " res.setHeader('content-type', 'application/json');", + " res.end(JSON.stringify({name: newer.name, 'dist-tags': {latest: newer.version}, versions: Object.fromEntries([reviewed, newer].map(pkg => [pkg.version, {...pkg, dist: {tarball: base + '/' + pkg.filename, integrity: pkg.integrity}}]))}));", + "});", + "server.listen(0, '127.0.0.1', () => console.log(server.address().port));", + ].join("\n")); + server = spawn(process.execPath, [join(root, "registry.cjs")], { cwd: root, stdio: ["ignore", "pipe", "inherit"] }); + const [ready] = await once(server.stdout, "data"); + const registry = `http://127.0.0.1:${Number(ready.toString().trim())}`; + const manifest = { name: "@diffexai/diffex", version: "1.2.3", dependencies: { "@fixture/reviewed": "^1.0.0" } }; + const lock = { name: manifest.name, version: manifest.version, lockfileVersion: 3, requires: true, packages: { + "": structuredClone(manifest), + "node_modules/@fixture/reviewed": { version: reviewed.version, resolved: `${registry}/${reviewed.filename}`, integrity: reviewed.integrity, hasInstallScript: true }, + } }; + const record = pack(manifest, { "npm-shrinkwrap.json": JSON.stringify(lock) }); + const unseeded = join(root, "unseeded"); + const env = isolatedEnvironment(unseeded); + writeFileSync(join(unseeded, "package.json"), JSON.stringify({ private: true, dependencies: { [manifest.name]: `file:${join(root, record.filename)}` } })); + execFileSync("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", `--registry=${registry}`], { cwd: unseeded, env, stdio: "inherit", timeout: 10000 }); + const unseededRequire = createRequire(join(unseeded, "node_modules", manifest.name, "package.json")); + assert.equal(unseededRequire("@fixture/reviewed/package.json").version, newer.version, "Unseeded local tarball installation must demonstrate actual resolver drift"); + const consumer = join(root, "seeded"); + installConsumer(consumer, "npm", [manifest], [record], root); + const target = join(consumer, "node_modules", manifest.name); + const reviewedRoot = join(target, "node_modules/@fixture/reviewed"); + assert.equal(JSON.parse(readFileSync(join(reviewedRoot, "package.json"), "utf8")).version, reviewed.version); + assert(!existsSync(join(reviewedRoot, "hook-ran")), "Scripts-disabled proof must not run the reviewed fixture hook"); + const installedLock = JSON.parse(readFileSync(join(consumer, "package-lock.json"), "utf8")); + assert.equal(installedLock.packages[`node_modules/${manifest.name}`].hasShrinkwrap, true); + assert.equal(installedLock.packages[`node_modules/${manifest.name}`].integrity, record.integrity); + assert.equal(installedLock.packages[`node_modules/${manifest.name}/node_modules/@fixture/reviewed`].version, reviewed.version); + assert.equal(installedLock.packages[""].dependencies[manifest.name], `file:${join(root, record.filename)}`); + // A verified archive is not sufficient if its shrinkwrap describes a different manifest. + lock.packages[""].dependencies = { "@fixture/reviewed": "^2.0.0" }; + const invalid = pack(manifest, { "npm-shrinkwrap.json": JSON.stringify(lock) }); + assert.throws(() => installConsumer(join(root, "invalid"), "npm", [manifest], [invalid], root), /shrinkwrap dependencies mismatch/); + assert.throws(() => installConsumer(join(root, "tampered"), "npm", [manifest], [{ ...invalid, sha256: "0".repeat(64) }], root), /Tarball checksum mismatch/); + } finally { + if (server) { + const closed = once(server, "close"); + server.kill("SIGKILL"); + await closed; + } + rmSync(root, { recursive: true, force: true }); + } +}); + +for (const mode of ["success", "failure", "timeout"]) { + test(`script-enabled npm synchronously stops background lifecycle groups on ${mode} before returning`, { timeout: 15000 }, async () => { + const root = mkdtempSync(join(tmpdir(), "diffex-lifecycle-group-")); + const pidFiles = [join(root, "background.pid"), join(root, "hook.pid")]; + const trigger = join(root, "trigger"); + const marker = join(root, "late-write"); + try { + const background = [ + "const fs = require('node:fs');", + "process.on('SIGTERM', () => {});", + `fs.writeFileSync(${JSON.stringify(pidFiles[0])}, String(process.pid));`, + `setInterval(() => { if (fs.existsSync(${JSON.stringify(trigger)})) fs.writeFileSync(${JSON.stringify(marker)}, 'survived'); }, 10);`, + ].join("\n"); + const hook = [ + "const fs = require('node:fs'), assert = require('node:assert/strict');", + "for (const name of ['NPM_TOKEN', 'NODE_AUTH_TOKEN', 'OPENAI_API_KEY', 'DAYTONA_API_KEY']) assert.equal(process.env[name], undefined);", + `fs.writeFileSync(${JSON.stringify(pidFiles[1])}, String(process.pid));`, + `require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(background)}], { stdio: 'ignore' }).unref();`, + "const ready = setInterval(() => {", + `if (!fs.existsSync(${JSON.stringify(pidFiles[0])})) return;`, + "clearInterval(ready);", + mode === "failure" ? "process.exit(23);" : "", + mode === "timeout" ? "process.on('SIGTERM', () => {}); setInterval(() => {}, 100);" : "", + "}, 10);", + ].join("\n"); + const pkg = { name: "@diffexai/diffex", version: "1.2.3", scripts: { postinstall: "node hook.cjs" } }; + const files = { "package.json": JSON.stringify(pkg), "hook.cjs": hook }; + const inventory = { files: Object.entries(files).map(([path, bytes]) => ({ path, sha256: createHash("sha256").update(bytes).digest("hex") })) }; + const stage = join(root, "package"); + mkdirSync(stage); + for (const [path, bytes] of Object.entries({ ...files, "distribution-files.json": JSON.stringify(inventory) })) writeFileSync(join(stage, path), bytes); + const filename = "diffexai-diffex-1.2.3.tgz"; + execFileSync("tar", ["-czf", join(root, filename), "-C", root, "package"]); + const record = { ...pkg, filename, sha256: createHash("sha256").update(readFileSync(join(root, filename))).digest("hex") }; + const consumer = join(root, "consumer"); + const started = Date.now(); + const install = () => installConsumer(consumer, "npm", [pkg], [record], root, join(root, "cache"), { + lifecycleScripts: "enabled", timeoutMilliseconds: mode === "timeout" ? 2000 : 10000, + }); + if (mode === "success") install(); + else assert.throws(install, mode === "timeout" ? /ETIMEDOUT/ : /npm installation exited with/); + assert(Date.now() - started < 12000, "Installation and group cleanup must remain bounded"); + for (const file of pidFiles) { + assert(existsSync(file), "Regression must reach the actual lifecycle hook and its descendant"); + const pid = Number(readFileSync(file, "utf8")); + try { + process.kill(pid, 0); + let state; + try { state = execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim(); } + catch (error) { if (error.status !== 1) throw error; } + if (state) assert(state.startsWith("Z") || (process.platform === "darwin" && state.includes("E")), `Lifecycle process must be terminated or exiting, not running: ${state}`); + } catch (error) { if (error.code !== "ESRCH") throw error; } + } + writeFileSync(trigger, "installation already returned"); + await delay(100); + assert(!existsSync(marker), "No lifecycle descendant may write after synchronous return"); + } finally { + for (const file of pidFiles) { + if (!existsSync(file)) continue; + try { process.kill(Number(readFileSync(file, "utf8")), "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("lifecycle modes reject invalid options and unsupported managers before creating or executing a consumer", () => { + const root = mkdtempSync(join(tmpdir(), "diffex-lifecycle-modes-")); + try { + const consumer = join(root, "consumer"); + for (const lifecycleScripts of [true, false, null, "enable", "", 1]) { + assert.throws(() => installConsumer(consumer, "npm", [], [], root, join(root, "cache"), { lifecycleScripts }), /Invalid lifecycle scripts mode/); + } + for (const options of [null, true, "enabled", []]) { + assert.throws(() => installConsumer(consumer, "npm", [], [], root, join(root, "cache"), options), /Invalid installation options/); + } + assert.throws(() => installConsumer(consumer, "npm", [], [], root, join(root, "cache"), { ignoreScripts: false }), /Unsupported installation option/); + for (const manager of ["pnpm", "bun"]) { + assert.throws(() => installConsumer(consumer, manager, [], [], root, join(root, "cache"), { lifecycleScripts: "enabled" }), /supported only for npm/); + } + for (const timeoutMilliseconds of [0, -1, NaN, Infinity, "600", null, 1.5, 600001]) { + assert.throws(() => installConsumer(consumer, "npm", [], [], root, join(root, "cache"), { lifecycleScripts: "enabled", timeoutMilliseconds }), /Invalid installation timeoutMilliseconds/); + } + assert.throws(() => installConsumer(consumer, "npm", [], [], root, join(root, "cache"), { timeoutMilliseconds: 100 }), /Custom timeout requires script-enabled npm/); + assert(!existsSync(consumer), "Rejected options must not create a consumer or execute its package manager"); + assert(!existsSync(join(root, "cache")), "Rejected options must not initialize caches"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("package manager selection is a fixed allowlist and invalid package paths fail before execution", () => { const root = mkdtempSync(join(tmpdir(), "diffex-artifact-install-")); try { diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index aeea0aa75..0e55c5360 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -26,6 +26,18 @@ test("script-test jobs provision exact pnpm before exercising real package-manag assert(local.includes('options.skipBunInstall ? ["npm", "pnpm"] : ["npm", "pnpm", "bun"]')); }); +test("script-test jobs provision exact Bun before exercising its installation fixtures", () => { + const release = parse(readFileSync(new URL("../.github/workflows/build-binaries.yml", import.meta.url), "utf8")); + for (const job of [workflow.jobs["workspace-tests"], release.jobs["prepare-artifacts"]]) { + const setup = job.steps.findIndex((step) => step.uses === "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6"); + const tests = job.steps.findIndex((step) => step.run?.includes("./test.sh")); + assert(setup !== -1 && tests > setup, "Pinned Bun must be available before the real fixture tests"); + assert.equal(job.steps[setup].with["bun-version"], "1.3.11"); + } + assert.equal(release.jobs["prepare-artifacts"].steps.find((step) => step.uses?.startsWith("oven-sh/setup-bun@")).if, + "steps.restore.outputs.restored != 'true'"); +}); + test("the CI aggregate requires build checks and every isolated test shard", () => { const jobs = workflow.jobs; assert.deepEqual(jobs["build-check-test"].needs, ["build-check", "workspace-tests", "coding-agent"]); diff --git a/scripts/daytona-behaviors-workflow.test.mjs b/scripts/daytona-behaviors-workflow.test.mjs index 9283ccf3a..ce5a6fd17 100644 --- a/scripts/daytona-behaviors-workflow.test.mjs +++ b/scripts/daytona-behaviors-workflow.test.mjs @@ -55,6 +55,11 @@ test("one 35-minute preparation freezes, builds offline, prepares and verifies t previous = position; } assert.ok(script.includes("npm ci --ignore-scripts")); + assert.ok(script.includes("npm install -g npm@11.16.0 pnpm@10.28.2 --ignore-scripts")); + assert.ok(prepare.steps.some((step) => step.uses?.startsWith("oven-sh/setup-bun@") && step.with["bun-version"] === "1.3.11")); + assert.ok(script.includes("scripts/daytona-behaviors/npm-global-install.test.mjs")); + assert.ok(script.includes("scripts/daytona-behaviors/npm-global-install.mjs")); + assert.ok(script.includes("scripts/npm-lifecycle-registry.mjs"), "Bundle must include the global validator's registry helper"); assert.ok(script.includes("sha256sum .artifacts/daytona-bundle/bundle.tar.gz")); assert.match(script, /-m unittest discover -s scripts\/daytona-behaviors/); assert.match(script, /-m unittest discover -s scripts -p 'test_verify_candidate_artifacts\.py'/); @@ -89,6 +94,13 @@ test("one 35-minute preparation freezes, builds offline, prepares and verifies t assert.deepEqual(workflow.jobs["daytona-behaviors"].needs, ["prepare", "deterministic", "live"]); }); +test("Daytona npm behaviors enable scripts while Bun and ordinary artifact consumers remain disabled", () => { + const installer = readFileSync(new URL("./daytona-behaviors/install.mjs", import.meta.url), "utf8"); + assert.match(installer, /const lifecycleScripts = manager === "npm" \? "enabled" : "disabled"/); + assert.match(installer, /\{ lifecycleScripts \}/); + assert.match(installer, /manager, lifecycleScripts, executable/); +}); + test("only a completed all-files credential guard permits sanitized evidence publication", () => { assert.equal(workflow.env.DAYTONA_API_KEY, undefined); assert.equal(workflow.env.OPENAI_API_KEY, undefined); @@ -150,7 +162,7 @@ for (const scenario of ["pass", "missing-artifact", "missing-result", "invalid-r })); const identities = mode === "live" ? ["artifact-integrity", "npm-package-integrity", "npm-live-openai"] - : ["artifact-integrity", "npm-package-integrity", "bun-package-integrity", ...["npm", "bun"].flatMap((manager) => + : ["artifact-integrity", "npm-global-installation", "npm-package-integrity", "bun-package-integrity", ...["npm", "bun"].flatMap((manager) => ["installation", "print", "builtin-auth-transport", "terminal-tools-cancel-resume", "artifact-terminal"].map((name) => `${manager}-${name}`))]; writeFileSync(join(directory, "evidence/results.json"), JSON.stringify({ schemaVersion: 1, commit, mode, outcome: "passed", artifactHashes: { "diffex.tgz": "c".repeat(64) }, diff --git a/scripts/daytona-behaviors/install.mjs b/scripts/daytona-behaviors/install.mjs index 16f96b728..2bc665f4a 100644 --- a/scripts/daytona-behaviors/install.mjs +++ b/scripts/daytona-behaviors/install.mjs @@ -15,10 +15,12 @@ const packages = manifest.packages; assert.equal(packages.length, 1, "Expected the standalone CLI distribution"); assert.equal(packages[0].name, "@diffexai/diffex"); verifyTarballs(artifactDirectory, packages, packages); -installConsumer(consumerDirectory, manager, packages, packages, artifactDirectory); +const lifecycleScripts = manager === "npm" ? "enabled" : "disabled"; +installConsumer(consumerDirectory, manager, packages, packages, artifactDirectory, + join(consumerDirectory, "cache"), { lifecycleScripts }); const executable = join(consumerDirectory, "node_modules/.bin/diffex"); assert(realpathSync(executable).startsWith(`${consumerDirectory}/node_modules/`)); writeFileSync(join(consumerDirectory, "installation.json"), `${JSON.stringify({ - manager, executable, resolved: realpathSync(executable), commit, + manager, lifecycleScripts, executable, resolved: realpathSync(executable), commit, artifacts: packages.map(({ name, sha256 }) => ({ name, sha256 })), integrity: "passed", }, null, 2)}\n`); diff --git a/scripts/daytona-behaviors/installed.py b/scripts/daytona-behaviors/installed.py index d322db508..0389ffde0 100644 --- a/scripts/daytona-behaviors/installed.py +++ b/scripts/daytona-behaviors/installed.py @@ -8,6 +8,7 @@ import re import shutil import shlex +import signal import subprocess import sys import tempfile @@ -26,6 +27,30 @@ def write_json(path, value): path.write_text(json.dumps(value, indent=2) + "\n") +def run_global_installation(command): + # The validator owns a 600-second total deadline and cleans its active npm + # groups on SIGTERM. Leave a bounded grace before forcing sandbox-local exit. + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, start_new_session=True) + timed_out = False + try: + stdout, stderr = process.communicate(timeout=630) + except subprocess.TimeoutExpired: + timed_out = True + for termination in (signal.SIGTERM, signal.SIGKILL): + try: + os.killpg(process.pid, termination) + except ProcessLookupError: + pass + try: + stdout, stderr = process.communicate(timeout=10) + break + except subprocess.TimeoutExpired: + if termination == signal.SIGKILL: + raise RuntimeError("Global npm validator did not terminate after SIGKILL") + return subprocess.CompletedProcess(command, 124 if timed_out else process.returncode, stdout, stderr) + + class Terminal: """Real tmux PTY, isolated server, bounded state waits, and incremental capture.""" def __init__(self, executable, args, env, project, evidence, deadline): @@ -395,12 +420,39 @@ def manifest_check(evidence): write_json(evidence / "manifest.json", manifest) if not self.scenario("artifact-integrity", manifest_check, "configuration"): return False + if self.args.mode == "deterministic": + def global_install(evidence): + result = run_global_installation(["node", str(HERE / "npm-global-install.mjs"), + str(self.args.artifacts), self.args.commit, str(evidence)]) + (evidence / "install.txt").write_text(result.stdout + result.stderr) + assert result.returncode == 0, "Normal global npm installation failed; see install.txt" + report = json.loads((evidence / "npm-global-installation.json").read_text()) + assert report["schemaVersion"] == 1 and report["sourceCommit"] == self.args.commit, "Global npm evidence identity mismatch" + assert report["status"] == "passed" and report["cleanup"] is True, "Global npm validation or cleanup failed" + assert report["publicCommand"] == "npm i -g @diffexai/diffex", "Unexpected public install command" + entries = report["entries"] + assert len(entries) == 2 and {entry["kind"] for entry in entries} == {"candidate", "public"}, "Incomplete global npm installations" + expected_hooks = [ + {"packageId": "@google/genai@2.31.0", "hook": "preinstall", "exitCode": 0, "signal": None}, + {"packageId": "protobufjs@7.6.5", "hook": "postinstall", "exitCode": 0, "signal": None}, + ] + assert all(entry["status"] == "passed" and entry["scripts"] == "enabled" and + entry["ignoreScripts"] == "false" and entry["coldCache"] is True and + entry["installedInventory"] == "passed" and + entry["lifecycleExecution"] == "passed" and entry["lifecycleHooks"] == expected_hooks + for entry in entries), "Global npm scripts, hook execution, cache or inventory not verified" + self.scenario("npm-global-installation", global_install, "configuration") for manager in (["npm"] if self.args.mode == "live" else ["npm", "bun"]): consumer = Path(tempfile.mkdtemp(prefix="diffex-installed-")) def install(evidence): - result = subprocess.run(["node", str(HERE / "install.mjs"), str(self.args.artifacts), str(consumer), manager, self.args.commit], capture_output=True, text=True, timeout=600) + # The inner npm deadline is 600s; leave time for synchronous + # process-group teardown, integrity checks and helper exit. + result = subprocess.run(["node", str(HERE / "install.mjs"), str(self.args.artifacts), str(consumer), manager, self.args.commit], capture_output=True, text=True, timeout=615) (evidence / "install.txt").write_text(result.stdout + result.stderr) assert result.returncode == 0, "Artifact installation/integrity failed; see install.txt" + installation = json.loads((consumer / "installation.json").read_text()) + expected_policy = "enabled" if manager == "npm" else "disabled" + assert installation["lifecycleScripts"] == expected_policy, "Unexpected installation script policy" shutil.copyfile(consumer / "installation.json", evidence / "installation.json") if self.scenario(manager + "-package-integrity", install, "configuration"): installations.append((manager, str(consumer / "node_modules/.bin/diffex"))) diff --git a/scripts/daytona-behaviors/npm-global-install.mjs b/scripts/daytona-behaviors/npm-global-install.mjs new file mode 100644 index 000000000..ad997eeda --- /dev/null +++ b/scripts/daytona-behaviors/npm-global-install.mjs @@ -0,0 +1,393 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; +import { assertContained, globalManagerLayout, isolatedEnvironment, resolveArtifactDirectory, verifyTarballs } from "../artifact-installation.mjs"; +import { createLifecycleRegistry } from "../npm-lifecycle-registry.mjs"; + +const PACKAGE = "@diffexai/diffex"; +const REGISTRY = "https://registry.npmjs.org"; +const MAX_BYTES = 256 * 1024 * 1024; +const TIMEOUT_MILLISECONDS = 600000; +const CLEANUP_GRACE_MILLISECONDS = 1000; +// Exact consumer-install hooks reviewed in docs/release/npm-install-scripts-review.md. +// Any new package/version/hook/command requires review, not automatic acceptance. +const REVIEWED_HOOKS = [ + { packageId: "@google/genai@2.31.0", hook: "preinstall", command: "echo 'preinstall: no-op'" }, + { packageId: "protobufjs@7.6.5", hook: "postinstall", command: "node scripts/postinstall" }, +]; +const hash = (bytes, algorithm = "sha256", encoding = "hex") => createHash(algorithm).update(bytes).digest(encoding); +const json = (file) => JSON.parse(readFileSync(file, "utf8")); + +// Every subprocess owns a POSIX group. Successful npm hooks may leave background +// children too: terminate the entire group and reap the direct child before return. +async function runProcess(command, args, { cwd, env, budget, maximum = 16 * 1024 * 1024, limit = Infinity }) { + const timeout = Math.min(budget.remaining(), limit); + const text = await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + const stdout = []; + const stderr = []; + let size = 0; + let failure; + let status = null; + let signal = null; + let closed = false; + let stopping = false; + let terminated = false; + const finish = () => { + if (!closed || !terminated) return; + budget.signal.removeEventListener("abort", abort); + if (failure) { + Object.assign(failure, { status, signal, stderr: Buffer.concat(stderr).toString("utf8") }); + reject(failure); + } else resolve(Buffer.concat(stdout).toString("utf8")); + }; + const kill = (name) => { + if (!child.pid) return false; + try { + process.kill(-child.pid, name); + return true; + } catch (error) { + if (error.code !== "ESRCH") { + failure ??= error; + child.kill("SIGKILL"); + } + return false; + } + }; + const stop = () => { + if (stopping) return; + stopping = true; + clearTimeout(timer); + if (kill("SIGTERM")) { + setTimeout(() => { + kill("SIGKILL"); + child.stdout.destroy(); + child.stderr.destroy(); + terminated = true; + finish(); + }, CLEANUP_GRACE_MILLISECONDS); + } else { + terminated = true; + finish(); + } + }; + const abort = () => { failure ??= budget.signal.reason; stop(); }; + const timer = setTimeout(() => { + failure ??= new Error(`${command} timed out within the shared global npm validation budget`); + stop(); + }, timeout); + budget.signal.addEventListener("abort", abort, { once: true }); + for (const [stream, chunks] of [[child.stdout, stdout], [child.stderr, stderr]]) { + stream.on("data", (chunk) => { + if (failure) return; + size += chunk.length; + if (size > maximum) { + failure = new Error(`${command} output exceeds size limit`); + stop(); + } else chunks.push(chunk); + }); + stream.on("error", (error) => { failure ??= error; stop(); }); + } + child.on("error", (error) => { failure ??= error; stop(); }); + child.on("exit", (code, name) => { + status = code; + signal = name; + if (code !== 0) failure ??= new Error(`${command} exited with ${code ?? name}`); + stop(); + }); + // close follows exit/reaping and drained (or explicitly destroyed) pipes. + child.on("close", () => { closed = true; finish(); }); + }); + budget.remaining(); + return text; +} + +async function download(url, request, maximum, budget) { + const signal = AbortSignal.any([budget.signal, AbortSignal.timeout(Math.min(30000, budget.remaining()))]); + const response = await request(url, { redirect: "error", signal }); + assert.equal(response.status, 200, `Registry request failed: ${url}`); + assert(response.body, "Registry response has no body"); + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + budget.remaining(); + size += chunk.length; + assert(size <= maximum, "Registry response exceeds size limit"); + chunks.push(chunk); + } + budget.remaining(); + return Buffer.concat(chunks); +} + +async function latest(request, budget) { + const metadata = JSON.parse(await download(`${REGISTRY}/${encodeURIComponent(PACKAGE)}/latest`, request, 1024 * 1024, budget)); + assert.equal(metadata.name, PACKAGE); + assert.match(metadata.version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + assert.equal(metadata.dist?.tarball, `${REGISTRY}/${PACKAGE}/-/diffex-${metadata.version}.tgz`, "Noncanonical public tarball URL"); + assert.match(metadata.dist.integrity, /^sha512-[A-Za-z0-9+/]{86}==$/); + return { name: PACKAGE, version: metadata.version, tarball: metadata.dist.tarball, integrity: metadata.dist.integrity }; +} + +function verifyFiles(directory, inventory, budget) { + assert(Array.isArray(inventory.files) && inventory.files.length > 0, "Missing byte inventory"); + assert.equal(new Set(inventory.files.map((file) => file.path)).size, inventory.files.length, "Duplicate inventory paths"); + assert.deepEqual(json(join(directory, "distribution-files.json")), inventory, "Installed inventory differs from checked tarball"); + for (const file of inventory.files) { + budget.remaining(); + assert(typeof file.path === "string" && !isAbsolute(file.path) && !file.path.split(/[\\/]/).some((part) => ["", ".", ".."].includes(part)), "Invalid inventory path"); + assert.match(file.sha256, /^[a-f0-9]{64}$/); + const path = join(directory, file.path); + assertContained(path, directory); + assert.equal(hash(readFileSync(path)), file.sha256, `Installed bytes differ: ${file.path}`); + } +} + +async function inspectArchive(archive, directory, identity, env, budget) { + mkdirSync(directory); + const options = { env, budget, limit: 30000, maximum: 64 * 1024 * 1024 }; + const paths = (await runProcess("tar", ["-tzf", archive], options)).trim().split("\n"); + assert(paths.every((path) => path.startsWith("package/") && !path.includes("\\") && !path.split("/").includes("..")), "Unsafe archive path"); + const entries = (await runProcess("tar", ["-tvzf", archive], options)).trim().split("\n"); + assert(entries.every((entry) => entry.startsWith("-") || entry.startsWith("d")), "Archive links or special files are not allowed"); + await runProcess("tar", ["-xzf", archive, "--no-same-owner", "--no-same-permissions", "-C", directory], options); + const root = join(directory, "package"); + const manifest = json(join(root, "package.json")); + assert.equal(manifest.name, identity.name); + assert.equal(manifest.version, identity.version); + const inventory = json(join(root, "distribution-files.json")); + assert.match(inventory.sourceCommit, /^[a-f0-9]{40}$/); + verifyFiles(root, inventory, budget); + return { manifest, inventory }; +} + +function lifecycleEvidence(cache, budget, redact) { + // Read only npm's debug logs, never config/auth files. Keep only reviewed + // identities and outcomes; neither raw logs nor arbitrary hook output escapes. + const directory = join(cache, "_logs"); + assert(lstatSync(directory).isDirectory() && !lstatSync(directory).isSymbolicLink(), "Invalid npm log directory"); + const events = REVIEWED_HOOKS.map(({ packageId, hook }) => ({ packageId, hook, started: 0, completed: 0, exitCode: null, signal: null })); + let bytes = 0; + for (const name of readdirSync(directory).sort()) { + if (!/^[\w.-]+-debug-\d+\.log$/.test(name)) continue; + budget.remaining(); + const path = join(directory, name); + const info = lstatSync(path); + assert(info.isFile() && !info.isSymbolicLink() && info.size <= 4 * 1024 * 1024, "Invalid or oversized npm debug log"); + bytes += info.size; + assert(bytes <= 16 * 1024 * 1024, "Npm lifecycle logs exceed size limit"); + for (const line of readFileSync(path, "utf8").split("\n")) { + const match = /^\d+ info run (\S+) (preinstall|install|postinstall|prepare) (.+)$/.exec(line); + if (!match) continue; + const [, packageId, hook, detail] = match; + const index = REVIEWED_HOOKS.findIndex((reviewed) => reviewed.packageId === packageId && reviewed.hook === hook); + if (index < 0) { + const rejectedHook = { packageId: /^[A-Za-z0-9@/._+-]{1,160}$/.test(packageId) ? redact(packageId) : "[invalid package identity]", hook }; + throw Object.assign(new Error(`Unreviewed npm lifecycle package/version/hook: ${rejectedHook.packageId} ${hook}`), { rejectedHook }); + } + const event = events[index]; + if (detail.startsWith("{")) { + assert.equal(detail, "{ code: 0, signal: null }", "Reviewed npm lifecycle hook failed"); + assert.equal(event.started, 1, "Npm lifecycle completion lacks a unique start"); + event.completed++; + event.exitCode = 0; + } else { + const start = /^\S+ (.+)$/.exec(detail); + assert.equal(start?.[1], REVIEWED_HOOKS[index].command, "Reviewed npm lifecycle command changed"); + event.started++; + } + } + } + assert(events.every((event) => event.started === 1 && event.completed === 1), "Missing or duplicate reviewed npm lifecycle execution"); + return events.map(({ packageId, hook, exitCode, signal }) => ({ packageId, hook, exitCode, signal })); +} + +export async function validateNpmGlobalInstallations(artifacts, commit, output, { request = fetch, timeoutMilliseconds = TIMEOUT_MILLISECONDS } = {}) { + assert(Number.isSafeInteger(timeoutMilliseconds) && timeoutMilliseconds > 0 && timeoutMilliseconds <= TIMEOUT_MILLISECONDS, "Invalid global npm timeoutMilliseconds"); + assert(process.platform !== "win32", "Global npm validation requires POSIX process groups"); + // Literal credential allowlist; redact before the first evidence write, even + // on failures. No environment or auth/config file is serialized or copied. + const secrets = [process.env.DAYTONA_API_KEY, process.env.OPENAI_API_KEY, process.env.NPM_TOKEN, + process.env.NODE_AUTH_TOKEN, process.env.COREPACK_NPM_TOKEN].filter((value) => value).sort((a, b) => b.length - a.length); + const replacement = secrets.some((secret) => "[REDACTED]".includes(secret)) ? "" : "[REDACTED]"; + const redact = (text) => { + for (const secret of secrets) text = text.replaceAll(secret, replacement); + return text; + }; + const controller = new AbortController(); + const deadline = performance.now() + timeoutMilliseconds; + const budget = { signal: controller.signal, remaining: () => { + controller.signal.throwIfAborted(); + const milliseconds = Math.ceil(deadline - performance.now()); + assert(milliseconds > 0, "Global npm validation exhausted its shared time budget"); + return milliseconds; + } }; + assert.match(commit, /^[a-f0-9]{40}$/); + artifacts = resolveArtifactDirectory(artifacts); + output = resolveArtifactDirectory(output); + mkdirSync(output, { recursive: true }); + const temporary = realpathSync(mkdtempSync(join(tmpdir(), "diffex-npm-global-"))); + const evidence = { + schemaVersion: 1, sourceCommit: commit, node: process.version, platform: process.platform, arch: process.arch, + timeoutMilliseconds, cleanupGraceMilliseconds: CLEANUP_GRACE_MILLISECONDS, aborted: null, + publicCommand: "npm i -g @diffexai/diffex", status: "failed", cleanup: false, entries: [], commands: [], + }; + const terminate = () => { evidence.aborted = "SIGTERM"; controller.abort(new Error("Global npm validation aborted by SIGTERM")); }; + const interrupt = () => { evidence.aborted = "SIGINT"; controller.abort(new Error("Global npm validation aborted by SIGINT")); }; + process.on("SIGTERM", terminate); + process.on("SIGINT", interrupt); + let candidateRegistry; + let registryEntry; + try { + const manifest = json(join(artifacts, "manifest.json")); + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.source.commit, commit); + assert.equal(manifest.packages.length, 1); + const candidate = manifest.packages[0]; + assert.equal(candidate.name, PACKAGE); + assert.equal(candidate.version, manifest.version); + verifyTarballs(artifacts, [candidate], [candidate]); + const published = await latest(request, budget); + const publicBytes = await download(published.tarball, request, MAX_BYTES, budget); + assert.equal(`sha512-${hash(publicBytes, "sha512", "base64")}`, published.integrity, "Public tarball SRI mismatch"); + const publicArchive = join(temporary, "public.tgz"); + writeFileSync(publicArchive, publicBytes); + for (const [kind, identity, archive] of [ + ["candidate", candidate, join(artifacts, candidate.filename)], + ["public", { ...published, sha256: hash(publicBytes) }, publicArchive], + ]) { + const directory = join(temporary, kind); + const env = isolatedEnvironment(directory); + const cwd = join(directory, "project"); + const { bin, packageRoot, executable } = globalManagerLayout("npm", directory, env); + env.PATH = `${bin}:${env.PATH}`; + const entry = { kind, name: identity.name, version: identity.version, sha256: identity.sha256, + integrity: identity.integrity, tarball: kind === "public" ? published.tarball : candidate.filename, + status: "failed", scripts: "enabled", ignoreScripts: null, coldCache: false, installedInventory: "unchecked", + lifecycleExecution: "unchecked", lifecycleHooks: [] }; + evidence.entries.push(entry); + const run = async (command, args) => { + const record = { kind, command, args, exitCode: null }; + evidence.commands.push(record); + try { + const text = await runProcess(command, args, { cwd, env, budget }); + record.exitCode = 0; + return text.trim(); + } catch (error) { + record.exitCode = error.status ?? null; + record.signal = error.signal; + record.stderr = String(error.stderr ?? "").slice(-16384); + throw error; + } + }; + const checked = await inspectArchive(archive, join(directory, "checked"), identity, env, budget); + entry.inventorySourceCommit = checked.inventory.sourceCommit; + if (kind === "candidate") { + assert.equal(checked.inventory.sourceCommit, commit); + const shrinkwrapPath = join(directory, "checked/package/npm-shrinkwrap.json"); + assert(existsSync(shrinkwrapPath), "Missing candidate shrinkwrap"); + assertContained(shrinkwrapPath, join(directory, "checked/package")); + const shrinkwrap = json(shrinkwrapPath); + assert.equal(shrinkwrap.lockfileVersion, 3, "Candidate shrinkwrap must use lockfileVersion 3"); + for (const field of ["name", "version"]) { + assert.equal(shrinkwrap[field], checked.manifest[field], `Candidate shrinkwrap ${field} mismatch`); + assert.equal(shrinkwrap.packages?.[""]?.[field], checked.manifest[field], `Candidate shrinkwrap root ${field} mismatch`); + } + for (const field of ["dependencies", "optionalDependencies", "peerDependencies", "peerDependenciesMeta"]) { + assert.deepEqual(shrinkwrap.packages[""][field] ?? {}, checked.manifest[field] ?? {}, `Candidate shrinkwrap root ${field} mismatch`); + } + entry.shrinkwrap = "validated"; + entry.transport = "scoped-loopback-registry"; + entry.archiveRequests = 0; + entry.registryClosed = false; + registryEntry = entry; + // npm's FileFetcher omits registry _hasShrinkwrap and can ignore an + // embedded lock. Advertise it only after validating the exact archive. + const bytes = readFileSync(archive); + assert.equal(hash(bytes), identity.sha256, "Candidate archive changed before registry staging"); + entry.integrity = `sha512-${hash(bytes, "sha512", "base64")}`; + candidateRegistry = createLifecycleRegistry({ + metadata: { ...checked.manifest, _hasShrinkwrap: true }, bytes, + getMode: () => "normal", onArchive: () => entry.archiveRequests++, + }); + await new Promise((resolve, reject) => { + candidateRegistry.once("error", reject); + candidateRegistry.listen(0, "127.0.0.1", resolve); + }); + entry.registryUrl = `http://127.0.0.1:${candidateRegistry.address().port}/`; + writeFileSync(env.npm_config_userconfig, `@diffexai:registry=${entry.registryUrl}\n`); + } + entry.npmVersion = await run("npm", ["--version"]); + assert(entry.npmVersion.length > 0, "Missing npm identity"); + entry.ignoreScripts = await run("npm", ["config", "get", "ignore-scripts"]); + assert.equal(entry.ignoreScripts, "false", "Normal npm installation requires scripts enabled by default"); + assert(!existsSync(join(env.npm_config_cache, "_cacache")), "Installation must start with a cold package cache"); + entry.coldCache = true; + if (kind === "public") assert.deepEqual(await latest(request, budget), published, "Public latest moved before installation"); + await run("npm", ["i", "-g", PACKAGE]); + if (kind === "candidate") assert(entry.archiveRequests > 0, "Candidate archive was not fetched from the scoped registry"); + entry.lifecycleHooks = lifecycleEvidence(env.npm_config_cache, budget, redact); + entry.lifecycleExecution = "passed"; + assertContained(packageRoot, directory); + assert.equal(json(join(packageRoot, "package.json")).name, identity.name); + assert.equal(json(join(packageRoot, "package.json")).version, identity.version, "Installed latest moved from checked public version"); + verifyFiles(packageRoot, checked.inventory, budget); + entry.installedInventory = "passed"; + const target = checked.manifest.bin?.diffex; + assert(typeof target === "string" && !isAbsolute(target), "Missing global diffex executable"); + assertContained(join(packageRoot, target), packageRoot); + assert.equal(realpathSync(executable), realpathSync(join(packageRoot, target))); + entry.resolvedExecutable = realpathSync(executable); + assert.equal(await run("diffex", ["--version"]), identity.version); + assert.match(await run("diffex", ["--help"]), /Diffex/); + assert((await run("diffex", ["--list-models"])).length > 100, "Missing offline models"); + verifyFiles(packageRoot, checked.inventory, budget); + if (kind === "public") assert.deepEqual(await latest(request, budget), published, "Public latest moved during installation"); + entry.status = "passed"; + if (candidateRegistry) { + candidateRegistry.closeAllConnections(); + await new Promise((resolve) => candidateRegistry.close(resolve)); + assert(!candidateRegistry.listening, "Candidate registry survived cleanup"); + registryEntry.registryClosed = true; + candidateRegistry = undefined; + } + } + budget.remaining(); + evidence.status = "passed"; + } catch (error) { + evidence.error = error.message; + if (error.rejectedHook) evidence.rejectedHook = error.rejectedHook; + throw error; + } finally { + try { + if (candidateRegistry) { + candidateRegistry.closeAllConnections(); + await new Promise((resolve) => candidateRegistry.close(resolve)); + assert(!candidateRegistry.listening, "Candidate registry survived cleanup"); + registryEntry.registryClosed = true; + } + rmSync(temporary, { recursive: true, force: true }); + evidence.cleanup = !existsSync(temporary); + assert(evidence.cleanup, "Global installation temporary prefix survived cleanup"); + } catch (error) { + evidence.status = "failed"; + throw error; + } finally { + process.off("SIGTERM", terminate); + process.off("SIGINT", interrupt); + Object.assign(evidence, JSON.parse(JSON.stringify(evidence, (_key, value) => typeof value === "string" ? redact(value) : value))); + writeFileSync(join(output, "npm-global-installation.json"), `${JSON.stringify(evidence, null, 2)}\n`); + } + } + return evidence; +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [artifacts, commit, output] = process.argv.slice(2); + assert(artifacts && commit && output, "Usage: npm-global-install.mjs ARTIFACTS COMMIT OUTPUT"); + await validateNpmGlobalInstallations(artifacts, commit, output); +} diff --git a/scripts/daytona-behaviors/npm-global-install.test.mjs b/scripts/daytona-behaviors/npm-global-install.test.mjs new file mode 100644 index 000000000..28fcb0bdb --- /dev/null +++ b/scripts/daytona-behaviors/npm-global-install.test.mjs @@ -0,0 +1,417 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { setTimeout as delay } from "node:timers/promises"; +import { validateNpmGlobalInstallations } from "./npm-global-install.mjs"; + +const COMMIT = "a".repeat(40); +const PUBLIC_COMMIT = "b".repeat(40); +const REGISTRY = "https://registry.npmjs.org"; +const PACKAGE = "@diffexai/diffex"; +const digest = (bytes, algorithm = "sha256", encoding = "hex") => createHash(algorithm).update(bytes).digest(encoding); +const readJson = (path) => JSON.parse(readFileSync(path, "utf8")); + +function archive(root, version, commit, { tamper = false, hook = "", lockFault, lockedRanges = false } = {}) { + const stage = join(root, version, "package"); + mkdirSync(stage, { recursive: true }); + // Reviewed local-only hook: write a package-local marker, reject inherited + // credentials/config, and optionally corrupt a checked file for the failure test. + // Local-only package identities model the two reviewed dependency hooks. + // Their synthetic code exercises failures/isolation without registry access. + const dependencies = {}; + const lockedPackages = {}; + for (const [name, dependencyVersion, scripts, contents] of [ + ["@google/genai", "2.31.0", { preinstall: "echo 'preinstall: no-op'" }, {}], + ["protobufjs", "7.6.5", { postinstall: "node scripts/postinstall" }, { "scripts/postinstall.js": [ + "const assert = require('node:assert/strict');", + "const fs = require('node:fs');", + "for (const key of ['NPM_TOKEN', 'NODE_AUTH_TOKEN', 'DAYTONA_API_KEY', 'OPENAI_API_KEY', 'NPM_CONFIG_IGNORE_SCRIPTS']) assert.equal(process.env[key], undefined);", + "assert.notEqual(process.env.npm_config_ignore_scripts, 'true');", + "fs.writeFileSync('lifecycle-marker', 'postinstall executed');", + tamper ? "fs.appendFileSync(require('node:path').join(__dirname, '../../../cli.cjs'), '\\n// postinstall tampering\\n');" : "", + hook, + ].join("\n") }], + ]) { + const directory = join(root, version, name.replaceAll("/", "-")); + for (const [path, bytes] of Object.entries({ "package.json": JSON.stringify({ name, version: dependencyVersion, scripts }), ...contents })) { + mkdirSync(dirname(join(directory, "package", path)), { recursive: true }); + writeFileSync(join(directory, "package", path), bytes); + } + const archive = `${directory}.tgz`; + execFileSync("tar", ["-czf", archive, "-C", directory, "package"]); + dependencies[name] = lockedRanges ? `^${dependencyVersion}` : `file:${archive}`; + lockedPackages[`node_modules/${name}`] = { version: dependencyVersion, resolved: `file:${archive}`, + integrity: `sha512-${digest(readFileSync(archive), "sha512", "base64")}`, hasInstallScript: true }; + } + const manifest = { name: PACKAGE, version, dependencies, bin: { diffex: "cli.cjs" } }; + const shrinkwrap = { name: PACKAGE, version, lockfileVersion: 3, packages: { "": { name: PACKAGE, version, dependencies }, ...lockedPackages } }; + if (lockFault === "name") shrinkwrap.name = "unreviewed"; + if (lockFault === "version") shrinkwrap.version = "1.0.0"; + if (lockFault === "root") shrinkwrap.packages[""].version = "1.0.0"; + if (lockFault === "dependencies") shrinkwrap.packages[""] = { ...shrinkwrap.packages[""], dependencies: {} }; + if (lockFault === "schema") shrinkwrap.lockfileVersion = 2; + const files = { + "package.json": JSON.stringify(manifest), + ...(lockFault === "missing" ? {} : { "npm-shrinkwrap.json": JSON.stringify(shrinkwrap) }), + "cli.cjs": [ + "#!/usr/bin/env node", + "const fs = require('node:fs');", + "const path = require('node:path');", + "if (!fs.existsSync(path.join(path.dirname(require.resolve('protobufjs/package.json')), 'lifecycle-marker'))) throw new Error('postinstall did not execute');", + `if (process.argv[2] === '--version') console.log('${version}');`, + "else if (process.argv[2] === '--help') console.log('Diffex local fixture');", + "else if (process.argv[2] === '--list-models') console.log('fixture-model '.repeat(12));", + "else process.exit(1);", + ].join("\n"), + }; + const inventory = { sourceCommit: commit, algorithm: "sha256", files: Object.entries(files).map(([path, bytes]) => ({ path, sha256: digest(bytes) })) }; + for (const [path, bytes] of Object.entries({ ...files, "distribution-files.json": JSON.stringify(inventory) })) + writeFileSync(join(stage, path), bytes, { mode: 0o755 }); + const filename = `diffexai-diffex-${version}.tgz`; + const path = join(root, filename); + execFileSync("tar", ["-czf", path, "-C", dirname(stage), "package"]); + const bytes = readFileSync(path); + return { name: PACKAGE, version, filename, sha256: digest(bytes), integrity: `sha512-${digest(bytes, "sha512", "base64")}`, bytes, path }; +} + +async function fixture(options, run) { + const root = realpathSync(mkdtempSync(join(tmpdir(), "diffex-global-test-"))); + const original = { ...process.env }; + const listeners = ["SIGTERM", "SIGINT"].map((signal) => process.listenerCount(signal)); + const pidFile = join(root, "background.pid"); + const hookPidFile = join(root, "hook.pid"); + const trigger = join(root, "trigger-background"); + const marker = join(root, "late-marker"); + try { + // Synthetic local background hook ignores TERM and writes only after the + // test releases a barrier AFTER validation. SIGKILL must prevent that write. + const background = [ + "const fs = require('node:fs');", + "process.on('SIGTERM', () => {});", + `fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));`, + `setInterval(() => { if (fs.existsSync(${JSON.stringify(trigger)})) fs.writeFileSync(${JSON.stringify(marker)}, 'survived'); }, 10);`, + ].join("\n"); + const hook = options.background ? [ + "const { spawn } = require('node:child_process');", + `fs.writeFileSync(${JSON.stringify(hookPidFile)}, String(process.pid));`, + `spawn(process.execPath, ['-e', ${JSON.stringify(background)}], { stdio: 'ignore' }).unref();`, + "const ready = setInterval(() => {", + ` if (!fs.existsSync(${JSON.stringify(pidFile)})) return;`, + " clearInterval(ready);", + options.background === "hang" ? " process.on('SIGTERM', () => {}); setInterval(() => {}, 100);" : "", + options.background === "fail" ? " process.exit(23);" : "", + options.background === "overflow" ? " process.stdout.write(Buffer.alloc(17 * 1024 * 1024, 'x')); setInterval(() => {}, 100);" : "", + "}, 10);", + ].join("\n") : ""; + const hookFile = join(root, "synthetic-hook.cjs"); + writeFileSync(hookFile, `const fs = require('node:fs');\n${hook}`); + const candidate = archive(root, "0.0.3", COMMIT, { tamper: options.tamper, hook, lockFault: options.lockFault, lockedRanges: options.lockedRanges }); + const published = archive(root, "0.4.0", PUBLIC_COMMIT); + const { bytes: _bytes, path: _path, ...record } = candidate; + writeFileSync(join(root, "manifest.json"), JSON.stringify({ schemaVersion: 1, version: candidate.version, source: { commit: COMMIT }, packages: [record] })); + const realNpm = realpathSync(execFileSync("which", ["npm"], { encoding: "utf8" }).trim()); + const bin = join(root, "bin"); + mkdirSync(bin); + // Candidate uses the real scoped loopback registry. Only public npm is + // substituted with its local fixture archive; all other registry access is blocked. + writeFileSync(join(bin, "npm"), [ + `#!${process.execPath}`, + "const { execFileSync } = require('node:child_process');", + "const fs = require('node:fs');", + "const args = process.argv.slice(2);", + "process.env.npm_config_registry = 'http://127.0.0.1:9'; process.env.npm_config_fetch_retries = '0';", + options.ignoreScripts ? "if (args.join(' ') === 'config get ignore-scripts') { console.log('true'); process.exit(0); }" : "", + options.background === "overflow" ? `if (args[0] === 'i') { execFileSync(process.execPath, [${JSON.stringify(hookFile)}], { stdio: 'inherit' }); process.exit(0); }` : "", + `if (args.join(' ') === 'i -g ${PACKAGE}' && !fs.readFileSync(process.env.npm_config_userconfig, 'utf8').includes('@diffexai:registry=')) args[2] = ${JSON.stringify(options.wrongPublicVersion ? candidate.path : published.path)};`, + options.skipLifecycle ? "if (args[0] === 'i') args.push('--ignore-scripts');" : "", + options.stderrSecret ? "if (args[0] === 'i') { console.error('synthetic-private-value'); process.exit(23); }" : "", + `try { execFileSync(${JSON.stringify(process.execPath)}, [${JSON.stringify(realNpm)}, ...args], { env: process.env, stdio: 'inherit' }); }`, + "catch (error) { process.exit(error.status || 1); }", + options.logFault ? [ + "if (args[0] === 'i') {", + "const fs = require('node:fs'), path = require('node:path');", + "const directory = path.join(process.env.npm_config_cache, '_logs');", + "for (const name of fs.readdirSync(directory).filter(name => name.endsWith('.log'))) {", + "const file = path.join(directory, name); let text = fs.readFileSync(file, 'utf8');", + options.logFault === "missing" ? "text = text.split('\\n').filter(line => !line.includes('info run protobufjs@7.6.5 postinstall {')).join('\\n');" : "", + options.logFault === "failed" ? "text = text.replace(/(info run protobufjs@7.6.5 postinstall \\{ code: )0/, '$123');" : "", + options.logFault === "command" ? "text = text.replace(/(info run protobufjs@7.6.5 postinstall .* )node scripts\\/postinstall/, '$1node changed');" : "", + options.logFault === "version" ? "text = text.replaceAll('protobufjs@7.6.5', 'protobufjs@7.6.6');" : "", + options.logFault === "extra" ? "text += '\\n999 info run unreviewed@1.0.0 install node_modules/unreviewed node hook.js\\n';" : "", + options.logFault === "secret" ? "text += '\\n999 silly ignored synthetic-private-value\\n'; for (const name of ['auth.json', '.npmrc', '.netrc', '.env']) fs.writeFileSync(path.join(directory, name), 'synthetic-private-value');" : "", + options.logFault === "duplicate" ? "text += '\\n999 info run protobufjs@7.6.5 postinstall node_modules/protobufjs node scripts/postinstall\\n';" : "", + options.logFault === "oversized" ? "text += 'x'.repeat(4 * 1024 * 1024);" : "", + "fs.writeFileSync(file, text); } }", + ].join("\n") : "", + ].join("\n"), { mode: 0o755 }); + process.env.PATH = `${bin}:${original.PATH}`; + process.env.npm_config_ignore_scripts = "true"; + process.env.NPM_CONFIG_IGNORE_SCRIPTS = "true"; + process.env.npm_config_userconfig = "/private/credentials.npmrc"; + for (const key of ["NPM_TOKEN", "NODE_AUTH_TOKEN", "DAYTONA_API_KEY", "OPENAI_API_KEY"]) + process.env[key] = "synthetic-private-value"; + let latestCalls = 0; + const requests = []; + const request = async (url, init) => { + if (options.fetchSignal) return new Promise((resolve, reject) => { + const timer = setTimeout(() => process.emit(options.fetchSignal), 20); + init.signal.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal.reason); }, { once: true }); + }); + if (options.slowFetch) return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Fetch escaped shared deadline")), 1000); + init.signal.addEventListener("abort", () => { clearTimeout(timer); reject(init.signal.reason); }, { once: true }); + }); + assert.equal(init.redirect, "error"); + assert(init.signal instanceof AbortSignal); + requests.push(url); + if (url === `${REGISTRY}/${encodeURIComponent(PACKAGE)}/latest`) { + latestCalls++; + const version = options.moveAt && latestCalls >= options.moveAt ? "0.4.1" : published.version; + return Response.json({ name: PACKAGE, version, dist: { + tarball: options.untrustedUrl ?? `${REGISTRY}/${PACKAGE}/-/diffex-${version}.tgz`, + integrity: options.badSri ? candidate.integrity : published.integrity, + } }); + } + assert.equal(url, `${REGISTRY}/${PACKAGE}/-/diffex-${published.version}.tgz`); + if (options.oversized) return { status: 200, body: (async function* () { yield { length: 256 * 1024 * 1024 + 1 }; })() }; + return new Response(published.bytes, { status: options.redirect ? 302 : 200 }); + }; + const output = join(root, "evidence"); + await run({ root, candidate, published, request, output, requests, pidFile, hookPidFile, trigger, marker }); + const reportPath = join(output, "npm-global-installation.json"); + if (existsSync(reportPath)) { + for (const entry of readJson(reportPath).entries) { + if (entry.registryUrl) { + assert.equal(entry.registryClosed, true); + await assert.rejects(fetch(entry.registryUrl), "Candidate registry must not accept connections after success, failure or abort"); + } + } + } + assert.deepEqual(["SIGTERM", "SIGINT"].map((signal) => process.listenerCount(signal)), listeners, "Validator must unregister signal handlers"); + } finally { + for (const path of [pidFile, hookPidFile]) { + if (!existsSync(path)) continue; + try { process.kill(Number(readFileSync(path, "utf8")), "SIGKILL"); } + catch (error) { if (error.code !== "ESRCH") throw error; } + } + process.env = original; + rmSync(root, { recursive: true, force: true }); + } +} + +function assertClean(report) { + assert.equal(report.cleanup, true); + for (const entry of report.entries) { + if (entry.resolvedExecutable) assert(!existsSync(entry.resolvedExecutable), "Temporary global prefix survived cleanup"); + } +} + +test("normal global npm runs local lifecycle hooks, resolves bare PATH, and independently verifies candidate and public inventories", { timeout: 30000 }, async () => { + await fixture({}, async ({ root, candidate, published, request, output }) => { + const report = await validateNpmGlobalInstallations(root, COMMIT, output, { request }); + assert.deepEqual(report, readJson(join(output, "npm-global-installation.json"))); + assert.equal(report.status, "passed"); + assert.equal(report.publicCommand, "npm i -g @diffexai/diffex"); + assert.equal(report.timeoutMilliseconds, 600000); + assert.equal(report.cleanupGraceMilliseconds, 1000); + assert.equal(report.aborted, null); + assert.deepEqual(report.entries.map((entry) => [entry.kind, entry.version, entry.inventorySourceCommit]), [ + ["candidate", "0.0.3", COMMIT], ["public", "0.4.0", PUBLIC_COMMIT], + ]); + for (const entry of report.entries) { + assert.equal(entry.scripts, "enabled"); + assert.equal(entry.ignoreScripts, "false"); + assert.equal(entry.coldCache, true); + assert.equal(entry.installedInventory, "passed"); + assert.equal(entry.lifecycleExecution, "passed"); + assert.deepEqual(entry.lifecycleHooks, [ + { packageId: "@google/genai@2.31.0", hook: "preinstall", exitCode: 0, signal: null }, + { packageId: "protobufjs@7.6.5", hook: "postinstall", exitCode: 0, signal: null }, + ]); + assert.equal(entry.status, "passed"); + } + assert.deepEqual(report.commands.filter((item) => item.args[0] === "i").map((item) => item.args), [ + ["i", "-g", PACKAGE], ["i", "-g", PACKAGE], + ]); + assert.equal(report.entries[0].shrinkwrap, "validated"); + assert.equal(report.entries[0].transport, "scoped-loopback-registry"); + assert(report.entries[0].archiveRequests > 0); + assert.equal(report.entries[0].integrity, candidate.integrity); + assert.equal(report.entries[1].transport, undefined, "Public path must not inherit candidate registry transport"); + assert.equal(report.entries[1].registryUrl, undefined); + assert(report.commands.every((item) => item.exitCode === 0 && !item.args.some((arg) => arg.startsWith("--ignore-scripts")))); + assert.deepEqual(report.commands.filter((item) => item.command === "diffex").map((item) => item.args), [ + ["--version"], ["--help"], ["--list-models"], ["--version"], ["--help"], ["--list-models"], + ]); + assert.equal(report.entries[1].integrity, published.integrity); + assertClean(report); + }); +}); + +test("candidate registry metadata preserves its embedded shrinkwrap with semver dependencies and external registry blocked", { timeout: 30000 }, async () => { + await fixture({ lockedRanges: true }, async ({ root, request, output }) => { + const report = await validateNpmGlobalInstallations(root, COMMIT, output, { request }); + assert.equal(report.status, "passed"); + assert.equal(report.entries[0].shrinkwrap, "validated"); + assert(report.entries[0].archiveRequests > 0); + assert.deepEqual(report.entries[0].lifecycleHooks.map((event) => event.packageId), ["@google/genai@2.31.0", "protobufjs@7.6.5"]); + assertClean(report); + }); +}); + +test("global npm retains only reviewed hook metadata, not raw logs, auth files or credentials", { timeout: 30000 }, async () => { + for (const options of [{ logFault: "secret" }, { stderrSecret: true }]) { + await fixture(options, async ({ root, request, output }) => { + if (options.stderrSecret) await assert.rejects(validateNpmGlobalInstallations(root, COMMIT, output, { request }), /exited with/); + else await validateNpmGlobalInstallations(root, COMMIT, output, { request }); + assert.deepEqual(readdirSync(output), ["npm-global-installation.json"], "Never publish npm logs or auth/config files"); + const text = readFileSync(join(output, "npm-global-installation.json"), "utf8"); + assert(!text.includes("synthetic-private-value"), "Redact before the first report write"); + const report = JSON.parse(text); + if (options.stderrSecret) assert.equal(report.commands.at(-1).stderr.trim(), "[REDACTED]"); + else assert(report.entries.every((entry) => entry.lifecycleHooks.length === 2)); + assertClean(report); + }); + } +}); + +function assertStopped(path) { + assert(existsSync(path), "Synthetic lifecycle descendant must have started"); + const pid = Number(readFileSync(path, "utf8")); + try { process.kill(pid, 0); } + catch (error) { if (error.code === "ESRCH") return; throw error; } + try { + const state = execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).trim(); + // Linux init may retain an already terminated orphan as a zombie. + assert.match(state, /^Z/, `Lifecycle process ${pid} is still running`); + } catch (error) { if (error.status !== 1) throw error; } +} + +for (const [mode, pattern] of [["hang", /timed out|shared time budget/], ["fail", /exited with/], ["overflow", /output exceeds size limit/], ["success", null]]) { + test(`global npm terminates background lifecycle groups on ${mode} before cleanup`, { timeout: 15000 }, async () => { + await fixture({ background: mode }, async ({ root, request, output, pidFile, hookPidFile, trigger, marker }) => { + const started = Date.now(); + const validation = validateNpmGlobalInstallations(root, COMMIT, output, { request, timeoutMilliseconds: mode === "hang" ? 2500 : 10000 }); + if (pattern) await assert.rejects(validation, pattern); + else await validation; + const report = readJson(join(output, "npm-global-installation.json")); + assert.equal(report.status, pattern ? "failed" : "passed"); + assertClean(report); + assertStopped(pidFile); + assertStopped(hookPidFile); + writeFileSync(trigger, "validation already returned"); + await delay(100); + assert(!existsSync(marker), "Killed lifecycle child must not write after validation/cleanup"); + if (mode === "hang") { + assert(Date.now() - started < 5000, "Shared timeout plus cleanup grace must bound the whole validation"); + assert.equal(report.commands.at(-1).args[0], "i", "Timeout must exercise the actual local dependency hook"); + } + }); + }); +} + +for (const signal of ["SIGTERM", "SIGINT"]) { + test(`global npm handles ${signal} by terminating its active command group and persisting failure cleanup`, { timeout: 15000 }, async () => { + await fixture({ background: "hang" }, async ({ root, published, output, pidFile, hookPidFile, trigger, marker }) => { + const runner = join(root, "signal-runner.mjs"); + writeFileSync(runner, [ + `import { validateNpmGlobalInstallations } from ${JSON.stringify(new URL("./npm-global-install.mjs", import.meta.url).href)};`, + "import { readFileSync } from 'node:fs';", + `const metadata = ${JSON.stringify({ name: PACKAGE, version: published.version, dist: { tarball: `${REGISTRY}/${PACKAGE}/-/diffex-${published.version}.tgz`, integrity: published.integrity } })};`, + `const request = async url => url.endsWith('/latest') ? Response.json(metadata) : new Response(readFileSync(${JSON.stringify(published.path)}));`, + `await validateNpmGlobalInstallations(${JSON.stringify(root)}, ${JSON.stringify(COMMIT)}, ${JSON.stringify(output)}, { request });`, + ].join("\n")); + const child = spawn(process.execPath, [runner], { detached: true, stdio: "ignore", env: process.env }); + const closed = once(child, "close"); + try { + const deadline = Date.now() + 5000; + while (!existsSync(pidFile) && Date.now() < deadline) await delay(10); + assert(existsSync(pidFile), "Signal regression must reach the local lifecycle hook"); + process.kill(-child.pid, signal); + const [status, exitSignal] = await closed; + assert.equal(status, 1, "Handled signal should reject validation after finally, not kill Node"); + assert.equal(exitSignal, null); + const report = readJson(join(output, "npm-global-installation.json")); + assert.equal(report.status, "failed"); + assert.match(report.error, new RegExp(`aborted by ${signal}`)); + assert.equal(report.aborted, signal); + assertClean(report); + assertStopped(pidFile); + assertStopped(hookPidFile); + writeFileSync(trigger, "validator signal cleanup completed"); + await delay(100); + assert(!existsSync(marker)); + } finally { + if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, "SIGKILL"); + await closed; + } + }); + }); + + test(`global npm ${signal} abort cancels an active registry fetch and removes handlers`, { timeout: 3000 }, async () => { + await fixture({ fetchSignal: signal }, async ({ root, request, output }) => { + await assert.rejects(validateNpmGlobalInstallations(root, COMMIT, output, { request }), new RegExp(`aborted by ${signal}`)); + const report = readJson(join(output, "npm-global-installation.json")); + assert.equal(report.status, "failed"); + assertClean(report); + assert.deepEqual(report.commands, []); + assert.equal(report.aborted, signal); + }); + }); +} + +test("global npm shares its deadline with registry downloads and rejects invalid budgets before initialization", { timeout: 3000 }, async () => { + await fixture({ slowFetch: true }, async ({ root, request, output }) => { + for (const timeoutMilliseconds of [0, -1, NaN, Infinity, "600", null, 1.5, 600001]) { + await assert.rejects(validateNpmGlobalInstallations(root, COMMIT, output, { request, timeoutMilliseconds }), /Invalid global npm timeoutMilliseconds/); + } + assert(!existsSync(output), "Invalid budgets must fail before creating evidence/prefixes"); + await assert.rejects(validateNpmGlobalInstallations(root, COMMIT, output, { request, timeoutMilliseconds: 50 }), /timeout|time budget/i); + const report = readJson(join(output, "npm-global-installation.json")); + assert.equal(report.status, "failed"); + assert.equal(report.timeoutMilliseconds, 50); + assertClean(report); + assert.deepEqual(report.commands, []); + }); +}); + +for (const [name, options, pattern] of [ + ["untrusted tarball URL", { untrustedUrl: "https://attacker.invalid/package.tgz" }, /Noncanonical public tarball/], + ["registry redirect", { redirect: true }, /Registry request failed/], + ["tarball SRI mismatch", { badSri: true }, /SRI mismatch/], + ["oversized registry tarball", { oversized: true }, /exceeds size limit/], + ["hidden scripts-disabled config", { ignoreScripts: true }, /requires scripts enabled by default/], + ["lifecycle modification of inventoried bytes", { tamper: true }, /Installed bytes differ/], + ["latest movement before installation", { moveAt: 2 }, /latest moved before installation/], + ["latest movement after installation", { moveAt: 3 }, /latest moved during installation/], + ["installed public version differs from checked latest", { wrongPublicVersion: true }, /Installed latest moved/], + ["scripts enabled in config but hooks did not execute", { skipLifecycle: true }, /Missing or duplicate reviewed npm lifecycle execution/], + ["missing hook completion", { logFault: "missing" }, /Missing or duplicate reviewed npm lifecycle execution/], + ["failed hook completion", { logFault: "failed" }, /Reviewed npm lifecycle hook failed/], + ["changed hook command", { logFault: "command" }, /Reviewed npm lifecycle command changed/], + ["unreviewed hook version", { logFault: "version" }, /Unreviewed npm lifecycle/], + ["additional unreviewed hook", { logFault: "extra" }, /Unreviewed npm lifecycle/], + ["duplicate hook start", { logFault: "duplicate" }, /Missing or duplicate reviewed npm lifecycle execution|completion lacks a unique start/], + ["oversized lifecycle log", { logFault: "oversized" }, /Invalid or oversized npm debug log/], + ...["missing", "name", "version", "root", "dependencies", "schema"].map((lockFault) => + [`invalid candidate shrinkwrap ${lockFault}`, { lockFault }, /[Cc]andidate shrinkwrap/]), +]) { + test(`global npm fails closed on ${name} and records positive cleanup`, { timeout: 30000 }, async () => { + await fixture(options, async ({ root, request, output }) => { + await assert.rejects(validateNpmGlobalInstallations(root, COMMIT, output, { request }), pattern); + const report = readJson(join(output, "npm-global-installation.json")); + assert.equal(report.status, "failed"); + assert.match(report.error, pattern); + assertClean(report); + if (options.ignoreScripts || options.badSri || options.untrustedUrl || options.redirect || options.oversized || options.lockFault) + assert(!report.commands.some((item) => item.args[0] === "i"), "Rejected configuration must not execute installation"); + if (options.moveAt === 2) + assert(!report.commands.some((item) => item.kind === "public" && item.args[0] === "i")); + }); + }); +} diff --git a/scripts/daytona-behaviors/test_installed.py b/scripts/daytona-behaviors/test_installed.py index 3bc77c32a..f9155cc8e 100644 --- a/scripts/daytona-behaviors/test_installed.py +++ b/scripts/daytona-behaviors/test_installed.py @@ -6,7 +6,7 @@ from pathlib import Path import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import xml.etree.ElementTree as ET SPEC = importlib.util.spec_from_file_location("installed", Path(__file__).with_name("installed.py")) @@ -45,6 +45,80 @@ def test_corrupt_artifact_fails_before_install(self): self.assertEqual([s["id"] for s in runner.results], ["artifact-integrity"]) self.assertIn("checksum", runner.results[0]["error"]) + def test_global_validator_timeout_terminates_group_gracefully_then_forces_and_reaps(self): + for expirations in (0, 1, 2): + with self.subTest(expirations=expirations): + process = Mock(pid=12345, returncode=0) + process.communicate.side_effect = [installed.subprocess.TimeoutExpired("fixture", 630)] * expirations + [("output", "diagnostics")] + with patch.object(installed.subprocess, "Popen", return_value=process) as spawn, \ + patch.object(installed.os, "killpg") as kill: + result = installed.run_global_installation(["node", "fixture.mjs"]) + self.assertEqual(result.returncode, 124 if expirations else 0) + self.assertEqual((result.stdout, result.stderr), ("output", "diagnostics")) + self.assertTrue(spawn.call_args.kwargs["start_new_session"]) + self.assertEqual([call.args[1] for call in kill.call_args_list], + [installed.signal.SIGTERM, installed.signal.SIGKILL][:expirations]) + self.assertEqual([call.kwargs["timeout"] for call in process.communicate.call_args_list], + [630] + [10] * expirations) + + def test_normal_npm_global_validation_and_local_script_policy_fail_closed(self): + for fault in (None, "global-exit", "global-report", "global-cleanup", "global-identity", "global-script-policy", "global-entry-missing", "global-hook-execution", "global-hook-missing", "global-hook-version", "global-hook-failed", "npm-policy", "bun-policy"): + with self.subTest(fault=fault), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + installed.write_json(root / "manifest.json", { + "source": {"commit": "a" * 40}, "packages": []}) + runner = self.runner(root / "output", root) + calls = [] + + def check_output(command, **kwargs): + return {"node": "v22.23.2", "npm": "11.16.0", "bun": "1.3.11"}.get(command[0], "fixture") + + def run(command, **kwargs): + calls.append(command) + if command[1].endswith("npm-global-install.mjs"): + self.assertEqual(command[2:4], [str(root), "a" * 40]) + hooks = [ + {"packageId": "@google/genai@2.31.0", "hook": "preinstall", "exitCode": 0, "signal": None}, + {"packageId": "protobufjs@7.6.6" if fault == "global-hook-version" else "protobufjs@7.6.5", + "hook": "postinstall", "exitCode": 23 if fault == "global-hook-failed" else 0, "signal": None}, + ] + entries = [{"kind": kind, "status": "passed", "scripts": "enabled", + "ignoreScripts": "true" if fault == "global-script-policy" else "false", + "coldCache": True, "installedInventory": "passed", + "lifecycleExecution": "unchecked" if fault == "global-hook-execution" else "passed", + "lifecycleHooks": hooks[:1] if fault == "global-hook-missing" else hooks} + for kind in ("candidate", "public")] + installed.write_json(Path(command[4]) / "npm-global-installation.json", { + "schemaVersion": 1, "sourceCommit": ("b" if fault == "global-identity" else "a") * 40, + "publicCommand": "npm i -g @diffexai/diffex", + "entries": entries[:1] if fault == "global-entry-missing" else entries, + "status": "failed" if fault == "global-report" else "passed", + "cleanup": fault != "global-cleanup"}) + return installed.subprocess.CompletedProcess(command, + 1 if fault == "global-exit" else 0, "global install output", "") + self.assertTrue(command[1].endswith("install.mjs")) + self.assertEqual(kwargs["timeout"], 615, "Outer deadline must allow inner npm timeout and group teardown") + manager = command[4] + policy = "enabled" if manager == "npm" else "disabled" + if fault == manager + "-policy": + policy = "disabled" if manager == "npm" else "enabled" + installed.write_json(Path(command[3]) / "installation.json", { + "manager": manager, "lifecycleScripts": policy}) + return installed.subprocess.CompletedProcess(command, 0, "local install output", "") + + with patch.object(installed.subprocess, "check_output", check_output), \ + patch.object(installed.subprocess, "run", run), \ + patch.object(installed, "run_global_installation", run), \ + patch.object(installed.Runner, "deterministic"): + self.assertEqual(runner.run(), fault is None) + self.assertEqual(len([c for c in calls if c[1].endswith("npm-global-install.mjs")]), 1) + global_result = next(r for r in runner.results if r["id"] == "npm-global-installation") + self.assertEqual(global_result["outcome"], "failed" if fault and fault.startswith("global-") else "passed") + for manager in ("npm", "bun"): + result = next(r for r in runner.results if r["id"] == manager + "-package-integrity") + self.assertEqual(result["outcome"], "failed" if fault == manager + "-policy" else "passed") + self.assertEqual((root / "output/npm-global-installation/install.txt").read_text(), "global install output") + def test_provider_and_configuration_failures_are_distinct(self): for status, category in ((400, "configuration"), (401, "configuration"), (404, "configuration"), (429, "provider"), (503, "provider")): with self.subTest(status=status), tempfile.TemporaryDirectory() as temporary: diff --git a/scripts/prepare-distribution.test.mjs b/scripts/prepare-distribution.test.mjs index f066f2dd5..afed3cebe 100644 --- a/scripts/prepare-distribution.test.mjs +++ b/scripts/prepare-distribution.test.mjs @@ -18,6 +18,11 @@ import { test } from "node:test"; import { PROJECT_CHANGELOGS } from "./prepare-distribution.mjs"; import { cliDistribution } from "./cli-distribution.mjs"; +test("public install entry points document the ordinary npm command", () => { + for (const file of ["README.md", "packages/coding-agent/README.md", "packages/coding-agent/docs/quickstart.md", "packages/coding-agent/docs/installation.md"]) + assert(readFileSync(file, "utf8").includes("\nnpm i -g @diffexai/diffex\n"), `${file} must show the normal npm command`); +}); + test("packed CLI links to online documentation and excludes the docs directory", () => { const root = mkdtempSync(join(tmpdir(), "diffex-cli-presentation-")); try { @@ -134,7 +139,7 @@ test("packed CLI links to online documentation and excludes the docs directory", assert.equal(component.license, "MIT", component.name); } const readme = execFileSync("tar", ["-xOf", archive, "package/README.md"], { encoding: "utf8" }); - for (const text of ["Node.js 22.19.0", "macOS and Linux", "arm64 and x64", "npm install -g --ignore-scripts @diffexai/diffex", "pnpm add -g --ignore-scripts @diffexai/diffex", "diffex --version", "diffex --help", "diffex update --self", "/login", "THIRD-PARTY-NOTICES.txt"]) + for (const text of ["Node.js 22.19.0", "macOS and Linux", "arm64 and x64", "npm i -g @diffexai/diffex", "pnpm add -g --ignore-scripts @diffexai/diffex", "diffex --version", "diffex --help", "diffex update --self", "/login", "THIRD-PARTY-NOTICES.txt"]) assert(readme.includes(text), text); assert(!packed.files.some(({ path }) => path.split("/").includes("docs"))); for (const file of readdirSync(join(pkg, "docs"), { recursive: true })) { diff --git a/scripts/public-release-acceptance.mjs b/scripts/public-release-acceptance.mjs index 03184acd8..007735969 100644 --- a/scripts/public-release-acceptance.mjs +++ b/scripts/public-release-acceptance.mjs @@ -55,6 +55,23 @@ export function verifyInstalledFiles(directory, inventory) { } } +export function installPublicPackage(manager, version, run) { + assert(ACCEPTANCE_MANAGERS.includes(manager), "Unsupported package manager"); + assert(stable(version), "Expected a stable installation version"); + const lifecycleScripts = manager === "npm" ? "enabled" : "disabled"; + const ignoreScripts = manager === "npm" ? run("npm", ["config", "get", "ignore-scripts"]) : null; + if (manager === "npm") + assert.equal(ignoreScripts, "false", "Ordinary public npm installation requires scripts enabled by default"); + run(manager, [ + "install", + "-g", + ...(manager === "npm" ? [] : ["--ignore-scripts"]), + `${PACKAGE}@${version}`, + ...(manager === "npm" ? [`--registry=${REGISTRY}`] : []), + ]); + return { version, lifecycleScripts, ignoreScripts }; +} + export async function acceptPublicRelease(manifest, previous, output) { const pkg = validateAcceptanceManifest(manifest, previous); const evidence = { @@ -161,20 +178,12 @@ export async function acceptPublicRelease(manifest, previous, output) { throw error; } }; - const install = (version) => - run(manager, [ - "install", - "-g", - "--ignore-scripts", - `${PACKAGE}@${version}`, - ...(manager === "npm" ? [`--registry=${REGISTRY}`] : []), - ]); const smoke = (version) => { assert.equal(run(executable, ["--version"]), version); assert.match(run(executable, ["--help"]), /Diffex/); assert(run(executable, ["--list-models"], { DIFFEX_OFFLINE: "1" }).length > 100); }; - install(previous); + const initialInstallation = installPublicPackage(manager, previous, run); smoke(previous); const beforeVersion = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version; // Empty manager caches prove the updater retrieves the new public artifact. @@ -203,13 +212,15 @@ export async function acceptPublicRelease(manifest, previous, output) { rmSync(env.npm_config_store_dir, { recursive: true, force: true }); assert(!existsSync(env.npm_config_store_dir)); } - install(pkg.version); + const freshInstallation = installPublicPackage(manager, pkg.version, run); smoke(pkg.version); verifyInstalledFiles(packageRoot, inventory); remove(); evidence.managers.push({ manager, managerVersion: run(manager, ["--version"]), + installations: [initialInstallation, freshInstallation], + updaterLifecycleScripts: "disabled", beforeVersion, afterVersion, freshInstall: "passed", diff --git a/scripts/public-release-acceptance.test.mjs b/scripts/public-release-acceptance.test.mjs index 01e4a9e6e..48a79b2e2 100644 --- a/scripts/public-release-acceptance.test.mjs +++ b/scripts/public-release-acceptance.test.mjs @@ -6,6 +6,7 @@ import { test } from "node:test"; import { createHash } from "node:crypto"; import { ACCEPTANCE_MANAGERS, PNPM_VERSION } from "./artifact-installation.mjs"; import { + installPublicPackage, validateAcceptanceManifest, verifyInstalledFiles, verifyPublicDiscovery, @@ -39,6 +40,44 @@ test("native and public acceptance provision the same exact pnpm release without assert(publicWorkflow.includes("/scripts/artifact-installation.mjs"), "Sparse public tools must include isolated pnpm support"); }); +test("public npm initial and fresh installs require scripts enabled by default without overriding configuration", () => { + const commands = []; + const run = (command, args) => { + commands.push({ command, args }); + return args[0] === "config" ? "false" : "installed"; + }; + for (const version of ["0.2.1", manifest.version]) { + assert.deepEqual(installPublicPackage("npm", version, run), { + version, lifecycleScripts: "enabled", ignoreScripts: "false", + }); + } + assert.deepEqual(commands, ["0.2.1", manifest.version].flatMap((version) => [ + { command: "npm", args: ["config", "get", "ignore-scripts"] }, + { command: "npm", args: ["install", "-g", `@diffexai/diffex@${version}`, "--registry=https://registry.npmjs.org"] }, + ])); + for (const ignoreScripts of ["true", "", "undefined"]) { + const rejected = []; + assert.throws(() => installPublicPackage("npm", manifest.version, (command, args) => { + rejected.push({ command, args }); + return ignoreScripts; + }), /requires scripts enabled by default/); + assert.deepEqual(rejected, [{ command: "npm", args: ["config", "get", "ignore-scripts"] }]); + } +}); + +test("public pnpm and Bun installs retain explicit scripts-disabled behavior and evidence", () => { + for (const manager of ["pnpm", "bun"]) { + const commands = []; + assert.deepEqual(installPublicPackage(manager, manifest.version, (command, args) => { + commands.push({ command, args }); + }), { version: manifest.version, lifecycleScripts: "disabled", ignoreScripts: null }); + assert.deepEqual(commands, [{ command: manager, args: ["install", "-g", "--ignore-scripts", `@diffexai/diffex@${manifest.version}`] }]); + } + const unexpected = () => assert.fail("Invalid install must not execute a command"); + assert.throws(() => installPublicPackage("unknown", manifest.version, unexpected), /Unsupported package manager/); + assert.throws(() => installPublicPackage("npm", "latest", unexpected), /stable installation version/); +}); + test("public acceptance rejects unreviewed identities and non-upgrades before installation", () => { assert.equal(validateAcceptanceManifest(manifest, "0.2.1"), manifest.packages[0]); for (const previous of ["0.2.2", "0.3.0", "0.2.1-beta.1", "--force", "0.2.1\n"]) diff --git a/scripts/stage-cli-distribution.mjs b/scripts/stage-cli-distribution.mjs index c44796b5e..30cf083d0 100644 --- a/scripts/stage-cli-distribution.mjs +++ b/scripts/stage-cli-distribution.mjs @@ -19,7 +19,7 @@ export function stageCliDistribution(directory, stage) { join(stage, "README.md"), "# Diffex\n\nCoding agent for your terminal.\n\n" + "## Requirements\n\nNode.js 22.19.0 or newer. Supports macOS and Linux on arm64 and x64.\n\n" + - "## Install\n\n```sh\nnpm install -g --ignore-scripts @diffexai/diffex\n```\n\n" + + "## Install\n\n```sh\nnpm i -g @diffexai/diffex\n```\n\n" + "Or with pnpm:\n\n```sh\npnpm add -g --ignore-scripts @diffexai/diffex\n```\n\n" + "## Usage\n\n```sh\ndiffex --version\ndiffex --help\ndiffex\n```\n\n" + "Use `/login` in Diffex to connect your provider account.\n\n" +