diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..aa3081c --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,79 @@ +## Fix: contextWindow and maxTokens not read from OpenAI-compatible servers + +### maxTokens Issue + +**Problem**: The generic OpenAI-compatible adapter always used a hardcoded default of 4096 for `maxTokens`, even when servers reported `max_completion_tokens` in the `/v1/models` response. This was way too small for modern models with large context windows. + +**Fix**: +1. First determine the `contextWindow` (checking `context_window`, `max_model_len`, `context_length`, `max_context_length`) +2. Try to read `max_completion_tokens` from the server response +3. If the server provides it, use that value +4. Otherwise, default to **half the context window** (reasonable balance between input and output) + +### contextWindow Issue + +### Problem + +The generic OpenAI-compatible adapter in `src/adapters/generic.ts` only checks for these context-window fields in the `/v1/models` response: + +- `context_window` +- `context_length` +- `max_context_length` + +Many servers (e.g. **omlx**) report the value under **`max_model_len`** instead. When this field is encountered, `contextWindow` stays `undefined` and a default fallback is used, giving wrong values to the user. + +### Fix + +In `src/adapters/generic.ts`: + +1. **Add `max_model_len` to the response type** so TypeScript recognizes the field. +2. **Insert `max_model_len` into the context-window detection chain** as the second check (after `context_window`, before `context_length` and `max_context_length`). + +### Files changed + +- `src/adapters/generic.ts` + +### Diff + +```diff +--- a/src/adapters/generic.ts ++++ b/src/adapters/generic.ts +@@ -122,7 +122,7 @@ + headers["Authorization"] = `Bearer ${cred.apiKey}`; + } + +- const body = r.json as { data?: Array<{ id?: unknown; max_completion_tokens?: number; context_length?: number; max_context_length?: number; context_window?: number }> } | undefined; ++ const body = r.json as { data?: Array<{ id?: unknown; max_completion_tokens?: number; max_model_len?: number; context_length?: number; max_context_length?: number; context_window?: number }> } | undefined; + if (!Array.isArray(body?.data)) return []; + + return body.data +@@ -135,13 +135,18 @@ + const contextWindow = + typeof item.context_window === "number" && item.context_window > 0 + ? item.context_window ++ : typeof item.max_model_len === "number" && item.max_model_len > 0 ++ ? item.max_model_len + : typeof item.context_length === "number" && item.context_length > 0 + ? item.context_length + : typeof item.max_context_length === "number" && item.max_context_length > 0 + ? item.max_context_length + : undefined; + ++ const finalContextWindow = contextWindow ?? DEFAULT_CONTEXT_WINDOW; ++ + const maxTokens = + typeof item.max_completion_tokens === "number" && item.max_completion_tokens > 0 + ? item.max_completion_tokens ++ : Math.floor(finalContextWindow / 2); + + return { + id: item.id, + name: item.id, +- contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW, +- maxTokens: DEFAULT_MAX_TOKENS, ++ contextWindow: finalContextWindow, ++ maxTokens, + input: ["text"], + reasoning: false, + embeddings: isEmbedding, +``` diff --git a/package-lock.json b/package-lock.json index 45fe4d3..2a79e74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -678,9 +678,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -698,9 +695,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -718,9 +712,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -738,9 +729,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -758,9 +746,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2172,9 +2157,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2192,9 +2174,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2212,9 +2191,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2232,9 +2208,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2252,9 +2225,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2272,9 +2242,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2780,9 +2747,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2804,9 +2768,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2828,9 +2789,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2852,9 +2810,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/adapters/generic.ts b/src/adapters/generic.ts index a2a85fc..552572c 100644 --- a/src/adapters/generic.ts +++ b/src/adapters/generic.ts @@ -91,7 +91,7 @@ class GenericAdapter implements BackendAdapter { /** * GET /v1/models → map data[].id to ModelDescriptor. - * Conservative defaults are applied (contextWindow 8192, maxTokens 4096, input ["text"]). + * Conservative defaults are applied (contextWindow 8192, maxTokens = half of contextWindow, input ["text"]). * Common embedding/reranking model families are excluded from chat registration. * Throws on non-ok / 401 / status:0. */ @@ -112,21 +112,52 @@ class GenericAdapter implements BackendAdapter { if (r.status === 0) throw new Error("listModels failed: server unreachable (status 0)"); if (!r.ok) throw new Error(`listModels failed: HTTP ${r.status}`); - const body = r.json as { data?: Array<{ id?: unknown }> } | undefined; + interface GenericModelEntry { + id?: unknown; + max_completion_tokens?: number; + max_model_len?: number; + context_length?: number; + max_context_length?: number; + context_window?: number; + } + interface GenericModelEntryWithId extends GenericModelEntry { + id: string; + } + + const body = r.json as { data?: GenericModelEntry[] } | undefined; if (!Array.isArray(body?.data)) return []; return body.data - .filter((item): item is { id: string } => typeof item?.id === "string") + .filter((item): item is GenericModelEntryWithId => typeof item?.id === "string") .map((item): ModelDescriptor => { const normalizedId = item.id.toLowerCase(); const isEmbedding = /(^|[/:._-])(embed|embedding|bge|gte|e5|reranker)([/:._-]|$)/.test(normalizedId) || normalizedId.includes("nomic-embed"); + + const contextWindow = + typeof item.context_window === "number" && item.context_window > 0 + ? item.context_window + : typeof item.max_model_len === "number" && item.max_model_len > 0 + ? item.max_model_len + : typeof item.context_length === "number" && item.context_length > 0 + ? item.context_length + : typeof item.max_context_length === "number" && item.max_context_length > 0 + ? item.max_context_length + : undefined; + + const finalContextWindow = contextWindow ?? DEFAULT_CONTEXT_WINDOW; + + const maxTokens = + typeof item.max_completion_tokens === "number" && item.max_completion_tokens > 0 + ? item.max_completion_tokens + : Math.floor(finalContextWindow / 2); + return { id: item.id, name: item.id, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, + contextWindow: finalContextWindow, + maxTokens, input: ["text"], reasoning: false, embeddings: isEmbedding, diff --git a/src/adapters/index.ts b/src/adapters/index.ts index a5b0269..56475e6 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -19,6 +19,7 @@ import { vllmAdapter } from "./vllm.ts"; import { omlxAdapter } from "./omlx.ts"; import { openaiAdapter } from "./openai.ts"; import { anthropicAdapter } from "./anthropic.ts"; +import { unslothAdapter } from "./unsloth.ts"; import { genericAdapter } from "./generic.ts"; /** Every adapter Crossbar ships. */ @@ -31,6 +32,7 @@ export const ADAPTERS: readonly BackendAdapter[] = [ omlxAdapter, openaiAdapter, anthropicAdapter, + unslothAdapter, genericAdapter, ]; @@ -63,5 +65,6 @@ export { omlxAdapter, openaiAdapter, anthropicAdapter, + unslothAdapter, genericAdapter, }; diff --git a/src/adapters/unsloth.ts b/src/adapters/unsloth.ts new file mode 100644 index 0000000..2e82166 --- /dev/null +++ b/src/adapters/unsloth.ts @@ -0,0 +1,246 @@ +/** + * Unsloth Studio backend adapter for Crossbar. + * + * Unsloth Studio (https://unsloth.ai) serves an OpenAI/Anthropic-compatible surface + * (`/v1/models`, `/v1/chat/completions`, `/v1/messages`, `/v1/responses`, `/v1/completions`, + * `/v1/embeddings`). Unlike most local backends, it has NO unauthenticated mode: every + * request — including `GET /v1/models` — requires `Authorization: Bearer sk-unsloth-…`. + * + * `authRequired: true` tells the onboarding flow this backend can never be added with + * `auth: "none"` — see ARCHITECTURE.md and the BackendAdapter contract. + * + * # Fingerprint discriminator — verified against a live instance (2026-08-19) + * + * Unsloth Studio sets `Server: unsloth-studio` on EVERY response — 200, 401 with no + * Authorization header, and 401 with a wrong/expired key alike. Confirmed via curl against a + * running server: + * + * $ curl -sD- https://:8888/v1/models # no header + * HTTP/2 401 + * server: unsloth-studio + * www-authenticate: Bearer + * {"error":{"message":"Not authenticated","type":"authentication_error","param":null,"code":null}} + * + * $ curl -sD- https://:8888/v1/models -H "Authorization: Bearer sk-unsloth-…" + * HTTP/2 200 + * server: unsloth-studio + * {"object":"list","data":[{"id":"unsloth/Qwen3.8-27B-GGUF","owned_by":"unsloth-studio", + * "quant":"UD-Q4_K_XL","context_length":49152,"max_context_length":229888, + * "native_context_length":262144,"loaded":true}, ...]} + * + * This is a real, explicit, always-present product header — a MUCH stronger discriminator + * than guessing at error-message wording (an earlier version of this adapter tried to match + * a hypothetical FastAPI `{"detail": "Missing authentication token"}` 401 body, which turned + * out not to match the actual server at all: the real 401 body is an OpenAI-style + * `{"error": {"type": "authentication_error", ...}}` envelope instead). The `Server` header + * lets `fingerprint()` positively identify Unsloth Studio — AND flag that it needs a key — + * from a single unauthenticated probe, before the user has entered a working key at all, + * instead of falling through every adapter to the generic "could not identify the server" + * dead end. Each model entry in the authenticated `data[]` also self-reports + * `owned_by: "unsloth-studio"` and a `loaded: boolean` residency flag, used below for + * IntrospectLoaded. + * + * Uses ONLY the injected Probe — never calls fetch directly. + */ + +import { Capability } from "../core/capability.ts"; +import type { BackendAdapter, PiApiType } from "../core/backend-adapter.ts"; +import type { + DiscoveredServer, + LoadedState, + ModelDescriptor, + PiModelEntry, + Probe, + ServerCredential, +} from "../core/types.ts"; + +// --------------------------------------------------------------------------- +// API response shapes +// --------------------------------------------------------------------------- + +interface UnslothModelEntry { + id: string; + owned_by?: string; + quant?: string; + display_name?: string; + /** Currently-configured context for a loaded model. Absent when not loaded. */ + context_length?: number; + /** Usable context ceiling (may be less than native due to available VRAM/RAM). */ + max_context_length?: number; + /** The model's absolute trained/architectural context length. */ + native_context_length?: number; + loaded?: boolean; +} + +interface UnslothModelsResponse { + data?: UnslothModelEntry[]; +} + +/** The literal header value Unsloth Studio sets on every response. */ +const SERVER_HEADER_VALUE = "unsloth-studio"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Unsloth Studio's documented default port (`UNSLOTH_STUDIO_URL` default). */ +const DEFAULT_PORT = 8888; + +const DEFAULT_CONTEXT_WINDOW = 8192; +const DEFAULT_MAX_TOKENS = 4096; + +function isUnslothStudioResponse(headers: Record): boolean { + // Probe lowercases header names AND we compare the value case-insensitively — cheap + // insurance against a future casing change upstream, no behavioural cost today. + const server = headers["server"]; + return typeof server === "string" && server.toLowerCase() === SERVER_HEADER_VALUE; +} + +function isEmbeddingId(id: string): boolean { + const normalized = id.toLowerCase(); + return ( + /(^|[/:._-])(embed|embedding|bge|gte|e5|reranker)([/:._-]|$)/.test(normalized) || + normalized.includes("nomic-embed") + ); +} + +/** + * Prefer the model's currently-configured context (only present while loaded), then the + * usable ceiling, then the architectural native max, falling back to a conservative default + * when the model has never been loaded and reports none of them. + */ +function contextWindowFor(entry: UnslothModelEntry): number { + if (typeof entry.context_length === "number" && entry.context_length > 0) return entry.context_length; + if (typeof entry.max_context_length === "number" && entry.max_context_length > 0) return entry.max_context_length; + if (typeof entry.native_context_length === "number" && entry.native_context_length > 0) { + return entry.native_context_length; + } + return DEFAULT_CONTEXT_WINDOW; +} + +// --------------------------------------------------------------------------- +// UnslothAdapter +// --------------------------------------------------------------------------- + +class UnslothAdapter implements BackendAdapter { + readonly kind = "unsloth" as const; + readonly displayName = "Unsloth Studio"; + readonly defaultPorts: readonly number[] = [DEFAULT_PORT]; + readonly piApi: PiApiType = "openai-completions"; + readonly capabilities: ReadonlySet = new Set([ + Capability.ListModels, + Capability.IntrospectLoaded, + Capability.Streaming, + ]); + /** Unsloth Studio rejects every request — including GET /v1/models — without a valid key. */ + readonly authRequired = true; + + // --- fingerprint ------------------------------------------------------------------------ + + async fingerprint(baseUrl: string, probe: Probe): Promise { + const r = await probe("/v1/models"); + if (r.status === 0) return null; + + // The `Server: unsloth-studio` header is present on every response this backend gives, + // authenticated or not — the one thing that's actually unique to this product (see the + // header comment above for a verified capture of both branches). + if (!isUnslothStudioResponse(r.headers)) return null; + + return { + kind: "unsloth", + baseUrl, + // The backend requires a key unconditionally, regardless of whether THIS particular + // probe happened to carry a working one. + auth: "apiKey", + label: `Unsloth Studio (${baseUrl.replace(/^https?:\/\//, "")})`, + confidence: 0.95, + }; + } + + // --- listModels --------------------------------------------------------------------------- + + async listModels( + _server: DiscoveredServer, + cred: ServerCredential, + probe: Probe, + ): Promise { + const headers: Record = {}; + if (cred.mode === "apiKey" && cred.apiKey) { + headers["Authorization"] = `Bearer ${cred.apiKey}`; + } + + const r = await probe("/v1/models", { headers }); + + if (r.status === 401) throw new Error("401 Unauthorized: invalid or missing Unsloth API key"); + if (r.status === 0) throw new Error("listModels failed: server unreachable (status 0)"); + if (!r.ok) throw new Error(`listModels failed: HTTP ${r.status}`); + + const body = r.json as UnslothModelsResponse | undefined; + if (!Array.isArray(body?.data)) return []; + + return body.data + .filter((entry): entry is UnslothModelEntry => typeof entry?.id === "string") + .map((entry): ModelDescriptor => { + const contextWindow = contextWindowFor(entry); + return { + id: entry.id, + name: entry.display_name ?? entry.id, + contextWindow, + maxTokens: Math.floor(contextWindow / 2) || DEFAULT_MAX_TOKENS, + input: ["text"], + reasoning: false, + embeddings: isEmbeddingId(entry.id), + loaded: entry.loaded === true, + raw: entry, + }; + }); + } + + // --- introspectLoaded ---------------------------------------------------------------------- + + /** + * Each `/v1/models` entry self-reports `loaded: boolean` — no separate endpoint needed. + * Reuses `listModels`'s parsing so the two never drift on field handling. + */ + async introspectLoaded( + server: DiscoveredServer, + cred: ServerCredential, + probe: Probe, + ): Promise { + const models = await this.listModels(server, cred, probe); + return { + loadedModelIds: models.filter((m) => m.loaded === true).map((m) => m.id), + source: "introspection", + }; + } + + // --- toPiModel ------------------------------------------------------------------------------ + + toPiModel(_server: DiscoveredServer, model: ModelDescriptor): PiModelEntry { + return { + id: model.id, + name: model.name, + reasoning: model.reasoning ?? false, + input: model.input.length > 0 ? model.input : ["text"], + // Local inference is free — cost is zero — but cache-hit token COUNTS still matter, so + // streaming usage stays enabled (never fabricated) in case llama-server reports them. + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW, + maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS, + compat: { supportsUsageInStreaming: true }, + }; + } + + // --- inferenceBaseUrl ------------------------------------------------------------------------ + + inferenceBaseUrl(server: DiscoveredServer): string { + const stripped = server.baseUrl.endsWith("/") ? server.baseUrl.slice(0, -1) : server.baseUrl; + return stripped.endsWith("/v1") ? stripped : `${stripped}/v1`; + } +} + +// --------------------------------------------------------------------------- +// Singleton export +// --------------------------------------------------------------------------- + +export const unslothAdapter: BackendAdapter = new UnslothAdapter(); diff --git a/src/core/backend-adapter.ts b/src/core/backend-adapter.ts index c419b2e..ae16ac2 100644 --- a/src/core/backend-adapter.ts +++ b/src/core/backend-adapter.ts @@ -28,8 +28,12 @@ import type { ServerCredential, } from "./types.ts"; -/** Bumped on any breaking change to this interface. Adapters and the registry assert on it. */ -export const CONTRACT_VERSION = 2 as const; +/** + * Bumped on any change to this interface. Adapters and the registry assert on it. + * v3: added optional `authRequired` (non-breaking — existing adapters are unaffected; only + * backends that can never work without a key, e.g. Unsloth Studio, need to set it). + */ +export const CONTRACT_VERSION = 3 as const; /** Which built-in Pi API type the adapter registers its models under. */ export type PiApiType = "openai-completions" | "anthropic-messages"; @@ -45,6 +49,14 @@ export interface BackendAdapter { readonly piApi: PiApiType; /** The capabilities this backend exposes. Drives UX and which optional methods are present. */ readonly capabilities: ReadonlySet; + /** + * True when this backend rejects EVERY request without a valid API key — there is no + * unauthenticated mode at all (e.g. Unsloth Studio). Defaults to false (most local backends + * are keyless by default). Onboarding uses this to skip/short-circuit the "No authentication" + * choice for adapters that can never work without a key, instead of silently failing + * fingerprint and surfacing a generic "could not identify the server" error. + */ + readonly authRequired?: boolean; /** * Decide whether `baseUrl` is *this* backend. MUST use only unauthenticated metadata endpoints diff --git a/src/core/capability.ts b/src/core/capability.ts index f939473..537d117 100644 --- a/src/core/capability.ts +++ b/src/core/capability.ts @@ -48,6 +48,7 @@ export type BackendKind = | "oobabooga" | "jan" | "llamafile" + | "unsloth" | "openai-generic"; /** Backends that are remote cloud services (configured, never port-probed). */ diff --git a/src/index.ts b/src/index.ts index 6ebd90d..7e0889f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ import { createProbe } from "./discovery/probe.ts"; import { catalogueChanged, pollAll } from "./poll.ts"; import { preloadCachedProviders } from "./preload.ts"; import { loadConfig, saveConfig } from "./registry/persistence.ts"; -import { createPiCredentialStore } from "./registry/pi-credential-store.ts"; +import { createAuthJsonCredentialStore } from "./registry/auth-json-credential-store.ts"; import { serverId } from "./registry/ids.ts"; import { ServerRegistry } from "./registry/registry.ts"; import { registerServer, unregisterServer } from "./shim/provider-shim.ts"; @@ -173,7 +173,7 @@ export default async function crossbar(pi: ExtensionAPI): Promise { pollTimer = undefined; } - const store = createPiCredentialStore(ctx.modelRegistry.authStorage); + const store = createAuthJsonCredentialStore(); const reg = new ServerRegistry({ store, persist: (cfg) => saveConfig(cfg) }); const cfg = await loadConfig(); reg.load(cfg); // registry now owns discovery settings (cfg.settings) diff --git a/src/preload.ts b/src/preload.ts index 43341e0..dd55152 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -6,12 +6,23 @@ * pi.registerProvider for each enabled server that has a cached model catalogue. * It NEVER performs network requests, discovery, UI work, timers, or credential * writes, and it NEVER throws — a failure here must not prevent Pi from starting. + * + * It DOES perform one credential READ: for `auth: "apiKey"` records, it looks up the + * already-persisted key (auth.json, via the same store `registerServer()` uses at + * session_start) and bridges it into `process.env[envVarFor(record.id)]` — the exact + * variable the `$ENV` sentinel in `buildProviderConfig()` references. Without this, a keyed + * server's preloaded models would be unusable (`pi.setModel()` returns false) for the brief + * window between factory load and `session_start`'s authoritative `refreshAndRegister()` + * pass, which performs the same bridge. See `shim/provider-shim.ts`'s header for why Pi's own + * config-value resolver needs this rather than resolving auth.json by provider id itself. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { PersistenceOpts } from "./registry/persistence.ts"; import { loadConfig } from "./registry/persistence.ts"; import { registerCachedServer } from "./shim/provider-shim.ts"; +import { createAuthJsonCredentialStore } from "./registry/auth-json-credential-store.ts"; +import { envVarFor } from "./registry/ids.ts"; /** * Read crossbar.json and register each enabled server with a non-empty chat @@ -32,10 +43,23 @@ export async function preloadCachedProviders( return; } + const credentialStore = createAuthJsonCredentialStore( + opts?.dir ? { authPath: `${opts.dir}/auth.json` } : undefined, + ); + for (const record of cfg.servers) { try { if (!record.enabled) continue; if (!record.lastKnownModels || record.lastKnownModels.length === 0) continue; + if (record.auth === "apiKey") { + const key = await credentialStore.get(record.id); + // Bridge the key into process.env when we already have one cached; when we don't + // (first-ever load before any key was entered, or a store read failure), still + // register with the unresolved `$ENV` sentinel exactly as before this bridge existed + // — session_start's refreshAndRegister() re-registers every enabled record shortly + // after with an authoritative credential resolution regardless. + if (key !== undefined) process.env[envVarFor(record.id)] = key; + } registerCachedServer(pi, record, record.lastKnownModels); } catch { // One malformed record must not block others. diff --git a/src/registry/auth-json-credential-store.ts b/src/registry/auth-json-credential-store.ts new file mode 100644 index 0000000..0c04542 --- /dev/null +++ b/src/registry/auth-json-credential-store.ts @@ -0,0 +1,142 @@ +/** + * Direct auth.json–backed CredentialStore. + * + * Background: pi-coding-agent 0.80+ removed the extension-facing `AuthStorage` + * class from its public SDK surface (see CHANGELOG "Replaced the SDK's + * `CreateAgentSessionOptions.authStorage` and `modelRegistry` options with the + * async `modelRuntime` option. `AuthStorage` and its storage backends are no + * longer exported"). `ExtensionContext.modelRegistry` no longer exposes + * `authStorage` at all, so `createPiCredentialStore()` (which read + * `ctx.modelRegistry.authStorage`) crashes with "Cannot read properties of + * undefined (reading 'set')" the moment Crossbar tries to persist a key. + * + * Only `readStoredCredential()` (one-off, read-only) is still exported by the + * SDK. There is currently no supported write path for extensions. + * + * This module restores Crossbar's original guarantee — "secrets live only in + * Pi's auth.json, never crossbar.json" — by reading/writing that file directly, + * in the exact flat-map shape Pi itself uses: + * + * { "": { "type": "api_key", "key": "<...>" }, ... } + * + * Writes are read-modify-write + atomic rename (temp file on the same + * filesystem), so a concurrent Pi-side write (e.g. an OAuth login finishing + * around the same time) can only ever lose one side's *own* key entry in the + * unlikely event both writes race — never corrupt the file. Every entry + * belonging to other providers (anthropic, openrouter, github-copilot, ...) + * is preserved verbatim; Crossbar only ever touches its own provider ids. + * + * File mode is forced to 0600, matching Pi's own auth.json permissions. + * + * # Why built-in providers (anthropic, openai, github-copilot, ...) still "just work" + * + * This might look contradictory at first: Crossbar needed this whole module because + * `AuthStorage` was removed, yet `/login` for Anthropic/OpenAI/GitHub Copilot and typing an + * API key for one of them still transparently reads and writes `auth.json` with no issue. + * The resolution is that pi-coding-agent 0.80.8 removed `AuthStorage` from the EXTENSION-FACING + * SDK surface only — not from pi-coding-agent itself. Built-in providers are driven by Pi's + * internal `ModelRuntime` (`setRuntimeApiKey()`, `login()`, etc. — see + * `dist/core/model-runtime.d.ts`), which keeps full, privileged, in-process access to the same + * `auth.json`. The CHANGELOG line is precise about the scope: + * + * "AuthStorage and its storage backends are no longer exported" + * + * — exported meaning exported *to extensions*, not removed from the core. What actually + * disappeared is only the bridge extensions used to reach in from outside + * (`CreateAgentSessionOptions.authStorage`, `ExtensionContext.modelRegistry.authStorage`). + * + * Crossbar doesn't register its backends (Unsloth Studio, llama.cpp, vLLM, ...) as built-in + * providers — it registers them through the EXTENSION provider API, `pi.registerProvider(id, + * config)`, which is exactly the API that lost its privileged path into `auth.json` when + * `AuthStorage` stopped being exported. This module doesn't bypass any security boundary Pi + * introduced; it replicates, for Crossbar's own provider ids, the same read/write/0600 + * behaviour Pi's internal `ModelRuntime` already performs for its built-in providers — and, + * per the read-modify-write contract above, never touches an id it doesn't own. + */ + +import { readFileSync, writeFileSync, renameSync, chmodSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { CredentialStore } from "./persistence.ts"; + +interface ApiKeyCredential { + type: "api_key"; + key: string; +} + +/** Other credential shapes (oauth, etc.) are opaque to us — preserved as-is. */ +type AuthJsonEntry = ApiKeyCredential | Record; +type AuthJsonData = Record; + +export interface AuthJsonStoreOpts { + /** Override the auth.json path (tests only). Default: getAgentDir()/auth.json. */ + authPath?: string; +} + +function resolvePath(opts?: AuthJsonStoreOpts): string { + return opts?.authPath ?? join(getAgentDir(), "auth.json"); +} + +function readAll(path: string): AuthJsonData { + try { + if (!existsSync(path)) return {}; + const text = readFileSync(path, "utf-8"); + if (text.trim().length === 0) return {}; + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as AuthJsonData; + } + return {}; + } catch { + // Missing, unreadable, or corrupt — treat as empty rather than throwing. + // A subsequent write will recreate the file; other providers' credentials + // may already be unrecoverable at that point, but we never make it worse. + return {}; + } +} + +function writeAll(path: string, data: AuthJsonData): void { + const json = JSON.stringify(data, null, 2); + const tmp = `${path}.crossbar-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`; + writeFileSync(tmp, json, { encoding: "utf-8", mode: 0o600 }); + renameSync(tmp, path); + try { + chmodSync(path, 0o600); + } catch { + // Best effort — some filesystems (e.g. certain network mounts) reject chmod. + } +} + +function isApiKeyCredential(entry: AuthJsonEntry | undefined): entry is ApiKeyCredential { + return !!entry && (entry as Record)["type"] === "api_key" && typeof (entry as Record)["key"] === "string"; +} + +/** + * Build a {@link CredentialStore} that reads/writes auth.json directly. + * Every operation re-reads the file first so concurrent external changes + * (Pi logging a provider in/out in the same process) are never clobbered + * for keys other than the one being touched. + */ +export function createAuthJsonCredentialStore(opts?: AuthJsonStoreOpts): CredentialStore { + const path = resolvePath(opts); + + return { + get(id: string): string | undefined { + const data = readAll(path); + const entry = data[id]; + return isApiKeyCredential(entry) ? entry.key : undefined; + }, + set(id: string, key: string): void { + const data = readAll(path); + data[id] = { type: "api_key", key }; + writeAll(path, data); + }, + remove(id: string): void { + const data = readAll(path); + if (id in data) { + delete data[id]; + writeAll(path, data); + } + }, + }; +} diff --git a/src/registry/pi-credential-store.ts b/src/registry/pi-credential-store.ts deleted file mode 100644 index 56a7c56..0000000 --- a/src/registry/pi-credential-store.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Bridges Pi's `AuthStorage` (reached at runtime via `ctx.modelRegistry.authStorage`) to Crossbar's - * framework-free {@link CredentialStore} boundary. This is the ONLY place keys cross into Pi's store — - * they land in `auth.json` (mode 0600) keyed by the Crossbar provider id, exactly like Pi's own creds. - * - * Keeping the adapter here (not in persistence.ts) preserves the rule that the persistence/registry - * core never imports Pi runtime, so they stay unit-testable with a fake store. - */ - -import type { AuthStorage } from "@earendil-works/pi-coding-agent"; -import type { CredentialStore } from "./persistence.ts"; - -/** Wrap a Pi `AuthStorage` as a Crossbar `CredentialStore` (api-key credentials only). */ -export function createPiCredentialStore(authStorage: AuthStorage): CredentialStore { - return { - get(id: string): string | undefined { - const cred = authStorage.get(id); - return cred?.type === "api_key" ? cred.key : undefined; - }, - set(id: string, key: string): void { - authStorage.set(id, { type: "api_key", key }); - }, - remove(id: string): void { - authStorage.remove(id); - }, - }; -} diff --git a/src/shim/provider-shim.ts b/src/shim/provider-shim.ts index aa70306..6a7c372 100644 --- a/src/shim/provider-shim.ts +++ b/src/shim/provider-shim.ts @@ -12,8 +12,22 @@ * Pi documents this explicitly for local models: the API key is required by Pi, * but local OpenAI-compatible servers ignore it, so any value works. * - * - Keyed server: use the `$ENV` sentinel. Pi reads the real key from auth.json - * first, so no plaintext secret is stored in ProviderConfig. + * - Keyed server: use the `$ENV` sentinel, where the env var name is + * `envVarFor(record.id)`. `registerServer()` below is responsible for setting + * that exact `process.env` entry to the real key (resolved via the registry's + * CredentialStore, ultimately backed by auth.json) immediately before calling + * `pi.registerProvider` — Pi's own config-value resolver + * (`resolveConfigValue`) reads live `process.env`, with no separate lookup of + * auth.json BY PROVIDER ID for extension-registered providers. An earlier + * version of this comment assumed Pi did that auth.json lookup itself for any + * provider id, built-in or extension; that turned out to be true only for + * Pi's own BUILT-IN providers (driven by its internal `ModelRuntime`, see + * `auth-json-credential-store.ts` for the full writeup) — `pi.setModel()` + * silently returned `false` for an extension-registered model until this env + * var bridge was added, with no diagnostic beyond "Pi could not select + * ``" client-side. The real key itself is never embedded in + * `ProviderConfig` (only the `$ENV` reference is), so it still never reaches + * crossbar.json or Pi's in-memory provider config objects as plaintext. * - No-auth server: use a fixed, non-secret placeholder. This passes both Pi's * registration and request-time auth checks. */ @@ -127,6 +141,10 @@ export async function registerServer( if (credential.apiKey === undefined) { throw new Error(`API key missing for ${record.label}; add it again through /crossbar`); } + // Bridge the resolved secret into the exact process.env entry the `$ENV` sentinel in + // buildProviderConfig() references, so Pi's own config-value resolver can actually find + // it at request time (see the module header for why this is necessary, not optional). + process.env[envVarFor(record.id)] = credential.apiKey; } // Retrieve the DiscoveredServer shape from the record. diff --git a/src/ui/onboarding.ts b/src/ui/onboarding.ts index 027ba8b..f8add43 100644 --- a/src/ui/onboarding.ts +++ b/src/ui/onboarding.ts @@ -1279,21 +1279,69 @@ export async function openOnboarding( continue; } - const authChoice = await ctx.ui.select( - "Authentication", - ["No authentication (open server)", "Enter API key"], - ); - if (authChoice === undefined) continue; + // Pre-probe unauthenticated, once, before asking the auth question. Some backends + // (e.g. Unsloth Studio) declare `authRequired: true` because they reject EVERY + // request — including their own metadata endpoints — without a key. For those, the + // adapter can still identify itself from public response shape/headers even on a + // bare 401 (see e.g. unsloth.ts). Detecting that up front lets Crossbar skip the + // "No authentication" choice entirely for a backend that can never work with it, + // instead of letting the user pick it and land on the generic "could not identify + // the server" dead end once every adapter's fingerprint 401s. + let preProbeAdapter: BackendAdapter | undefined; + let preProbeServer: DiscoveredServer | undefined; + try { + const bareProbe = createProbe(targetBaseUrl, { + auth: { mode: "none" }, + defaultTimeoutMs: 3000, + }); + const { DISCOVERY_ADAPTERS } = await import("../adapters/index.ts"); + for (const adapter of DISCOVERY_ADAPTERS) { + try { + const result = await adapter.fingerprint(targetBaseUrl, bareProbe); + if (result) { + preProbeAdapter = adapter; + preProbeServer = result; + break; + } + } catch { + // Try the next adapter. + } + } + } catch { + // Pre-probe is best-effort only — network trouble here just falls through to the + // normal auth question below, exactly like before this pre-probe existed. + } - selectedAuth = authChoice === "Enter API key" ? "apiKey" : "none"; - if (selectedAuth === "apiKey") { - const key = await ctx.ui.input("API key", "Paste your key (hidden after this dialog)"); + if (preProbeAdapter?.authRequired && preProbeServer) { + ctx.ui.notify( + `Crossbar: ${preProbeServer.label} requires an API key.`, + "info", + ); + selectedAuth = "apiKey"; + const key = await ctx.ui.input("API key", `Required by ${preProbeAdapter.displayName}`); if (key === undefined) continue; if (key.length === 0) { ctx.ui.notify("Crossbar: API key cannot be empty.", "warning"); continue; } manualApiKey = key; + } else { + const authChoice = await ctx.ui.select( + "Authentication", + ["No authentication (open server)", "Enter API key"], + ); + if (authChoice === undefined) continue; + + selectedAuth = authChoice === "Enter API key" ? "apiKey" : "none"; + if (selectedAuth === "apiKey") { + const key = await ctx.ui.input("API key", "Paste your key (hidden after this dialog)"); + if (key === undefined) continue; + if (key.length === 0) { + ctx.ui.notify("Crossbar: API key cannot be empty.", "warning"); + continue; + } + manualApiKey = key; + } } } else { const existingRecord = registry.list().find((r) => r.baseUrl === chosenBaseUrl); diff --git a/tests/adapters/unsloth.fixture.ts b/tests/adapters/unsloth.fixture.ts new file mode 100644 index 0000000..a46a092 --- /dev/null +++ b/tests/adapters/unsloth.fixture.ts @@ -0,0 +1,139 @@ +/** + * Conformance fixture for the Unsloth Studio adapter. + * + * Routes below are captured verbatim from a live Unsloth Studio instance (2026-08-19, curl + * against `/v1/models` with and without a valid `Authorization` header) — see unsloth.ts for + * the full capture and rationale. + * + * Unsloth Studio characteristics exercised: + * - Fingerprint via the `Server: unsloth-studio` header, present on both the unauthenticated + * 401 and the authenticated 200 — the only signal actually unique to this product (see + * unsloth.test.ts for the discrimination edge cases the shared harness doesn't reach). + * - listModels via the same endpoint, authenticated; `owned_by`, `context_length` / + * `max_context_length` / `native_context_length`, and `loaded` are real response fields. + * - IntrospectLoaded, sourced from the same `loaded: boolean` field per model — no separate + * endpoint. + * - `authRequired: true`. + * - No SwitchModel / LoadUnload / Health (capability honesty) — not exposed by this backend. + */ + +import type { AdapterFixture } from "../conformance/fixtures.ts"; +import type { ProbeInit, ProbeResult } from "../../src/core/types.ts"; +import { unslothAdapter } from "../../src/adapters/unsloth.ts"; + +const LOADED_MODEL_ID = "unsloth/Qwen3.8-27B-GGUF"; +const UNLOADED_MODEL_ID = "Qwen3.6-35B-A3B-UD-Q4_K_XL"; +const EMBED_ID = "nomic-embed-text-v1.5"; + +/** Captured verbatim: `GET /v1/models` with no (or an invalid) Authorization header. */ +const UNAUTHENTICATED_RESPONSE: ProbeResult = { + status: 401, + ok: false, + headers: { + server: "unsloth-studio", + "www-authenticate": "Bearer", + "content-type": "application/json", + }, + json: { error: { message: "Not authenticated", type: "authentication_error", param: null, code: null } }, +}; + +/** Captured verbatim (trimmed to the fields this adapter reads) from the same live instance. */ +const AUTHENTICATED_RESPONSE: ProbeResult = { + status: 200, + ok: true, + headers: { + server: "unsloth-studio", + "content-type": "application/json", + }, + json: { + object: "list", + data: [ + { + id: LOADED_MODEL_ID, + object: "model", + owned_by: "unsloth-studio", + quant: "UD-Q4_K_XL", + context_length: 49152, + max_context_length: 229888, + native_context_length: 262144, + loaded: true, + }, + { + id: UNLOADED_MODEL_ID, + object: "model", + owned_by: "unsloth-studio", + loaded: false, + display_name: UNLOADED_MODEL_ID, + }, + { + id: EMBED_ID, + object: "model", + owned_by: "unsloth-studio", + loaded: false, + display_name: EMBED_ID, + }, + ], + }, +}; + +/** + * Real Unsloth Studio behaviour: `GET /v1/models` 401s without a bearer token and 200s with + * one, but sets the SAME `Server: unsloth-studio` header either way. `fingerprint()` calls the + * bare probe with no headers of its own (in production it never sees the raw credential — the + * orchestrator's bound `Probe` closure does that); `listModels()` attaches the `Authorization` + * header itself from `cred`. This one factory route reproduces both paths so the harness's + * fingerprint-positive test (no headers) and listModels happy-path test (cred's header + * attached) — which share this same `routes` map — each see the real response shape. + */ +const MODELS_ROUTE = (init?: ProbeInit): ProbeResult => + init?.headers?.["Authorization"] ? AUTHENTICATED_RESPONSE : UNAUTHENTICATED_RESPONSE; + +/** + * Another backend's response shape: no `Server: unsloth-studio` header at all. This is the + * only signal this adapter's fingerprint claims a kind from, so anything lacking it — even a + * plausible-looking 200 + `data[]` — must yield null. + */ +const NEGATIVE_ROUTES: Record = { + "/v1/models": { + status: 200, + ok: true, + headers: { server: "some-other-backend" }, + json: { object: "list", data: [{ id: "some-other-model", owned_by: "someone-else" }] }, + }, +}; + +/** Auth failure for the shared harness's listModels 401 test — no header needed here. */ +const AUTH_FAILURE_ROUTES: Record = { + "/v1/models": UNAUTHENTICATED_RESPONSE, +}; + +export const unslothFixture: AdapterFixture = { + name: "Unsloth Studio", + adapter: unslothAdapter, + cred: { mode: "apiKey", apiKey: "sk-unsloth-test-key" }, + + routes: { + "/v1/models": MODELS_ROUTE, + }, + + negativeRoutes: NEGATIVE_ROUTES, + authFailureRoutes: AUTH_FAILURE_ROUTES, + + expect: { + fingerprint: { + kind: "unsloth", + confidenceMin: 0.9, + confidenceMax: 1.0, + }, + models: { + includedIds: [LOADED_MODEL_ID, UNLOADED_MODEL_ID], + excludedIds: [EMBED_ID], + minCount: 2, + }, + loadedState: { + anyOf: [LOADED_MODEL_ID], + source: "introspection", + }, + inferenceBaseUrlPrefix: "http://", + }, +}; diff --git a/tests/adapters/unsloth.test.ts b/tests/adapters/unsloth.test.ts new file mode 100644 index 0000000..f1dd563 --- /dev/null +++ b/tests/adapters/unsloth.test.ts @@ -0,0 +1,124 @@ +/** + * Conformance tests for the Unsloth Studio backend adapter. + * + * Delegates the standard contract checks to the shared conformance harness, then adds + * adapter-specific coverage for the discriminator that actually motivated this adapter: + * identifying Unsloth Studio from its `Server: unsloth-studio` response header even when no + * working API key is in hand yet, and declaring `authRequired` so onboarding can't offer a + * "No authentication" option for it. + */ + +import { describe, it, expect } from "vitest"; + +import { runConformance } from "../conformance/run-conformance.ts"; +import { createFakeProbe } from "../conformance/fake-probe.ts"; +import { unslothAdapter } from "../../src/adapters/unsloth.ts"; +import { unslothFixture } from "./unsloth.fixture.ts"; + +runConformance([unslothFixture]); + +describe("[unsloth] adapter-specific", () => { + it("declares authRequired — this backend has no unauthenticated mode", () => { + expect(unslothAdapter.authRequired).toBe(true); + }); + + it("identifies the server from the Server header alone, even on an unauthenticated 401", async () => { + const probe = createFakeProbe({ + "/v1/models": { + status: 401, + ok: false, + headers: { server: "unsloth-studio", "www-authenticate": "Bearer" }, + json: { error: { message: "Not authenticated", type: "authentication_error" } }, + }, + }); + const result = await unslothAdapter.fingerprint("http://127.0.0.1:8888", probe); + expect(result).not.toBeNull(); + expect(result?.kind).toBe("unsloth"); + expect(result?.auth).toBe("apiKey"); + expect(result?.confidence).toBeGreaterThan(0.9); + }); + + it("matches the Server header value case-insensitively", async () => { + const probe = createFakeProbe({ + "/v1/models": { + status: 401, + ok: false, + headers: { server: "Unsloth-Studio" }, + }, + }); + const result = await unslothAdapter.fingerprint("http://127.0.0.1:8888", probe); + expect(result?.kind).toBe("unsloth"); + }); + + it("does NOT claim a 401 from a different backend lacking the Server header", async () => { + const probe = createFakeProbe({ + "/v1/models": { + status: 401, + ok: false, + headers: { "content-type": "application/json" }, + json: { error: "Unauthorized" }, + }, + }); + const result = await unslothAdapter.fingerprint("http://127.0.0.1:8888", probe); + expect(result).toBeNull(); + }); + + it("does NOT claim a 200 + data[] response lacking the Server header, no matter how plausible", async () => { + const probe = createFakeProbe({ + "/v1/models": { + status: 200, + ok: true, + headers: { "content-type": "application/json" }, + json: { object: "list", data: [{ id: "totally-plausible-model", owned_by: "unsloth-studio" }] }, + }, + }); + const result = await unslothAdapter.fingerprint("http://127.0.0.1:8888", probe); + expect(result).toBeNull(); + }); + + it("returns null on connection refused (status 0)", async () => { + const probe = createFakeProbe({}); + const result = await unslothAdapter.fingerprint("http://127.0.0.1:8888", probe); + expect(result).toBeNull(); + }); + + it("introspectLoaded reports only models with loaded: true", async () => { + const probe = createFakeProbe({ + "/v1/models": { + status: 200, + ok: true, + headers: { server: "unsloth-studio" }, + json: { + data: [ + { id: "a", owned_by: "unsloth-studio", loaded: true }, + { id: "b", owned_by: "unsloth-studio", loaded: false }, + ], + }, + }, + }); + const server = { + kind: "unsloth" as const, + baseUrl: "http://127.0.0.1:8888", + auth: "apiKey" as const, + label: "Unsloth Studio", + confidence: 0.95, + }; + const result = await unslothAdapter.introspectLoaded?.(server, { mode: "apiKey", apiKey: "k" }, probe); + expect(result?.loadedModelIds).toEqual(["a"]); + expect(result?.source).toBe("introspection"); + }); + + it("inferenceBaseUrl appends /v1 exactly once", () => { + const server = { + kind: "unsloth" as const, + baseUrl: "http://127.0.0.1:8888", + auth: "apiKey" as const, + label: "Unsloth Studio", + confidence: 0.95, + }; + expect(unslothAdapter.inferenceBaseUrl(server)).toBe("http://127.0.0.1:8888/v1"); + expect(unslothAdapter.inferenceBaseUrl({ ...server, baseUrl: "http://127.0.0.1:8888/v1" })).toBe( + "http://127.0.0.1:8888/v1", + ); + }); +}); diff --git a/tests/integration/cli-preload.test.ts b/tests/integration/cli-preload.test.ts index 7c73550..7922db5 100644 --- a/tests/integration/cli-preload.test.ts +++ b/tests/integration/cli-preload.test.ts @@ -179,4 +179,25 @@ describe("[integration] programmatic preload via CLI", () => { // The specific model should not be treated as available (no key) expect(output).not.toMatch(/test-model|TestKeyed/i); }); + + // End-to-end regression test for the real bug: a key was written to auth.json (via + // createAuthJsonCredentialStore), but `pi.setModel()` still failed because nothing ever + // bridged it into the process.env variable the registered ProviderConfig's `$ENV` sentinel + // references. This spawns the REAL local `pi` binary (no mocks, no fakes) against an + // isolated agent dir with a genuine (throwaway, never-real) auth.json entry, and asserts the + // cached model shows up as an AVAILABLE model — the exact opposite assertion from the + // "keyed-without-key" test above, using the same record shape plus one auth.json entry. + it("keyed-WITH-key (in auth.json) is registered AND available via --list-models", () => { + writeCrossbarJson(agentDir, [keyedRecord]); + writeAuthJson(agentDir, { + [keyedRecord.id]: { type: "api_key", key: "sk-test-dummy-throwaway-key" }, + }); + + const { status, output } = runPi(["--extension", EXT_PATH, "--list-models", "--no-session"]); + + expect(output).not.toMatch(/Error|exception|crash|API key missing/i); + // Unlike the no-key case, the model must now be listed as available. + expect(output).toMatch(/test-model|TestKeyed/i); + expect(status).toBe(0); + }); }); diff --git a/tests/preload.test.ts b/tests/preload.test.ts index e4f7a5f..be16ce3 100644 --- a/tests/preload.test.ts +++ b/tests/preload.test.ts @@ -38,6 +38,9 @@ beforeEach(() => { afterEach(() => { rmSync(dir, { recursive: true, force: true }); + // Defensive: the process.env bridge (for auth.json-backed keys, see below) must never leak + // into other test files sharing this worker process. + delete process.env[envVarFor(keyedRecord.id)]; }); // --------------------------------------------------------------------------- @@ -252,6 +255,30 @@ describe("preloadCachedProviders", () => { expect(config.apiKey).toBe("$CROSSBAR_OPENAI"); }); + it("bridges an already-persisted key from auth.json into process.env for the $ENV sentinel", async () => { + await writeCfg([keyedRecord]); + writeFileSync( + join(dir, "auth.json"), + JSON.stringify({ [keyedRecord.id]: { type: "api_key", key: "sk-preloaded-key" } }, null, 2), + ); + const { pi } = makeFakePi(); + + await preloadCachedProviders(pi, { dir }); + + expect(process.env[envVarFor(keyedRecord.id)]).toBe("sk-preloaded-key"); + }); + + it("registers with the unresolved $ENV sentinel (no throw) when auth.json has no entry yet", async () => { + await writeCfg([keyedRecord]); + delete process.env[envVarFor(keyedRecord.id)]; + const { pi, registerProvider } = makeFakePi(); + + await preloadCachedProviders(pi, { dir }); + + expect(registerProvider).toHaveBeenCalledOnce(); + expect(process.env[envVarFor(keyedRecord.id)]).toBeUndefined(); + }); + it("does not throw and registers zero when the config file is missing", async () => { // dir exists but crossbar.json was never written const { pi, registerProvider } = makeFakePi(); diff --git a/tests/registry/auth-json-credential-store.test.ts b/tests/registry/auth-json-credential-store.test.ts new file mode 100644 index 0000000..2731987 --- /dev/null +++ b/tests/registry/auth-json-credential-store.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for the auth.json–backed CredentialStore. + * + * This module exists because pi-coding-agent ≥0.80.8 removed the extension-facing + * `AuthStorage` class (see CHANGELOG "AuthStorage and its storage backends are no longer + * exported") — `ctx.modelRegistry.authStorage` used to be the write path and is now + * `undefined`, which crashed Crossbar's original credential store with "Cannot read + * properties of undefined (reading 'set')". These tests lock in the replacement's contract: + * read/write auth.json directly, in Pi's own flat shape, without ever touching entries that + * belong to other providers. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, statSync, existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createAuthJsonCredentialStore } from "../../src/registry/auth-json-credential-store.ts"; + +let dir: string; +let authPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "crossbar-auth-test-")); + authPath = join(dir, "auth.json"); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("createAuthJsonCredentialStore", () => { + it("get() returns undefined when auth.json does not exist yet", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + expect(await store.get("crossbar-unsloth-example")).toBeUndefined(); + }); + + it("round-trips a key through set() then get()", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + await store.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + expect(await store.get("crossbar-unsloth-example")).toBe("sk-unsloth-abc123"); + }); + + it("persists across store instances (survives a process restart)", async () => { + const first = createAuthJsonCredentialStore({ authPath }); + await first.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + + const second = createAuthJsonCredentialStore({ authPath }); + expect(await second.get("crossbar-unsloth-example")).toBe("sk-unsloth-abc123"); + }); + + it("writes the exact flat shape Pi's own auth.json uses", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + await store.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + + const raw = JSON.parse(readFileSync(authPath, "utf-8")); + expect(raw).toEqual({ + "crossbar-unsloth-example": { type: "api_key", key: "sk-unsloth-abc123" }, + }); + }); + + it("never touches other providers' entries — oauth, api_key, or otherwise", async () => { + writeFileSync( + authPath, + JSON.stringify( + { + "github-copilot": { type: "oauth", refresh: "ghu_x", access: "y", expires: 123 }, + anthropic: { type: "api_key", key: "sk-ant-existing" }, + }, + null, + 2, + ), + ); + + const store = createAuthJsonCredentialStore({ authPath }); + await store.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + + const raw = JSON.parse(readFileSync(authPath, "utf-8")); + expect(raw["github-copilot"]).toEqual({ type: "oauth", refresh: "ghu_x", access: "y", expires: 123 }); + expect(raw["anthropic"]).toEqual({ type: "api_key", key: "sk-ant-existing" }); + expect(raw["crossbar-unsloth-example"]).toEqual({ type: "api_key", key: "sk-unsloth-abc123" }); + }); + + it("remove() deletes only the targeted id", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + await store.set("crossbar-unsloth-a", "key-a"); + await store.set("crossbar-unsloth-b", "key-b"); + + await store.remove("crossbar-unsloth-a"); + + expect(await store.get("crossbar-unsloth-a")).toBeUndefined(); + expect(await store.get("crossbar-unsloth-b")).toBe("key-b"); + }); + + it("remove() on an unknown id is a no-op, not a throw", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + await store.remove("never-existed"); + // No throw is the assertion; also confirm the file wasn't created for nothing. + expect(existsSync(authPath)).toBe(false); + }); + + it("get() on a non-api_key entry (e.g. oauth) returns undefined rather than the wrong shape", async () => { + writeFileSync( + authPath, + JSON.stringify({ "github-copilot": { type: "oauth", refresh: "ghu_x" } }, null, 2), + ); + const store = createAuthJsonCredentialStore({ authPath }); + expect(await store.get("github-copilot")).toBeUndefined(); + }); + + it("treats a corrupt auth.json as empty rather than throwing, and recovers on the next write", async () => { + writeFileSync(authPath, "not valid json{{{"); + const store = createAuthJsonCredentialStore({ authPath }); + + expect(await store.get("crossbar-unsloth-example")).toBeUndefined(); + await store.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + expect(await store.get("crossbar-unsloth-example")).toBe("sk-unsloth-abc123"); + }); + + it("writes auth.json with 0600 permissions", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + await store.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + + const mode = statSync(authPath).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("leaves no stray temp files behind after a write", async () => { + const store = createAuthJsonCredentialStore({ authPath }); + await store.set("crossbar-unsloth-example", "sk-unsloth-abc123"); + + const leftover = readdirSync(dir).filter((f) => f !== "auth.json"); + expect(leftover).toEqual([]); + expect(existsSync(authPath)).toBe(true); + }); +}); diff --git a/tests/shim/provider-shim.test.ts b/tests/shim/provider-shim.test.ts index f4a5fee..19604f7 100644 --- a/tests/shim/provider-shim.test.ts +++ b/tests/shim/provider-shim.test.ts @@ -12,7 +12,7 @@ * - No-auth providers use a resolved, non-secret placeholder key. */ -import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from "vitest"; import { buildProviderConfig, registerCachedServer, registerServer, unregisterServer, reRegisterServer } from "../../src/shim/provider-shim.ts"; import { ollamaAdapter } from "../../src/adapters/ollama.ts"; import { openaiAdapter } from "../../src/adapters/openai.ts"; @@ -324,6 +324,16 @@ function makeRegistry(resolvedKey?: string): ServerRegistry { } describe("registerServer", () => { + // registerServer() bridges the resolved key into process.env[envVarFor(record.id)] so Pi's + // own $ENV config-value resolution can find it (see shim/provider-shim.ts's header). That's + // a real, intentional side effect on the shared, process-global process.env — clean it up + // after every test in this file so it can't leak into the (deliberately isolated, real- + // ModelRegistry) "keyed availability" tests below, which reuse the same provider id to + // exercise the OPPOSITE case (no credential at all). + afterEach(() => { + delete process.env[envVarFor(openaiRecord.id)]; + }); + it("calls pi.registerProvider with the record id", async () => { const { pi, registerProvider } = makePi(); const registry = makeRegistry(); @@ -357,6 +367,20 @@ describe("registerServer", () => { expect(JSON.stringify(config)).not.toContain(plaintextKey); }); + it("bridges the resolved key into process.env for the $ENV sentinel to resolve", async () => { + const plaintextKey = "sk-realkey-for-env-bridge"; + const { pi, registry } = { ...makePi(), registry: makeRegistry(plaintextKey) }; + await registerServer(pi, registry, openaiRecord, [gpt4oModel]); + expect(process.env[envVarFor(openaiRecord.id)]).toBe(plaintextKey); + }); + + it("does not touch process.env for a no-auth server", async () => { + const { pi, registry } = { ...makePi(), registry: makeRegistry() }; + delete process.env[envVarFor(ollamaRecord.id)]; + await registerServer(pi, registry, ollamaRecord, [chatModel]); + expect(process.env[envVarFor(ollamaRecord.id)]).toBeUndefined(); + }); + it("rejects a keyed server when its stored credential is missing", async () => { const { pi, registerProvider } = makePi(); const registry = makeRegistry(); diff --git a/tests/ui/onboarding-flow.test.ts b/tests/ui/onboarding-flow.test.ts index 00b46a6..7836f3b 100644 --- a/tests/ui/onboarding-flow.test.ts +++ b/tests/ui/onboarding-flow.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const adapterMocks = vi.hoisted(() => ({ listModels: vi.fn(), @@ -41,6 +41,7 @@ import type { DiscoveredServer, ModelDescriptor, ServerRecord } from "../../src/ import { ServerRegistry } from "../../src/registry/registry.ts"; import { registerServer } from "../../src/shim/provider-shim.ts"; import { openOnboarding } from "../../src/ui/onboarding.ts"; +import { DISCOVERY_ADAPTERS } from "../../src/adapters/index.ts"; const model: ModelDescriptor = { id: "local-model", @@ -84,6 +85,26 @@ function makeRegistry(records: ServerRecord[] = []): ServerRegistry { return registry; } +/** Like {@link makeRegistry}, but the credential store actually round-trips set()/get() — needed + * for scenarios that add an apiKey-auth server and then rely on registerServer() resolving it. */ +function makeRegistryWithRealCredentialStore(records: ServerRecord[] = []): ServerRegistry { + const keys = new Map(); + const registry = new ServerRegistry({ + store: { + get: vi.fn(async (id: string) => keys.get(id)), + set: vi.fn(async (id: string, key: string) => { + keys.set(id, key); + }), + remove: vi.fn(async (id: string) => { + keys.delete(id); + }), + }, + persist: vi.fn(async () => undefined), + }); + registry.load({ version: 1, servers: records }); + return registry; +} + function makeHarness(customResults: unknown[]) { const registered = new Map(); const registerProvider = vi.fn((id: string, config: ProviderConfig) => { @@ -354,4 +375,76 @@ describe("openOnboarding navigation and registration", () => { // customs: server, settings, ports1, ports2, settings2, server2 expect(custom).toHaveBeenCalledTimes(6); }); + + describe("manual add — authRequired backends skip the auth question", () => { + const mockAdapter = DISCOVERY_ADAPTERS[0] as unknown as { + authRequired?: boolean; + fingerprint: ReturnType; + }; + const authRequiredServer: DiscoveredServer = { + kind: "llamacpp", + baseUrl: "http://mock-auth-required:8888", + auth: "apiKey", + label: "Mock Auth-Required Backend (mock-auth-required:8888)", + confidence: 0.9, + }; + + afterEach(() => { + delete mockAdapter.authRequired; + mockAdapter.fingerprint.mockReset(); + }); + + it("skips the auth question and goes straight to the API-key prompt", async () => { + mockAdapter.authRequired = true; + mockAdapter.fingerprint.mockResolvedValue(authRequiredServer); + + const registry = makeRegistryWithRealCredentialStore(); + const { pi, ctx, registerProvider } = makeHarness([ + "__manual__", // server selector → manual add + model.id, // model picker + null, // server selector closes + ]); + (ctx.ui.input as ReturnType) + .mockResolvedValueOnce("mock-auth-required:8888") // Server URL + .mockResolvedValueOnce("the-real-key"); // API key (asked directly, no auth menu) + + await openOnboarding(pi, ctx, { registry, discover: async () => [] }); + + // The auth choice menu (["No authentication", "Enter API key"]) must never appear — + // authRequired short-circuits straight to the key prompt. + expect(ctx.ui.select).not.toHaveBeenCalledWith( + "Authentication", + expect.arrayContaining(["No authentication (open server)"]), + ); + expect(ctx.ui.notify).toHaveBeenCalledWith( + expect.stringContaining("requires an API key"), + "info", + ); + expect(registerProvider).toHaveBeenCalledOnce(); + const record = registry.list()[0]!; + expect(record.auth).toBe("apiKey"); + }); + + it("still asks the normal auth question when no adapter declares authRequired", async () => { + delete mockAdapter.authRequired; + mockAdapter.fingerprint.mockResolvedValue(authRequiredServer); + + const registry = makeRegistry(); + const { pi, ctx, registerProvider } = makeHarness([ + "__manual__", + model.id, + null, + ]); + (ctx.ui.input as ReturnType).mockResolvedValueOnce("mock-auth-required:8888"); + (ctx.ui.select as ReturnType).mockResolvedValueOnce("No authentication (open server)"); + + await openOnboarding(pi, ctx, { registry, discover: async () => [] }); + + expect(ctx.ui.select).toHaveBeenCalledWith( + "Authentication", + ["No authentication (open server)", "Enter API key"], + ); + expect(registerProvider).toHaveBeenCalledOnce(); + }); + }); });