diff --git a/.github/workflows/verify-release-publication.yml b/.github/workflows/verify-release-publication.yml index 236b3ed66..2e3cf08bc 100644 --- a/.github/workflows/verify-release-publication.yml +++ b/.github/workflows/verify-release-publication.yml @@ -21,6 +21,25 @@ concurrency: cancel-in-progress: false jobs: + production-credential-scope: + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: diffex-release-upload + steps: + - name: Checkout reviewed source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + - name: Verify production publisher bucket isolation without writes + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + run: node scripts/verify-release-credentials.mjs --production + verify: runs-on: ubuntu-latest timeout-minutes: 15 @@ -39,6 +58,11 @@ jobs: run: npm ci --ignore-scripts - name: Verify AWS CLI run: aws --version + - name: Verify staging publisher bucket isolation + env: + AWS_ACCESS_KEY_ID: ${{ secrets.DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + run: node scripts/verify-release-credentials.mjs --staging - name: Check controlled publisher regressions run: node --test scripts/publish-release-announcement.test.mjs - name: Verify publication against staging storage diff --git a/AGENTS.md b/AGENTS.md index ab9ad8f6c..99bf2ee9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,3 +161,11 @@ Attribution: ## User Override If the user's instructions conflict with any rule in this document, ask for explicit confirmation before overriding. Only then execute their instructions. + +- 2026-09-13: Publication checks assumed an HTTPS repository field and invocation directory -> Inspect canonical package metadata and run workspace inventory from the owning repository before validating trust. + +- 2026-09-13: An unnamed historical credential note was presented as a known active key -> Identify the credential and verify its current scope/state before proposing retirement or treating it as a blocker. + +- 2026-09-13: npm created latest on first publication despite a bootstrap tag -> Verify registry behavior, clean only a newly uploaded exact bootstrap artifact, and retain a regression for partial retries. + +- 2026-09-13: Node rejects package import targets containing node_modules -> Preserve existing package import targets and validate staged tarballs through installed CLI startup before publication. diff --git a/RELEASING.md b/RELEASING.md index 299d2fa21..81f56d51b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing Diffex -Diffex publishes its public workspaces in lockstep and uses GitHub Actions trusted publishing for normal npm releases. +Diffex publishes only `@diffexai/diffex` and uses GitHub Actions trusted publishing for normal npm releases. The release tag publishes the approved npm packages and then announces the verified npm release. Standalone archives and other installation channels are deferred by the [approved distribution policy](docs/release/distribution-policy.json). @@ -14,7 +14,9 @@ Standalone archives and other installation channels are deferred by the [approve ## Approved payloads -The eleven public packages are derived from `scripts/release-packages.mjs`. +The single public CLI package is derived from `scripts/release-packages.mjs`. +Internal workspaces remain private and required runtime modules are included inside the CLI tarball. +The generated publication manifest and installation locks contain no registry dependencies on separate Diffex packages. Every package declares Apache-2.0 and ships the full root license, including the complete MIT notice for Pi-derived portions. The generated `NOTICE`, `THIRD-PARTY-NOTICES.txt`, and `distribution-components.json` identify applicable portions and bundled components. Readable runtime JavaScript, declarations, runtime JSON/SQL data, HTML/CSS templates, and documentation are approved. @@ -33,35 +35,56 @@ The CLI tarball carries `release/distribution-manifest.json`, `release/install-p Each package carries `distribution-files.json` with SHA-256 hashes for its approved runtime files. The complete tarball's npm `dist.integrity` is the registry verification value. Versioned anonymous downloads use `https://registry.npmjs.org//-/-.tgz`. -For the CLI at version 0.2.0, that URL is `https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.0.tgz`; it becomes available only after separately authorized publication. +For the CLI at version 0.2.1, that URL is `https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.1.tgz`; it becomes available only after separately authorized publication. No GitHub release publication or separate asset-repository credential is used. Npm trust remains bound to the private source repository and the existing workflow filename. ## First npm publication +The owner has already verified npm account and organization prerequisites; do not repeat login or ownership checks unless authentication fails. +Use the approved interactive npm session for bootstrap and trust configuration. npm requires a package to exist before a trusted publisher can be configured. Use this bootstrap process only once, before the first normal release. -1. Sign in with an npm account that can publish public packages under `@diffexai`. +1. Confirm the current candidate has the owner's `/cl` refresh, approved payload review, and local/native artifact acceptance. + Configure the reviewed `npm-publish` GitHub environment before public bootstrap publication. + See [publication trust and bootstrap evidence](docs/release/publication-trust.md) for the exact settings and outstanding owner decisions. + +2. Build and validate every package without publishing anything. ```bash - npm login + npm run release:bootstrap-npm:dry ``` -2. Build and validate every package without publishing anything. + For frozen candidate inputs, use the same offline build path as normal CI from a clean committed checkout: ```bash - npm run release:bootstrap-npm:dry + npm run hydrate:model-data + node scripts/release-artifacts.mjs freeze /tmp/diffex-bootstrap-candidate + npm run build:offline + node scripts/release-artifacts.mjs prepare /tmp/diffex-bootstrap-candidate + node scripts/publish.mjs --bootstrap --dry-run --artifacts /tmp/diffex-bootstrap-candidate ``` + Retain the entire private directory, including `manifest.json` and frozen source inputs. + Publish or retry these exact reviewed bytes with `node scripts/publish.mjs --bootstrap --artifacts /tmp/diffex-bootstrap-candidate`; do not rebuild them. + A new source commit requires a new reviewed candidate directory before any publication. + 3. Publish the current lockstep version under the non-default `bootstrap` dist-tag. ```bash npm run release:bootstrap-npm ``` - Bootstrap publication deliberately omits provenance because it runs interactively outside the trusted CI environment. - It does not assign the `latest` dist-tag. + Both bootstrap and normal publication explicitly disable provenance because npm does not support it for the private source repository. + Normal CI still authenticates through the exact OIDC trusted publisher; it needs no npm token secret. + npm can create `latest` on first registration despite `--tag bootstrap`. + Bootstrap verifies each newly uploaded artifact and removes only an automatically created `latest` pointing at that exact version, then checks that only the intended bootstrap tag remains. + There can be a registry exposure window between first publication and successful tag removal; retain actual read-back evidence rather than assuming the tag flag prevented it. + An existing `latest` tag blocks bootstrap before uploads. + Failed tag cleanup stops the batch and requires operator inspection of the retained manifest, exact published bytes, and tags before removing the unintended tag with `npm dist-tag rm latest --registry=https://registry.npmjs.org`. + Never remove an established release tag or a tag pointing at another version. + After cleanup or a propagation failure, rerun against the same retained artifact directory without rebuilding. The command is resumable after a partial failure: it downloads and hashes packages already published at the exact workspace version against the retained approved artifacts and refuses packages that already exist only at other versions. 4. Run every `npm trust github` command printed by the bootstrap command. @@ -70,15 +93,17 @@ Use this bootstrap process only once, before the first normal release. - Repository: `diffexai/diffex` - Workflow file: `build-binaries.yml` - Environment: `npm-publish` - - Allowed action: `npm publish` + - Allowed action: `npm publish`; npm also automatically grants `npm stage publish` on relationships created after 2026-09-03. 5. Verify each package has the expected trusted publisher. ```bash - npm trust list @diffexai/diffex + node scripts/npm-publication-trust.mjs --verify ``` 6. Restrict traditional token publishing after trusted publishing is verified. + In every package's npm access settings, require two-factor authentication and disallow tokens, then retain the reviewed settings alongside the trust evidence. + Do not revoke unrelated account tokens or claim enforcement from an OIDC configuration alone. ## Normal release diff --git a/docs/release/artifact-acceptance.md b/docs/release/artifact-acceptance.md index b79094872..d4b68059b 100644 --- a/docs/release/artifact-acceptance.md +++ b/docs/release/artifact-acceptance.md @@ -13,19 +13,10 @@ Runtime imports and representative operations execute in fresh Node processes. Installed runtime inventories must equal the inventory extracted from the checked tarball, and every recorded installed file must match its SHA-256 digest. The operation inventory fails closed if a new public package has no representative operation. -| Package | Representative operation | -| --- | --- | -| `diffex-agent-core` | Construct an agent and change its state | -| `diffex-ai` | Complete and consume an assistant event stream | -| `diffex-client` | Construct and dispose a client; additionally list a hosted session over WebSocket | -| `diffex` | Initialize model runtime, load clipboard native module, execute image WASM, export a session to HTML | -| `diffex-coding-agent-server` | Close usage reporter; start its actual executable, reject wrong credentials, list its session, terminate cleanly | -| `diffex-harness-state` | Persist and reload the initial harness version | -| `diffex-protocol` | Encode and decode CBOR | -| `diffex-server` | Start and close a loopback WebSocket listener | -| `diffex-session-backend-sqlite-node` | Create, write, and query SQLite | -| `diffex-telemetry` | Execute a telemetry span callback | -| `diffex-tui` | Render text and load the current platform's native modifier helper where applicable | +The current public inventory contains only `@diffexai/diffex`. +Its required internal modules ship inside the tarball, and installation must succeed while separate Diffex registry requests are blocked. +The representative CLI operation initializes model runtime, loads clipboard native code, executes image WASM, and exports a session to HTML. +Hosted server packages remain private and are covered by repository tests rather than public npm artifact acceptance. The packed CLI additionally executes help, version, model listing, controlled inference, a terminal prompt/reply, account-menu inspection, and clean exit. The terminal path exercises packaged themes and the HTML export path exercises its templates, CSS, and bundled renderer resources. @@ -75,11 +66,13 @@ shasum -a 256 -c /path/to/private-evidence/SHA256SUMS On Linux, `sha256sum -c` accepts the same checksum file. Regression coverage alters archive bytes and confirms verification fails before installation. -The release workflow retains the source snapshot and manifests privately, while npm publication supplies registry integrity and provenance through the existing publisher. +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. The baseline CLI-only acceptance passed on Darwin arm64 but emitted no per-package operation or hosted-server result. -The expanded local acceptance covers all eleven packages for both managers and the server lifecycle. +The previous expanded acceptance covered eleven packages and the hosted server lifecycle before the owner reduced the public inventory on 2026-09-13. +Current acceptance validates the complete CLI-only tarball through both managers. The owner specified that no live provider test environment is available, so real login and inference remain pending. Native runner results and exact committed-candidate evidence are recorded in the implementation pull request and accompanying acceptance record. Public downloads and public-to-public upgrades remain unexecuted until publication; local fixture inference does not establish those results. diff --git a/docs/release/cli-only-candidate-evidence.json b/docs/release/cli-only-candidate-evidence.json new file mode 100644 index 000000000..e019438dd --- /dev/null +++ b/docs/release/cli-only-candidate-evidence.json @@ -0,0 +1,76 @@ +{ + "sourceCommit": "f2a661684af35fe6e9a3209c6f9720144e1fd423", + "version": "0.2.1", + "packages": [ + { + "name": "@diffexai/diffex", + "version": "0.2.1", + "filename": "diffexai-diffex-0.2.1.tgz", + "size": 3530237, + "sha256": "64c161d63ef416f6bf116a6b35953225dc49c236ccba0ef4b88c2ac8ff4452b0", + "integrity": "sha512-ZBrTmPGnJy/C7u8DhZA/owIDELJGzPf2ujbRtZCPyvSfQEAtXGZk2C0PQ1/ctbyFKMXXpkXPlO7Asm5E/s//5g==" + } + ], + "localAcceptance": { + "platform": "darwin", + "arch": "arm64", + "node": "v22.19.0", + "status": "passed", + "cleanup": true, + "packages": [ + { + "manager": "npm", + "managerVersion": "10.9.3", + "name": "@diffexai/diffex", + "version": "0.2.1", + "entrypoints": [ + "@diffexai/diffex", + "@diffexai/diffex/rpc-entry", + "@diffexai/diffex/client" + ], + "operation": "passed", + "localDependencies": [ + "@diffexai/diffex" + ] + }, + { + "manager": "bun", + "managerVersion": "1.3.11", + "name": "@diffexai/diffex", + "version": "0.2.1", + "entrypoints": [ + "@diffexai/diffex", + "@diffexai/diffex/rpc-entry", + "@diffexai/diffex/client" + ], + "operation": "passed", + "localDependencies": [ + "@diffexai/diffex" + ] + } + ], + "processes": [ + { + "manager": "npm", + "server": "Excluded from CLI distribution; hosted runtime remains private", + "terminal": "passed", + "helpers": { + "fd": "fd 10.4.2", + "rg": "ripgrep 15.2.0 (rev e89fff89ac)\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)" + } + }, + { + "manager": "bun", + "server": "Excluded from CLI distribution; hosted runtime remains private", + "terminal": "passed", + "helpers": { + "fd": "fd 10.4.2", + "rg": "ripgrep 15.2.0 (rev e89fff89ac)\n\nfeatures:+pcre2\nsimd(compile):+NEON\nsimd(runtime):+NEON\n\nPCRE2 10.45 is available (JIT is available)" + } + } + ] + }, + "nativeWorkflow": "https://github.com/diffexai/diffex/actions/runs/34765173973", + "nativeStatus": "Blocked before execution by GitHub billing or spending limit", + "published": false +} diff --git a/docs/release/distribution-policy.json b/docs/release/distribution-policy.json index 90ea197e4..cf7ffd738 100644 --- a/docs/release/distribution-policy.json +++ b/docs/release/distribution-policy.json @@ -1,7 +1,7 @@ { "status": "approved", "sourceRepository": "diffexai/diffex", - "readableJavaScript": "allow-for-all-eleven-public-packages", + "readableJavaScript": "allow-for-cli-and-bundled-runtime-workspaces", "declarations": "allow", "typeScriptExamples": "exclude", "nativeSources": "exclude-subject-to-redistribution-review", @@ -24,8 +24,8 @@ "interactiveContainer": "defer" }, "approval": { - "date": "2026-09-12", - "authority": "owner explicit approval in implementation conversation" + "date": "2026-09-13", + "authority": "owner approved CLI-only 0.2.1 replacement and retirement of 0.2.0 and separate packages" }, "standaloneDeferral": "Bun static runtime redistribution and native dependency source/object review must be completed before enabling standalone publication.", "approvedRuntimeAssets": [ @@ -41,5 +41,19 @@ "publicHosting": "npmjs", "publicRegistry": "https://registry.npmjs.org", "immutableTarballUrl": "https://registry.npmjs.org//-/-.tgz", - "verification": "npm dist.integrity verifies complete tarball bytes against registry metadata; distribution-files.json records SHA-256 for approved runtime files" + "verification": "npm dist.integrity verifies complete tarball bytes against registry metadata; distribution-files.json records SHA-256 for approved runtime files", + "npmPackages": [ + "@diffexai/diffex" + ], + "bundledWorkspaces": [ + "agent", + "ai", + "client", + "evolution", + "harness-state", + "harness-trace", + "protocol", + "telemetry", + "tui" + ] } diff --git a/docs/release/distribution-review.md b/docs/release/distribution-review.md index 8a3d0f7e5..53037e9d3 100644 --- a/docs/release/distribution-review.md +++ b/docs/release/distribution-review.md @@ -9,13 +9,15 @@ This work does not authorize npm bootstrap or production publication. ## Distributions and source disclosure [distribution-policy.json](distribution-policy.json) records the approved decisions. -The manifest generator derives all eleven public packages from `scripts/release-packages.mjs`, including `@diffexai/diffex-coding-agent-server` and canonical CLI `@diffexai/diffex`. +On 2026-09-13 the owner revised the public inventory to only `@diffexai/diffex` and approved a bundled `0.2.1` replacement with retirement of `0.2.0`. +The manifest generator now rejects every additional public package. It rejects private workspace names, the generated private installer package as an npm publication, obsolete `@diffexai/diffex-coding-agent`, incomplete inventories, and mixed versions. The source commit is a full identity string and is not copied into another repository's Git history. Readable JavaScript and declarations ship in all public packages. The CLI also ships runtime JavaScript and declarations from `dist/vendor/evolution` and `dist/vendor/harness-trace`; their private workspace flags do not make those delivered files private. -These are the only private workspace implementations approved for vendoring. +The approved CLI-only replacement also includes the agent, AI, client, harness-state, protocol, telemetry, and TUI runtime workspaces under `dist/node_modules/@diffexai`. +Their previously reviewed runtime assets and complete notices are preserved, while publication and installation locks exclude separate Diffex registry dependencies. TypeScript examples and native source/build files are excluded. Runtime JSON, SQLite migration SQL, HTML/CSS export templates, documentation with inline examples, and Diffex-owned TUI native prebuilds remain included. The TUI helpers call system APIs; their source remains in the private build input and their project notices remain in the package. @@ -30,7 +32,7 @@ Delivered code remains inspectable; repository privacy, minification, obfuscatio ## License and notice inventory -All eleven shipped package manifests declare `Apache-2.0`. +The CLI and all bundled workspace manifests declare `Apache-2.0`. The two vendored Diffex workspaces also declare `Apache-2.0`. Each public package receives the complete root `LICENSE`, preserving the full Apache text, Luntraa, Inc. ownership, and the complete upstream MIT license and copyright notice for Pi-derived portions. The package declaration does not replace the MIT terms of those portions or other third-party terms. @@ -67,7 +69,7 @@ The full source archive remains a private build input and is excluded from all p Its owning validator requires the root license and model snapshot invariants. The public cloud release record remains the existing npm publication contract; the richer embedded manifest is not a replacement R2 schema. -## Changelog reset and local acceptance +## Historical changelog reset and local acceptance The owner explicitly approved the one-time reset after automatic review required direct authorization. Only the nine paths listed in the release audit were cleared; their previous contents remain at `ae811d134dd63b0a688aea10ea0368c0935e7e48`. diff --git a/docs/release/promotion.md b/docs/release/promotion.md index 83d7434ce..007469b04 100644 --- a/docs/release/promotion.md +++ b/docs/release/promotion.md @@ -1,7 +1,7 @@ # Release promotion and recovery This implementation builds on CLI PR #209 and cloud PR #150. -The approved policy enables the eleven npm packages and defers standalone binaries, Python, secondary installers, and runtime images. +The approved policy enables only the CLI npm package and defers standalone binaries, Python, secondary installers, and runtime images. No binary or GitHub Release is created, and the private source repository stays private. Enabling another channel fails closed until required asset enumeration, immutable publication, and anonymous byte verification exist for that channel. The R2 schema remains the [npm release contract](contract.md), without a binary manifest or new client fields. @@ -44,7 +44,7 @@ The source archive and `inputs.json` freeze inputs before the build. Packing rejects changes to tracked source or frozen model data, and `manifest.json` must agree with those original inputs. Registry requests reject redirects and use thirty-second timeouts; tarballs are streamed with a 256 MiB limit. Missing packages are eligible for publication, while mismatched existing bytes abort before publishing any missing package. -Announcement re-downloads all eleven tarballs and verifies their bytes before either R2 write. +Announcement re-downloads the CLI tarball and verifies its bytes before either R2 write. No failed prerequisite advances the pointer. ## Retained artifacts and retries diff --git a/docs/release/publication-trust.md b/docs/release/publication-trust.md new file mode 100644 index 000000000..6ab749f98 --- /dev/null +++ b/docs/release/publication-trust.md @@ -0,0 +1,121 @@ +# Publication trust and bootstrap evidence + +## Supported npm identity + +The source repository remains private at `diffexai/diffex`. +Normal publication uses GitHub Actions OIDC with workflow `build-binaries.yml`, environment `npm-publish`, and direct `npm publish` permission. +Since 2026-09-03 npm automatically also grants `npm stage publish` on new trusted relationships. +The current inventory comes from `scripts/release-packages.mjs` and contains only `@diffexai/diffex`. +The owner approved replacing the initial eleven-package `0.2.0` bootstrap with CLI-only `0.2.1` and retiring the old versions after replacement validation. +No npm token secret belongs in the release workflow. + +[npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) supports private repositories, but does not generate provenance for them. +The publisher therefore sets `--provenance=false` explicitly, including when inherited npm configuration requests provenance. +It pins the public npm registry and publishes only retained, reviewed tarball bytes with lifecycle scripts disabled. +OIDC authentication does not supply public source provenance or make packaged implementation code confidential. +The supported verification path is the private source/input manifest, SHA-256 and SHA-512 tarball identities, downloaded registry bytes, and inspected npm trust relationships. +Source snapshots and debugging maps remain in private artifact storage. + +The workflow pins Node 22.19.0 and npm 11.16.0. +[npm trust](https://docs.npmjs.com/cli/v11/commands/npm-trust/) requires npm 11.15.0 or newer and an existing package. +Its verified JSON output must contain GitHub type, the exact repository/file/environment, and exactly `createPackage` plus npm's automatically granted `createStagedPackage` permission. +Missing, foreign, or broader relationships fail verification. + +```sh +node scripts/npm-publication-trust.mjs --plan +node scripts/npm-publication-trust.mjs --verify +``` + +The plan prints commands without registry writes. +Run the printed commands only after approved bootstrap bytes exist publicly. +The verifier queries every relationship and prints only configuration metadata. +Traditional publishing restrictions must be checked separately in every package's access settings after trust is confirmed. +An OIDC relationship alone does not disable token publishing. + +## Environment and credential boundaries + +The `npm-publish` environment was configured on 2026-09-13 and permits branch `main` and tags `v*` only. +The owner approved `Pepps233` reviewer protection with administrator bypass disabled, plus a ref-only fallback if the plan rejected reviewer protection. +GitHub returned HTTP 422 because the repository plan does not support required reviewers. +The approved fallback was applied and inspected; administrator bypass remains enabled and there is no enforced human review gate. + +| Destination | Environment | Credential scope | +| --- | --- | --- | +| Public npm artifacts | `npm-publish` | Per-package OIDC tuple; no stored npm publication token | +| Production release records | `diffex-release-upload` | `DIFFEX_ARTIFACTS_R2_ACCESS_KEY_ID` and `DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY`, production bucket only | +| Staging release fixtures | `diffex-release-staging` | Separately installed secrets with the same names, staging bucket only | +| Model catalogs | `diffex-model-upload` | Independent configuration; no release credentials copied | +| Production Worker deployment | Cloud repository `public-api-production` | `CLOUDFLARE_PUBLIC_API_DEPLOY_TOKEN`; no R2 publisher keys | + +The approved public asset host is npm. +Standalone and alternate-host assets are deferred, so the unused `diffex-distribution` environment requires no publisher credential. +Production release storage is `diffex-artifacts`; staging is `diffex-artifacts-staging` in the reviewed Cloudflare account. +Existing buckets and credentials must be reused. +Bucket scope grants access to the entire selected bucket; the release prefix remains a code boundary. + +The manual verification workflow now checks each release credential independently using read-only bucket listings. +Each key must access its own bucket and receive explicit `AccessDenied` for the other release bucket and `image-converter`. +Timeouts, missing buckets, and invalid credentials cannot count as isolation evidence. +The production check makes no writes; staging fixture verification retains exact ownership checks and cleanup. +The new scope checks require a run after this workflow change reaches `main`, without relaxing the existing environment restrictions. + +## Live evidence recorded 2026-09-13 + +CLI candidate `a25f52bc2` contains the merged distribution, promotion, and native acceptance work. +Anonymous registry inspection at 14:21 UTC returned 404 for all eleven package names at current lockstep version `0.2.0`. +No versions or dist-tags were present. +The owner previously verified npm account prerequisites; this inspection did not repeat login or ownership checks. + +GitHub inspection confirmed private source visibility, existing release environments, production `main`/`v*` and staging `main` policies, and the separate secret entries installed on 2026-09-10. +The distribution and model-upload environments had no secret entries. +The cloud production environment had its separate Worker deployment secret. +Secret-name inspection does not prove credential values differ or establish current bucket capability. + +[Staging run 34762316585](https://github.com/diffexai/diffex/actions/runs/34762316585) passed on `main` using the existing workflow with `check_public_api=true` and `check_client_canaries=false`. +It exercised controlled npm responses, real staging R2 writes/readback, repeated publication, conditional advancement, interrupted-pointer recovery, and anonymous HTTP projection and rejection behavior. +Fixture versions were `0.1789309179924.0` and `0.1789309179924.1`. +At 14:20:48 UTC it confirmed both version records, the latest fixture pointer, and `verification/release-publication.lock` were absent. +It made no production write and did not publish npm packages. +This run predates the additional credential scope checks and does not claim to validate them live. + +## Outstanding acceptance + +The owner confirmed the current `/cl` refresh and approved public bootstrap publication of `0.2.0` once validation passes. +The initial environment attempt was blocked by automatic approval review; the owner subsequently approved the exact settings and fallback, and configuration succeeded. +The historical shared-key sentence refers to R2, not Worker deployment. +It has no retained credential identifier, location, or current-state evidence and is not an actionable revocation requirement. +No credential was revoked or replaced on the strength of that note. +All eleven original `0.2.0` packages were uploaded from native-accepted source `e3f96b95ebc7fdad96220e315ec144f74383de22`. +Registry read-back unexpectedly showed both `bootstrap` and `latest` on all names despite explicit `--tag bootstrap`; a reviewed removal attempt returned HTTP 400. +This failed the no-latest acceptance gate and no production announcement was made. +The CLI trusted publisher was created and read back with the exact repository, file, environment, and current npm permission pair. +The owner then requested a CLI-only distribution and approved `0.2.1` with retirement of `0.2.0`. +The replacement tarball includes seven required workspace packages plus the existing evolution and harness-trace runtime modules. +Replacement publication, old-package removal, final dist-tags, and traditional-token restrictions remain pending until their live results are retained. +No R2 production pointer or generally available release may be created by bootstrap. +These pending items prevent publication-trust acceptance from being marked complete. + +## CLI-only replacement validation + +Final publication candidate `f2a661684af35fe6e9a3209c6f9720144e1fd423` packages only `@diffexai/diffex@0.2.1`. +The [local candidate evidence](cli-only-candidate-evidence.json) retains its exact tarball size, SHA-256, SHA-512, supported entrypoints, npm/Bun acceptance, terminal outcomes, and cleanup. +The source quality gate, full isolated `./test.sh` suite, package inventory regressions, and disclosure-policy rejection tests passed. +The final local tarball passed both package managers with the separate Diffex registry scope blocked. +These local bytes are not published. + +[Native run 34765040811](https://github.com/diffexai/diffex/actions/runs/34765040811) passed Darwin arm64 and both Linux targets on implementation commit `8c8d8f529`; Darwin x64 was cancelled when the final README revision was pushed. +[Final candidate run 34765173973](https://github.com/diffexai/diffex/actions/runs/34765173973) failed before executing any build command. +GitHub's job annotation reports failed account payments or an Actions spending limit and directs the owner to Billing and plans. +Rerun that retained run after the account issue is resolved so source identity remains pinned to the final candidate. +Download its private artifact, inspect its manifest and checksums, and require every native target to pass before replacement publication. +Do not substitute the earlier partial matrix or represent local validation as four-platform acceptance. + +The owner approved publishing the validated replacement under `bootstrap` and retiring the original CLI `0.2.0` plus the ten separate npm packages. +The existing CLI `0.2.0` depends on those packages, so removal waits for validated replacement bytes. +Reinspect registry versions before deletion, remove dependents before dependencies, preserve `@diffexai/diffex@0.2.1`, and verify all retired versions are absent. +The existing first-registration bootstrap guard intentionally rejects established versions; this explicitly approved replacement requires the retained exact tarball command with `--access public --tag bootstrap --registry=https://registry.npmjs.org --provenance=false --ignore-scripts`. +Verify bytes and final tags, then require package publishing 2FA with tokens disallowed and inspect trust again. +No new general release tag or R2 announcement is part of this replacement. + +At 15:21 UTC the production probe still returned no release on `/api/latest-version` (404), healthy Render readiness (200), and anonymous auth rejection (401). +No current R2 or Worker credential was replaced or revoked. diff --git a/docs/release/publication.md b/docs/release/publication.md index b4712b78c..7aa6f34ce 100644 --- a/docs/release/publication.md +++ b/docs/release/publication.md @@ -11,6 +11,8 @@ Standalone binaries remain deferred, and the npm record contains no binary manif See [promotion and recovery](promotion.md) for the complete prerequisites, private artifact retention, and partial-failure procedures. This setup does not authorize running a release or bypass the repository's release requirements. +See [publication trust and bootstrap evidence](publication-trust.md) for the private-source OIDC decision and the current live verification record. + ## Credentials and environment boundaries Release storage belongs to account `6b24afa849f8254ab5e6e2d1956239cc`. @@ -111,7 +113,9 @@ Dispatch it from `main` once the workflow is merged; adding a workflow on a pull On 2026-09-10, both newly supplied keys successfully listed release records in their own buckets and received `AccessDenied` for the other release bucket and the unrelated `image-converter` bucket. Both release namespaces were empty before the staging check. -The previous shared key was not installed in GitHub; revoking it remains a separate Cloudflare owner action. +The original record referred to a previous shared R2 key that was not installed in GitHub. +That record retained no credential name, identifier, location, or confirmed scope, so it does not establish that an active credential exists or justify revocation. +Only an identified, verified superseded credential could be considered for owner-directed retirement; the current dedicated R2 keys and Worker deployment token are unaffected. The 14 focused publisher regressions, `npm run check`, `./test.sh`, and actionlint 1.7.12 checks of both changed workflows passed. No real release was run. diff --git a/package-lock.json b/package-lock.json index c40d2f65c..513701902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1556,9 +1556,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1575,9 +1572,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1594,9 +1588,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1613,9 +1604,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1632,9 +1620,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5514,11 +5499,11 @@ }, "packages/agent": { "name": "@diffexai/diffex-agent-core", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-telemetry": "^0.2.0", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-telemetry": "^0.2.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -5553,12 +5538,12 @@ }, "packages/ai": { "name": "@diffexai/diffex-ai", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@diffexai/diffex-telemetry": "^0.2.0", + "@diffexai/diffex-telemetry": "^0.2.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -5600,10 +5585,10 @@ }, "packages/client": { "name": "@diffexai/diffex-client", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-protocol": "^0.2.0" + "@diffexai/diffex-protocol": "^0.2.1" }, "devDependencies": { "shx": "0.4.0", @@ -5615,15 +5600,15 @@ }, "packages/coding-agent": { "name": "@diffexai/diffex", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-agent-core": "^0.2.0", - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-client": "^0.2.0", - "@diffexai/diffex-harness-state": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", - "@diffexai/diffex-tui": "^0.2.0", + "@diffexai/diffex-agent-core": "^0.2.1", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-client": "^0.2.1", + "@diffexai/diffex-harness-state": "^0.2.1", + "@diffexai/diffex-protocol": "^0.2.1", + "@diffexai/diffex-tui": "^0.2.1", "@napi-rs/keyring": "1.3.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", @@ -5666,18 +5651,18 @@ }, "packages/coding-agent-server": { "name": "@diffexai/diffex-coding-agent-server", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", - "@diffexai/diffex-server": "^0.2.0" + "@diffexai/diffex": "^0.2.1", + "@diffexai/diffex-protocol": "^0.2.1", + "@diffexai/diffex-server": "^0.2.1" }, "bin": { "diffex-runtime-server": "dist/cli.js" }, "devDependencies": { - "@diffexai/diffex-client": "^0.2.0", + "@diffexai/diffex-client": "^0.2.1", "shx": "0.4.0", "vitest": "4.1.9", "ws": "8.21.0" @@ -5688,25 +5673,25 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "diffex-extension-custom-provider-anthropic", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "diffex-extension-custom-provider-gitlab-duo", - "version": "0.2.0" + "version": "0.2.1" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "diffex-extension-sandbox", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "diffex-extension-with-deps", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "ms": "2.1.3" }, @@ -5742,10 +5727,10 @@ }, "packages/evals": { "name": "@diffexai/diffex-evals", - "version": "0.2.0", + "version": "0.2.1", "devDependencies": { - "@diffexai/diffex": "^0.2.0", - "@diffexai/diffex-ai": "^0.2.0", + "@diffexai/diffex": "^0.2.1", + "@diffexai/diffex-ai": "^0.2.1", "@types/node": "24.12.4", "shx": "0.4.0", "typescript": "5.9.3", @@ -5772,11 +5757,11 @@ }, "packages/evolution": { "name": "@diffexai/diffex-evolution", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-harness-state": "0.2.0", - "@diffexai/diffex-harness-trace": "0.2.0", + "@diffexai/diffex-harness-state": "^0.2.1", + "@diffexai/diffex-harness-trace": "^0.2.1", "proper-lockfile": "4.1.2" }, "devDependencies": { @@ -5809,7 +5794,7 @@ }, "packages/harness-state": { "name": "@diffexai/diffex-harness-state", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "proper-lockfile": "4.1.2", @@ -5827,10 +5812,10 @@ }, "packages/harness-trace": { "name": "@diffexai/diffex-harness-trace", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-telemetry": "0.2.0" + "@diffexai/diffex-telemetry": "^0.2.1" }, "devDependencies": { "@types/node": "24.12.4", @@ -5860,7 +5845,7 @@ }, "packages/protocol": { "name": "@diffexai/diffex-protocol", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "typebox": "1.3.7" @@ -5875,11 +5860,11 @@ }, "packages/server": { "name": "@diffexai/diffex-server", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-protocol": "^0.2.1", "ws": "8.21.0" }, "devDependencies": { @@ -5893,11 +5878,11 @@ }, "packages/session-backends/sqlite-node": { "name": "@diffexai/diffex-session-backend-sqlite-node", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-agent-core": "^0.2.0", - "@diffexai/diffex-ai": "^0.2.0" + "@diffexai/diffex-agent-core": "^0.2.1", + "@diffexai/diffex-ai": "^0.2.1" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", @@ -5909,7 +5894,7 @@ }, "packages/telemetry": { "name": "@diffexai/diffex-telemetry", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "devDependencies": { "@types/node": "24.12.4", @@ -5938,7 +5923,7 @@ }, "packages/tui": { "name": "@diffexai/diffex-tui", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/package.json b/packages/agent/package.json index e1bbca15c..81c909069 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-agent-core", - "version": "0.2.0", + "version": "0.2.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -41,8 +41,8 @@ "prepack": "node ../../scripts/prepare-distribution.mjs packages/agent" }, "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-telemetry": "^0.2.0", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-telemetry": "^0.2.1", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", @@ -70,5 +70,6 @@ "@vitest/coverage-v8": "4.1.9", "typescript": "5.9.3", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/ai/package.json b/packages/ai/package.json index f428b3556..83b5cfca7 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-ai", - "version": "0.2.0", + "version": "0.2.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -68,7 +68,7 @@ "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@diffexai/diffex-telemetry": "^0.2.0", + "@diffexai/diffex-telemetry": "^0.2.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -103,5 +103,6 @@ "@types/node": "24.12.4", "canvas": "3.2.3", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/client/package.json b/packages/client/package.json index 5f02a4e34..f095d4a25 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-client", - "version": "0.2.0", + "version": "0.2.1", "description": "Transport-neutral client for remote Diffex sessions over framed CBOR bytes", "type": "module", "main": "./dist/index.js", @@ -61,10 +61,11 @@ "node": ">=22.19.0" }, "dependencies": { - "@diffexai/diffex-protocol": "^0.2.0" + "@diffexai/diffex-protocol": "^0.2.1" }, "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/coding-agent-server/package.json b/packages/coding-agent-server/package.json index cc5cb10a0..38a9f6009 100644 --- a/packages/coding-agent-server/package.json +++ b/packages/coding-agent-server/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-coding-agent-server", - "version": "0.2.0", + "version": "0.2.1", "description": "Hosted Diffex protocol service backed by the Diffex coding-agent runtime", "type": "module", "main": "./dist/index.js", @@ -41,14 +41,15 @@ "directory": "packages/coding-agent-server" }, "dependencies": { - "@diffexai/diffex": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", - "@diffexai/diffex-server": "^0.2.0" + "@diffexai/diffex": "^0.2.1", + "@diffexai/diffex-protocol": "^0.2.1", + "@diffexai/diffex-server": "^0.2.1" }, "devDependencies": { - "@diffexai/diffex-client": "^0.2.0", + "@diffexai/diffex-client": "^0.2.1", "shx": "0.4.0", "vitest": "4.1.9", "ws": "8.21.0" - } + }, + "private": true } diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index e67e4eef7..5aa2c68fc 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -715,11 +715,10 @@ Apache-2.0 for Diffex, copyright 2026 Luntraa, Inc. Pi-derived portions retain their MIT terms and upstream copyright notice. See LICENSE, NOTICE, and THIRD-PARTY-NOTICES.txt in the distribution. -## See Also +## Included modules -- [@diffexai/diffex-ai](https://www.npmjs.com/package/@diffexai/diffex-ai): Core LLM toolkit -- [@diffexai/diffex-agent-core](https://www.npmjs.com/package/@diffexai/diffex-agent-core): Agent framework -- [@diffexai/diffex-tui](https://www.npmjs.com/package/@diffexai/diffex-tui): Terminal UI components +The CLI package includes the Diffex AI toolkit, agent framework, terminal UI, and other required internal runtime modules. +Install only `@diffexai/diffex`; separate Diffex module packages are not required or published.

The upstream project's domain was graciously donated by diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 24bb2a6e0..90eae6c96 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "diffex-extension-custom-provider-anthropic", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diffex-extension-custom-provider-anthropic", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index c10887bf8..770ba6368 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "diffex-extension-custom-provider-anthropic", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 10843230d..dbca455d7 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "diffex-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index 90336d81e..2b5186b20 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "diffex-extension-sandbox", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diffex-extension-sandbox", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 16d5d1458..59730777d 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "diffex-extension-sandbox", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index ccccaa54b..5a2875858 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "diffex-extension-with-deps", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diffex-extension-with-deps", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "ms": "2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index 96839e8fa..abd706f49 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "diffex-extension-with-deps", "private": true, - "version": "0.2.0", + "version": "0.2.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index ca2f71c03..a2541f21e 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@diffexai/diffex-coding-agent-install", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@diffexai/diffex-coding-agent-install", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { - "@diffexai/diffex": "0.2.0" + "@diffexai/diffex": "0.2.1" }, "engines": { "node": ">=22.19.0" @@ -450,28 +450,34 @@ } }, "node_modules/@diffexai/diffex": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.0.tgz", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@diffexai/diffex/-/diffex-0.2.1.tgz", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-agent-core": "^0.2.0", - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-client": "^0.2.0", - "@diffexai/diffex-harness-state": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", - "@diffexai/diffex-tui": "^0.2.0", + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", "@napi-rs/keyring": "1.3.0", + "@opentelemetry/api": "1.9.0", "@silvia-odwyer/photon-node": "0.3.4", + "@smithy/node-http-handler": "4.7.3", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", + "get-east-asian-width": "1.6.0", "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", "ignore": "7.0.5", "jiti": "2.7.0", + "marked": "18.0.5", "minimatch": "10.2.5", + "openai": "6.26.0", + "partial-json": "0.1.7", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", @@ -488,102 +494,6 @@ "node": ">=22.19.0" } }, - "node_modules/@diffexai/diffex-agent-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-agent-core/-/diffex-agent-core-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-telemetry": "^0.2.0", - "diff": "8.0.4", - "ignore": "7.0.5", - "typebox": "1.3.7", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-ai": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-ai/-/diffex-ai-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@diffexai/diffex-telemetry": "^0.2.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", - "@opentelemetry/api": "1.9.0", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.3.7" - }, - "bin": { - "diffex-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-client": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-client/-/diffex-client-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "@diffexai/diffex-protocol": "^0.2.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-harness-state": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-harness-state/-/diffex-harness-state-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "proper-lockfile": "4.1.2", - "typebox": "1.3.7", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-protocol": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-protocol/-/diffex-protocol-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "typebox": "1.3.7" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-telemetry": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-telemetry/-/diffex-telemetry-0.2.0.tgz", - "license": "Apache-2.0", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-tui": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-tui/-/diffex-tui-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", @@ -912,9 +822,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-arm64-musl": { @@ -931,9 +838,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { @@ -950,9 +854,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-x64-gnu": { @@ -969,9 +870,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-x64-musl": { @@ -988,9 +886,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true }, "node_modules/@napi-rs/keyring-win32-arm64-msvc": { diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index e06072378..2abce6d14 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@diffexai/diffex-coding-agent-install", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Lockfile root used by the Diffex installer and updater.", "dependencies": { - "@diffexai/diffex": "0.2.0" + "@diffexai/diffex": "0.2.1" }, "overrides": { "protobufjs": "7.6.5", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 90d6b3cba..478549fc1 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,32 +1,38 @@ { "name": "@diffexai/diffex", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@diffexai/diffex", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { - "@diffexai/diffex-agent-core": "^0.2.0", - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-client": "^0.2.0", - "@diffexai/diffex-harness-state": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", - "@diffexai/diffex-tui": "^0.2.0", + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", "@napi-rs/keyring": "1.3.0", + "@opentelemetry/api": "1.9.0", "@silvia-odwyer/photon-node": "0.3.4", + "@smithy/node-http-handler": "4.7.3", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", + "get-east-asian-width": "1.6.0", "glob": "13.0.6", "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", "ignore": "7.0.5", "jiti": "2.7.0", + "marked": "18.0.5", "minimatch": "10.2.5", + "openai": "6.26.0", + "partial-json": "0.1.7", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", @@ -478,102 +484,6 @@ "node": ">=6.9.0" } }, - "node_modules/@diffexai/diffex-agent-core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-agent-core/-/diffex-agent-core-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-telemetry": "^0.2.0", - "diff": "8.0.4", - "ignore": "7.0.5", - "typebox": "1.3.7", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-ai": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-ai/-/diffex-ai-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "@anthropic-ai/sdk": "0.91.1", - "@aws-sdk/client-bedrock-runtime": "3.1048.0", - "@diffexai/diffex-telemetry": "^0.2.0", - "@google/genai": "1.52.0", - "@mistralai/mistralai": "2.2.6", - "@opentelemetry/api": "1.9.0", - "@smithy/node-http-handler": "4.7.3", - "http-proxy-agent": "7.0.2", - "https-proxy-agent": "7.0.6", - "openai": "6.26.0", - "partial-json": "0.1.7", - "typebox": "1.3.7" - }, - "bin": { - "diffex-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-client": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-client/-/diffex-client-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "@diffexai/diffex-protocol": "^0.2.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-harness-state": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-harness-state/-/diffex-harness-state-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "proper-lockfile": "4.1.2", - "typebox": "1.3.7", - "yaml": "2.9.0" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-protocol": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-protocol/-/diffex-protocol-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "typebox": "1.3.7" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-telemetry": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-telemetry/-/diffex-telemetry-0.2.0.tgz", - "license": "Apache-2.0", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@diffexai/diffex-tui": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@diffexai/diffex-tui/-/diffex-tui-0.2.0.tgz", - "license": "Apache-2.0", - "dependencies": { - "get-east-asian-width": "1.6.0", - "marked": "18.0.5" - }, - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", @@ -902,9 +812,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-arm64-musl": { @@ -921,9 +828,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { @@ -940,9 +844,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-x64-gnu": { @@ -959,9 +860,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "optional": true }, "node_modules/@napi-rs/keyring-linux-x64-musl": { @@ -978,9 +876,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "optional": true }, "node_modules/@napi-rs/keyring-win32-arm64-msvc": { diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index dc9e24da1..02c527050 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex", - "version": "0.2.0", + "version": "0.2.1", "description": "Coding agent CLI with read, bash, edit, write, update_plan tools and session management", "type": "module", "imports": { @@ -31,7 +31,6 @@ "files": [ "dist", "docs", - "containerization.md", "CHANGELOG.md", "npm-shrinkwrap.json", "LICENSE", @@ -53,12 +52,12 @@ "prepack": "node ../../scripts/prepare-distribution.mjs packages/coding-agent" }, "dependencies": { - "@diffexai/diffex-agent-core": "^0.2.0", - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-client": "^0.2.0", - "@diffexai/diffex-harness-state": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", - "@diffexai/diffex-tui": "^0.2.0", + "@diffexai/diffex-agent-core": "^0.2.1", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-client": "^0.2.1", + "@diffexai/diffex-harness-state": "^0.2.1", + "@diffexai/diffex-protocol": "^0.2.1", + "@diffexai/diffex-tui": "^0.2.1", "@napi-rs/keyring": "1.3.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index ad3488ddc..7ab317e35 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -3,7 +3,7 @@ import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils * Tests for AgentSession concurrent prompt guard. */ -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@diffexai/diffex-agent-core"; @@ -61,16 +61,17 @@ function createAssistantMessage(text: string): AssistantMessage { describe("AgentSession concurrent prompt guard", () => { let session: AgentSession; let tempDir: string; + let streamStarted: Promise; beforeEach(async () => { - tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`); - mkdirSync(tempDir, { recursive: true }); + tempDir = mkdtempSync(join(tmpdir(), "diffex-concurrent-test-")); }); afterEach(async () => { delete (globalThis as typeof globalThis & { testExtensionApi?: unknown }).testExtensionApi; delete (globalThis as typeof globalThis & { testCommandRuns?: unknown }).testCommandRuns; if (session) { + await session.abort(); await session.dispose(); } if (tempDir && existsSync(tempDir)) { @@ -79,6 +80,10 @@ describe("AgentSession concurrent prompt guard", () => { }); async function createSession() { + let notifyStreamStarted!: () => void; + streamStarted = new Promise((resolve) => { + notifyStreamStarted = resolve; + }); const model = getModel("anthropic", "claude-sonnet-4-5")!; let abortSignal: AbortSignal | undefined; @@ -95,6 +100,7 @@ describe("AgentSession concurrent prompt guard", () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); + notifyStreamStarted(); const checkAbort = () => { if (abortSignal?.aborted) { stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }); @@ -133,8 +139,8 @@ describe("AgentSession concurrent prompt guard", () => { // Start first prompt (don't await, it will block until abort) const firstPrompt = session.prompt("First message"); - // Wait a tick for isStreaming to be set - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for the actual mock stream, or surface prompt startup failure. + await Promise.race([streamStarted, firstPrompt]); // Verify we're streaming expect(session.isStreaming).toBe(true); @@ -154,7 +160,7 @@ describe("AgentSession concurrent prompt guard", () => { // Start first prompt const firstPrompt = session.prompt("First message"); - await new Promise((resolve) => setTimeout(resolve, 10)); + await Promise.race([streamStarted, firstPrompt]); // steer should work while streaming expect(() => session.steer("Steering message")).not.toThrow(); @@ -170,7 +176,7 @@ describe("AgentSession concurrent prompt guard", () => { // Start first prompt const firstPrompt = session.prompt("First message"); - await new Promise((resolve) => setTimeout(resolve, 10)); + await Promise.race([streamStarted, firstPrompt]); // followUp should work while streaming expect(() => session.followUp("Follow-up message")).not.toThrow(); diff --git a/packages/evals/package.json b/packages/evals/package.json index 9a228a462..5cda28ae5 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-evals", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "scripts": { @@ -9,8 +9,8 @@ "test": "vitest run --config vitest.test.config.ts" }, "devDependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex": "^0.2.0", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex": "^0.2.1", "@types/node": "24.12.4", "shx": "0.4.0", "typescript": "5.9.3", diff --git a/packages/evolution/package.json b/packages/evolution/package.json index 22b6dd342..a27a9760c 100644 --- a/packages/evolution/package.json +++ b/packages/evolution/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-evolution", - "version": "0.2.0", + "version": "0.2.1", "description": "Private evolution pipeline contracts and orchestration for Diffex", "private": true, "type": "module", @@ -36,8 +36,8 @@ "node": ">=22.19.0" }, "dependencies": { - "@diffexai/diffex-harness-state": "0.2.0", - "@diffexai/diffex-harness-trace": "0.2.0", + "@diffexai/diffex-harness-state": "^0.2.1", + "@diffexai/diffex-harness-trace": "^0.2.1", "proper-lockfile": "4.1.2" }, "devDependencies": { diff --git a/packages/harness-state/package.json b/packages/harness-state/package.json index d20fbfb97..4d2cfedb9 100644 --- a/packages/harness-state/package.json +++ b/packages/harness-state/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-harness-state", - "version": "0.2.0", + "version": "0.2.1", "description": "Persistent harness state schema and bundle storage for Diffex", "type": "module", "main": "./dist/index.js", @@ -51,5 +51,6 @@ "@types/proper-lockfile": "4.1.4", "shx": "0.4.0", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/harness-trace/package.json b/packages/harness-trace/package.json index 6514fb1a5..c079de99a 100644 --- a/packages/harness-trace/package.json +++ b/packages/harness-trace/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-harness-trace", - "version": "0.2.0", + "version": "0.2.1", "description": "Private durable harness trace recording for Diffex", "private": true, "type": "module", @@ -36,7 +36,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@diffexai/diffex-telemetry": "0.2.0" + "@diffexai/diffex-telemetry": "^0.2.1" }, "devDependencies": { "@types/node": "24.12.4", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 6d9e1d1f0..114e7dd3a 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-protocol", - "version": "0.2.0", + "version": "0.2.1", "description": "Transport-neutral CBOR protocol for remote Diffex sessions", "type": "module", "main": "./dist/index.js", @@ -50,5 +50,6 @@ "devDependencies": { "shx": "0.4.0", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/server/package.json b/packages/server/package.json index 10835754e..80ab19899 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-server", - "version": "0.2.0", + "version": "0.2.1", "description": "Experimental server package for Diffex", "type": "module", "main": "./dist/index.js", @@ -57,13 +57,14 @@ "node": ">=22.19.0" }, "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-protocol": "^0.2.0", + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-protocol": "^0.2.1", "ws": "8.21.0" }, "devDependencies": { "@types/ws": "8.18.1", "shx": "0.4.0", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/session-backends/sqlite-node/package.json b/packages/session-backends/sqlite-node/package.json index 456d3482b..fa634ed67 100644 --- a/packages/session-backends/sqlite-node/package.json +++ b/packages/session-backends/sqlite-node/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-session-backend-sqlite-node", - "version": "0.2.0", + "version": "0.2.1", "description": "Node sqlite session backend for @diffexai/diffex-agent-core sessions", "type": "module", "main": "./dist/index.js", @@ -40,11 +40,12 @@ "node": ">=22.19.0" }, "dependencies": { - "@diffexai/diffex-ai": "^0.2.0", - "@diffexai/diffex-agent-core": "^0.2.0" + "@diffexai/diffex-ai": "^0.2.1", + "@diffexai/diffex-agent-core": "^0.2.1" }, "devDependencies": { "@vitest/coverage-v8": "4.1.9", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/telemetry/package.json b/packages/telemetry/package.json index 3d4d25ace..f702b45ae 100644 --- a/packages/telemetry/package.json +++ b/packages/telemetry/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-telemetry", - "version": "0.2.0", + "version": "0.2.1", "description": "Vendor-neutral telemetry contracts and typed schema utilities for Diffex", "type": "module", "main": "./dist/index.js", @@ -49,5 +49,6 @@ "devDependencies": { "@types/node": "24.12.4", "vitest": "4.1.9" - } + }, + "private": true } diff --git a/packages/tui/package.json b/packages/tui/package.json index 1405a77b7..c11c20af4 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@diffexai/diffex-tui", - "version": "0.2.0", + "version": "0.2.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", @@ -53,5 +53,6 @@ "devDependencies": { "@xterm/headless": "5.5.0", "chalk": "5.6.2" - } + }, + "private": true } diff --git a/scripts/artifact-acceptance.mjs b/scripts/artifact-acceptance.mjs index 972ca2e10..c2b4cfd0a 100644 --- a/scripts/artifact-acceptance.mjs +++ b/scripts/artifact-acceptance.mjs @@ -15,6 +15,7 @@ import { import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cliDistribution } from "./cli-distribution.mjs"; import { getPublicWorkspacePackages } from "./release-packages.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -220,7 +221,7 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = "Expected npm or Bun acceptance", ); const packages = getPublicWorkspacePackages().map((pkg) => ({ - ...readJson(join(root, pkg.directory, "package.json")), + ...cliDistribution(root).manifest, directory: pkg.directory, })); const raw = readJson(join(artifactDirectory, "manifest.json")); @@ -299,13 +300,6 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = ["fd", "rg"].map((tool) => [tool, execFileSync(tool, ["--version"], helperOptions).trim()]), ); cpSync(join(root, "scripts/fixtures/artifact-network-guard.mjs"), join(consumer, "guard.mjs")); - cpSync(join(root, "scripts/fixtures/artifact-server.mjs"), join(consumer, "server-smoke.mjs")); - execFileSync(process.execPath, ["--import", join(consumer, "guard.mjs"), join(consumer, "server-smoke.mjs")], { - cwd: join(consumer, "project"), - env, - stdio: "inherit", - timeout: 45_000, - }); const layout = join(temporary, manager, "terminal"); mkdirSync(layout); // The terminal harness accepts explicit consumer paths, avoiding workspace resolution. @@ -319,7 +313,12 @@ export function acceptArtifacts(artifactDirectory, outputDirectory, { managers = } finally { cpSync(layout, join(outputDirectory, manager), { recursive: true }); } - evidence.processes.push({ manager, server: "passed", terminal: "passed", helpers }); + evidence.processes.push({ + manager, + server: "Excluded from CLI distribution; hosted runtime remains private", + terminal: "passed", + helpers, + }); } verifyTarballs(tarballDirectory, records, packages); writeFileSync( diff --git a/scripts/artifact-acceptance.test.mjs b/scripts/artifact-acceptance.test.mjs index 16f7cfcac..94030738b 100644 --- a/scripts/artifact-acceptance.test.mjs +++ b/scripts/artifact-acceptance.test.mjs @@ -12,13 +12,12 @@ import { packageClosure, verifyTarballs, } from "./artifact-acceptance.mjs"; +import { cliDistribution } from "./cli-distribution.mjs"; import { getPublicWorkspacePackages } from "./release-packages.mjs"; test("every public package has an operation and a dependency-first local closure", () => { - const packages = getPublicWorkspacePackages().map((pkg) => - JSON.parse(readFileSync(join(pkg.directory, "package.json"), "utf8")), - ); - assert.equal(packages.length, 11); + const packages = getPublicWorkspacePackages().map((pkg) => cliDistribution().manifest); + assert.equal(packages.length, 1); for (const pkg of packages) { assert(operations[pkg.name]); const closure = packageClosure(pkg.name, packages); diff --git a/scripts/check-distribution-payload.py b/scripts/check-distribution-payload.py index 7c9596942..c842ddc76 100644 --- a/scripts/check-distribution-payload.py +++ b/scripts/check-distribution-payload.py @@ -148,6 +148,9 @@ def inspect_archive(file, license_file, expected_name=None, expected_version=Non if hashlib.sha256(entries[root + item['path']]).hexdigest() != item['sha256']: fail(str(file), 'runtime file hash mismatch') if metadata.get('name') == '@diffexai/diffex': + for field in ['dependencies', 'optionalDependencies', 'peerDependencies']: + if any(name.startswith('@diffexai/') for name in metadata.get(field, {})): + fail(str(file), 'CLI must not fetch separate Diffex packages') release_files = ['release/distribution-manifest.json', 'release/install-package.json', 'release/install-package-lock.json'] for name in release_files: if not entries.get(root + name, b'').strip(): @@ -161,7 +164,7 @@ def inspect_archive(file, license_file, expected_name=None, expected_version=Non fail(str(file), 'release manifest violates approved channels') packages = manifest.get('packages', []) names = [package['name'] for package in packages] - if len(names) != 11 or len(set(names)) != 11 or '@diffexai/diffex' not in names or '@diffexai/diffex-coding-agent-server' not in names: + if names != ['@diffexai/diffex']: fail(str(file), 'release manifest package inventory mismatch') for package in packages: name = package['name'] diff --git a/scripts/cli-distribution.mjs b/scripts/cli-distribution.mjs new file mode 100644 index 000000000..b249d8d19 --- /dev/null +++ b/scripts/cli-distribution.mjs @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { findPackageDirectories } from "./package-workspaces.mjs"; + +export function cliDistribution(root = ".") { + const workspaces = new Map( + findPackageDirectories(join(root, "packages")) + .filter((directory) => !directory.endsWith("/install-lock")) + .map((directory) => { + const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + return [manifest.name, { directory, manifest }]; + }), + ); + const cli = workspaces.get("@diffexai/diffex"); + if (!cli) throw new Error("Missing CLI workspace"); + const bundled = new Map(); + const dependencies = {}; + const optionalDependencies = {}; + function visit(name) { + if (bundled.has(name)) return; + const workspace = workspaces.get(name); + if (!workspace) throw new Error(`Missing internal runtime workspace: ${name}`); + bundled.set(name, workspace); + for (const [field, target] of [ + ["dependencies", dependencies], + ["optionalDependencies", optionalDependencies], + ]) { + for (const [dependency, version] of Object.entries(workspace.manifest[field] ?? {})) { + if (dependency.startsWith("@diffexai/")) visit(dependency); + else { + if ((dependencies[dependency] ?? optionalDependencies[dependency] ?? version) !== version) + throw new Error(`Conflicting bundled runtime dependency: ${dependency}`); + target[dependency] = version; + } + } + } + } + visit(cli.manifest.name); + visit("@diffexai/diffex-evolution"); + visit("@diffexai/diffex-harness-trace"); + bundled.delete(cli.manifest.name); + const policy = JSON.parse(readFileSync(join(root, "docs/release/distribution-policy.json"), "utf8")); + const actual = [...bundled.values()] + .map((pkg) => relative(join(root, "packages"), pkg.directory).replaceAll("\\", "/")) + .sort(); + if ( + policy.status !== "approved" || + !Array.isArray(policy.bundledWorkspaces) || + JSON.stringify([...policy.bundledWorkspaces].sort()) !== JSON.stringify(actual) + ) + throw new Error("Bundled runtime inventory differs from the approved disclosure policy"); + for (const pkg of bundled.values()) + if (pkg.manifest.version !== cli.manifest.version) + throw new Error(`Bundled workspace version differs from CLI: ${pkg.manifest.name}`); + for (const name of Object.keys(dependencies)) delete optionalDependencies[name]; + const manifest = structuredClone(cli.manifest); + manifest.dependencies = Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b))); + manifest.optionalDependencies = Object.fromEntries( + Object.entries(optionalDependencies).sort(([a], [b]) => a.localeCompare(b)), + ); + bundled.delete("@diffexai/diffex-evolution"); + bundled.delete("@diffexai/diffex-harness-trace"); + delete manifest.devDependencies; + delete manifest.scripts; + return { manifest, bundled: [...bundled.values()].sort((a, b) => a.manifest.name.localeCompare(b.manifest.name)) }; +} diff --git a/scripts/cli-distribution.test.mjs b/scripts/cli-distribution.test.mjs new file mode 100644 index 000000000..b571b5503 --- /dev/null +++ b/scripts/cli-distribution.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { cliDistribution } from "./cli-distribution.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +test("only the CLI is public and its install manifest never fetches private Diffex packages", () => { + const { manifest, bundled } = cliDistribution(); + assert.deepEqual( + getPublicWorkspacePackages().map((pkg) => pkg.name), + ["@diffexai/diffex"], + ); + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) + assert(!Object.keys(manifest[field] ?? {}).some((name) => name.startsWith("@diffexai/"))); + assert.equal(manifest.imports["#evolution"], "./dist/vendor/evolution/index.js"); + assert.equal(manifest.imports["#harness-trace"], "./dist/vendor/harness-trace/index.js"); + assert.deepEqual( + bundled.map((pkg) => pkg.manifest.name), + [ + "@diffexai/diffex-agent-core", + "@diffexai/diffex-ai", + "@diffexai/diffex-client", + "@diffexai/diffex-harness-state", + "@diffexai/diffex-protocol", + "@diffexai/diffex-telemetry", + "@diffexai/diffex-tui", + ], + ); + for (const pkg of bundled) assert.equal(pkg.manifest.private, true); + for (const file of ["npm-shrinkwrap.json", "install-lock/package-lock.json"]) { + const lock = JSON.parse(readFileSync(join("packages/coding-agent", file), "utf8")); + for (const entry of Object.values(lock.packages)) { + for (const dependency of Object.keys({ ...entry.dependencies, ...entry.optionalDependencies })) + assert(!dependency.startsWith("@diffexai/") || dependency === "@diffexai/diffex"); + if (entry.resolved?.includes("/@diffexai/")) assert.match(entry.resolved, /\/@diffexai\/diffex\/-\/diffex-/); + } + } +}); + +test("an unreviewed private dependency cannot silently enter the public CLI tarball", () => { + const directory = mkdtempSync(join(tmpdir(), "diffex-disclosure-")); + try { + const write = (path, value) => { + mkdirSync(join(directory, path, ".."), { recursive: true }); + writeFileSync(join(directory, path), JSON.stringify(value)); + }; + write("docs/release/distribution-policy.json", { + status: "approved", + bundledWorkspaces: ["evolution", "harness-trace"], + }); + write("packages/coding-agent/package.json", { + name: "@diffexai/diffex", + version: "0.2.1", + dependencies: { "@diffexai/private-extra": "0.2.1" }, + }); + for (const [path, name] of [ + ["evolution", "diffex-evolution"], + ["harness-trace", "diffex-harness-trace"], + ["private-extra", "private-extra"], + ]) + write(`packages/${path}/package.json`, { name: `@diffexai/${name}`, version: "0.2.1", private: true }); + assert.throws(() => cliDistribution(directory), /approved disclosure policy/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/scripts/fixtures/release-publication/harness.mjs b/scripts/fixtures/release-publication/harness.mjs index b388a964d..3d66496e4 100644 --- a/scripts/fixtures/release-publication/harness.mjs +++ b/scripts/fixtures/release-publication/harness.mjs @@ -34,7 +34,7 @@ export function createPublicationFixture({ staging = false } = {}) { mkdirSync(artifactDirectory); writeFileSync(join(artifactDirectory, "source.tar.gz"), "fixture frozen source"); if (staging) rmSync(aws); - const packages = getPublicWorkspacePackages(); + const packages = [{ name: "@diffexai/diffex-agent-core" }, ...getPublicWorkspacePackages()]; const bucket = staging ? "diffex-artifacts-staging" : "fixture-releases"; const endpoint = staging ? "https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com" @@ -57,6 +57,12 @@ export function createPublicationFixture({ staging = false } = {}) { failPublishAt, mutateManifest, mutatePolicy, + bootstrap = false, + dryRun = false, + latest, + wrongBootstrap = false, + newLatest, + failTagRemoval = false, } = {}, ) { writeFileSync(join(directory, "package.json"), JSON.stringify({ version, private: true })); @@ -101,7 +107,20 @@ export function createPublicationFixture({ staging = false } = {}) { writeFileSync(join(artifactDirectory, "inputs.json"), JSON.stringify(inputs)); if (mutateManifest) mutateManifest(manifest); writeFileSync(join(artifactDirectory, "manifest.json"), JSON.stringify(manifest)); - writeFileSync(fixturePath, JSON.stringify({ packages: manifests, failure, commit, missing, failPublishAt })); + writeFileSync( + fixturePath, + JSON.stringify({ + packages: manifests, + failure, + commit, + missing, + failPublishAt, + latest, + wrongBootstrap, + newLatest, + failTagRemoval, + }), + ); const env = { PATH: `${join(directory, "bin")}:${process.env.PATH}`, DIFFEX_FIXTURE_REAL_AWS: staging ? "true" : "", @@ -120,7 +139,13 @@ export function createPublicationFixture({ staging = false } = {}) { "--import", registry, ...(command === "publish" - ? [npmPublisher, "--artifacts", artifactDirectory] + ? [ + npmPublisher, + "--artifacts", + artifactDirectory, + ...(bootstrap ? ["--bootstrap"] : []), + ...(dryRun ? ["--dry-run"] : []), + ] : [ publisher, "--artifacts", diff --git a/scripts/fixtures/release-publication/npm.mjs b/scripts/fixtures/release-publication/npm.mjs index 52c594449..0b2b0e8e1 100644 --- a/scripts/fixtures/release-publication/npm.mjs +++ b/scripts/fixtures/release-publication/npm.mjs @@ -5,7 +5,30 @@ const path = process.env.DIFFEX_PUBLICATION_STORAGE; const state = JSON.parse(readFileSync(path, "utf8")); const fixture = JSON.parse(readFileSync(process.env.DIFFEX_PUBLICATION_FIXTURE, "utf8")); const args = process.argv.slice(2); -if (args[0] !== "publish" || !args.includes("--ignore-scripts") || !args.includes("--provenance")) +if (args[0] === "dist-tag") { + const pkg = fixture.packages.find((pkg) => pkg.name === args[2]); + if ( + args.length !== 5 || + args[1] !== "rm" || + args[3] !== "latest" || + args[4] !== "--registry=https://registry.npmjs.org" || + !pkg || + state.automaticLatest?.[pkg.name] !== pkg.version || + fixture.failTagRemoval + ) + throw new Error("Unexpected or failed tag removal"); + delete state.automaticLatest[pkg.name]; + state.removedLatest ??= []; + state.removedLatest.push(pkg.name); + writeFileSync(path, JSON.stringify(state)); + process.exit(0); +} +if ( + args[0] !== "publish" || + !args.includes("--ignore-scripts") || + !args.includes("--provenance=false") || + !args.includes("--registry=https://registry.npmjs.org") +) throw new Error("Unmocked npm request"); const file = basename(args.at(-1)); const pkg = fixture.packages.find( @@ -17,4 +40,11 @@ state.published ??= []; if (state.published.includes(pkg.name)) throw new Error("Attempted to rewrite a published npm version"); if (fixture.failPublishAt === pkg.name) process.exit(1); state.published.push(pkg.name); +if (args.includes("--tag")) { + if (args[args.indexOf("--tag") + 1] !== "bootstrap") throw new Error("Unexpected npm tag"); + state.bootstrap ??= []; + state.bootstrap.push(pkg.name); + state.automaticLatest ??= {}; + state.automaticLatest[pkg.name] = fixture.newLatest ?? pkg.version; +} writeFileSync(path, JSON.stringify(state)); diff --git a/scripts/fixtures/release-publication/registry.mjs b/scripts/fixtures/release-publication/registry.mjs index 25f237bd0..75c819dc5 100644 --- a/scripts/fixtures/release-publication/registry.mjs +++ b/scripts/fixtures/release-publication/registry.mjs @@ -27,6 +27,20 @@ globalThis.fetch = async (input, options) => { state.registryCalls.push({ url: url.href, method: options?.method ?? "GET" }); writeFileSync(process.env.DIFFEX_PUBLICATION_STORAGE, JSON.stringify(state)); if (url.origin !== "https://registry.npmjs.org") throw new Error("Unexpected publication fixture origin"); + const inventory = fixture.packages.find((pkg) => url.pathname === `/${encodeURIComponent(pkg.name)}`); + if (inventory) { + if (fixture.missing && !state.published?.includes(inventory.name)) return new Response(null, { status: 404 }); + return Response.json({ + name: inventory.name, + "dist-tags": { + ...(state.bootstrap?.includes(inventory.name) + ? { bootstrap: fixture.wrongBootstrap ? "0.0.1" : inventory.version } + : {}), + ...(state.automaticLatest?.[inventory.name] ? { latest: state.automaticLatest[inventory.name] } : {}), + ...(fixture.latest ? { latest: fixture.latest } : {}), + }, + }); + } const pkg = fixture.packages.find((pkg) => url.pathname === `/${encodeURIComponent(pkg.name)}/${pkg.version}`); if (pkg) { if ( diff --git a/scripts/generate-coding-agent-install-lock.mjs b/scripts/generate-coding-agent-install-lock.mjs index 9f09ec85e..a8a6e9818 100644 --- a/scripts/generate-coding-agent-install-lock.mjs +++ b/scripts/generate-coding-agent-install-lock.mjs @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, posix, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cliDistribution } from "./cli-distribution.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); @@ -149,7 +150,10 @@ function getInternalWorkspaces(lockPackages) { workspaces.set(entry.name, { lockPath, - packageJson: readJson(join(repoRoot, lockPath, "package.json")), + packageJson: + entry.name === "@diffexai/diffex" + ? cliDistribution(repoRoot).manifest + : readJson(join(repoRoot, lockPath, "package.json")), }); } @@ -298,7 +302,9 @@ function validateGeneratedFiles(installerPackageJson, installLock, internalNames (packageName === "@diffexai/diffex" || packageName?.startsWith(internalPackagePrefix)) && entry.version !== installerPackageJson.version ) { - errors.push(`${lockPath} internal package version ${entry.version} does not match ${installerPackageJson.version}`); + errors.push( + `${lockPath} internal package version ${entry.version} does not match ${installerPackageJson.version}`, + ); } if (entry.hasInstallScript) { if (!packageName || !entry.version) { @@ -347,8 +353,9 @@ function validateGeneratedFiles(installerPackageJson, installLock, internalNames } } - const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc) - .length; + const platformPackageCount = Object.values(installLock.packages).filter( + (entry) => entry.os || entry.cpu || entry.libc, + ).length; if (platformPackageCount === 0) { errors.push("no platform-specific optional dependency entries found"); } @@ -365,7 +372,7 @@ function generateInstallLock() { } const lockPackages = rootLock.packages; - const codingAgentPackage = readJson(join(codingAgentDir, "package.json")); + const codingAgentPackage = cliDistribution(repoRoot).manifest; const installerPackageJson = createInstallerPackageJson(codingAgentPackage); const internalWorkspaces = getInternalWorkspaces(lockPackages); const installLockPackages = { @@ -430,8 +437,9 @@ try { writeFileSync(outputPackageJsonPath, packageJsonContent); writeFileSync(outputLockfilePath, lockfileContent); const packageCount = Object.keys(installLock.packages).length - 1; - const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc) - .length; + const platformPackageCount = Object.values(installLock.packages).filter( + (entry) => entry.os || entry.cpu || entry.libc, + ).length; console.log( `Wrote packages/coding-agent/install-lock/package.json and package-lock.json (${packageCount} packages, ${platformPackageCount} platform-specific).`, ); diff --git a/scripts/generate-coding-agent-shrinkwrap.mjs b/scripts/generate-coding-agent-shrinkwrap.mjs index 90739d049..4399b3bd9 100644 --- a/scripts/generate-coding-agent-shrinkwrap.mjs +++ b/scripts/generate-coding-agent-shrinkwrap.mjs @@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, posix, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { cliDistribution } from "./cli-distribution.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); @@ -142,7 +143,10 @@ function getInternalWorkspaces(lockPackages) { workspaces.set(entry.name, { lockPath, - packageJson: readJson(join(repoRoot, lockPath, "package.json")), + packageJson: + entry.name === "@diffexai/diffex" + ? cliDistribution(repoRoot).manifest + : readJson(join(repoRoot, lockPath, "package.json")), }); } @@ -269,7 +273,8 @@ function validateShrinkwrap(shrinkwrap, internalNames) { for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) { for (const dependencyName of Object.keys(packageDependencies(entry))) { const dependencyIncluded = [...includedPaths].some( - (candidate) => candidate === `node_modules/${dependencyName}` || candidate.endsWith(`/node_modules/${dependencyName}`), + (candidate) => + candidate === `node_modules/${dependencyName}` || candidate.endsWith(`/node_modules/${dependencyName}`), ); if (!dependencyIncluded) { errors.push(`${lockPath || "root"} dependency ${dependencyName} is missing`); @@ -277,7 +282,9 @@ function validateShrinkwrap(shrinkwrap, internalNames) { } } - const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length; + const platformPackageCount = Object.values(shrinkwrap.packages).filter( + (entry) => entry.os || entry.cpu || entry.libc, + ).length; if (platformPackageCount === 0) { errors.push("no platform-specific optional dependency entries found"); } @@ -294,7 +301,7 @@ function generateShrinkwrap() { } const lockPackages = rootLock.packages; - const codingAgentPackage = readJson(join(codingAgentDir, "package.json")); + const codingAgentPackage = cliDistribution(repoRoot).manifest; const internalWorkspaces = getInternalWorkspaces(lockPackages); const shrinkwrapPackages = { "": copyPackageJsonEntry(codingAgentPackage, { includeName: true }), @@ -354,7 +361,9 @@ try { } else { writeFileSync(shrinkwrapPath, content); const packageCount = Object.keys(shrinkwrap.packages).length - 1; - const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length; + const platformPackageCount = Object.values(shrinkwrap.packages).filter( + (entry) => entry.os || entry.cpu || entry.libc, + ).length; console.log( `Wrote packages/coding-agent/npm-shrinkwrap.json (${packageCount} packages, ${platformPackageCount} platform-specific).`, ); diff --git a/scripts/npm-publication-trust.mjs b/scripts/npm-publication-trust.mjs new file mode 100644 index 000000000..e1551a1b9 --- /dev/null +++ b/scripts/npm-publication-trust.mjs @@ -0,0 +1,64 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTrustedPublisherCommands } from "./npm-publish-plan.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +export function assertTrustedPublisher(value) { + if ( + value?.type !== "github" || + value.repository !== "diffexai/diffex" || + value.file !== "build-binaries.yml" || + value.environment !== "npm-publish" || + !Array.isArray(value.permissions) || + value.permissions.length !== 2 || + !value.permissions.includes("createPackage") || + !value.permissions.includes("createStagedPackage") + ) + throw new Error("Expected exactly the Diffex GitHub npm publish relationship"); +} + +export function verifyPublicationTrust(packages, query) { + return packages.map(({ name, directory }) => { + const pkg = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + if (pkg.repository?.url !== "git+https://github.com/diffexai/diffex.git") + throw new Error(`${name}: repository metadata must match the trusted source`); + const value = query(name); + assertTrustedPublisher(value); + return { + name, + type: value.type, + repository: value.repository, + file: value.file, + environment: value.environment, + permissions: value.permissions, + }; + }); +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + if (args.length !== 1 || !["--plan", "--verify"].includes(args[0])) + throw new Error("Usage: node scripts/npm-publication-trust.mjs <--plan|--verify>"); + const packages = getPublicWorkspacePackages(); + if (args[0] === "--plan") { + console.log(createTrustedPublisherCommands(packages.map(({ name }) => name)).join("\n")); + } else { + const verified = verifyPublicationTrust(packages, (name) => { + const result = spawnSync( + process.platform === "win32" ? "npm.cmd" : "npm", + ["trust", "list", name, "--json", "--registry=https://registry.npmjs.org"], + { encoding: "utf8", timeout: 120_000, stdio: ["inherit", "pipe", "pipe"] }, + ); + if (result.error || result.status !== 0) + throw new Error(`${name}: npm trust list failed; use npm >=11.15.0 with authorized interactive authentication`); + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`${name}: expected exactly one JSON trust configuration`); + } + }); + console.log(JSON.stringify({ verifiedAt: new Date().toISOString(), packages: verified }, null, 2)); + } +} diff --git a/scripts/npm-publication-trust.test.mjs b/scripts/npm-publication-trust.test.mjs new file mode 100644 index 000000000..baa9a89ac --- /dev/null +++ b/scripts/npm-publication-trust.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertTrustedPublisher, verifyPublicationTrust } from "./npm-publication-trust.mjs"; +import { getPublicWorkspacePackages } from "./release-packages.mjs"; + +const expected = { + type: "github", + repository: "diffexai/diffex", + file: "build-binaries.yml", + environment: "npm-publish", + permissions: ["createPackage", "createStagedPackage"], +}; + +test("verifies every public package including the exceptional CLI name", () => { + const visited = []; + const results = verifyPublicationTrust(getPublicWorkspacePackages(), (name) => { + visited.push(name); + return expected; + }); + assert.equal(results.length, 1); + assert.ok(visited.includes("@diffexai/diffex")); + assert.ok(!visited.includes("@diffexai/diffex-coding-agent-server")); + assert.ok(!visited.includes("@diffexai/diffex-coding-agent")); +}); + +for (const invalid of [ + null, + [], + {}, + { ...expected, type: "gitlab" }, + { ...expected, repository: "other/diffex" }, + { ...expected, file: "other.yml" }, + { ...expected, environment: "" }, + { ...expected, permissions: [] }, + { ...expected, permissions: ["createPackage", "createStagedPackage", "deletePackage"] }, + { ...expected, permissions: ["createStagedPackage"] }, +]) { + test("rejects missing, broader, or foreign publication trust", () => { + assert.throws(() => assertTrustedPublisher(invalid), /Expected exactly/); + }); +} diff --git a/scripts/npm-publish-plan.mjs b/scripts/npm-publish-plan.mjs index ce15284c0..a1784789c 100644 --- a/scripts/npm-publish-plan.mjs +++ b/scripts/npm-publish-plan.mjs @@ -23,7 +23,9 @@ export function createPublishArgs({ bootstrap }) { "publish", "--access", "public", - ...(bootstrap ? ["--tag", BOOTSTRAP_DIST_TAG] : ["--provenance"]), + ...(bootstrap ? ["--tag", BOOTSTRAP_DIST_TAG] : []), + "--registry=https://registry.npmjs.org", + "--provenance=false", "--ignore-scripts", ]; } @@ -42,6 +44,6 @@ export function assertBootstrapPackageStates(packageStates) { export function createTrustedPublisherCommands(packageNames) { return packageNames.map( (packageName) => - `npm trust github ${packageName} --file build-binaries.yml --repo diffexai/diffex --env npm-publish --allow-publish --yes`, + `npm trust github ${packageName} --file build-binaries.yml --repo diffexai/diffex --env npm-publish --allow-publish --registry=https://registry.npmjs.org --yes`, ); } diff --git a/scripts/npm-publish-plan.test.mjs b/scripts/npm-publish-plan.test.mjs index e60678ff0..feddc5f21 100644 --- a/scripts/npm-publish-plan.test.mjs +++ b/scripts/npm-publish-plan.test.mjs @@ -19,12 +19,13 @@ describe("npm publish planning", () => { assert.throws(() => parsePublishArgs(["--force"]), /Usage: node scripts\/publish\.mjs/); }); - it("uses provenance for releases and a non-default tag for bootstrap publication", () => { + it("disables unsupported private-source provenance and pins the bootstrap tag", () => { assert.deepEqual(createPublishArgs({ bootstrap: false }), [ "publish", "--access", "public", - "--provenance", + "--registry=https://registry.npmjs.org", + "--provenance=false", "--ignore-scripts", ]); assert.deepEqual(createPublishArgs({ bootstrap: true }), [ @@ -33,6 +34,8 @@ describe("npm publish planning", () => { "public", "--tag", BOOTSTRAP_DIST_TAG, + "--registry=https://registry.npmjs.org", + "--provenance=false", "--ignore-scripts", ]); }); @@ -58,7 +61,7 @@ describe("npm publish planning", () => { it("builds the trusted-publisher command for the release workflow", () => { assert.deepEqual(createTrustedPublisherCommands(["@scope/package"]), [ - "npm trust github @scope/package --file build-binaries.yml --repo diffexai/diffex --env npm-publish --allow-publish --yes", + "npm trust github @scope/package --file build-binaries.yml --repo diffexai/diffex --env npm-publish --allow-publish --registry=https://registry.npmjs.org --yes", ]); }); }); diff --git a/scripts/pack-reviewed-package.mjs b/scripts/pack-reviewed-package.mjs index 20bf14908..3f6f800b5 100644 --- a/scripts/pack-reviewed-package.mjs +++ b/scripts/pack-reviewed-package.mjs @@ -1,6 +1,8 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { stageCliDistribution } from "./stage-cli-distribution.mjs"; import { join, resolve } from "node:path"; import { assertChangelogsReady, preparePackageDistribution } from "./prepare-distribution.mjs"; @@ -9,21 +11,27 @@ import { assertChangelogsReady, preparePackageDistribution } from "./prepare-dis export function packReviewedPackage(pkg, outputDirectory) { assertChangelogsReady(); preparePackageDistribution(pkg.directory); - const [packed] = JSON.parse( - execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", resolve(outputDirectory)], { - cwd: pkg.directory, - encoding: "utf8", - maxBuffer: 20 * 1024 * 1024, - }), - ); - const file = join(resolve(outputDirectory), packed.filename); - const inspection = JSON.parse( - execFileSync( - "python3", - ["scripts/check-distribution-payload.py", file, "--name", pkg.name, "--version", pkg.version], - { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }, - ), - ); - const sha256 = createHash("sha256").update(readFileSync(file)).digest("hex"); - return { file, sha256, name: pkg.name, version: pkg.version, payloadFiles: inspection.payloadFiles }; + const stage = pkg.name === "@diffexai/diffex" ? mkdtempSync(join(tmpdir(), "diffex-cli-pack-")) : undefined; + try { + if (stage) stageCliDistribution(pkg.directory, stage); + const [packed] = JSON.parse( + execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", resolve(outputDirectory)], { + cwd: stage ?? pkg.directory, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }), + ); + const file = join(resolve(outputDirectory), packed.filename); + const inspection = JSON.parse( + execFileSync( + "python3", + ["scripts/check-distribution-payload.py", file, "--name", pkg.name, "--version", pkg.version], + { encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }, + ), + ); + const sha256 = createHash("sha256").update(readFileSync(file)).digest("hex"); + return { file, sha256, name: pkg.name, version: pkg.version, payloadFiles: inspection.payloadFiles }; + } finally { + if (stage) rmSync(stage, { recursive: true, force: true }); + } } diff --git a/scripts/prepare-candidate-artifacts.mjs b/scripts/prepare-candidate-artifacts.mjs index 9c5971396..2385834d2 100644 --- a/scripts/prepare-candidate-artifacts.mjs +++ b/scripts/prepare-candidate-artifacts.mjs @@ -45,5 +45,5 @@ for (const [consumer, command, args] of [ const info = JSON.parse(readFileSync(installed, "utf8")); if (info.name !== pkg.name || info.version !== pkg.version) throw new Error("Installed identity mismatch"); } - console.log(`All eleven local tarball identities verified in ${consumer} outside the workspace.`); + console.log(`All selected local tarball identities verified in ${consumer} outside the workspace.`); } diff --git a/scripts/publish-release-announcement.test.mjs b/scripts/publish-release-announcement.test.mjs index 948294c52..d8b87b2ca 100644 --- a/scripts/publish-release-announcement.test.mjs +++ b/scripts/publish-release-announcement.test.mjs @@ -307,3 +307,71 @@ test("enabling public binary assets without an availability gate fails before pr fixture.cleanup(); } }); + +test("bootstrap dry run writes nothing and completion verifies all packages without latest or R2", () => { + const fixture = createPublicationFixture(); + try { + const options = { command: "publish", missing: true, bootstrap: true }; + const dry = fixture.run("0.0.3", { ...options, dryRun: true }); + assert.equal(dry.status, 0, dry.stderr); + assert.equal(fixture.state().published, undefined); + const partial = fixture.run("0.0.3", { ...options, failPublishAt: "@diffexai/diffex" }); + assert.equal(partial.status, 1); + assert.ok(fixture.state().published.length > 0); + for (let attempt = 0; attempt < 2; attempt++) { + const result = fixture.run("0.0.3", options); + assert.equal(result.status, 0, result.stderr); + assert.equal(fixture.state().published.length, fixture.packageCount); + assert.equal(fixture.state().bootstrap.length, fixture.packageCount); + assert.deepEqual(fixture.state().automaticLatest, {}); + assert.equal(fixture.state().removedLatest.length, fixture.packageCount); + assert.deepEqual(fixture.state().objects, {}); + assert.deepEqual(fixture.state().calls, []); + } + } finally { + fixture.cleanup(); + } +}); + +test("bootstrap rejects established latest tags before publishing", () => { + const fixture = createPublicationFixture(); + try { + const result = fixture.run("0.0.3", { command: "publish", bootstrap: true, latest: "0.0.3" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /bootstrap requires no latest/); + assert.equal(fixture.state().published, undefined); + assert.deepEqual(fixture.state().objects, {}); + } finally { + fixture.cleanup(); + } +}); + +test("bootstrap cannot claim completion when registry tags disagree", () => { + const fixture = createPublicationFixture(); + try { + const result = fixture.run("0.0.3", { command: "publish", bootstrap: true, missing: true, wrongBootstrap: true }); + assert.equal(result.status, 1); + assert.match(result.stderr, /bootstrap tag verification failed/); + assert.doesNotMatch(result.stdout, /Bootstrap publication is complete/); + assert.deepEqual(fixture.state().objects, {}); + } finally { + fixture.cleanup(); + } +}); + +for (const options of [{ newLatest: "0.0.4" }, { failTagRemoval: true }]) { + test("bootstrap stops after the first upload when automatic tag cleanup is unsafe or fails", () => { + const fixture = createPublicationFixture(); + try { + const result = fixture.run("0.0.3", { command: "publish", bootstrap: true, missing: true, ...options }); + assert.equal(result.status, 1); + assert.equal(fixture.state().published.length, 1); + assert.equal(fixture.state().removedLatest, undefined); + assert.deepEqual(fixture.state().objects, {}); + assert.deepEqual(fixture.state().calls, []); + assert.doesNotMatch(result.stdout, /Bootstrap publication is complete/); + } finally { + fixture.cleanup(); + } + }); +} diff --git a/scripts/publish.mjs b/scripts/publish.mjs index ed1edaca6..a44546d92 100644 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { prepareReleaseArtifacts, readReleaseArtifacts } from "./release-artifacts.mjs"; -import { verifyNpmArtifact } from "./release-registry.mjs"; +import { readNpmPackageState, verifyNpmArtifact } from "./release-registry.mjs"; import { join } from "node:path"; import { assertBootstrapPackageStates, @@ -49,24 +49,6 @@ function run(command, args, options = {}) { return result; } -function isRegistered(name) { - const result = spawnSync(commandForPlatform("npm"), ["view", name, "version", "--json"], { - encoding: "utf8", - stdio: ["inherit", "pipe", "pipe"], - }); - - if (result.status === 0 && result.stdout.trim()) { - return true; - } - - const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); - if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) { - return false; - } - - throw new Error(output ? `Failed to query ${name}\n${output}` : `Failed to query ${name}`); -} - const packageVersions = new Map(packages.map((pkg) => [pkg.name, pkg.version])); const versions = [...new Set(packageVersions.values())]; @@ -106,7 +88,12 @@ for (const pkg of manifest.packages) { if (bootstrap) { for (const pkg of packageStates) { - pkg.registered = pkg.published || isRegistered(pkg.name); + const state = await readNpmPackageState(pkg.name); + pkg.registered = state.registered; + if (state.distTags.latest !== undefined || (pkg.published && state.distTags.bootstrap !== pkg.version)) + throw new Error( + `${pkg.name}: bootstrap requires no latest tag and an exact bootstrap tag for existing artifacts`, + ); } assertBootstrapPackageStates(packageStates); } @@ -141,10 +128,33 @@ for (const pkg of packageStates) { } run("npm", [...createPublishArgs({ bootstrap }), join(artifactDirectory, pkg.filename)]); + if (bootstrap) { + await verifyNpmArtifact(pkg); + const state = await readNpmPackageState(pkg.name); + if ( + state.distTags.bootstrap !== pkg.version || + (state.distTags.latest !== undefined && state.distTags.latest !== pkg.version) + ) + throw new Error(`${pkg.name}: bootstrap tag verification failed; inspect registry state before retrying`); + // npm can create latest on first registration even with --tag bootstrap. + // Only remove it for an exact artifact newly published by this invocation. + if (state.distTags.latest === pkg.version) { + run("npm", ["dist-tag", "rm", pkg.name, "latest", "--registry=https://registry.npmjs.org"]); + const cleaned = await readNpmPackageState(pkg.name); + if (cleaned.distTags.latest !== undefined || cleaned.distTags.bootstrap !== pkg.version) + throw new Error(`${pkg.name}: bootstrap tag cleanup failed; inspect registry state before retrying`); + } + } console.log(); } if (bootstrap) { + for (const pkg of packageStates) { + await verifyNpmArtifact(pkg); + const state = await readNpmPackageState(pkg.name); + if (state.distTags.latest !== undefined || state.distTags.bootstrap !== pkg.version) + throw new Error(`${pkg.name}: bootstrap tag verification failed; do not announce this version`); + } console.log("Bootstrap publication is complete. Configure trusted publishing with:\n"); for (const command of createTrustedPublisherCommands(packages.map((pkg) => pkg.name))) { console.log(` ${command}`); diff --git a/scripts/release-distribution-manifest.mjs b/scripts/release-distribution-manifest.mjs index 68f8f937b..eb4f4b484 100644 --- a/scripts/release-distribution-manifest.mjs +++ b/scripts/release-distribution-manifest.mjs @@ -23,27 +23,8 @@ export const VERIFICATION_ASSETS = ["release/distribution-manifest.json", "distr export function createDistributionManifest({ packages, sourceCommit, policy, artifacts = [] }) { if (!/^[0-9a-f]{40}$/.test(sourceCommit)) throw new Error("Source commit must be a full Git object ID"); const names = packages.map((pkg) => pkg.name); - if ( - names.length !== 11 || - new Set(names).size !== 11 || - !names.includes("@diffexai/diffex") || - !names.includes("@diffexai/diffex-coding-agent-server") - ) { - throw new Error("Reviewed distribution requires all eleven public packages"); - } - if ( - names.some((name) => - [ - "@diffexai/diffex-coding-agent", - "@diffexai/diffex-coding-agent-install", - "@diffexai/diffex-evolution", - "@diffexai/diffex-harness-trace", - "@diffexai/diffex-evals", - ].includes(name), - ) - ) { - throw new Error("Distribution inventory contains a prohibited package"); - } + if (names.length !== 1 || names[0] !== "@diffexai/diffex") + throw new Error("Reviewed distribution requires only the CLI package"); const versions = new Set(packages.map((pkg) => pkg.version)); if (versions.size !== 1 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(packages[0].version)) throw new Error("Public packages must use one stable version"); diff --git a/scripts/release-distribution-manifest.test.mjs b/scripts/release-distribution-manifest.test.mjs index 7200db771..3ec09991b 100644 --- a/scripts/release-distribution-manifest.test.mjs +++ b/scripts/release-distribution-manifest.test.mjs @@ -11,8 +11,8 @@ const candidate = () => ({ test("derives all public packages and excludes source from upload assets", () => { const manifest = createDistributionManifest(candidate()); - assert.equal(manifest.packages.length, 11); - assert(manifest.packages.some((pkg) => pkg.name === "@diffexai/diffex-coding-agent-server")); + assert.equal(manifest.packages.length, 1); + assert.equal(manifest.packages[0].name, "@diffexai/diffex"); assert.equal(manifest.publicAssets.filter((name) => name.endsWith(".tar.gz") || name.endsWith(".zip")).length, 0); assert(!manifest.publicAssets.some((name) => name.includes("source"))); assert.equal(manifest.source.commit, "a".repeat(40)); @@ -29,10 +29,10 @@ test("rejects incomplete, private, duplicate, and mixed-version package inventor value.packages[0] = { ...value.packages[0], name: "@diffexai/diffex-evolution" }; }, (value) => { - value.packages[0] = value.packages[1]; + value.packages.push(value.packages[0]); }, (value) => { - value.packages[0] = { ...value.packages[0], version: "9.9.9" }; + value.packages[0] = { ...value.packages[0], version: "invalid" }; }, (value) => { value.sourceCommit = "main"; diff --git a/scripts/release-registry.mjs b/scripts/release-registry.mjs index f60504ced..68039ef38 100644 --- a/scripts/release-registry.mjs +++ b/scripts/release-registry.mjs @@ -39,3 +39,24 @@ export async function verifyNpmArtifact(pkg, { allowMissing = false } = {}) { throw new Error(`${pkg.name}: published tarball bytes conflict with approved artifact`); return { name: pkg.name, version: pkg.version, tarball: expectedUrl, integrity: pkg.integrity }; } + +export async function readNpmPackageState(name) { + const response = await fetch(`${REGISTRY}/${encodeURIComponent(name)}`, { + headers: { accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(TIMEOUT), + }); + if (response.status === 404) return { registered: false, distTags: {} }; + if (!response.ok) throw new Error(`${name}: npm package inventory unavailable (${response.status})`); + const metadata = await response.json(); + const tags = metadata?.["dist-tags"]; + if ( + metadata?.name !== name || + !tags || + typeof tags !== "object" || + Array.isArray(tags) || + Object.values(tags).some((version) => typeof version !== "string") + ) + throw new Error(`${name}: invalid npm package inventory`); + return { registered: true, distTags: tags }; +} diff --git a/scripts/stage-cli-distribution.mjs b/scripts/stage-cli-distribution.mjs new file mode 100644 index 000000000..56cbfe3c5 --- /dev/null +++ b/scripts/stage-cli-distribution.mjs @@ -0,0 +1,68 @@ +import { cpSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; +import { cliDistribution } from "./cli-distribution.mjs"; +import { preparePackageDistribution } from "./prepare-distribution.mjs"; + +export function stageCliDistribution(directory, stage) { + const { manifest, bundled } = cliDistribution(); + mkdirSync(stage, { recursive: true }); + const notices = [ + "LICENSE", + "NOTICE", + "THIRD-PARTY-NOTICES.txt", + "distribution-components.json", + "distribution-files.json", + ]; + for (const file of manifest.files) cpSync(join(directory, file), join(stage, file), { recursive: true }); + cpSync(join(directory, "README.md"), join(stage, "README.md")); + const inventory = JSON.parse(readFileSync(join(directory, "distribution-files.json"), "utf8")); + const components = JSON.parse(readFileSync(join(directory, "distribution-components.json"), "utf8")).filter( + (component) => !bundled.some((pkg) => pkg.manifest.name === component.name), + ); + for (const pkg of bundled) { + const prepared = preparePackageDistribution(pkg.directory); + const prefix = `dist/node_modules/${pkg.manifest.name}`; + const target = join(stage, prefix); + const metadata = structuredClone(pkg.manifest); + for (const field of [ + "scripts", + "devDependencies", + "dependencies", + "optionalDependencies", + "peerDependencies", + "peerDependenciesMeta", + "files", + ]) + delete metadata[field]; + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "package.json"), JSON.stringify(metadata, null, 2) + "\n"); + for (const file of [...prepared.files.map((item) => item.path), ...notices]) { + mkdirSync(join(target, file, ".."), { recursive: true }); + cpSync(join(pkg.directory, file), join(target, file)); + } + for (const file of ["package.json", ...prepared.files.map((item) => item.path), ...notices]) + inventory.files.push({ + path: `${prefix}/${file}`, + sha256: createHash("sha256") + .update(readFileSync(join(target, file))) + .digest("hex"), + }); + const notice = `${prefix}/LICENSE`; + components.push({ + name: pkg.manifest.name, + version: pkg.manifest.version, + license: pkg.manifest.license, + portions: `${prefix}: bundled runtime, declarations, approved assets and native prebuilds`, + notice, + noticeSha256: createHash("sha256") + .update(readFileSync(join(stage, notice))) + .digest("hex"), + }); + } + inventory.files.sort((a, b) => a.path.localeCompare(b.path)); + writeFileSync(join(stage, "distribution-files.json"), JSON.stringify(inventory, null, 2) + "\n"); + writeFileSync(join(stage, "distribution-components.json"), JSON.stringify(components, null, 2) + "\n"); + writeFileSync(join(stage, "package.json"), JSON.stringify(manifest, null, 2) + "\n"); + return manifest; +} diff --git a/scripts/verify-release-credentials.mjs b/scripts/verify-release-credentials.mjs new file mode 100644 index 000000000..a27d376c5 --- /dev/null +++ b/scripts/verify-release-credentials.mjs @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const ENDPOINT = "https://6b24afa849f8254ab5e6e2d1956239cc.r2.cloudflarestorage.com"; + +export function verifyReleaseCredentialScope(target, execute) { + if (!["production", "staging"].includes(target)) throw new Error("Expected production or staging credential scope"); + const allowed = target === "production" ? "diffex-artifacts" : "diffex-artifacts-staging"; + return ["diffex-artifacts", "diffex-artifacts-staging", "image-converter"].map((bucket) => { + const result = execute([ + "s3api", + "list-objects-v2", + "--bucket", + bucket, + "--endpoint-url", + ENDPOINT, + "--prefix", + "releases/v1/", + "--max-keys", + "1", + ]); + const denied = result.status !== 0 && /An error occurred \(AccessDenied\)/.test(result.stderr ?? ""); + if (result.error || (bucket === allowed ? result.status !== 0 : !denied)) + throw new Error(`${target}: unexpected bucket scope for ${bucket}; provider diagnostics withheld`); + return { bucket, access: bucket === allowed ? "allowed" : "denied" }; + }); +} + +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.length !== 3 || !["--production", "--staging"].includes(process.argv[2])) + throw new Error("Usage: node scripts/verify-release-credentials.mjs <--production|--staging>"); + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) + throw new Error("Dedicated release publication credentials are required"); + const scope = verifyReleaseCredentialScope(process.argv[2].slice(2), (args) => + spawnSync("aws", args, { + encoding: "utf8", + timeout: 60000, + env: { ...process.env, AWS_DEFAULT_REGION: "auto", AWS_EC2_METADATA_DISABLED: "true", AWS_PAGER: "" }, + }), + ); + console.log(JSON.stringify({ checkedAt: new Date().toISOString(), scope }, null, 2)); +} diff --git a/scripts/verify-release-credentials.test.mjs b/scripts/verify-release-credentials.test.mjs new file mode 100644 index 000000000..3a3ae8d92 --- /dev/null +++ b/scripts/verify-release-credentials.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { verifyReleaseCredentialScope } from "./verify-release-credentials.mjs"; + +for (const target of ["production", "staging"]) { + test(`${target} credentials require own-bucket access and explicit denial elsewhere`, () => { + const bucket = target === "production" ? "diffex-artifacts" : "diffex-artifacts-staging"; + const calls = []; + const results = verifyReleaseCredentialScope(target, (args) => { + calls.push(args); + return args[3] === bucket ? { status: 0 } : { status: 1, stderr: "An error occurred (AccessDenied)" }; + }); + assert.equal(results.filter(({ access }) => access === "allowed").length, 1); + assert.ok(calls.every((args) => args[1] === "list-objects-v2")); + assert.throws(() => verifyReleaseCredentialScope(target, () => ({ status: 0 })), /unexpected bucket scope/); + for (const stderr of ["An error occurred (NoSuchBucket)", "timeout", "An error occurred (InvalidAccessKeyId)"]) { + assert.throws( + () => + verifyReleaseCredentialScope(target, (args) => (args[3] === bucket ? { status: 0 } : { status: 1, stderr })), + /unexpected bucket scope/, + ); + } + }); +} + +test("rejects unknown scope before invoking storage", () => { + assert.throws(() => verifyReleaseCredentialScope("other", () => assert.fail("unexpected storage"))); +});