Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/verify-release-publication.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
23 changes: 22 additions & 1 deletion docs/release/publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
32 changes: 32 additions & 0 deletions docs/release/self-update-target.md
Original file line number Diff line number Diff line change
@@ -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@<version>` 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.
7 changes: 4 additions & 3 deletions docs/release/version-update-reproduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions packages/coding-agent/src/package-manager-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -489,9 +490,12 @@ async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
};

Expand Down
42 changes: 15 additions & 27 deletions packages/coding-agent/test/package-command-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down Expand Up @@ -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(() => {});
Expand Down Expand Up @@ -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(() => {});
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(() => {});
Expand All @@ -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");
Expand Down Expand Up @@ -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" })),
Expand All @@ -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();
Expand Down
48 changes: 33 additions & 15 deletions packages/coding-agent/test/version-update-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
Expand All @@ -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" : "",
Expand Down Expand Up @@ -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",
Expand All @@ -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) => {
Expand All @@ -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 () => {
Expand Down
Loading
Loading