diff --git a/docs/release/self-update-target.md b/docs/release/self-update-target.md index 8208fcc06..ec69af1ef 100644 --- a/docs/release/self-update-target.md +++ b/docs/release/self-update-target.md @@ -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@` 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: ` | +| 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. diff --git a/docs/release/version-update-reproduction.md b/docs/release/version-update-reproduction.md index 5041676a1..db7672328 100644 --- a/docs/release/version-update-reproduction.md +++ b/docs/release/version-update-reproduction.md @@ -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 diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 2edb26456..9672ae80d 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -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, @@ -482,7 +487,8 @@ async function getSelfUpdatePlan(force: boolean): Promise { 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, }); } diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index aaf66c357..73ba3ef23 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -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); @@ -54,37 +71,50 @@ export async function getLatestDiffexRelease( ): Promise { 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( diff --git a/packages/coding-agent/test/fixtures/version-update-guard.mjs b/packages/coding-agent/test/fixtures/version-update-guard.mjs index 04b120f0e..f0b92f782 100644 --- a/packages/coding-agent/test/fixtures/version-update-guard.mjs +++ b/packages/coding-agent/test/fixtures/version-update-guard.mjs @@ -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, diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index c4d380b85..ee6f1cc46 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; +import { MockAgent, fetch as undiciFetch } from "undici"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { checkForNewDiffexVersion, @@ -9,19 +11,21 @@ import { } from "../src/utils/version-check.ts"; import { allowNetwork } from "./test-network-env.ts"; -const originalSkipVersionCheck = process.env.DIFFEX_SKIP_VERSION_CHECK; +const endpoint = "https://api.diffex.ai/api/latest-version"; +const release = { version: "1.2.4", packageName: "@diffexai/diffex" }; +const fixtures = JSON.parse( + readFileSync(new URL("../../../scripts/fixtures/release-contract.json", import.meta.url), "utf8"), +) as { responses: { name: string; status: number; body: unknown; valid: boolean }[] }; beforeEach(() => { allowNetwork(); + vi.stubEnv("DIFFEX_SKIP_VERSION_CHECK", undefined); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); - if (originalSkipVersionCheck === undefined) { - delete process.env.DIFFEX_SKIP_VERSION_CHECK; - } else { - process.env.DIFFEX_SKIP_VERSION_CHECK = originalSkipVersionCheck; - } + vi.unstubAllEnvs(); }); describe("version checks", () => { @@ -34,46 +38,226 @@ describe("version checks", () => { expect(isNewerPackageVersion("0.70.6", "0.70.5")).toBe(true); }); - it("returns only newer versions", async () => { - const fetchMock = vi.fn(async () => Response.json({ version: "1.2.3" })); + it.each(["1.2.4", "1.2.5"])("does not notify callers at %s about equal or older versions", async (current) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(release)), + ); + await expect(checkForNewDiffexVersion(current)).resolves.toBeUndefined(); + }); + + it("returns only validated newer versions", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(release)), + ); + await expect(checkForNewDiffexVersion("1.2.3")).resolves.toEqual(release); + }); + + it("uses only the Diffex authority with a user agent and no credentials or redirects", async () => { + const fetchMock = vi.fn(async () => Response.json(release)); vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexVersion("1.2.3")).resolves.toBe("1.2.4"); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(endpoint, { + redirect: "manual", + credentials: "omit", + cache: "no-store", + headers: { "User-Agent": expect.stringMatching(/^diffex\/1\.2\.3 /), accept: "application/json" }, + signal: expect.any(AbortSignal), + }); + }); + it.each(fixtures.responses)("enforces the shared HTTP fixture: $name", async (fixture) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(fixture.body, { status: fixture.status })), + ); + if (fixture.valid) { + await expect(getLatestDiffexRelease("0.0.0")).resolves.toEqual(fixture.body); + } else { + await expect(getLatestDiffexRelease("0.0.0")).rejects.toThrow(); + await expect(checkForNewDiffexVersion("0.0.0")).resolves.toBeUndefined(); + } + }); + + it.each(["9007199254740992.0.0", "1.9007199254740992.0", "1.2.9007199254740992"])( + "rejects unsupported semver numeric ranges: %s", + async (version) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ ...release, version })), + ); + await expect(getLatestDiffexRelease("1.2.3")).rejects.toMatchObject({ reason: "invalid-release" }); + }, + ); + + it("does not consume notes outside the current HTTP contract", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ ...release, note: "**Read this**" })), + ); + await expect(getLatestDiffexRelease("1.2.3")).rejects.toMatchObject({ reason: "invalid-release" }); + }); + + it("reports the exact no-release response without retries", async () => { + const fetchMock = vi.fn(async () => Response.json({ error: "no_published_release" }, { status: 404 })); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).rejects.toMatchObject({ reason: "no-release" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each( + [null, [], {}, { error: "not_found" }, { error: "no_published_release", extra: true }, release].map((body) => ({ + body, + })), + )("does not mistake an unrelated 404 for an absent release: $body", async ({ body }) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(body, { status: 404 })), + ); + await expect(getLatestDiffexRelease("1.2.3")).rejects.toMatchObject({ reason: "unavailable" }); + }); + + it.each([200, 404])("reports invalid JSON as unavailable for HTTP %s", async (status) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("", { status, headers: { "Content-Type": "application/json" } }), + ), + ); + await expect(getLatestDiffexRelease("1.2.3")).rejects.toMatchObject({ reason: "unavailable" }); await expect(checkForNewDiffexVersion("1.2.3")).resolves.toBeUndefined(); - await expect(checkForNewDiffexVersion("1.2.2")).resolves.toEqual({ version: "1.2.3" }); }); - it("uses the pi.dev version check API with a Diffex user agent", async () => { - const fetchMock = vi.fn(async () => Response.json({ version: "1.2.4" })); + it.each([undefined, "text/html", "text/plain", "application/json-malformed"])( + "rejects unexpected content type %s", + async (contentType) => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify(release), { + headers: contentType ? { "Content-Type": contentType } : {}, + }), + ), + ); + await expect(getLatestDiffexRelease("1.2.3")).rejects.toMatchObject({ reason: "unavailable" }); + }, + ); + + it("accepts JSON content type with charset parameters", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(release, { headers: { "Content-Type": "application/json; charset=utf-8" } })), + ); + await expect(getLatestDiffexRelease("1.2.3")).resolves.toEqual(release); + }); + + it.each([201, 204, 206, 304, 401, 403, 405, 500, 503])("rejects HTTP %s with no fallback", async (status) => { + const fetchMock = vi.fn(async () => new Response(null, { status })); vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexRelease("1.2.3")).rejects.toMatchObject({ reason: "unavailable" }); + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(endpoint, expect.anything()); + }); - await expect(getLatestDiffexVersion("1.2.3")).resolves.toBe("1.2.4"); - expect(fetchMock).toHaveBeenCalledWith( - "https://pi.dev/api/latest-version", - expect.objectContaining({ - headers: expect.objectContaining({ - "User-Agent": expect.stringMatching(/^diffex\/1\.2\.3 /), - accept: "application/json", + it.each([301, 302, 303, 307, 308])( + "does not forward HTTP %s to another origin in the real fetch implementation", + async (status) => { + const dispatcher = new MockAgent(); + dispatcher.disableNetConnect(); + const foreignRequest = vi.fn(() => JSON.stringify(release)); + dispatcher + .get("https://api.diffex.ai") + .intercept({ path: "/api/latest-version" }) + .reply(status, "", { headers: { location: "https://unreviewed.invalid/latest" } }); + dispatcher.get("https://unreviewed.invalid").intercept({ path: "/latest" }).reply(200, foreignRequest); + const fetchMock = vi.fn((input: string, init?: RequestInit) => + undiciFetch(input, { + headers: Object.fromEntries(new Headers(init?.headers)), + redirect: init?.redirect, + signal: init?.signal, + dispatcher, }), - }), - ); + ); + vi.stubGlobal("fetch", fetchMock); + try { + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).rejects.toMatchObject({ + reason: "unavailable", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(foreignRequest).not.toHaveBeenCalled(); + expect(dispatcher.pendingInterceptors()).toHaveLength(1); + } finally { + await dispatcher.close(); + } + }, + ); + + it("also rejects same-origin redirects without following them", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 302, headers: { Location: "/other-release" } })); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).rejects.toMatchObject({ reason: "unavailable" }); + expect(fetchMock).toHaveBeenCalledOnce(); }); - it("retries a transient version request when explicitly requested", async () => { + it("retries a transient version request only when explicitly requested", async () => { const fetchMock = vi .fn() .mockRejectedValueOnce(new Error("fetch failed")) - .mockRejectedValueOnce(new Error("fetch failed")) - .mockResolvedValueOnce(Response.json({ version: "1.2.4" })); + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce(Response.json(release)); vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).resolves.toEqual(release); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); - await expect(getLatestDiffexRelease("1.2.3", { retry: true })).resolves.toEqual({ version: "1.2.4" }); + it("bounds manual HTTP retries and shares one ten-second budget", async () => { + const timeout = vi.spyOn(AbortSignal, "timeout"); + const fetchMock = vi.fn(async (_input: unknown, _init?: RequestInit) => new Response(null, { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).rejects.toMatchObject({ reason: "unavailable" }); expect(fetchMock).toHaveBeenCalledTimes(3); + expect(timeout).toHaveBeenCalledExactlyOnceWith(10000); + expect(new Set(fetchMock.mock.calls.map(([, init]) => init?.signal)).size).toBe(1); }); - it("keeps automatic version checks to one request", async () => { - const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed")); + it("stops retrying as soon as the shared timeout expires", async () => { + const controller = new AbortController(); + vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const fetchMock = vi.fn(async () => { + controller.abort(new DOMException("Timed out", "TimeoutError")); + controller.signal.throwIfAborted(); + }); vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).rejects.toThrow("Timed out"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it("keeps automatic checks quiet and bounded when the response body times out", async () => { + const controller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const fetchMock = vi.fn(async (_input: unknown, init?: RequestInit) => { + const response = new Response( + new ReadableStream({ + start(stream) { + init?.signal?.addEventListener("abort", () => stream.error(init.signal?.reason), { once: true }); + }, + }), + { headers: { "Content-Type": "application/json" } }, + ); + queueMicrotask(() => controller.abort(new DOMException("Timed out", "TimeoutError"))); + return response; + }); + vi.stubGlobal("fetch", fetchMock); + await expect(checkForNewDiffexVersion("1.2.3")).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(timeout).toHaveBeenCalledExactlyOnceWith(10000); + }); + + it("keeps automatic version checks to one request on transport failure", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("fetch failed")); + vi.stubGlobal("fetch", fetchMock); await expect(checkForNewDiffexVersion("1.2.3")).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledOnce(); }); @@ -85,46 +269,24 @@ describe("version checks", () => { Object.assign(new Error("network unreachable"), { code: "ENETUNREACH" }), ]), }); - expect(formatVersionCheckError(error)).toBe("fetch failed (ETIMEDOUT, ENETUNREACH)"); }); - it("returns the active package metadata from the version check api", async () => { - const fetchMock = vi.fn(async () => - Response.json({ - packageName: "@new-scope/diffex-coding-agent", - version: "1.2.4", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - await expect(getLatestDiffexRelease("1.2.3")).resolves.toEqual({ - packageName: "@new-scope/diffex-coding-agent", - version: "1.2.4", - }); - }); - - it("returns update notes from the version check api", async () => { - const fetchMock = vi.fn(async () => Response.json({ note: " **Read this** ", version: "1.2.4" })); - vi.stubGlobal("fetch", fetchMock); - - await expect(getLatestDiffexRelease("1.2.3")).resolves.toEqual({ note: "**Read this**", version: "1.2.4" }); - }); - - it("skips automatic api calls when version checks are disabled", async () => { - process.env.DIFFEX_SKIP_VERSION_CHECK = "1"; + it("skips all API calls while offline", async () => { + vi.stubEnv("DIFFEX_OFFLINE", "1"); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - await expect(checkForNewDiffexVersion("1.2.3")).resolves.toBeUndefined(); + await expect(getLatestDiffexRelease("1.2.3", { retry: true })).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); }); - it("allows direct api calls when automatic version checks are disabled", async () => { - process.env.DIFFEX_SKIP_VERSION_CHECK = "1"; - const fetchMock = vi.fn(async () => Response.json({ version: "1.2.4" })); + it("skips only automatic calls when automatic version checks are disabled", async () => { + vi.stubEnv("DIFFEX_SKIP_VERSION_CHECK", "1"); + const fetchMock = vi.fn(async () => Response.json(release)); vi.stubGlobal("fetch", fetchMock); - + await expect(checkForNewDiffexVersion("1.2.3")).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); await expect(getLatestDiffexVersion("1.2.3")).resolves.toBe("1.2.4"); expect(fetchMock).toHaveBeenCalledOnce(); }); diff --git a/packages/coding-agent/test/version-update-e2e.test.ts b/packages/coding-agent/test/version-update-e2e.test.ts index c7ecf1f9c..028b45057 100644 --- a/packages/coding-agent/test/version-update-e2e.test.ts +++ b/packages/coding-agent/test/version-update-e2e.test.ts @@ -9,13 +9,14 @@ const packageDir = resolve(import.meta.dirname, ".."); 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 endpoint = "https://api.diffex.ai/api/latest-version"; interface RecordedEvent { type: string; url?: string; method?: string; userAgent?: string; + redirect?: string; command?: string; args?: string[]; } @@ -28,6 +29,9 @@ async function reproduce(options: { failure?: boolean; packageName?: string; version?: string; + response?: string; + status?: number; + redirect?: string; }) { const root = mkdtempSync(join(tmpdir(), "diffex-version-repro-")); const agentDir = join(root, "agent"); @@ -52,6 +56,9 @@ async function reproduce(options: { DIFFEX_REPRO_RECORD: recordPath, DIFFEX_REPRO_VERSION: options.version ?? (options.force ? installed.version : "9999.0.0"), DIFFEX_REPRO_PACKAGE: options.packageName ?? "@foreign-fixture/untrusted-cli", + DIFFEX_REPRO_RESPONSE: options.response, + DIFFEX_REPRO_STATUS: String(options.status ?? 200), + DIFFEX_REPRO_REDIRECT: options.redirect, DIFFEX_REPRO_FAILURE: options.failure ? "1" : "", DIFFEX_OFFLINE: options.offline ? "1" : "", DIFFEX_SKIP_VERSION_CHECK: options.skip ? "1" : "", @@ -131,6 +138,87 @@ function versionRequests(events: RecordedEvent[]) { // The source CLI must reject untrusted targets before dispatching package commands. describe.skipIf(process.platform === "win32")("version discovery through the source CLI", () => { + it("contacts only the canonical authority with redirects disabled", async () => { + const result = await reproduce({ packageName: "@diffexai/diffex" }); + expect(result.code, result.output).toBe(0); + expect(result.events.filter((event) => event.type === "request")).toEqual([ + { + type: "request", + url: endpoint, + method: "GET", + userAgent: expect.stringMatching(/^diffex\//), + redirect: "manual", + }, + ]); + }); + + it.each([ + { + name: "no published release", + status: 404, + response: '{"error":"no_published_release"}', + requests: 1, + message: "No published Diffex release is available yet.", + }, + { + name: "service unavailable", + status: 503, + response: '{"error":"release_unavailable"}', + requests: 3, + message: "Diffex update service is unavailable.", + }, + { + name: "unrelated not found", + status: 404, + response: '{"error":"not_found"}', + requests: 1, + message: "Diffex update service is unavailable.", + }, + { + name: "invalid JSON", + status: 200, + response: "{", + requests: 1, + message: "Diffex update service is unavailable.", + }, + { + name: "missing identity", + status: 200, + response: '{"version":"9999.0.0"}', + requests: 1, + message: "Refusing self-update", + }, + { + name: "unexpected note", + status: 200, + response: '{"version":"9999.0.0","packageName":"@diffexai/diffex","note":"unverified note"}', + requests: 1, + message: "Refusing self-update", + }, + { + name: "foreign redirect", + status: 302, + response: "{}", + redirect: "https://unreviewed.invalid/latest", + requests: 1, + message: "Diffex update service is unavailable.", + }, + ])("reports $name without package mutation, including force", async ({ requests, message, ...options }) => { + const result = await reproduce({ ...options, force: true }); + expect(result.code, result.output).toBe(1); + expect(result.output).toContain(message); + expect(versionRequests(result.events)).toHaveLength(requests); + expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); + }); + + it.each(["0.0.0", installed.version])("does not install an older or equal release %s", async (version) => { + const result = await reproduce({ packageName: "@diffexai/diffex", version }); + expect(result.code, result.output).toBe(0); + expect(versionRequests(result.events)).toHaveLength(1); + expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); + expect(result.output).toContain("already up to date"); + }); + 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); @@ -167,6 +255,7 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou "1.2.3+build", "v1.2.3", "01.2.3", + "9007199254740992.0.0", ])("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); @@ -193,14 +282,17 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou expect(result.code, result.output).toBe(1); expect(versionRequests(result.events)).toHaveLength(3); expect(result.events.filter((event) => event.type === "spawn")).toEqual([]); - expect(result.output).toContain("Could not determine latest diffex version: controlled network failure"); + expect(result.output).toContain("Diffex update service is unavailable: controlled network failure"); }); it.each([ { name: "foreign response", expectedRequests: 1 }, + { name: "canonical response", packageName: "@diffexai/diffex", expectedRequests: 1 }, { name: "offline", offline: true, expectedRequests: 0 }, { name: "automatic opt-out", skip: true, expectedRequests: 0 }, { name: "network failure", failure: true, expectedRequests: 1 }, + { name: "no release", status: 404, response: '{"error":"no_published_release"}', expectedRequests: 1 }, + { name: "service failure", status: 503, response: '{"error":"release_unavailable"}', expectedRequests: 1 }, ])("interactive startup stays quiet for $name", async ({ expectedRequests, ...options }) => { const result = await reproduce({ ...options, interactive: true }); expect(result.code, result.output).toBe(0); @@ -210,5 +302,8 @@ describe.skipIf(process.platform === "win32")("version discovery through the sou expect(result.output).not.toContain("New version"); expect(result.output).not.toContain("Could not determine latest"); expect(result.output).not.toContain("controlled network failure"); + expect(result.output).not.toContain("update service"); + expect(result.output).not.toContain("No published Diffex release"); + expect(result.output).not.toContain("Refusing self-update"); }); });