From 8318c0904eb3e0bf2595be9d23168cd7932df43e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:20:15 +0000 Subject: [PATCH 1/4] fix(meta): keep the Meta catalog owned when Pi's pi.dev overlay shares its store key On Pi 0.86.1+, Pi's built-in pi.dev `meta` catalog overlay refreshes before this extension through the same models-store entry. Persisted entries had no lastModified, so the overlay re-downloaded pi.dev on every refresh, a pi.dev failure skipped the Meta catalog refresh, and an overlay write left after a failed Meta refresh was later restored as the Meta catalog, cached baseUrl included. Persist with lastModified 0 and a source marker, restore only owned or legacy entries, republish the last good catalog after a failed Meta refresh, and pin restored models to https://api.meta.ai/v1. tests/model-runtime.test.ts drives the real Pi ModelRuntime and skips where the built-in provider is absent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dqv6ZT1fRfoPdsgQCydQVL --- src/meta/model-store.ts | 63 +++++++++- src/meta/models.ts | 3 +- tests/model-runtime.test.ts | 223 ++++++++++++++++++++++++++++++++++++ tests/model-store.test.ts | 201 +++++++++++++++++++++++++++++++- tests/models.test.ts | 16 +++ 5 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 tests/model-runtime.test.ts 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/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([ { From 37ea165b1a73f868866a30b839bac7fdf7487dae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:20:15 +0000 Subject: [PATCH 2/4] fix(meta): bound OAuth requests and trust only http(s) server URLs Device-authorization, token-poll, and key-mint requests had no timeout, so a stalled connection hung login past device-code expiry and hung Pi 0.83 refreshes (which pass no signal). Bound each request to 30 s, cap token polls at the deadline, and report timeouts and expiry explicitly. Report Pi 0.84+'s refresh timeout as a timeout rather than a cancellation, and accept verification and setup URLs only as normalized http(s) URLs, matching Pi's built-in Meta login, so control sequences cannot reach the terminal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dqv6ZT1fRfoPdsgQCydQVL --- src/meta/constants.ts | 2 + src/meta/http.ts | 52 ++++++-- src/meta/oauth.ts | 117 +++++++++++++----- tests/http.test.ts | 43 ++++++- tests/meta-failures.test.ts | 234 +++++++++++++++++++++++++++++++++++- tests/oauth.test.ts | 17 ++- 6 files changed, 415 insertions(+), 50 deletions(-) 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/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/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/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-failures.test.ts b/tests/meta-failures.test.ts index e725056..27b4ae9 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 = {}, 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(); } From 667829fdfe42d00ad0d45f7dcc20c74cc08ee822 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:20:15 +0000 Subject: [PATCH 3/4] fix(meta): bind request hints to the in-flight model; treat blank key aliases as missing ctx.model is the session's current model, so a model switch mid-request could strip reasoning and add prompt_cache_retention to another provider's payload. Apply the hints only when the payload's model is the Meta model. Pi treats an empty $META_API_KEY as unset, so an exported empty value used to block the MODEL_API_KEY alias. Treat empty and blank values as missing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dqv6ZT1fRfoPdsgQCydQVL --- extensions/meta.ts | 10 +++++++++- src/meta/environment.ts | 9 +++++++-- tests/environment.test.ts | 19 ++++++++++++++++++- tests/meta-cache.test.ts | 22 +++++++++++++++++----- tests/meta-failures.test.ts | 3 ++- 5 files changed, 53 insertions(+), 10 deletions(-) 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/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/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/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 27b4ae9..9458a5e 100644 --- a/tests/meta-failures.test.ts +++ b/tests/meta-failures.test.ts @@ -953,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", }); }); From a0b8a7e9426105a1d9e09b3f2a515d22c4da4b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:20:15 +0000 Subject: [PATCH 4/4] docs: install from npm or main and document the pi.dev catalog overlay The README installed the meta-oauth-only branch, which is pinned at v0.6.0 and is not an ancestor of main. Install from npm or main, guard against pinned git sources in tests, document the shared models-store entry, and align the test-driver agent's examples with its own hermetic-runner rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Dqv6ZT1fRfoPdsgQCydQVL --- .pi/agents/test-driver.md | 2 +- AGENTS.md | 2 ++ CHANGELOG.md | 7 +++++++ README.md | 17 +++++++++++++++-- tests/package.test.ts | 20 ++++++++++++++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) 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/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]); + }); });