diff --git a/.pi/agents/test-driver.md b/.pi/agents/test-driver.md index df2f29d..880cc4b 100644 --- a/.pi/agents/test-driver.md +++ b/.pi/agents/test-driver.md @@ -20,7 +20,7 @@ Procedure: 1. Detect the test command first — check `package.json` scripts (test / test:unit / etc.), then common defaults (`bun test`, `npm test`, `yarn test`, `cargo test`, `go test ./...`, `pytest`). Run it and capture the failures. 2. For each failing test, read the test file and the code it exercises. Distinguish the failure kinds: assertion expectation drift, missing edge case, broken logic, environment/ordering issue, or a test that is simply wrong. Never change a test's expectations to force green without flagging it as a decision. -3. Fix the underlying cause with minimal edits that match the codebase's existing patterns. After each fix, rerun the targeted failing test first (e.g. `bun test ` or `bun test -t `), then the full suite. +3. Fix the underlying cause with minimal edits that match the codebase's existing patterns. After each fix, rerun the targeted failing test first (e.g. `bun run test ` or `bun run test -t `), then the full suite. 4. Keep the loop tight: one failure cluster at a time, don't batch half-understood edits. If a fix doesn't change the outcome, stop guessing and investigate — read the surrounding code and error stack before editing again. Hard rules: diff --git a/AGENTS.md b/AGENTS.md index a0b5a69..9a1e73b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,8 @@ Keep both Pi refresh-context shapes working: - Pi 0.83: mutable `store` read/write API - Pi 0.84: immutable `stored` snapshot plus generation-checked `publish` +On Pi 0.86.1+, Pi's built-in pi.dev `meta` catalog overlay refreshes before this extension through the same `models-store.json` entry. Keep persisted entries marked with `lastModified: 0` and `source: "pi-meta-oauth"`, restore only owned (or legacy unmarked) entries, and pin restored `baseUrl` to `https://api.meta.ai/v1`. `tests/model-runtime.test.ts` drives the real Pi `ModelRuntime` and skips on Pi versions without the built-in provider. + Hermetic OAuth and catalog tests live in `tests/meta.test.ts`. Failure-path and wire-shape tests (exact error messages, polling back-off, request shapes, catalog fallbacks, bundled model table) live in `tests/meta-failures.test.ts`. ## Prompt caching diff --git a/CHANGELOG.md b/CHANGELOG.md index eaa7f99..7b7bf5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Honor login cancellation throughout device authorization, polling, and key minting, and stop polling when the device code expires. - Validate malformed catalog and credential responses and return independent fallback/cache model metadata. - Require Contributor literal `max` opt-in even when the catalog advertises it, and limit the Muse fingerprint to the bare direct endpoint. +- Keep the Meta catalog in `models-store.json` on Pi 0.86.1 and later, where Pi's built-in pi.dev `meta` overlay shares the store entry and refreshes first. The extension persists its catalog with `lastModified: 0` and a `source` marker so the overlay skips pi.dev while the Meta catalog is fresh. It ignores overlay-written entries on restore, republishes its last good catalog after a failed Meta refresh, and always restores `https://api.meta.ai/v1` as the base URL. +- Bound each device-authorization, token-poll, and key-mint request to 30 seconds, and token polls also to the device-code deadline, so a stalled connection fails with a timeout or expiry error instead of hanging login or Pi 0.83 refreshes. +- Report Pi's refresh timeout as `Meta token refresh timed out` instead of a cancellation. +- Accept only http(s) verification and setup URLs from Meta, normalized like Pi's built-in Meta login so control characters cannot reach the terminal. +- Apply the Responses cache hints only when the payload's model is the session's Meta model, so a mid-request model switch cannot rewrite another provider's request. +- Treat an empty or blank `META_API_KEY` or `MODEL_API_KEY` as unset when mirroring the two aliases. +- Point the README install command at the npm package or `main` instead of the stale v0.6.0 `meta-oauth-only` branch. ## [0.7.0] - 2026-09-26 diff --git a/README.md b/README.md index d50a06e..d88a651 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,10 @@ Meta Model API OAuth for [pi](https://pi.dev). ## Install ```bash -# OAuth-only branch -pi install git:github.com/BlockedPath/pi-meta-oauth@meta-oauth-only +pi install npm:pi-meta-oauth + +# Or track main +pi install git:github.com/BlockedPath/pi-meta-oauth # Or from a local checkout pi install /absolute/path/to/pi-meta-oauth @@ -94,6 +96,17 @@ count. Pi writes the cache during interactive or RPC startup, and again after not itself trigger a network catalog refresh. The cached catalog is also used when Pi starts without network access. +On Pi 0.86.1 and later, Pi's built-in pi.dev catalog overlay for `meta` shares +this store entry and refreshes before the extension. The extension persists its +catalog with `lastModified: 0` and `source: "pi-meta-oauth"`, so the overlay +skips pi.dev for 4 hours after a successful Meta refresh. On restore, entries +written by the overlay are ignored in favor of the bundled models, and the base +URL is always `https://api.meta.ai/v1`. If a Meta refresh fails after the +overlay wrote its entry, the extension republishes its last good catalog. If +pi.dev fails while the cached Meta entry is missing, older than 4 hours, or +written by an earlier release, Pi aborts that refresh before the Meta catalog is +fetched. + The bundled fallback uses Meta's nominal `1,048,576`-token context window. A cached Muse Code 0.1.0/R708.1 catalog observed on 2026-08-06 reported a lower effective limit of `1,007,997` for `muse-spark-1.2` and diff --git a/extensions/meta.ts b/extensions/meta.ts index 3812f27..c4e210a 100644 --- a/extensions/meta.ts +++ b/extensions/meta.ts @@ -1,6 +1,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { META_PROVIDER_ID } from "../src/meta/constants.ts"; import { synchronizeMetaApiKeyAliases } from "../src/meta/environment.ts"; +import { asRecord } from "../src/meta/http.ts"; import { createMetaProviderConfig } from "../src/meta/provider.ts"; import { applyMetaModelHeaders, @@ -38,7 +39,14 @@ export default function metaOAuthProvider(pi: ExtensionAPI): void { synchronizeMetaApiKeyAliases(process.env); pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig()); pi.on("before_provider_request", (event, ctx) => { - if (ctx.model?.provider !== META_PROVIDER_ID) return undefined; + // ctx.model is the session's current model, which can change mid-request; + // the Responses payload's own model id binds the hints to this request. + if ( + ctx.model?.provider !== META_PROVIDER_ID || + asRecord(event.payload)?.model !== ctx.model.id + ) { + return undefined; + } return applyMetaResponsesCacheHints(event.payload); }); pi.on("before_provider_headers", (event, ctx) => { diff --git a/src/meta/constants.ts b/src/meta/constants.ts index 7d1030e..fa8f056 100644 --- a/src/meta/constants.ts +++ b/src/meta/constants.ts @@ -12,6 +12,8 @@ export const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`; export const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key"; export const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; export const API_KEY_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** Bound each OAuth or mint request, matching Pi's built-in Meta flow. */ +export const META_REQUEST_TIMEOUT_MS = 30_000; /** * Captured Muse CLI fingerprint. Preserve its bytes, including the platform diff --git a/src/meta/environment.ts b/src/meta/environment.ts index d5a43bb..573176f 100644 --- a/src/meta/environment.ts +++ b/src/meta/environment.ts @@ -1,12 +1,17 @@ import { META_ENV_VAR, MODEL_API_ENV_VAR } from "./constants.ts"; import type { MetaEnv } from "./types.ts"; +/** Pi treats an empty $META_API_KEY as unset, and a blank one is never a usable key. */ +function isMissing(value: string | undefined): boolean { + return !value?.trim(); +} + /** Pi interpolates $META_API_KEY; Muse tooling may use MODEL_API_KEY. */ export function synchronizeMetaApiKeyAliases(env: MetaEnv): void { - if (env[META_ENV_VAR] === undefined && env[MODEL_API_ENV_VAR] !== undefined) { + if (isMissing(env[META_ENV_VAR]) && !isMissing(env[MODEL_API_ENV_VAR])) { env[META_ENV_VAR] = env[MODEL_API_ENV_VAR]; } - if (env[MODEL_API_ENV_VAR] === undefined && env[META_ENV_VAR] !== undefined) { + if (isMissing(env[MODEL_API_ENV_VAR]) && !isMissing(env[META_ENV_VAR])) { env[MODEL_API_ENV_VAR] = env[META_ENV_VAR]; } } diff --git a/src/meta/http.ts b/src/meta/http.ts index a0dc761..3abe3a2 100644 --- a/src/meta/http.ts +++ b/src/meta/http.ts @@ -1,5 +1,11 @@ +import { META_REQUEST_TIMEOUT_MS } from "./constants.ts"; import type { Fetch } from "./types.ts"; +/** A request outlived its own timeout while the caller's signal stayed live. */ +export class RequestTimeoutError extends Error { + override name = "RequestTimeoutError"; +} + /** Keep untrusted JSON as unknown until its fields have been validated. */ export function asRecord(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -27,23 +33,49 @@ export function errorDetail(body: Record): string | undefined { return undefined; } +/** + * Run one request under the caller's signal and its own timeout, so a stalled + * connection cannot hang a login or refresh. Only a timeout the caller did not + * cause becomes a RequestTimeoutError; caller cancellation passes through. + */ +export async function withRequestTimeout( + signal: AbortSignal | undefined, + timeoutMs: number, + request: (signal: AbortSignal) => Promise, +): Promise { + const timeout = AbortSignal.timeout(timeoutMs); + try { + return await request(signal ? AbortSignal.any([signal, timeout]) : timeout); + } catch (error) { + if (timeout.aborted && !signal?.aborted) { + throw new RequestTimeoutError(`Request timed out after ${timeoutMs} ms`, { + cause: error, + }); + } + throw error; + } +} + export async function postForm( url: string, fields: Record, fetchImpl: Fetch, signal?: AbortSignal, + timeoutMs = META_REQUEST_TIMEOUT_MS, ): Promise<{ response: Response; body: Record }> { - const response = await fetchImpl(url, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams(fields), - redirect: "manual", - signal, + return withRequestTimeout(signal, timeoutMs, async (requestSignal) => { + const response = await fetchImpl(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(fields), + redirect: "manual", + signal: requestSignal, + }); + return { response, body: await responseBody(response) }; }); - return { response, body: await responseBody(response) }; } export function delay(milliseconds: number): Promise { diff --git a/src/meta/model-store.ts b/src/meta/model-store.ts index b9f65f9..34c5bd7 100644 --- a/src/meta/model-store.ts +++ b/src/meta/model-store.ts @@ -17,6 +17,14 @@ import { } from "./models.ts"; import type { Fetch, MetaProviderModel } from "./types.ts"; +/** + * Pi >=0.86.1 refreshes its built-in pi.dev `meta` overlay before this extension, through the same + * models-store key, so persisted entries carry a provenance marker. + */ +const STORE_SOURCE = "pi-meta-oauth"; + +type OwnedStoreEntry = ModelsStoreEntry & { source: typeof STORE_SOURCE }; + interface LegacyCatalogStore { read(): Promise; write(entry: ModelsStoreEntry): Promise; @@ -34,19 +42,60 @@ interface CompatibleRefreshContext { }): Promise; } +/** Accept marked entries and unmarked ones from older releases; pi.dev overlays set lastModified whenever they persist a catalog. */ +function isOwnedEntry(value: unknown): boolean { + const entry = asRecord(value); + if (!entry) return false; + if (entry.source === STORE_SOURCE) return true; + return ( + entry.source === undefined && + entry.lastModified === undefined && + entry.etag === undefined + ); +} + async function cachedOrFallbackModels( context: CompatibleRefreshContext, ): Promise { try { const stored = context.stored ?? (await context.store?.read()); - const models = restoreProviderModels(asRecord(stored)?.models); - if (models.length > 0) return models; + if (isOwnedEntry(stored)) { + const models = restoreProviderModels(asRecord(stored)?.models); + if (models.length > 0) return models; + } } catch { // Persistence is best-effort; an unreadable cache must not disable the provider. } return fallbackModels(); } +/** Undo a pi.dev overlay write from this refresh so the last good Meta catalog stays persisted. */ +async function republishOwnedEntry( + context: CompatibleRefreshContext, +): Promise { + const stored = context.stored; + if (context.signal?.aborted || !context.publish || !stored) return; + if (!isOwnedEntry(stored)) return; + // Keep the original checkedAt: this refresh did not validate the catalog. + const entry: OwnedStoreEntry = { + ...stored, + lastModified: 0, + source: STORE_SOURCE, + }; + try { + await context.publish({ persist: entry }); + } catch { + // The restored catalog remains usable when its persistence fails. + } +} + +async function restoreAfterFailedRefresh( + context: CompatibleRefreshContext, +): Promise { + await republishOwnedEntry(context); + return cachedOrFallbackModels(context); +} + function modelsForStore( models: MetaProviderModel[], ): Model<"openai-responses">[] { @@ -65,9 +114,12 @@ async function persistModels( context: CompatibleRefreshContext, models: MetaProviderModel[], ): Promise { - const entry: ModelsStoreEntry = { + const entry: OwnedStoreEntry = { models: modelsForStore(models), checkedAt: Date.now(), + // Pi's overlay reads lastModified 0 as "no pi.dev catalog" and skips pi.dev while this entry is fresh. + lastModified: 0, + source: STORE_SOURCE, }; if (context.publish) { // A false result means this generation was superseded; never bypass that check with a legacy write. @@ -113,7 +165,8 @@ export async function refreshMetaModels( ); } const models = toProviderModels(body); - if (models.length === 0) return cachedOrFallbackModels(compatibleContext); + if (models.length === 0) + return restoreAfterFailedRefresh(compatibleContext); if (!context.signal?.aborted) { try { await persistModels(compatibleContext, models); @@ -125,6 +178,6 @@ export async function refreshMetaModels( } catch (error) { // An in-flight cancellation belongs to the host; do not disguise it as a successful refresh. if (context.signal?.aborted) throw error; - return cachedOrFallbackModels(compatibleContext); + return restoreAfterFailedRefresh(compatibleContext); } } diff --git a/src/meta/models.ts b/src/meta/models.ts index e9d9b46..20fcb3d 100644 --- a/src/meta/models.ts +++ b/src/meta/models.ts @@ -328,7 +328,8 @@ export function restoreProviderModels(value: unknown): MetaProviderModel[] { ...fallback, name: nonemptyString(entry.name) ?? fallback.name, api: "openai-responses", - baseUrl: nonemptyString(entry.baseUrl) ?? META_API_BASE_URL, + // The extension only persists the direct endpoint; a cached URL must never redirect the key. + baseUrl: META_API_BASE_URL, reasoning: typeof entry.reasoning === "boolean" ? entry.reasoning diff --git a/src/meta/oauth.ts b/src/meta/oauth.ts index c6ebf35..c0a8639 100644 --- a/src/meta/oauth.ts +++ b/src/meta/oauth.ts @@ -10,8 +10,17 @@ import { DEVICE_TOKEN_URL, META_API_VERSION, META_CLIENT_ID, + META_REQUEST_TIMEOUT_MS, } from "./constants.ts"; -import { delay, errorDetail, postForm, responseBody } from "./http.ts"; +import { + asRecord, + delay, + errorDetail, + postForm, + RequestTimeoutError, + responseBody, + withRequestTimeout, +} from "./http.ts"; import type { Fetch, Sleep } from "./types.ts"; type Clock = () => number; @@ -41,11 +50,38 @@ function positiveSeconds(value: unknown, fallback: number): number { : fallback; } +/** + * Pi writes server-supplied URLs to the terminal, the device URI as a clickable + * link. Mirror its built-in Meta flow: http(s) only, re-serialized through + * URL so control characters arrive percent-encoded. + */ +function trustedHttpUrl(value: unknown): string | undefined { + if (!isNonBlankString(value)) return undefined; + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" + ? url.href + : undefined; + } catch { + return undefined; + } +} + function failure(message: string, body: Record): Error { const detail = errorDetail(body); return new Error(`${message}${detail ? `: ${detail}` : ""}`); } +/** Say which request timed out; every other failure passes through. */ +function timeoutAs(message: string): (error: unknown) => never { + return (error) => { + if (error instanceof RequestTimeoutError) { + throw new Error(message, { cause: error }); + } + throw error; + }; +} + function checkLoginCancellation(signal?: AbortSignal): void { if (signal?.aborted) throw new Error("Meta login was cancelled"); } @@ -102,7 +138,7 @@ async function requestDeviceAuthorization( { client_id: META_CLIENT_ID }, fetchImpl, signal, - ); + ).catch(timeoutAs("Meta login request timed out")); checkLoginCancellation(signal); if (!response.ok) { throw failure( @@ -110,10 +146,13 @@ async function requestDeviceAuthorization( body, ); } + const verificationUri = + trustedHttpUrl(body.verification_uri_complete) ?? + trustedHttpUrl(body.verification_uri); if ( !isNonBlankString(body.device_code) || !isNonBlankString(body.user_code) || - !isNonBlankString(body.verification_uri) + verificationUri === undefined ) { throw new Error( "Meta device authorization returned an incomplete response", @@ -123,9 +162,7 @@ async function requestDeviceAuthorization( return { deviceCode: body.device_code, userCode: body.user_code, - verificationUri: isNonBlankString(body.verification_uri_complete) - ? body.verification_uri_complete - : body.verification_uri, + verificationUri, intervalSeconds: positiveSeconds( body.interval, DEFAULT_POLL_INTERVAL_SECONDS, @@ -157,8 +194,11 @@ async function pollIdentityToken( ); checkLoginCancellation(signal); // A device code can expire while waiting, especially after slow_down. - if (now() >= deadline) break; + const pollMilliseconds = deadline - now(); + if (pollMilliseconds <= 0) break; + // Cap a stalled poll at the deadline, where its timeout means expiry. + const expiresFirst = pollMilliseconds <= META_REQUEST_TIMEOUT_MS; const { response, body } = await postForm( DEVICE_TOKEN_URL, { @@ -168,6 +208,13 @@ async function pollIdentityToken( }, fetchImpl, signal, + expiresFirst ? Math.max(1, pollMilliseconds) : META_REQUEST_TIMEOUT_MS, + ).catch( + timeoutAs( + expiresFirst + ? "Meta login request expired" + : "Meta login request timed out", + ), ); checkLoginCancellation(signal); if (response.ok && isNonBlankString(body.access_token)) { @@ -194,20 +241,27 @@ export async function mintMetaApiKey( identityToken: string, fetchImpl: Fetch = globalThis.fetch, signal?: AbortSignal, + timeoutMs = META_REQUEST_TIMEOUT_MS, ): Promise { signal?.throwIfAborted(); - const response = await fetchImpl(API_KEY_MINT_URL, { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${identityToken}`, - "Content-Type": "application/json", - "x-api-version": META_API_VERSION, - }, - body: "{}", + const { response, body } = await withRequestTimeout( signal, - }); - const body = await responseBody(response); + timeoutMs, + async (requestSignal) => { + const response = await fetchImpl(API_KEY_MINT_URL, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${identityToken}`, + "Content-Type": "application/json", + "x-api-version": META_API_VERSION, + }, + body: "{}", + signal: requestSignal, + }); + return { response, body: await responseBody(response) }; + }, + ).catch(timeoutAs("Meta API-key mint timed out")); signal?.throwIfAborted(); if (!response.ok) { const message = @@ -217,9 +271,8 @@ export async function mintMetaApiKey( throw failure(message, body); } if (!isNonBlankString(body.api_key)) { - const setup = isNonBlankString(body.action_url) - ? ` Complete setup at ${body.action_url}.` - : ""; + const actionUrl = trustedHttpUrl(body.action_url); + const setup = actionUrl ? ` Complete setup at ${actionUrl}.` : ""; throw new Error(`Meta did not issue an API key.${setup}`); } return body.api_key; @@ -273,6 +326,16 @@ export async function loginMeta( } } +/** Pi 0.84+ also aborts refresh on its own timeout, which is not a cancel. */ +function refreshAborted(signal: AbortSignal, options?: ErrorOptions): Error { + return new Error( + asRecord(signal.reason)?.name === "TimeoutError" + ? "Meta token refresh timed out" + : "Meta token refresh was cancelled", + options, + ); +} + export async function refreshMetaToken( credentials: OAuthCredentials, fetchOrSignal: Fetch | AbortSignal = globalThis.fetch, @@ -288,21 +351,15 @@ export async function refreshMetaToken( typeof fetchOrSignal === "function" ? fetchOrSignal : globalThis.fetch; const signal = typeof fetchOrSignal === "function" ? undefined : fetchOrSignal; - if (signal?.aborted) { - throw new Error("Meta token refresh was cancelled"); - } + if (signal?.aborted) throw refreshAborted(signal); let apiKey: string; try { apiKey = await mintMetaApiKey(credentials.refresh, fetchImpl, signal); } catch (error) { - if (signal?.aborted) { - throw new Error("Meta token refresh was cancelled", { cause: error }); - } + if (signal?.aborted) throw refreshAborted(signal, { cause: error }); throw error; } - if (signal?.aborted) { - throw new Error("Meta token refresh was cancelled"); - } + if (signal?.aborted) throw refreshAborted(signal); return { ...credentials, access: apiKey, diff --git a/tests/environment.test.ts b/tests/environment.test.ts index fdaa303..d2b1cc2 100644 --- a/tests/environment.test.ts +++ b/tests/environment.test.ts @@ -13,9 +13,26 @@ test.each([ { META_API_KEY: "meta", MODEL_API_KEY: "model" }, { META_API_KEY: "meta", MODEL_API_KEY: "model" }, ], + // Pi treats an empty $META_API_KEY as unset, and a blank one is never a usable key. [ { META_API_KEY: "", MODEL_API_KEY: "model" }, - { META_API_KEY: "", MODEL_API_KEY: "model" }, + { META_API_KEY: "model", MODEL_API_KEY: "model" }, + ], + [ + { META_API_KEY: " ", MODEL_API_KEY: "model" }, + { META_API_KEY: "model", MODEL_API_KEY: "model" }, + ], + [ + { META_API_KEY: "meta", MODEL_API_KEY: "" }, + { META_API_KEY: "meta", MODEL_API_KEY: "meta" }, + ], + [ + { META_API_KEY: "", MODEL_API_KEY: "" }, + { META_API_KEY: "", MODEL_API_KEY: "" }, + ], + [ + { META_API_KEY: " ", MODEL_API_KEY: " " }, + { META_API_KEY: " ", MODEL_API_KEY: " " }, ], ])("synchronizes only missing API key aliases (%j)", (initial, expected) => { const env: MetaEnv = { ...initial }; diff --git a/tests/http.test.ts b/tests/http.test.ts index abb3b36..7af893f 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,7 +1,19 @@ import { expect, test } from "bun:test"; -import { errorDetail, postForm, responseBody } from "../src/meta/http.ts"; +import { + errorDetail, + postForm, + RequestTimeoutError, + responseBody, +} from "../src/meta/http.ts"; import type { Fetch } from "../src/meta/types.ts"; +/** A transport that never answers and rejects only when its signal aborts. */ +const stalledFetch: Fetch = (_url, init) => + new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener("abort", () => reject(signal.reason)); + }); + test.each(["", "not JSON", "null", "[]", "42", '"string"'])( "handles non-object JSON response %j", async (body) => { @@ -48,3 +60,32 @@ test("device form transport URL-encodes fields and disables redirects", async () ); expect(body).toEqual({ access_token: "identity" }); }); + +test("device form transport times out a stalled request", async () => { + for (const signal of [undefined, new AbortController().signal]) { + const error = await postForm( + "https://auth.meta.com/test", + {}, + stalledFetch, + signal, + 20, + ).catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(RequestTimeoutError); + expect((error as Error).cause).toMatchObject({ name: "TimeoutError" }); + } +}); + +test("device form transport leaves caller cancellation to the caller", async () => { + const controller = new AbortController(); + const pending = postForm( + "https://auth.meta.com/test", + {}, + stalledFetch, + controller.signal, + 1_000, + ); + controller.abort(); + const error = await pending.catch((reason: unknown) => reason); + expect(error).not.toBeInstanceOf(RequestTimeoutError); + expect(error).toBe(controller.signal.reason); +}); diff --git a/tests/meta-cache.test.ts b/tests/meta-cache.test.ts index c778fe4..d637c37 100644 --- a/tests/meta-cache.test.ts +++ b/tests/meta-cache.test.ts @@ -300,7 +300,7 @@ describe("Meta Responses cache and reasoning contracts", () => { test("registers a Meta-only before_provider_request hook that applies the hints", async () => { type RequestHandler = ( event: { payload: unknown }, - ctx: { model?: { provider: string } }, + ctx: { model?: { provider: string; id: string } }, ) => unknown; let handler: RequestHandler | undefined; metaOAuthProvider({ @@ -315,18 +315,30 @@ describe("Meta Responses cache and reasoning contracts", () => { const other = handler?.( { payload: { model: "gpt" } }, - { model: { provider: "openai" } }, + { model: { provider: "openai", id: "gpt" } }, ); expect(other).toBeUndefined(); const meta = handler?.( - { payload: { model: "muse-spark-1.2" } }, - { model: { provider: META_PROVIDER_ID } }, + { payload: { model: "muse-spark-1.2", reasoning: { effort: "none" } } }, + { model: { provider: META_PROVIDER_ID, id: "muse-spark-1.2" } }, ); - expect(meta).toMatchObject({ + expect(meta).toEqual({ model: "muse-spark-1.2", prompt_cache_retention: "24h", }); + + // ctx.model is the session's current model: after a mid-request switch to + // Meta, another model's in-flight payload must pass through untouched. + const payload = { model: "gpt-5", reasoning: { effort: "none" } }; + const switched = handler?.( + { payload }, + { + model: { provider: META_PROVIDER_ID, id: "muse-spark-1.3-contributor" }, + }, + ); + expect(switched).toBeUndefined(); + expect(payload).toEqual({ model: "gpt-5", reasoning: { effort: "none" } }); }); }); diff --git a/tests/meta-failures.test.ts b/tests/meta-failures.test.ts index e725056..9458a5e 100644 --- a/tests/meta-failures.test.ts +++ b/tests/meta-failures.test.ts @@ -1,5 +1,5 @@ /// -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import type { ModelsStoreEntry, OAuthLoginCallbacks, @@ -16,6 +16,7 @@ import metaOAuthProvider, { refreshMetaModels, refreshMetaToken, } from "../extensions/meta.ts"; +import { META_REQUEST_TIMEOUT_MS } from "../src/meta/constants.ts"; import { withMuseUserAgent } from "./muse-env.ts"; // Characterization tests for the failure, fallback, and wire-shape paths of @@ -44,6 +45,24 @@ function scriptedFetch(...responses: Array) { return { fetchMock, requests }; } +/** Answer with the scripted responses, then stall until the request aborts. */ +function stallingFetch(...responses: Response[]) { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + requests.push({ url: String(input), init }); + const next = responses.shift(); + if (next) return next; + return new Promise((_resolve, reject) => { + const signal = init?.signal; + signal?.addEventListener("abort", () => reject(signal.reason)); + }); + }) as unknown as typeof fetch; + return { fetchMock, requests }; +} + async function rejectionMessage(promise: Promise): Promise { try { await promise; @@ -170,6 +189,64 @@ describe("Meta device login failures", () => { ).toBe("Meta device authorization returned an incomplete response"); }); + async function forwardedVerificationUri( + extra: Record, + ): Promise { + const deviceCodes: Array<{ verificationUri?: unknown }> = []; + const { fetchMock } = scriptedFetch( + deviceAuthorization(extra), + jsonResponse({ access_token: "identity-token" }), + jsonResponse({ api_key: "model-api-key" }), + ); + await loginMeta(loginCallbacks({ deviceCodes }), fetchMock, noSleep); + return deviceCodes[0]?.verificationUri; + } + + for (const complete of [ + "javascript:alert(1)", + "file:///etc/passwd", + "not a URL", + ]) { + test(`falls back to verification_uri when the complete URI is ${complete}`, async () => { + expect( + await forwardedVerificationUri({ + verification_uri_complete: complete, + }), + ).toBe("https://auth.meta.com/device"); + }); + } + + for (const uri of ["javascript:alert(1)", "file:///etc/passwd"]) { + test(`rejects a ${uri} verification URI without a trusted fallback`, async () => { + const { fetchMock, requests } = scriptedFetch( + deviceAuthorization({ verification_uri: uri }), + ); + expect( + await rejectionMessage(loginMeta(loginCallbacks(), fetchMock, noSleep)), + ).toBe("Meta device authorization returned an incomplete response"); + expect(requests).toHaveLength(1); + }); + } + + test("percent-encodes control characters before Pi renders the link", async () => { + expect( + await forwardedVerificationUri({ + verification_uri_complete: + "https://auth.meta.com/device\x07\x1b]52;c;AAAA\x07\x1b[2J", + }), + ).toBe("https://auth.meta.com/device%07%1B]52;c;AAAA%07%1B[2J"); + }); + + test("accepts a trusted complete URI without verification_uri", async () => { + expect( + await forwardedVerificationUri({ + verification_uri: undefined, + verification_uri_complete: + "https://auth.meta.com/device?code=ABCD-1234", + }), + ).toBe("https://auth.meta.com/device?code=ABCD-1234"); + }); + test("expires when approval never arrives before the deadline", async () => { const pending = () => jsonResponse({ error: "authorization_pending" }, 400); const { fetchMock } = scriptedFetch( @@ -374,6 +451,28 @@ describe("Meta API-key minting failures", () => { ); }); + for (const actionUrl of ["javascript:x", "file:///etc/passwd", "billing"]) { + test(`omits an untrusted setup link (${actionUrl})`, async () => { + const { fetchMock } = scriptedFetch( + jsonResponse({ action_url: actionUrl }), + ); + expect( + await rejectionMessage(mintMetaApiKey("identity-token", fetchMock)), + ).toBe("Meta did not issue an API key."); + }); + } + + test("percent-encodes control characters in the setup link", async () => { + const { fetchMock } = scriptedFetch( + jsonResponse({ action_url: "https://dev.meta.ai/billing\x1b[2J" }), + ); + expect( + await rejectionMessage(mintMetaApiKey("identity-token", fetchMock)), + ).toBe( + "Meta did not issue an API key. Complete setup at https://dev.meta.ai/billing%1B[2J.", + ); + }); + test("requires an identity token to refresh", async () => { expect( await rejectionMessage( @@ -382,13 +481,17 @@ describe("Meta API-key minting failures", () => { ).toBe("Meta login is missing its identity token; run /login meta again"); }); - test("forwards the AbortSignal to the mint request", async () => { - const signal = new AbortController().signal; + test("ties the mint request to the caller's AbortSignal", async () => { + const controller = new AbortController(); const { fetchMock, requests } = scriptedFetch( jsonResponse({ api_key: "key" }), ); - await mintMetaApiKey("identity-token", fetchMock, signal); - expect(requests[0]?.init?.signal).toBe(signal); + await mintMetaApiKey("identity-token", fetchMock, controller.signal); + const signal = requests[0]?.init?.signal; + expect(signal?.aborted).toBe(false); + controller.abort(); + expect(signal?.aborted).toBe(true); + expect(signal?.reason).toBe(controller.signal.reason); }); test("keeps other credential fields and extends expiry on refresh", async () => { @@ -415,6 +518,127 @@ describe("Meta API-key minting failures", () => { }); }); +describe("Meta request timeouts", () => { + const credentials = { refresh: "identity-token", access: "old", expires: 0 }; + + /** Shrink every request timeout to 20ms and record the requested length. */ + async function withShortTimeouts( + run: (requested: number[]) => Promise, + ): Promise { + const timeout = AbortSignal.timeout.bind(AbortSignal); + const requested: number[] = []; + const spy = spyOn(AbortSignal, "timeout").mockImplementation( + (milliseconds: number) => { + requested.push(milliseconds); + return timeout(20); + }, + ); + try { + await run(requested); + } finally { + spy.mockRestore(); + } + } + + async function withStalledGlobalFetch(run: () => Promise) { + const { fetchMock } = stallingFetch(); + const spy = spyOn(globalThis, "fetch").mockImplementation(fetchMock); + try { + await run(); + } finally { + spy.mockRestore(); + } + } + + test("expires a stalled token poll at the device-code deadline", async () => { + const { fetchMock, requests } = stallingFetch( + deviceAuthorization({ interval: 0.05, expires_in: 0.2 }), + ); + const started = Date.now(); + expect( + await rejectionMessage(loginMeta(loginCallbacks(), fetchMock, noSleep)), + ).toBe("Meta login request expired"); + expect(Date.now() - started).toBeLessThan(1000); + expect(requests).toHaveLength(2); + }); + + test("times out a stalled device authorization after 30 seconds", async () => { + await withShortTimeouts(async (requested) => { + const { fetchMock } = stallingFetch(); + expect( + await rejectionMessage(loginMeta(loginCallbacks(), fetchMock, noSleep)), + ).toBe("Meta login request timed out"); + expect(requested).toEqual([META_REQUEST_TIMEOUT_MS]); + }); + }); + + test("times out a stalled token poll well before the deadline", async () => { + await withShortTimeouts(async (requested) => { + const { fetchMock, requests } = stallingFetch(deviceAuthorization()); + expect( + await rejectionMessage(loginMeta(loginCallbacks(), fetchMock, noSleep)), + ).toBe("Meta login request timed out"); + expect(requested).toEqual([ + META_REQUEST_TIMEOUT_MS, + META_REQUEST_TIMEOUT_MS, + ]); + expect(requests).toHaveLength(2); + }); + }); + + for (const signal of [undefined, new AbortController().signal]) { + test(`times out a stalled mint ${signal ? "with" : "without"} a caller signal`, async () => { + const { fetchMock } = stallingFetch(); + expect( + await rejectionMessage( + mintMetaApiKey("identity-token", fetchMock, signal, 20), + ), + ).toBe("Meta API-key mint timed out"); + }); + } + + test("bounds a Pi 0.83 refresh, which passes no signal", async () => { + await withShortTimeouts(async (requested) => { + await withStalledGlobalFetch(async () => { + expect(await rejectionMessage(refreshMetaToken(credentials))).toBe( + "Meta API-key mint timed out", + ); + }); + expect(requested).toEqual([META_REQUEST_TIMEOUT_MS]); + }); + }); + + test("reports Pi's refresh timeout as a timeout, not a cancellation", async () => { + await withStalledGlobalFetch(async () => { + expect( + await rejectionMessage( + refreshMetaToken(credentials, AbortSignal.timeout(20)), + ), + ).toBe("Meta token refresh timed out"); + }); + }); + + test("still reports a caller abort during a stalled request as cancelled", async () => { + await withStalledGlobalFetch(async () => { + const controller = new AbortController(); + const refresh = refreshMetaToken(credentials, controller.signal); + setTimeout(() => controller.abort(), 10); + expect(await rejectionMessage(refresh)).toBe( + "Meta token refresh was cancelled", + ); + }); + const controller = new AbortController(); + const { fetchMock } = stallingFetch(deviceAuthorization()); + const login = loginMeta( + { ...loginCallbacks(), signal: controller.signal } as OAuthLoginCallbacks, + fetchMock, + noSleep, + ); + setTimeout(() => controller.abort(), 10); + expect(await rejectionMessage(login)).toBe("Meta login was cancelled"); + }); +}); + describe("Meta catalog refresh fallbacks", () => { function context( overrides: Record = {}, @@ -729,10 +953,11 @@ describe("Meta extension entry point", () => { register, ), ).toEqual({ META_API_KEY: "meta", MODEL_API_KEY: "model" }); + // Pi resolves $META_API_KEY with `||`, so an empty value is not set. expect( await withKeys({ META_API_KEY: "", MODEL_API_KEY: "model" }, register), ).toEqual({ - META_API_KEY: "", + META_API_KEY: "model", MODEL_API_KEY: "model", }); }); diff --git a/tests/model-runtime.test.ts b/tests/model-runtime.test.ts new file mode 100644 index 0000000..29c8ee7 --- /dev/null +++ b/tests/model-runtime.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + type CredentialStore, + InMemoryModelsStore, + type ModelsStore, +} from "@earendil-works/pi-ai"; +import { + META_API_BASE_URL, + META_MODEL_CATALOG_URL, + META_PROVIDER_ID, +} from "../src/meta/constants.ts"; +import { createMetaProviderConfig } from "../src/meta/provider.ts"; + +const CATALOG_BASE_URL = "https://pidev.invalid"; +const META_ONLY_MODEL = "muse-spark-meta-only"; +const PIDEV_ONLY_MODEL = "muse-spark-pidev-only"; +const NETWORK_REFRESH = { + allowNetwork: true, + providers: [META_PROVIDER_ID], +} as const; + +/** The ModelRuntime surface these tests drive, typed locally so every CI Pi version typechecks. */ +interface PiModelRuntime { + getModels(providerId: string): readonly { id: string; baseUrl: string }[]; + registerProvider(providerId: string, config: unknown): void; + refresh(options: { + allowNetwork: boolean; + force?: boolean; + providers: readonly string[]; + }): Promise<{ errors: ReadonlyMap }>; +} +interface PiModelRuntimeClass { + create(options: Record): Promise; +} + +function credentials(): CredentialStore { + const credential = { + type: "oauth" as const, + refresh: "identity-token", + access: "model-api-key", + expires: Date.now() + 3_600_000, + }; + return { + read: async (providerId: string) => + providerId === META_PROVIDER_ID ? credential : undefined, + list: async () => [{ providerId: META_PROVIDER_ID, type: "oauth" }], + modify: async () => credential, + delete: async () => {}, + }; +} + +function runtimeOptions(modelsStore: ModelsStore): Record { + return { + credentials: credentials(), + modelsPath: null, + modelsStore, + catalogBaseUrl: CATALOG_BASE_URL, + refreshOnCreate: false, + }; +} + +/** Only Pi >=0.87 composes this extension over a built-in `meta` provider with a pi.dev overlay. */ +async function loadModelRuntime(): Promise { + // A non-literal specifier keeps older Pi declarations, which lack this module, typechecking. + const builtinMetaProvider = "@earendil-works/pi-ai/providers/meta"; + try { + await import(builtinMetaProvider); + } catch { + return undefined; + } + // Imported only here: Pi 0.85.0's entrypoint cannot load without an undeclared dependency. + const agent: Record = await import( + "@earendil-works/pi-coding-agent" + ); + return agent.ModelRuntime as PiModelRuntimeClass; +} + +const ModelRuntime = await loadModelRuntime(); +const runtimeTest = ModelRuntime ? test : test.skip; + +async function metaRuntime(modelsStore: ModelsStore): Promise { + if (!ModelRuntime) throw new Error("Pi ModelRuntime is unavailable"); + const runtime = await ModelRuntime.create(runtimeOptions(modelsStore)); + runtime.registerProvider(META_PROVIDER_ID, createMetaProviderConfig()); + return runtime; +} + +function jsonResponse( + body: unknown, + status = 200, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); +} + +function pidevCatalog(): Response { + return jsonResponse( + { + models: [ + { + id: PIDEV_ONLY_MODEL, + name: "pi.dev only", + api: "openai-responses", + baseUrl: META_API_BASE_URL, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 131_072, + }, + ], + }, + 200, + { etag: '"pidev"', "last-modified": new Date().toUTCString() }, + ); +} + +function metaCatalog(): Response { + return jsonResponse({ + data: [{ id: "muse-spark-1.3" }, { id: META_ONLY_MODEL }], + }); +} + +interface CatalogRoutes { + pidev: () => Response; + meta: () => Response; +} + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Route pi.dev and Meta catalog requests to `routes`, counting each host's requests. */ +function stubCatalogs(routes: CatalogRoutes) { + const calls = { pidev: 0, meta: 0, unexpected: [] as string[] }; + globalThis.fetch = Object.assign( + async (input: Parameters[0]) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith(`${CATALOG_BASE_URL}/`)) { + calls.pidev++; + return routes.pidev(); + } + if (url === META_MODEL_CATALOG_URL) { + calls.meta++; + return routes.meta(); + } + calls.unexpected.push(url); + throw new Error(`Unexpected request: ${url}`); + }, + { preconnect: originalFetch.preconnect }, + ); + return calls; +} + +describe("Meta catalog refresh inside Pi's ModelRuntime", () => { + runtimeTest( + "a persisted Meta catalog keeps pi.dev inside its freshness window", + async () => { + const calls = stubCatalogs({ pidev: pidevCatalog, meta: metaCatalog }); + const store = new InMemoryModelsStore(); + const runtime = await metaRuntime(store); + for (let refresh = 0; refresh < 2; refresh++) { + const result = await runtime.refresh(NETWORK_REFRESH); + expect(result.errors.get(META_PROVIDER_ID)).toBeUndefined(); + } + expect(calls).toEqual({ pidev: 1, meta: 2, unexpected: [] }); + const entry = await store.read(META_PROVIDER_ID); + expect(entry).toMatchObject({ lastModified: 0, source: "pi-meta-oauth" }); + expect(entry?.etag).toBeUndefined(); + }, + ); + + runtimeTest( + "a pi.dev outage does not skip the Meta catalog refresh while the Meta entry is fresh", + async () => { + const routes: CatalogRoutes = { pidev: pidevCatalog, meta: metaCatalog }; + const calls = stubCatalogs(routes); + const runtime = await metaRuntime(new InMemoryModelsStore()); + await runtime.refresh(NETWORK_REFRESH); + routes.pidev = () => { + throw new TypeError("pi.dev blocked"); + }; + const result = await runtime.refresh(NETWORK_REFRESH); + expect(result.errors.get(META_PROVIDER_ID)).toBeUndefined(); + expect(calls).toEqual({ pidev: 1, meta: 2, unexpected: [] }); + }, + ); + + runtimeTest( + "a failed Meta refresh keeps the last Meta catalog over pi.dev's overlay for offline restores", + async () => { + const routes: CatalogRoutes = { pidev: pidevCatalog, meta: metaCatalog }; + const calls = stubCatalogs(routes); + const store = new InMemoryModelsStore(); + const runtime = await metaRuntime(store); + await runtime.refresh(NETWORK_REFRESH); + routes.meta = () => jsonResponse({ message: "unavailable" }, 502); + // Forcing the overlay makes it rewrite the shared `meta` entry before the Meta refresh fails. + const failed = await runtime.refresh({ ...NETWORK_REFRESH, force: true }); + expect(failed.errors.get(META_PROVIDER_ID)).toBeUndefined(); + expect(calls).toEqual({ pidev: 2, meta: 2, unexpected: [] }); + + const restarted = await metaRuntime(store); + await restarted.refresh({ + allowNetwork: false, + providers: [META_PROVIDER_ID], + }); + const models = restarted.getModels(META_PROVIDER_ID); + expect(models.map(({ id }) => id)).toEqual([ + "muse-spark-1.3", + META_ONLY_MODEL, + ]); + expect(models.every(({ baseUrl }) => baseUrl === META_API_BASE_URL)).toBe( + true, + ); + }, + ); +}); diff --git a/tests/model-store.test.ts b/tests/model-store.test.ts index aa09a6b..698ffc3 100644 --- a/tests/model-store.test.ts +++ b/tests/model-store.test.ts @@ -47,6 +47,30 @@ function cachedCatalog(id = "muse-cached"): ModelsStoreEntry { }; } +const STORE_SOURCE = "pi-meta-oauth"; + +/** The marked shape this extension persists since sharing `meta` with Pi's pi.dev overlay. */ +function ownedCatalog( + id = "muse-cached", +): ModelsStoreEntry & { source: string } { + return { + ...cachedCatalog(id), + checkedAt: 1234, + lastModified: 0, + source: STORE_SOURCE, + }; +} + +/** The shape Pi's pi.dev overlay writes to the same `meta` store key. */ +function overlayCatalog(): ModelsStoreEntry { + return { + ...cachedCatalog("muse-pidev"), + checkedAt: Date.now(), + lastModified: Date.now(), + etag: '"pidev"', + }; +} + function fetchMock( handler: (...args: Parameters) => Promise, ): Fetch { @@ -123,6 +147,8 @@ describe("Meta catalog persistence compatibility", () => { baseUrl: META_API_BASE_URL, }); expect(persisted?.checkedAt).toBeGreaterThan(0); + expect(persisted).toMatchObject({ lastModified: 0, source: STORE_SOURCE }); + expect(persisted).not.toHaveProperty("etag"); stored.cost.input = 42; stored.input.length = 0; if (stored.thinkingLevelMap) stored.thinkingLevelMap.high = "changed"; @@ -134,6 +160,28 @@ describe("Meta catalog persistence compatibility", () => { expect(fresh.compat).toMatchObject({ supportsToolSearch: true }); }); + test("publishes a marked entry that Pi's pi.dev overlay treats as fresh", async () => { + let persisted: ModelsStoreEntry | null | undefined; + const before = Date.now(); + await refreshMetaModels( + refreshContext({ + publish: async (publication: { persist?: ModelsStoreEntry | null }) => { + persisted = publication.persist; + return true; + }, + }), + catalogFetch(), + ); + expect(Object.keys(persisted ?? {}).sort()).toEqual([ + "checkedAt", + "lastModified", + "models", + "source", + ]); + expect(persisted).toMatchObject({ lastModified: 0, source: STORE_SOURCE }); + expect(persisted?.checkedAt).toBeGreaterThanOrEqual(before); + }); + test("prefers generation-checked publish and never falls through to a legacy write after rejection", async () => { let published = 0; let writes = 0; @@ -220,6 +268,65 @@ describe("Meta catalog persistence compatibility", () => { }); } + for (const [name, stored] of [ + ["marked", ownedCatalog()], + ["legacy unmarked", cachedCatalog()], + ] as const) { + for (const api of ["stored", "store"] as const) { + test(`restores a ${name} entry from the ${api} API`, async () => { + const context = refreshContext( + api === "stored" + ? { allowNetwork: false, stored } + : { allowNetwork: false, store: { read: async () => stored } }, + ); + expect( + (await refreshMetaModels(context, unavailableFetch))[0]?.id, + ).toBe("muse-cached"); + }); + } + } + + for (const [name, stored] of [ + ["pi.dev overlay", overlayCatalog()], + ["etag-only", { ...cachedCatalog(), etag: '"pidev"' }], + ["nonzero lastModified", { ...cachedCatalog(), lastModified: 1 }], + ["unmarked lastModified 0", { ...cachedCatalog(), lastModified: 0 }], + ["foreign source", { ...cachedCatalog(), source: "pi.dev" }], + ] as const) { + for (const api of ["stored", "store"] as const) { + test(`ignores a ${name} entry from the ${api} API`, async () => { + const context = refreshContext( + api === "stored" + ? { allowNetwork: false, stored } + : { allowNetwork: false, store: { read: async () => stored } }, + ); + expect(await refreshMetaModels(context, unavailableFetch)).toEqual( + fallbackModels(), + ); + }); + } + } + + test("never restores a cached baseUrl other than the direct Meta endpoint", async () => { + for (const baseUrl of [ + "https://evil.example/v1", + "http://api.meta.ai/v1", + ]) { + const [model] = cachedCatalog().models; + if (!model) throw new Error("Expected cached model"); + const models = await refreshMetaModels( + refreshContext({ + allowNetwork: false, + stored: { ...ownedCatalog(), models: [{ ...model, baseUrl }] }, + }), + unavailableFetch, + ); + expect(models.map((restored) => restored.baseUrl)).toEqual([ + META_API_BASE_URL, + ]); + } + }); + test("cache read failures and malformed snapshots retain bundled fallbacks", async () => { for (const fields of [ { stored: { models: null } }, @@ -267,22 +374,110 @@ describe("Meta catalog persistence compatibility", () => { }), ], ] as const) { - test(`retains the cache after ${name} catalogs without persisting`, async () => { + test(`republishes the owned cache after ${name} catalogs`, async () => { + for (const stored of [ + ownedCatalog(), + { ...cachedCatalog(), checkedAt: 1234 }, + ]) { + const published: unknown[] = []; + const context = refreshContext({ + stored, + publish: async (publication: { + persist?: ModelsStoreEntry | null; + }) => { + published.push(publication.persist); + return true; + }, + }); + expect((await refreshMetaModels(context, fetchImpl))[0]?.id).toBe( + "muse-cached", + ); + // Pi's pi.dev overlay may have replaced the shared entry earlier in this refresh. + expect(published).toEqual([ + { + ...stored, + checkedAt: 1234, + lastModified: 0, + source: STORE_SOURCE, + }, + ]); + } + }); + + test(`does not republish a pi.dev overlay entry after ${name} catalogs`, async () => { let publishes = 0; const context = refreshContext({ - stored: cachedCatalog(), + stored: overlayCatalog(), publish: async () => { publishes++; return true; }, }); + expect(await refreshMetaModels(context, fetchImpl)).toEqual( + fallbackModels(), + ); + expect(publishes).toBe(0); + }); + + test(`never writes the Pi 0.83 legacy store after ${name} catalogs`, async () => { + let writes = 0; + const context = refreshContext({ + store: { + read: async () => ownedCatalog(), + write: async () => { + writes++; + }, + }, + }); expect((await refreshMetaModels(context, fetchImpl))[0]?.id).toBe( "muse-cached", ); - expect(publishes).toBe(0); + expect(writes).toBe(0); }); } + test("keeps the restored cache when republishing it fails", async () => { + const context = refreshContext({ + stored: ownedCatalog(), + publish: async () => { + throw new Error("Persistence unavailable"); + }, + }); + expect( + ( + await refreshMetaModels( + context, + fetchMock(async () => jsonResponse({ data: [] })), + ) + )[0]?.id, + ).toBe("muse-cached"); + }); + + test("does not republish when an empty catalog completes after cancellation", async () => { + const controller = new AbortController(); + let publishes = 0; + const context = refreshContext({ + signal: controller.signal, + stored: ownedCatalog(), + publish: async () => { + publishes++; + return true; + }, + }); + expect( + ( + await refreshMetaModels( + context, + fetchMock(async () => { + controller.abort(); + return jsonResponse({ data: [] }); + }), + ) + )[0]?.id, + ).toBe("muse-cached"); + expect(publishes).toBe(0); + }); + test("restoring a snapshot re-gates Contributor max after the opt-in changes", async () => { const cached = await withMuseUserAgent("1", () => cachedCatalog("muse-spark-1.3-contributor"), diff --git a/tests/models.test.ts b/tests/models.test.ts index 5b4c8b2..6eee03b 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -255,6 +255,22 @@ describe("Meta cached model decoding", () => { expect(restoreProviderModels(value)).toEqual([]); }); + test("pins restored models to the direct Meta endpoint whatever baseUrl was cached", () => { + const valid = { + ...requiredModel(fallbackModels()), + provider: META_PROVIDER_ID, + api: "openai-responses", + }; + const models = restoreProviderModels([ + { ...valid, id: "muse-spark-1.3", baseUrl: "https://evil.example/v1" }, + { ...valid, id: "muse-spark-1.2", baseUrl: "http://api.meta.ai/v1" }, + ]); + expect(models.map(({ id, baseUrl }) => [id, baseUrl])).toEqual([ + ["muse-spark-1.3", META_API_BASE_URL], + ["muse-spark-1.2", META_API_BASE_URL], + ]); + }); + test("repairs malformed cached fields and preserves valid overrides", () => { const [model] = restoreProviderModels([ { diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 1aa65ed..d4243fd 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -360,9 +360,13 @@ describe("Meta device OAuth", () => { loginMeta(cancellableCallbacks, fetchImpl, clock.sleep, clock.now), ).rejects.toThrow("Meta login was cancelled"); expect(signals).toHaveLength(requestCount); - expect(signals.every((signal) => signal === controller.signal)).toBe( - true, - ); + // Each request signal follows Pi's signal as well as its own timeout. + expect( + signals.every( + (signal) => + signal?.aborted && signal.reason === controller.signal.reason, + ), + ).toBe(true); expect(deviceCodes).toHaveLength(phase === "authorization" ? 0 : 1); }); } @@ -502,8 +506,10 @@ describe("Meta key minting and refresh", () => { test("refresh passes Pi's cancellation signal to mint and rejects a late cancellation", async () => { const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined; + let abortedBeforePi: boolean | undefined; const fetchImpl: Fetch = async (_input, init) => { receivedSignal = init?.signal; + abortedBeforePi = receivedSignal?.aborted; controller.abort(); return jsonResponse({ api_key: "new-key" }); }; @@ -517,7 +523,10 @@ describe("Meta key minting and refresh", () => { controller.signal, ), ).rejects.toThrow("Meta token refresh was cancelled"); - expect(receivedSignal).toBe(controller.signal); + // The mint signal adds a request timeout but still aborts with Pi's. + expect(abortedBeforePi).toBe(false); + expect(receivedSignal?.aborted).toBe(true); + expect(receivedSignal?.reason).toBe(controller.signal.reason); } finally { fetchSpy.mockRestore(); } diff --git a/tests/package.test.ts b/tests/package.test.ts index 1f9397d..d92162d 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; const projectRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); interface PackageManifest { + name: string; files: string[]; pi?: { extensions?: string[] }; dependencies?: Record; @@ -90,4 +91,23 @@ describe("OAuth-only package", () => { [...runtimeDependencies].sort(), ); }); + + test("README install commands track the published package and main", () => { + const readme = readFileSync(join(projectRoot, "README.md"), "utf8"); + const gitSources = [ + ...readme.matchAll( + /^\s*pi install (git:github\.com\/BlockedPath\/pi-meta-oauth\S*)/gm, + ), + ].map((match) => match[1]); + expect(gitSources.length).toBeGreaterThan(0); + for (const source of gitSources) { + expect(source, `Pinned git install source: ${source}`).not.toMatch( + /[@#]/, + ); + } + const npmSources = [...readme.matchAll(/^\s*pi install npm:(\S+)/gm)].map( + (match) => match[1], + ); + expect(npmSources).toEqual([readManifest().name]); + }); });