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
19 changes: 10 additions & 9 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6984,19 +6984,20 @@ <h2 id="governance-review-title">Confirm governance change</h2>
const baseModel = config.data.baseModel || config.data.baseModelDefault || "";
const baseProvider = onboardingProviderForModel(baseModel);
const baseStatus = onboardingModelStatuses.find((item) => item.provider === baseProvider);
onboardingBadge(
"onboarding-model-badge",
baseStatus?.configured ? "Ready" : "Needs a key",
Boolean(baseStatus?.configured),
);
const harnessAuth = models.data.harnessAuth;
const baseHarnessAuth = Boolean(harnessAuth && harnessAuth.provider === baseProvider);
const baseReady = Boolean(baseModel) && (Boolean(baseStatus?.configured) || baseHarnessAuth);
onboardingBadge("onboarding-model-badge", baseReady ? "Ready" : "Needs a key", baseReady);
$("onboarding-model-summary").textContent = !baseModel
? "No base model is configured yet — pick a provider and model below."
: baseStatus?.configured
? baseModel + " · " + (baseStatus.source === "admin" ? "admin-managed key" : "deployment key")
: baseModel +
" cannot run until its " +
(MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
" key is configured.";
: baseHarnessAuth
? baseModel + " · authenticated by the " + harnessAuth.harnessId + " harness — no API key needed."
: baseModel +
" cannot run until its " +
(MODEL_PROVIDER_LABELS[baseProvider] || connectorName(baseProvider)) +
" key is configured.";
renderOnboardingProviderOptions(baseProvider);
renderOnboardingModelOptions(baseModel);

Expand Down
92 changes: 92 additions & 0 deletions plugins/admin/test/onboarding-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,98 @@ function resolveView(pathname: string, search: string): string {
return vm.runInContext(src, context);
}

interface FakeElement {
textContent: string;
className: string;
value: string;
placeholder: string;
disabled: boolean;
href: string;
options: Array<{ value?: string; textContent?: string }>;
appendChild(option: { value?: string; textContent?: string }): void;
}

async function runLoadOnboarding(modelProviders: unknown): Promise<Record<string, FakeElement>> {
const src = slice("let onboardingModels = {};", '$("onboarding-model-provider").onchange') + "\nloadOnboarding();";
const elements: Record<string, FakeElement> = {};
const fixtures: Record<string, unknown> = {
"/api/model-providers": modelProviders,
"/api/slack-installation": { configured: false },
"/api/connector-catalog": { catalog: [] },
"/api/scopes/org%3Adefault-org": { baseModel: "claude-opus-5" },
};
const context = vm.createContext({
$: (id: string) =>
(elements[id] ??= {
textContent: "",
className: "",
value: "",
placeholder: "",
disabled: false,
href: "",
options: [],
appendChild(option) {
this.options.push(option);
},
}),
api: async (_method: string, path: string) => ({ ok: true, data: fixtures[path] ?? {} }),
orgScope: () => "org:default-org",
encodeURIComponent,
setStatus: () => {},
connectorName: (id: string) => id,
viewLoadedAt: {},
Date,
document: { createElement: () => ({}) },
});
await vm.runInContext(src, context);
return elements;
}

const UNCONFIGURED_PROVIDERS = [
{ provider: "anthropic", configured: false, source: "absent" },
{ provider: "openai", configured: false, source: "absent" },
{ provider: "openrouter", configured: false, source: "absent" },
];
const ANTHROPIC_MODELS = [{ id: "claude-opus-5", name: "Claude Opus 5", provider: "anthropic" }];

test("harness-carried auth shows the model step as ready without a stored key", async () => {
const elements = await runLoadOnboarding({
providers: UNCONFIGURED_PROVIDERS,
models: ANTHROPIC_MODELS,
harnessAuth: { harnessId: "claude", provider: "anthropic" },
});
assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready");
assert.equal(elements["onboarding-model-badge"]!.className, "badge ok");
assert.equal(
elements["onboarding-model-summary"]!.textContent,
"claude-opus-5 · authenticated by the claude harness — no API key needed.",
);
});

test("without harness auth an unconfigured provider still needs a key", async () => {
const elements = await runLoadOnboarding({
providers: UNCONFIGURED_PROVIDERS,
models: ANTHROPIC_MODELS,
});
assert.equal(elements["onboarding-model-badge"]!.textContent, "Needs a key");
assert.equal(elements["onboarding-model-badge"]!.className, "badge warn");
assert.match(elements["onboarding-model-summary"]!.textContent, /cannot run until its Anthropic key is configured/);
});

test("a stored key keeps its summary even when the harness also carries auth", async () => {
const elements = await runLoadOnboarding({
providers: [
{ provider: "anthropic", configured: true, source: "admin" },
{ provider: "openai", configured: false, source: "absent" },
{ provider: "openrouter", configured: false, source: "absent" },
],
models: ANTHROPIC_MODELS,
harnessAuth: { harnessId: "claude", provider: "anthropic" },
});
assert.equal(elements["onboarding-model-badge"]!.textContent, "Ready");
assert.equal(elements["onboarding-model-summary"]!.textContent, "claude-opus-5 · admin-managed key");
});

test("onboarding is a navigable view", () => {
assert.match(html, /\{ label: "Admin", views: \["onboarding",/);
});
Expand Down
3 changes: 2 additions & 1 deletion src/api/deps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ModelProviderAvailability } from "../model/pi-models.ts";
import type { ModelProvider, ModelProviderAvailability } from "../model/pi-models.ts";
import type { ModelCredentialStore } from "../model/model-credential-store.ts";
import type { ReplayDedupe } from "../auth/replay-dedupe.ts";
import type { FetchLike, OAuthClientResolver } from "../connectors/oauth.ts";
Expand Down Expand Up @@ -83,6 +83,7 @@ export interface ServerDeps {
modelCredentialFetch?: typeof fetch;
brandingDefault?: { accent?: string; mark?: string; selfLabel?: string };
harnessId?: string;
harnessCarriedModelAuth?: ModelProvider;
admin?: AdminService;
rateLimiter?: RateLimiter;
sessions?: SessionStore;
Expand Down
3 changes: 3 additions & 0 deletions src/api/routes/admin/model-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export async function getModelProviders(ctx: ApiCtx): Promise<void> {
return sendJson(ctx.res, 200, {
providers: await ctx.deps.modelCredentials.statuses(),
models: await selectableModelCatalog(ctx.deps.modelCredentialFetch),
...(ctx.deps.harnessCarriedModelAuth
? { harnessAuth: { harnessId: ctx.deps.harnessId ?? "pi", provider: ctx.deps.harnessCarriedModelAuth } }
: {}),
});
}

Expand Down
4 changes: 3 additions & 1 deletion src/api/routes/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,9 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise<void> {
webuiModels: configuredPicker.length ? configuredPicker : allowed,
baseModel: resolvedBase,
harnessId,
...(managedKeys ? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) } : {}),
...(managedKeys
? { modelProviderConfigured: Object.values(managedKeys).some(Boolean) || Boolean(deps.harnessCarriedModelAuth) }
: {}),
externalSlackParticipants,
...(Object.keys(resolvedBranding).length ? { branding: resolvedBranding } : {}),
});
Expand Down
10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ export function baseModelProviders(config: Config): ModelProviderAvailability |
return config.modelProvider ? onlyProvider(config.modelProvider) : undefined;
}

export function harnessCarriedModelAuth(config: Config): ModelProvider | undefined {
if (
config.harness === "claude" &&
(config.claudeProcessEnv.CLAUDE_CODE_OAUTH_TOKEN || config.claudeProcessEnv.ANTHROPIC_AUTH_TOKEN)
)
return "anthropic";
if (config.harness === "codex" && config.codexProcessEnv.CODEX_ACCESS_TOKEN) return "openai";
return undefined;
}

interface AwsSandboxEnv {
region: string;
profile?: string;
Expand Down
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { baseModelProviders, configuredModelForHarness, loadConfig, providerKeysPresent } from "./config.ts";
import {
baseModelProviders,
configuredModelForHarness,
harnessCarriedModelAuth,
loadConfig,
providerKeysPresent,
} from "./config.ts";
import { buildApp, stopWithBackstop } from "./wiring.ts";
import { createServer } from "./api/server.ts";
import { errMessage } from "./util/errors.ts";
Expand All @@ -8,6 +14,7 @@ import { slackPluginConfigFromEnv, startSlackPlugin } from "./slack/index.ts";
import { createSlackRuntimeReconciler } from "./surfaces/slack-runtime.ts";

const config = loadConfig();
const carriedModelAuth = harnessCarriedModelAuth(config);

const built = buildApp(config);
const envSlackConfig = slackPluginConfigFromEnv(process.env);
Expand Down Expand Up @@ -35,6 +42,7 @@ const server = createServer(built.app, {
modelCredentials: built.modelCredentials,
...(config.brandingDefault ? { brandingDefault: config.brandingDefault } : {}),
harnessId: config.harness,
...(carriedModelAuth ? { harnessCarriedModelAuth: carriedModelAuth } : {}),
connectorTokens: built.connectorTokens,
slackInstallation: built.slackInstallation,
slackEnvironmentState,
Expand Down
55 changes: 48 additions & 7 deletions test/model-credential-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { join } from "node:path";
import { test } from "node:test";
import { createInsecureTestServer } from "../src/api/server.ts";
import { buildApp, type BuiltApp } from "../src/wiring.ts";
import { harnessCarriedModelAuth } from "../src/config.ts";
import { testConfig } from "./support/test-config.ts";
import { createModelCredentialStore, type StoredModelCredential } from "../src/model/model-credential-store.ts";
import { createMemoryMap } from "../src/persistence/durable-map.ts";
Expand All @@ -22,18 +23,17 @@ function start(
built: BuiltApp;
close: () => Promise<void>;
} {
const built = buildApp(
testConfig({
dataDir: mkdtempSync(join(tmpdir(), "model-credential-route-")),
...config,
}),
{ modelCredentialFetch },
);
const cfg = testConfig({
dataDir: mkdtempSync(join(tmpdir(), "model-credential-route-")),
...config,
});
const built = buildApp(cfg, { modelCredentialFetch });
const server = createInsecureTestServer(built.app, {
config: built.config,
modelCredentials: built.modelCredentials,
modelCredentialFetch,
harnessId: config.harness ?? "pi",
...(harnessCarriedModelAuth(cfg) ? { harnessCarriedModelAuth: harnessCarriedModelAuth(cfg) } : {}),
providerKeys: {
anthropic: Boolean(config.anthropicApiKey),
openai: Boolean(config.openaiApiKey),
Expand Down Expand Up @@ -401,6 +401,47 @@ test("surface-config reports whether any model provider is configured", async ()
}
});

test("a claude harness with an OAuth token counts as configured without corrupting store statuses", async () => {
const srv = start({
harness: "claude",
claudeProcessEnv: { CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat-test" } as NodeJS.ProcessEnv,
});
try {
const surface = await fetch(`${srv.base}/v1/surface-config`);
assert.equal(surface.status, 200);
assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, true);

const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN });
assert.equal(providers.status, 200);
const body = (await providers.json()) as {
providers: Array<{ provider: string; configured: boolean; source: string }>;
harnessAuth?: { harnessId: string; provider: string };
};
assert.deepEqual(body.harnessAuth, { harnessId: "claude", provider: "anthropic" });
assert.deepEqual(
body.providers.find((item) => item.provider === "anthropic"),
{ provider: "anthropic", configured: false, source: "absent" },
);
} finally {
await srv.close();
}
});

test("a claude harness without any token stays unconfigured", async () => {
const srv = start({ harness: "claude" });
try {
const surface = await fetch(`${srv.base}/v1/surface-config`);
assert.equal(surface.status, 200);
assert.equal(((await surface.json()) as { modelProviderConfigured?: boolean }).modelProviderConfigured, false);

const providers = await fetch(`${srv.base}/v1/admin/model-providers`, { headers: ADMIN });
assert.equal(providers.status, 200);
assert.equal("harnessAuth" in ((await providers.json()) as Record<string, unknown>), false);
} finally {
await srv.close();
}
});

test("admin model credentials survive a second app instance on the same durable store", async () => {
const backing = createMemoryMap<StoredModelCredential>();
const first = createModelCredentialStore({ backing, keyMaterial: "shared-model-key" });
Expand Down