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
86 changes: 65 additions & 21 deletions docs/release/self-update-target.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,76 @@
# Canonical self-update target
# Diffex version discovery and canonical self-update

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.
Interactive startup and manual self-update share `https://api.diffex.ai/api/latest-version` as their only version authority.
The request uses a Diffex user agent, JSON accept header, omitted credentials, and disabled caching.
There is no endpoint setting or environment override in production code.
There is no Pi, npm latest-tag, or alternate-source fallback.
All redirects are rejected without following their location, including same-origin redirects.
This keeps request forwarding restricted to the exact reviewed endpoint.

The shared checker validates the untrusted JSON with `isLatestVersion` from the [release contract](contract.md).
Only HTTP 200 JSON containing exactly a canonical stable `version` and `packageName: "@diffexai/diffex"` is accepted.
Missing or foreign identities, arrays, null, unknown fields, URLs, npm aliases, prereleases, build metadata, leading zeroes, leading `v`, whitespace, and trailing newlines are rejected before comparison.
The checker also rejects numeric components outside npm semver's supported range instead of rounding them.
The manual planner independently checks the stable version and identity before the force decision, installation detection, Windows dependency preparation, and process dispatch.
It constructs the installation specification from the local `RELEASE_PACKAGE_NAME` constant and the validated version.
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.
Equal or older releases do not install unless force is requested.

## Failure and opt-out behavior

Automatic checks make at most one request and remain silent on failure.
Manual checks retain at most two additional attempts for transient HTTP or transport failures.
All attempts share a ten-second deadline, which also bounds consumption of the response body.
Redirects, invalid payloads, and absent releases do not trigger retries.
`DIFFEX_OFFLINE` disables both automatic and manual discovery, including forced updates.
`DIFFEX_SKIP_VERSION_CHECK` disables only automatic discovery; explicit manual checks still run.

| Condition | Manual result |
| --- | --- |
| Exact HTTP 404 JSON `{"error":"no_published_release"}` | `No published Diffex release is available yet.` |
| Other HTTP errors, redirects, wrong content type, invalid JSON, or body failure | `Diffex update service is unavailable. Try again later.` |
| Invalid success schema, identity, or version | `Refusing self-update: expected a stable @diffexai/diffex release.` |
| Transport failure or request timeout | `Diffex update service is unavailable: <transport diagnostic>` |
| Offline | `Could not determine latest diffex version.` |

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.
Each failure exits manual update with status 1 before package mutation.
An unrelated 404, HTML error page, or unavailable service cannot be presented as an intentionally absent first release.
Server response bodies and redirect destinations are not copied into errors.

## Update notes

The former checker accepted an optional remote `note` field, and both manual and interactive code contain intentional note rendering.
The current stored record and HTTP projection have no note field.
The consumer therefore rejects responses containing notes as unsupported contract extensions.
The `LatestDiffexRelease.note` display capability, manual Markdown renderer, and interactive notification renderer remain intact for a future coordinated contract extension.
This migration does not invent an unverified note source or enable the currently disabled automatic update notification.

## 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:
On 2026-09-10, the existing 18-case controlled source-CLI suite passed on the parent branch `feat/pin-self-update-package` before the authority change.
New CLI regressions then reproduced the Pi URL, default redirect handling, and identical generic errors for no release and service failure.
They also captured acceptance and rendering of an unsupported note before intercepted installation.
The earlier unsafe foreign-package reproduction remains documented in the [historical reproduction record](version-update-reproduction.md).

Run focused validation from `packages/coding-agent`:

```sh
node ../../node_modules/vitest/dist/cli.js --run test/version-update-e2e.test.ts test/package-command-paths.test.ts --reporter=verbose
node ../../node_modules/vitest/dist/cli.js --run test/version-check.test.ts test/version-update-e2e.test.ts test/package-command-paths.test.ts test/release-contract.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.
The checker suite applies the shared HTTP corpus through the production consumer and covers strict JSON content types, unsupported semver ranges, exact no-release classification, retries, timeout cancellation, offline mode, and automatic opt-out.
Redirect tests run the real Undici fetch implementation against an isolated `MockAgent` with external network access disabled and assert zero requests to the redirected host.
The source CLI suite captures actual startup and manual discovery requests and package-command dispatch, including forced updates and adversarial responses.
Interactive cases use a real 100-column, 30-row PTY and exit through `/exit` without submitting model prompts.
The harness isolates home, credentials, project resources, and package prefix, blocks alternate network paths, intercepts package-manager execution, and removes every temporary directory.
All accepted installation attempts are pinned to the canonical package and explicit validated version; no install or uninstall executes.

The final checker and source-CLI run passed 101 cases: 69 checker cases and 32 source-CLI cases.
The package-command integration and consumer fixture suites also passed all 31 and 23 cases respectively.
`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.
The Cloud-to-CLI contract document and fixture comparison passed, and the executable consumer paths contain no Pi version endpoint.
Publisher and Worker contract fixtures remain unchanged.
The source consumer and test harness need no Cloud or landing application changes.
Controlled fixtures are supplied only by the test preload or isolated fetch dispatcher, never through a production authority override.
These checks establish source-CLI behavior, not a deployed R2-to-Worker-to-CLI canary or acceptance of a published client.
Live staging client canaries, production routing activation, and release publication remain separate work before distributing the migrated client.
2 changes: 1 addition & 1 deletion docs/release/version-update-reproduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The reproduction captures both commands before execution and simulates successfu
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.
The shared source consumer now uses the Diffex authority as documented in the [consumer migration record](self-update-target.md); deployed client canaries and production activation remain separate validation work.

## Repeat the reproduction

Expand Down
10 changes: 8 additions & 2 deletions packages/coding-agent/src/package-manager-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ 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 {
formatVersionCheckError,
getLatestDiffexRelease,
isNewerPackageVersion,
VersionCheckError,
} from "./utils/version-check.ts";
import {
cleanupWindowsSelfUpdateQuarantine,
quarantineWindowsNativeDependencies,
Expand Down Expand Up @@ -482,7 +487,8 @@ async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
try {
latestRelease = await getLatestDiffexRelease(VERSION, { retry: true });
} catch (error: unknown) {
throw new Error(`Could not determine latest ${APP_NAME} version: ${formatVersionCheckError(error)}`, {
if (error instanceof VersionCheckError) throw error;
throw new Error(`Diffex update service is unavailable: ${formatVersionCheckError(error)}`, {
cause: error,
});
}
Expand Down
94 changes: 62 additions & 32 deletions packages/coding-agent/src/utils/version-check.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { compare, valid } from "semver";
import type { RequestInit as UndiciRequestInit } from "undici";
import { getDiffexUserAgent } from "./diffex-user-agent.ts";
import { fetchWithRetry } from "./management-http.ts";
import { isLatestVersion, type LatestVersion } from "./release-contract.ts";

const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
const LATEST_VERSION_URL = "https://api.diffex.ai/api/latest-version";
const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000;

export interface LatestDiffexRelease {
version: string;
packageName?: string;
export interface LatestDiffexRelease extends LatestVersion {
// Retain note rendering for a future coordinated contract extension.
// The current HTTP contract has no note field and cannot populate this.
note?: string;
}

export class VersionCheckError extends Error {
readonly reason: "no-release" | "unavailable" | "invalid-release";

constructor(reason: VersionCheckError["reason"]) {
const messages = {
"no-release": "No published Diffex release is available yet.",
unavailable: "Diffex update service is unavailable. Try again later.",
"invalid-release": "Refusing self-update: expected a stable @diffexai/diffex release.",
};
super(messages[reason]);
this.name = "VersionCheckError";
this.reason = reason;
}
}

/** Include useful errno details hidden behind Node's generic "fetch failed" error. */
export function formatVersionCheckError(error: unknown): string {
const rootMessage = error instanceof Error && error.message ? error.message : String(error);
Expand Down Expand Up @@ -54,37 +71,50 @@ export async function getLatestDiffexRelease(
): Promise<LatestDiffexRelease | undefined> {
if (process.env.DIFFEX_OFFLINE) return undefined;

const response = await fetchWithRetry(
LATEST_VERSION_URL,
{
headers: {
"User-Agent": getDiffexUserAgent(currentVersion),
accept: "application/json",
},
},
{
maxRetries: options.retry ? 2 : 0,
timeoutMs: options.timeoutMs ?? DEFAULT_VERSION_CHECK_TIMEOUT_MS,
const requestOptions = {
// Never forward a version request to a redirected authority or path.
redirect: "manual",
credentials: "omit",
cache: "no-store",
headers: {
"User-Agent": getDiffexUserAgent(currentVersion),
accept: "application/json",
},
);
if (!response.ok) return undefined;
} satisfies UndiciRequestInit;
const response = await fetchWithRetry(LATEST_VERSION_URL, requestOptions, {
maxRetries: options.retry ? 2 : 0,
timeoutMs: options.timeoutMs ?? DEFAULT_VERSION_CHECK_TIMEOUT_MS,
});
if (
(response.status !== 200 && response.status !== 404) ||
response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json"
) {
await response.body?.cancel().catch(() => {});
throw new VersionCheckError("unavailable");
}

const data = (await response.json()) as {
packageName?: unknown;
version?: unknown;
note?: unknown;
};
if (typeof data.version !== "string" || !data.version.trim()) {
return undefined;
const data: unknown = await response.json().catch(() => {
throw new VersionCheckError("unavailable");
});
if (response.status === 404) {
if (
typeof data === "object" &&
data !== null &&
!Array.isArray(data) &&
Object.keys(data).length === 1 &&
"error" in data &&
data.error === "no_published_release"
) {
throw new VersionCheckError("no-release");
}
throw new VersionCheckError("unavailable");
}
// The shared contract permits arbitrary integer components; npm's semver
// implementation cannot safely install values outside its supported range.
if (!isLatestVersion(data) || valid(data.version) !== data.version) {
throw new VersionCheckError("invalid-release");
}
const packageName =
typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined;
const note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : undefined;
return {
version: data.version.trim(),
packageName,
...(note ? { note } : {}),
};
return { version: data.version, packageName: data.packageName };
}

export async function getLatestDiffexVersion(
Expand Down
19 changes: 17 additions & 2 deletions packages/coding-agent/test/fixtures/version-update-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,24 @@ const record = (event) => appendFileSync(recordPath, `${JSON.stringify(event)}\n
globalThis.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
const headers = new Headers(init?.headers);
record({ type: "request", url, method: init?.method ?? "GET", userAgent: headers.get("user-agent") });
if (url !== "https://pi.dev/api/latest-version") return new Response(null, { status: 503 });
record({
type: "request",
url,
method: init?.method ?? "GET",
userAgent: headers.get("user-agent"),
redirect: init?.redirect,
});
if (url !== "https://api.diffex.ai/api/latest-version") return new Response(null, { status: 503 });
if (process.env.DIFFEX_REPRO_FAILURE === "1") throw new Error("controlled network failure");
if (process.env.DIFFEX_REPRO_RESPONSE) {
return new Response(process.env.DIFFEX_REPRO_RESPONSE, {
status: Number(process.env.DIFFEX_REPRO_STATUS || "200"),
headers: {
"Content-Type": "application/json",
...(process.env.DIFFEX_REPRO_REDIRECT ? { Location: process.env.DIFFEX_REPRO_REDIRECT } : {}),
},
});
}
return Response.json({
version: process.env.DIFFEX_REPRO_VERSION,
packageName: process.env.DIFFEX_REPRO_PACKAGE,
Expand Down
Loading
Loading