diff --git a/tests/web/pi-runtime.test.ts b/tests/web/pi-runtime.test.ts index ae6f3f78..92d7bcef 100644 --- a/tests/web/pi-runtime.test.ts +++ b/tests/web/pi-runtime.test.ts @@ -352,6 +352,51 @@ test("stale expected Session fails before prompt dispatch", async () => { assert.equal(session.calls.length, 0); }); +test("provider auth projection is bounded and never serializes credentials", () => { + const secret = "sk-must-never-reach-web"; + const providers = Array.from({ length: 252 }, (_, index) => ({ + id: index === 0 ? "invalid\u0000provider" : `provider-${index}`, + name: index === 1 ? "n".repeat(500) : `Provider ${index}`, + auth: { + apiKey: { secret }, + ...(index % 2 === 0 ? { oauth: { token: secret } } : {}), + }, + })); + const modelRuntime = { + getProviders: () => providers, + getProviderAuthStatus: () => ({ + configured: true, + source: "stored" as const, + label: secret, + }), + isUsingSubscription: (id: string) => id === "provider-2", + }; + const runtime = Object.create(PiWebRuntime.prototype) as { + runtime: { services: { modelRuntime: typeof modelRuntime } }; + listProviderAuth: PiWebRuntime["listProviderAuth"]; + }; + runtime.runtime = { services: { modelRuntime } }; + + const projection = runtime.listProviderAuth(); + assert.equal(projection.providers.length, 250); + assert.equal(projection.truncation.providersOmitted, 2); + assert.equal(projection.truncation.namesTruncated, 1); + assert.equal(projection.truncation.truncated, true); + assert.deepEqual(projection.providers[0], { + id: "provider-1", + name: `${"n".repeat(159)}…`, + authMethods: ["api_key"], + configured: true, + source: "stored", + subscription: false, + nameTruncated: true, + }); + assert.deepEqual(projection.providers[1]?.authMethods, ["api_key", "oauth"]); + assert.equal(projection.providers[1]?.subscription, true); + assert.doesNotMatch(JSON.stringify(projection), /sk-must-never-reach-web/u); + assert.doesNotMatch(JSON.stringify(projection), /label|token|secret/u); +}); + test("model selection and Session activation are serialized", async () => { const applied = deferred(); const model = { provider: "fixture", id: "model-a", name: "Model A" }; diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..59924a00 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -65,6 +65,25 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn }, switchSession: async () => ({ cancelled: false }), listModels: () => [], + listProviderAuth: () => ({ + providers: [ + { + id: "fixture", + name: "Fixture", + authMethods: ["api_key"], + configured: true, + source: "environment", + subscription: false, + nameTruncated: false, + }, + ], + truncation: { + truncated: false, + providersOmitted: 0, + namesTruncated: 0, + maxProviders: 250, + }, + }), setModel: async () => { throw new WebRuntimeRequestError( "Model is not available", @@ -266,6 +285,30 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn }); assert.equal(modelsResponse.status, 200); assert.deepEqual((await modelsResponse.json()).models, snapshot.models); + const providerAuthResponse = await fetch( + `${launched.origin}/api/providers/auth-status`, + { headers: authorized }, + ); + assert.equal(providerAuthResponse.status, 200); + assert.deepEqual(await providerAuthResponse.json(), { + providers: [ + { + id: "fixture", + name: "Fixture", + authMethods: ["api_key"], + configured: true, + source: "environment", + subscription: false, + nameTruncated: false, + }, + ], + truncation: { + truncated: false, + providersOmitted: 0, + namesTruncated: 0, + maxProviders: 250, + }, + }); const unavailableModel = await fetch(`${launched.origin}/api/model`, { method: "POST", headers: authorized, diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..8c449a97 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -557,6 +557,15 @@ export class WebHost { } if (url.pathname === "/api/models") return this.json(response, 200, { models: this.runtime.listModels() }); + if (url.pathname === "/api/providers/auth-status") { + if (!this.runtime.listProviderAuth) { + return this.json(response, 501, { + code: "PROVIDER_AUTH_STATUS_UNAVAILABLE", + error: "provider authentication status is unavailable", + }); + } + return this.json(response, 200, this.runtime.listProviderAuth()); + } if (url.pathname === "/api/snapshot") { const cursor = this.sequence; const projection = await this.adapter.getSnapshot( diff --git a/web/runtime/pi-runtime.ts b/web/runtime/pi-runtime.ts index cf792d39..7f155dee 100644 --- a/web/runtime/pi-runtime.ts +++ b/web/runtime/pi-runtime.ts @@ -17,6 +17,8 @@ import { import { type WebModelSelectionOptions, type WebPromptOptions, + type WebProviderAuthProjection, + type WebProviderAuthSource, type WebRuntimeController, type WebRuntimeEvent, type WebSessionCreationOptions, @@ -36,6 +38,18 @@ import { const STARTUP_TIMEOUT_MS = 15_000; const BOOTSTRAP_WORKSPACE_DIRECTORY = ".bootstrap-workspace"; +const WEB_MAX_PROVIDER_AUTH_ITEMS = 250; +const WEB_MAX_PROVIDER_AUTH_SCANNED = 1_024; +const WEB_MAX_PROVIDER_ID_LENGTH = 160; +const WEB_MAX_PROVIDER_NAME_LENGTH = 160; +const WEB_PROVIDER_AUTH_SOURCES = new Set([ + "stored", + "runtime", + "environment", + "fallback", + "models_json_key", + "models_json_command", +]); type PromptTrace = { commandId: string; @@ -49,6 +63,24 @@ function errorText(error: unknown) { return error instanceof Error ? error.message : String(error); } +function safeProviderId(value: string) { + return value.length > 0 && + value.length <= WEB_MAX_PROVIDER_ID_LENGTH && + !/[\u0000-\u001f\u007f]/u.test(value) + ? value + : undefined; +} + +function boundedProviderName(value: string) { + const sanitized = value.replace(/[\u0000-\u001f\u007f]/gu, " "); + return sanitized.length <= WEB_MAX_PROVIDER_NAME_LENGTH + ? { value: sanitized, truncated: false } + : { + value: `${sanitized.slice(0, WEB_MAX_PROVIDER_NAME_LENGTH - 1)}…`, + truncated: true, + }; +} + async function canonicalDirectory(path: string) { const canonical = await realpath(resolve(path)); if (!(await stat(canonical)).isDirectory()) { @@ -194,6 +226,61 @@ export class PiWebRuntime implements WebRuntimeController { })); } + listProviderAuth(): WebProviderAuthProjection { + const modelRuntime = this.runtime.services.modelRuntime; + const allProviders = modelRuntime.getProviders(); + const providers = allProviders.slice(0, WEB_MAX_PROVIDER_AUTH_SCANNED); + const projection: WebProviderAuthProjection["providers"][number][] = []; + let omitted = Math.max( + 0, + allProviders.length - WEB_MAX_PROVIDER_AUTH_SCANNED, + ); + let namesTruncated = 0; + for (const provider of providers) { + if (projection.length >= WEB_MAX_PROVIDER_AUTH_ITEMS) { + omitted++; + continue; + } + try { + const id = safeProviderId(provider.id); + if (!id) { + omitted++; + continue; + } + const name = boundedProviderName(provider.name || id); + if (name.truncated) namesTruncated++; + const status = modelRuntime.getProviderAuthStatus(id); + const source = + status.source && WEB_PROVIDER_AUTH_SOURCES.has(status.source) + ? status.source + : undefined; + projection.push({ + id, + name: name.value, + authMethods: [ + ...(provider.auth.apiKey ? (["api_key"] as const) : []), + ...(provider.auth.oauth ? (["oauth"] as const) : []), + ], + configured: status.configured, + ...(source ? { source } : {}), + subscription: modelRuntime.isUsingSubscription(id), + nameTruncated: name.truncated, + }); + } catch { + omitted++; + } + } + return { + providers: projection, + truncation: { + truncated: omitted > 0 || namesTruncated > 0, + providersOmitted: omitted, + namesTruncated, + maxProviders: WEB_MAX_PROVIDER_AUTH_ITEMS, + }, + }; + } + setModel( provider: string, modelId: string, diff --git a/web/runtime/types.ts b/web/runtime/types.ts index 2b66c3f0..eb582987 100644 --- a/web/runtime/types.ts +++ b/web/runtime/types.ts @@ -1,6 +1,34 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent"; import type { WebModelSummary } from "../protocol/types.ts"; +export type WebProviderAuthSource = + | "stored" + | "runtime" + | "environment" + | "fallback" + | "models_json_key" + | "models_json_command"; + +export interface WebProviderAuthSummary { + readonly id: string; + readonly name: string; + readonly authMethods: readonly ("api_key" | "oauth")[]; + readonly configured: boolean; + readonly source?: WebProviderAuthSource; + readonly subscription: boolean; + readonly nameTruncated: boolean; +} + +export interface WebProviderAuthProjection { + readonly providers: readonly WebProviderAuthSummary[]; + readonly truncation: { + readonly truncated: boolean; + readonly providersOmitted: number; + readonly namesTruncated: number; + readonly maxProviders: number; + }; +} + export interface WebRuntimeEvent { type: string; detail?: Record; @@ -61,6 +89,7 @@ export interface WebRuntimeController { ): Promise; switchSession(sessionPath: string): Promise<{ cancelled: boolean }>; listModels(): WebModelSummary[]; + listProviderAuth?(): WebProviderAuthProjection; setModel( provider: string, modelId: string,