From 37d48e26ffab3605ba02d2c1d2630b4eec3e309e Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Thu, 10 Sep 2026 14:34:44 -0400 Subject: [PATCH 1/2] fix(coding-agent): pin self-update targets - Prevent remote metadata from selecting a foreign installation package - Reject unsafe versions before forced updates or package-manager execution --- docs/release/self-update-target.md | 32 +++++++++++++ docs/release/version-update-reproduction.md | 7 +-- .../coding-agent/src/package-manager-cli.ts | 8 +++- .../test/fixtures/version-update-guard.mjs | 2 +- .../test/package-command-paths.test.ts | 42 ++++++---------- .../test/version-update-e2e.test.ts | 48 +++++++++++++------ 6 files changed, 91 insertions(+), 48 deletions(-) create mode 100644 docs/release/self-update-target.md diff --git a/docs/release/self-update-target.md b/docs/release/self-update-target.md new file mode 100644 index 000000000..8208fcc06 --- /dev/null +++ b/docs/release/self-update-target.md @@ -0,0 +1,32 @@ +# Canonical self-update target + +The manual self-update planner accepts only a response identifying `@diffexai/diffex` with a canonical stable version. +It constructs the installation specification from the local `RELEASE_PACKAGE_NAME` constant and the validated version, regardless of the response's package field. +Missing or foreign package identities, installation URLs, npm aliases, prereleases, build metadata, leading zeroes, and leading `v` cannot reach package-command execution. +Validation happens before the force decision, installation detection, Windows dependency preparation, and process dispatch. +Normal and forced valid updates attempt only `@diffexai/diffex@` without a package-migration uninstall. +Rejected targets print `Refusing self-update: expected a stable @diffexai/diffex release.` and exit with status 1. + +This is the client safeguard required by the independently deployed release Worker in `diffex-cloud`, implemented on `feat/serve-verified-releases`. +It does not activate the Diffex endpoint: the shared checker still requests `https://pi.dev/api/latest-version` until the consumer authority migration is completed. +Strict response parsing, redirect restrictions, no-release messages, and staged endpoint canaries remain part of that migration. +Current foreign-package responses now fail safely rather than migrating installations. +Existing note rendering, offline behavior, automatic opt-out, and bounded manual retries are preserved. + +## Reproduction and verification + +On 2026-09-10, the original 10-case source-CLI reproduction passed before the fix and captured both normal and forced foreign-package installation attempts. +The updated controlled suite passed 18 cases, including foreign-package rejection, unsafe-version rejection with force, canonical installation targets, offline behavior, automatic opt-out, manual retries, and real-terminal startup and exit. +The package-command integration suite passed 31 cases, including missing-identity rejection and canonical npm failure handling without an uninstall. +The relevant commands, run from `packages/coding-agent`, are: + +```sh +node ../../node_modules/vitest/dist/cli.js --run test/version-update-e2e.test.ts test/package-command-paths.test.ts --reporter=verbose +``` + +The controlled source CLI intercepted network requests and every package-manager execution before package mutation. +Canonical install commands were recorded with `--ignore-scripts` and an explicit version; no install or uninstall actually ran. +Every case removed its temporary home, package prefix, project directory, and event record. +`npm run check` and the complete isolated `./test.sh` suite passed. +No production endpoint, package publication, installed software, or remote storage changed. +This establishes source-CLI behavior with controlled responses, not a deployed Worker-to-CLI canary or published-client acceptance. diff --git a/docs/release/version-update-reproduction.md b/docs/release/version-update-reproduction.md index d1e9ae751..5041676a1 100644 --- a/docs/release/version-update-reproduction.md +++ b/docs/release/version-update-reproduction.md @@ -11,9 +11,10 @@ A response naming `@foreign-fixture/untrusted-cli` reaches the CLI's real packag `--force` also accepts the foreign identity when the response version equals the installed version. The reproduction captures both commands before execution and simulates successful completion; no package manager runs and no installed software changes. -These tests characterize the vulnerable behavior, not the desired update policy. -When the consumer is constrained to the Diffex authority and canonical package, replace the vulnerable expectations with rejection assertions while retaining the CLI and terminal coverage. -This change does not fix the updater or perform endpoint cutover. +The finding and captured commands below are historical evidence from the vulnerable implementation. +The same reproduction was confirmed again before adding the [canonical self-update safeguard](self-update-target.md). +The executable suite now asserts rejection of foreign targets and retains CLI and terminal coverage. +Endpoint cutover remains pending. ## Repeat the reproduction diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 48fa02f29..2edb26456 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -25,6 +25,7 @@ import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess } from "./utils/child-process.ts"; +import { isStableVersion, RELEASE_PACKAGE_NAME } from "./utils/release-contract.ts"; import { formatVersionCheckError, getLatestDiffexRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { cleanupWindowsSelfUpdateQuarantine, @@ -489,9 +490,12 @@ async function getSelfUpdatePlan(force: boolean): Promise { throw new Error(`Could not determine latest ${APP_NAME} version.`); } - const packageName = latestRelease.packageName ?? PACKAGE_NAME; + if (latestRelease.packageName !== RELEASE_PACKAGE_NAME || !isStableVersion(latestRelease.version)) { + throw new Error(`Refusing self-update: expected a stable ${RELEASE_PACKAGE_NAME} release.`); + } + const packageName = RELEASE_PACKAGE_NAME; const installSpec = `${packageName}@${latestRelease.version}`; - if (force || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) { + if (force || isNewerPackageVersion(latestRelease.version, VERSION)) { return { packageName, installSpec, diff --git a/packages/coding-agent/test/fixtures/version-update-guard.mjs b/packages/coding-agent/test/fixtures/version-update-guard.mjs index 686fa9614..04b120f0e 100644 --- a/packages/coding-agent/test/fixtures/version-update-guard.mjs +++ b/packages/coding-agent/test/fixtures/version-update-guard.mjs @@ -19,7 +19,7 @@ globalThis.fetch = async (input, init) => { if (process.env.DIFFEX_REPRO_FAILURE === "1") throw new Error("controlled network failure"); return Response.json({ version: process.env.DIFFEX_REPRO_VERSION, - packageName: "@foreign-fixture/untrusted-cli", + packageName: process.env.DIFFEX_REPRO_PACKAGE, }); }; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 2c544807c..fdb36e7a3 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -523,7 +523,7 @@ describe("package commands", () => { it("allows explicit self-update checks when automatic version checks are disabled", async () => { const previousSkipVersionCheck = process.env.DIFFEX_SKIP_VERSION_CHECK; process.env.DIFFEX_SKIP_VERSION_CHECK = "1"; - const fetchMock = vi.fn(async () => Response.json({ version: VERSION })); + const fetchMock = vi.fn(async () => Response.json({ version: VERSION, packageName: PACKAGE_NAME })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -553,7 +553,7 @@ describe("package commands", () => { .fn() .mockRejectedValueOnce(new Error("fetch failed")) .mockRejectedValueOnce(new Error("fetch failed")) - .mockResolvedValueOnce(Response.json({ version: VERSION })); + .mockResolvedValueOnce(Response.json({ version: VERSION, packageName: PACKAGE_NAME })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -598,7 +598,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); - const fetchMock = vi.fn(async () => Response.json({ version: VERSION })); + const fetchMock = vi.fn(async () => Response.json({ version: VERSION, packageName: PACKAGE_NAME })); vi.stubGlobal("fetch", fetchMock); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -623,7 +623,7 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); } }); - it("uses the current package name when the update check omits packageName", async () => { + it("rejects a self-update response that omits packageName", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); @@ -655,21 +655,16 @@ else fs.writeFileSync(${JSON.stringify(recordPath)},JSON.stringify(args)); try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); - expect(process.exitCode).toBeUndefined(); - expect(errorSpy).not.toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledOnce(); - const stdout = logSpy.mock.calls.map(([message]) => String(message)).join("\n"); - const recordedArgs = JSON.parse(readFileSync(recordPath, "utf-8")) as string[]; - expect(recordedArgs).toContain(`${PACKAGE_NAME}@${targetVersion}`); - expect(recordedArgs).not.toContain(PACKAGE_NAME); - expect(stdout).toContain(`Updated diffex from ${VERSION} to ${targetVersion}`); + expect(process.exitCode).toBe(1); + expect(existsSync(recordPath)).toBe(false); + expect(errorSpy.mock.calls.flat().join("\n")).toContain("Refusing self-update"); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); } }); - it("installs the active package name from the update check during self-update", async () => { + it("rejects a foreign package before uninstalling or installing", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); @@ -707,13 +702,9 @@ else { try { await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); - expect(process.exitCode).toBeUndefined(); - expect(errorSpy).not.toHaveBeenCalled(); - const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; - expect(recordedCalls).toEqual([ - expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), - expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]), - ]); + expect(process.exitCode).toBe(1); + expect(existsSync(recordPath)).toBe(false); + expect(errorSpy.mock.calls.flat().join("\n")).toContain("Refusing self-update"); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); @@ -742,7 +733,7 @@ else { }); vi.stubGlobal( "fetch", - vi.fn(async () => Response.json({ version: getNewerPatchVersion() })), + vi.fn(async () => Response.json({ version: getNewerPatchVersion(), packageName: PACKAGE_NAME })), ); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); @@ -764,7 +755,7 @@ else { } }); - it("fails self-update when renamed npm package installation fails", async () => { + it("reports canonical npm package installation failure without uninstalling", async () => { const globalPrefix = join(tempDir, "global-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent"); const fakeNpmPath = join(tempDir, "fake-npm-fail.cjs"); @@ -792,7 +783,7 @@ if(args.includes("install")) process.exit(23); value: join(selfPackageDir, "dist", "cli.js"), configurable: true, }); - const activePackageName = PACKAGE_NAME === "@new-scope/pi" ? "@newer-scope/pi" : "@new-scope/pi"; + const activePackageName = PACKAGE_NAME; vi.stubGlobal( "fetch", vi.fn(async () => Response.json({ packageName: activePackageName, version: "0.73.0" })), @@ -810,10 +801,7 @@ if(args.includes("install")) process.exit(23); expect(stdout).not.toContain(`Updated diffex`); expect(stderr).toContain("exited with code 23"); const recordedCalls = JSON.parse(readFileSync(recordPath, "utf-8")) as string[][]; - expect(recordedCalls).toEqual([ - expect.arrayContaining(["uninstall", "-g", PACKAGE_NAME]), - expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`]), - ]); + expect(recordedCalls).toEqual([expect.arrayContaining(["install", "-g", `${activePackageName}@0.73.0`])]); } finally { logSpy.mockRestore(); errorSpy.mockRestore(); diff --git a/packages/coding-agent/test/version-update-e2e.test.ts b/packages/coding-agent/test/version-update-e2e.test.ts index 8c462ae1f..c7ecf1f9c 100644 --- a/packages/coding-agent/test/version-update-e2e.test.ts +++ b/packages/coding-agent/test/version-update-e2e.test.ts @@ -10,7 +10,6 @@ const repositoryDir = resolve(packageDir, "../.."); const packageJson = readFileSync(join(packageDir, "package.json"), "utf8"); const installed = JSON.parse(packageJson) as { name: string; version: string }; const endpoint = "https://pi.dev/api/latest-version"; -const foreignPackage = "@foreign-fixture/untrusted-cli"; interface RecordedEvent { type: string; @@ -27,6 +26,8 @@ async function reproduce(options: { offline?: boolean; skip?: boolean; failure?: boolean; + packageName?: string; + version?: string; }) { const root = mkdtempSync(join(tmpdir(), "diffex-version-repro-")); const agentDir = join(root, "agent"); @@ -49,7 +50,8 @@ async function reproduce(options: { DIFFEX_CODING_AGENT_DIR: agentDir, DIFFEX_NO_LOCAL_LLM: "1", DIFFEX_REPRO_RECORD: recordPath, - DIFFEX_REPRO_VERSION: options.force ? installed.version : "9999.0.0", + DIFFEX_REPRO_VERSION: options.version ?? (options.force ? installed.version : "9999.0.0"), + DIFFEX_REPRO_PACKAGE: options.packageName ?? "@foreign-fixture/untrusted-cli", DIFFEX_REPRO_FAILURE: options.failure ? "1" : "", DIFFEX_OFFLINE: options.offline ? "1" : "", DIFFEX_SKIP_VERSION_CHECK: options.skip ? "1" : "", @@ -127,16 +129,21 @@ function versionRequests(events: RecordedEvent[]) { return events.filter((event) => event.type === "request" && event.url === endpoint); } -// Characterization of the vulnerable implementation, to be inverted when the consumer is constrained. +// The source CLI must reject untrusted targets before dispatching package commands. describe.skipIf(process.platform === "win32")("version discovery through the source CLI", () => { - it.each([false, true])("captures foreign-package uninstall/install attempts with force=%s", async (force) => { + it.each([false, true])("rejects foreign packages before any command with force=%s", async (force) => { const result = await reproduce({ force }); + expect(result.code, result.output).toBe(1); + expect(versionRequests(result.events)).toHaveLength(1); + expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); + expect(result.output).toContain("Refusing self-update: expected a stable @diffexai/diffex release."); + }); + + it.each([false, true])("pins canonical installation without uninstall with force=%s", async (force) => { + const result = await reproduce({ force, packageName: "@diffexai/diffex" }); expect(result.code, result.output).toBe(0); - expect(versionRequests(result.events)).toEqual([ - expect.objectContaining({ method: "GET", userAgent: expect.stringContaining(`diffex/${installed.version} `) }), - ]); + expect(versionRequests(result.events)).toHaveLength(1); expect(result.events.filter((event) => event.type === "spawn")).toEqual([ - { type: "spawn", command: "npm", args: ["--prefix", result.prefix, "uninstall", "-g", installed.name] }, { type: "spawn", command: "npm", @@ -147,13 +154,24 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou "-g", "--ignore-scripts", "--min-release-age=0", - `${foreignPackage}@${force ? installed.version : "9999.0.0"}`, + `@diffexai/diffex@${force ? installed.version : "9999.0.0"}`, ], }, ]); - expect(result.output).toContain( - `Updating diffex with npm --prefix ${result.prefix} uninstall -g ${installed.name}`, - ); + }); + + it.each([ + "npm:foreign@1.2.3", + "https://example.invalid/install.tgz", + "1.2.3-beta.1", + "1.2.3+build", + "v1.2.3", + "01.2.3", + ])("rejects unsafe version %s even with force", async (version) => { + const result = await reproduce({ force: true, packageName: "@diffexai/diffex", version }); + expect(result.code, result.output).toBe(1); + expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); + expect(result.output).toContain("Refusing self-update"); }); it.each([false, true])("offline suppresses manual discovery and commands with force=%s", async (force) => { @@ -163,11 +181,11 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou expect(result.output).toContain("Could not determine latest diffex version."); }); - it("automatic opt-out still permits manual foreign-package update", async () => { + it("automatic opt-out permits manual discovery but cannot bypass package validation", async () => { const result = await reproduce({ skip: true }); - expect(result.code, result.output).toBe(0); + expect(result.code, result.output).toBe(1); expect(versionRequests(result.events)).toHaveLength(1); - expect(result.events.filter((event) => event.type === "spawn")).toHaveLength(2); + expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); }); it("manual network failure retries twice without attempting package mutation", async () => { From efcbf187601380fedfd1e748816e86e79418055d Mon Sep 17 00:00:00 2001 From: Jay Yu Date: Thu, 10 Sep 2026 15:53:08 -0400 Subject: [PATCH 2/2] test(release): verify the staging public API - Exercise actual Worker responses against isolated publication fixtures - Preserve fixture ownership and cleanup without production writes --- .../workflows/verify-release-publication.yml | 13 +++- docs/release/publication.md | 23 ++++++- scripts/verify-release-publication.mjs | 60 +++++++++++++++++-- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/.github/workflows/verify-release-publication.yml b/.github/workflows/verify-release-publication.yml index f3e303b2c..62e6d1c99 100644 --- a/.github/workflows/verify-release-publication.yml +++ b/.github/workflows/verify-release-publication.yml @@ -2,6 +2,11 @@ name: Verify Release Publication on: workflow_dispatch: + inputs: + check_public_api: + description: Verify the deployed staging public API against release fixtures + type: boolean + default: false permissions: contents: read @@ -34,4 +39,10 @@ jobs: AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFFEX_ARTIFACTS_R2_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: auto AWS_EC2_METADATA_DISABLED: 'true' - run: node scripts/verify-release-publication.mjs --staging + CHECK_PUBLIC_API: ${{ inputs.check_public_api }} + run: | + if [ "$CHECK_PUBLIC_API" = "true" ]; then + node scripts/verify-release-publication.mjs --staging --public-api + else + node scripts/verify-release-publication.mjs --staging + fi diff --git a/docs/release/publication.md b/docs/release/publication.md index d94b275f8..56d446b99 100644 --- a/docs/release/publication.md +++ b/docs/release/publication.md @@ -42,7 +42,7 @@ The publisher itself uses fixed release-record keys. See [Cloudflare R2 token permissions](https://developers.cloudflare.com/r2/api/tokens/) for the storage capability boundary. Users call the public version endpoint without R2 credentials. -The planned Worker accesses private storage through a native binding, which supports both reads and writes. +The deployed staging Worker accesses private storage through a native binding, which supports both reads and writes. If enforced read-only storage access is required, configure a separate read-only credential per Worker environment during endpoint implementation. ## Publication invariants @@ -124,3 +124,24 @@ No secret values were printed, committed, or copied into client or Worker config This verifies credential configuration and real publisher-to-R2 behavior; a GitHub-hosted staging run remains available after the new manual workflow reaches `main`. A final credential-isolation check confirmed both release namespaces remained empty after staging cleanup. + +## Deployed public API verification + +The optional `--public-api` flag adds actual unauthenticated HTTP checks against the fixed staging Worker: + +```sh +node scripts/verify-release-publication.mjs --staging --public-api +``` + +The manual workflow exposes the same flag as `check_public_api`, disabled by default. +Only the CLI staging environment supplies publication credentials; the Worker deployment workflow never receives them. +The verifier tests canonical projection, missing releases, malformed JSON, unsupported schema, prereleases, ignored query selectors, rejected methods and suffixes, readiness, and unchanged storage after HTTP requests. +It restores malformed fixtures conditionally and removes only records owned by the run. +A final HTTP failure still releases the lock after successful object cleanup; actual storage cleanup failure retains the lock for investigation. + +Live verification passed on 2026-09-10 using synthetic versions `0.1789069419298.0` and `0.1789069419298.1`. +The real publisher used controlled npm responses for all 11 packages, real staging R2, and the deployed Worker. +Both release keys, the latest pointer, and the shared lock were confirmed absent afterward. +The production latest record remained absent. +The [cloud staging acceptance record](https://github.com/diffexai/diffex-cloud/blob/feat/serve-verified-releases/docs/public-api-staging.md) records Worker deployment identities, the separate missing-binding failure and recovery test, and unchanged Render routing. +This proves publisher-to-R2-to-HTTP behavior; the installed CLI authority migration and canary remain separate work. diff --git a/scripts/verify-release-publication.mjs b/scripts/verify-release-publication.mjs index e32e3e260..ab028583a 100644 --- a/scripts/verify-release-publication.mjs +++ b/scripts/verify-release-publication.mjs @@ -6,8 +6,9 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createPublicationFixture } from "./fixtures/release-publication/harness.mjs"; -if (process.argv.length !== 3 || process.argv[2] !== "--staging") { - throw new Error("Use --staging with staging-scoped AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."); +const checkPublicApi = process.argv.length === 4 && process.argv[3] === "--public-api"; +if (process.argv[2] !== "--staging" || (process.argv.length !== 3 && !checkPublicApi)) { + throw new Error("Use --staging [--public-api] with staging-scoped AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."); } if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { throw new Error("Staging verification requires AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."); @@ -22,6 +23,22 @@ const keys = [latestKey, `releases/v1/releases/${version}.json`, `releases/v1/re let locked = false; let ready = false; const failures = []; +const ownedInvalidBodies = new Set(); + +async function expectWorker(status, body, path = "/api/latest-version", method = "GET") { + if (!checkPublicApi) return; + const response = await fetch(`https://diffex-public-api-staging.yibai14651.workers.dev${path}`, { + method, + redirect: "error", + signal: AbortSignal.timeout(15000), + }); + assert.equal(response.status, status, `Unexpected Worker status for ${method} ${path}`); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.match(response.headers.get("content-type") ?? "", /^application\/json\b/); + if (status === 405) assert.equal(response.headers.get("allow"), "GET"); + if (method !== "HEAD") assert.deepEqual(await response.json(), body); + console.log(`Worker ${method} ${path}: ${status}`); +} function aws(operation, args, { allowMissing = false } = {}) { const result = spawnSync( @@ -58,7 +75,7 @@ function publish(value, options = {}) { function removeOwned(key) { const object = read(key); if (!object) return; - assert.ok(JSON.parse(object.body).sourceCommit === commit, "Refusing cleanup of an object owned by another writer."); + assert.ok(ownedInvalidBodies.has(object.body) || JSON.parse(object.body).sourceCommit === commit, "Refusing cleanup of an object owned by another writer."); aws("delete-object", ["--key", key]); assert.equal(read(key), undefined, "Staging object remains after cleanup."); } @@ -71,6 +88,8 @@ try { for (const key of keys) assert.ok(read(key) === undefined, "Staging release keys must be absent before verification."); ready = true; + await expectWorker(404, { error: "no_published_release" }); + await expectWorker(200, { status: "ready", release: "missing" }, "/health/ready"); console.log(`Staging publication fixture: ${version}, ${newer}`); for (const failure of ["package", "tarball"]) { @@ -85,6 +104,31 @@ try { assert.match(initial.metadata.ContentType, /^application\/json/); assert.equal(JSON.parse(initial.body).packages.length, fixture.packageCount); console.log("First verified publication creates matching versioned and latest records."); + await expectWorker(200, { version, packageName: "@diffexai/diffex" }); + await expectWorker(200, { version, packageName: "@diffexai/diffex" }, "/api/latest-version?key=unrelated&packageName=foreign&version=99.0.0"); + await expectWorker(200, { status: "ready", release: "published" }, "/health/ready"); + for (const method of ["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]) + await expectWorker(405, { error: "method_not_allowed" }, "/api/latest-version", method); + for (const path of ["/api/latest-version/extra", "/api/latest-version-other", "/auth/login", "/v1/auth/session"]) + await expectWorker(404, { error: "not_found" }, path); + assert.equal(read(latestKey).body, initial.body, "HTTP requests must not mutate release storage."); + if (checkPublicApi) { + const invalidFile = join(fixture.directory, "invalid-record.json"); + for (const invalid of [ + `{invalid-json-${commit}`, + JSON.stringify({ ...JSON.parse(initial.body), schemaVersion: 2 }), + JSON.stringify({ ...JSON.parse(initial.body), version: `${version}-beta.1` }), + ]) { + ownedInvalidBodies.add(invalid); + writeFileSync(invalidFile, invalid); + aws("put-object", ["--key", latestKey, "--body", invalidFile, "--if-match", read(latestKey).metadata.ETag]); + await expectWorker(503, { error: "release_unavailable" }); + await expectWorker(503, { status: "unavailable", release: "unavailable" }, "/health/ready"); + } + writeFileSync(invalidFile, initial.body); + aws("put-object", ["--key", latestKey, "--body", invalidFile, "--if-match", read(latestKey).metadata.ETag, "--content-type", "application/json", "--cache-control", "no-store"]); + await expectWorker(200, { version, packageName: "@diffexai/diffex" }); + } publish(version); assert.equal(read(latestKey).body, initial.body); assert.equal(read(keys[1]).body, initial.body); @@ -101,6 +145,7 @@ try { publish(version); assert.equal(read(latestKey).body, advanced); console.log("Conditional advancement succeeds and older announcements cannot regress latest."); + await expectWorker(200, { version: newer, packageName: "@diffexai/diffex" }); removeOwned(latestKey); publish(newer); assert.equal(read(latestKey).body, advanced); @@ -122,7 +167,14 @@ try { if (cleanupFailed) { console.error("Staging cleanup failed; retain the lock and inspect this run's keys."); } else { - removeOwned(lockKey); + try { + if (ready) { + await expectWorker(404, { error: "no_published_release" }); + await expectWorker(200, { status: "ready", release: "missing" }, "/health/ready"); + } + } finally { + removeOwned(lockKey); + } console.log("Cleanup verified: fixture keys are absent and the staging lock was removed."); } }