Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions tests/web/pi-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down
43 changes: 43 additions & 0 deletions tests/web/web-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions web/host/web-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
87 changes: 87 additions & 0 deletions web/runtime/pi-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
import {
type WebModelSelectionOptions,
type WebPromptOptions,
type WebProviderAuthProjection,
type WebProviderAuthSource,
type WebRuntimeController,
type WebRuntimeEvent,
type WebSessionCreationOptions,
Expand All @@ -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<WebProviderAuthSource>([
"stored",
"runtime",
"environment",
"fallback",
"models_json_key",
"models_json_command",
]);

type PromptTrace = {
commandId: string;
Expand All @@ -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()) {
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions web/runtime/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
Expand Down Expand Up @@ -61,6 +89,7 @@ export interface WebRuntimeController {
): Promise<WebSessionCreationResult>;
switchSession(sessionPath: string): Promise<{ cancelled: boolean }>;
listModels(): WebModelSummary[];
listProviderAuth?(): WebProviderAuthProjection;
setModel(
provider: string,
modelId: string,
Expand Down
Loading