diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index f019ed3..b96e906 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -6984,19 +6984,20 @@

Confirm governance change

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); diff --git a/plugins/admin/test/onboarding-view.test.ts b/plugins/admin/test/onboarding-view.test.ts index c7fe9b4..406390c 100644 --- a/plugins/admin/test/onboarding-view.test.ts +++ b/plugins/admin/test/onboarding-view.test.ts @@ -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> { + const src = slice("let onboardingModels = {};", '$("onboarding-model-provider").onchange') + "\nloadOnboarding();"; + const elements: Record = {}; + const fixtures: Record = { + "/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",/); }); diff --git a/src/api/deps.ts b/src/api/deps.ts index b31743f..1e778ed 100644 --- a/src/api/deps.ts +++ b/src/api/deps.ts @@ -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"; @@ -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; diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts index 66a2e09..69ae1ad 100644 --- a/src/api/routes/admin/model-providers.ts +++ b/src/api/routes/admin/model-providers.ts @@ -51,6 +51,9 @@ export async function getModelProviders(ctx: ApiCtx): Promise { 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 } } + : {}), }); } diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 397a558..cd1a28f 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -889,7 +889,9 @@ async function getSurfaceConfig(ctx: ApiCtx): Promise { 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 } : {}), }); diff --git a/src/config.ts b/src/config.ts index 534134b..23d516d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/index.ts b/src/index.ts index fbd390c..e833d80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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); @@ -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, diff --git a/test/model-credential-route.test.ts b/test/model-credential-route.test.ts index e127025..bf3c80c 100644 --- a/test/model-credential-route.test.ts +++ b/test/model-credential-route.test.ts @@ -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"; @@ -22,18 +23,17 @@ function start( built: BuiltApp; close: () => Promise; } { - 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), @@ -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), false); + } finally { + await srv.close(); + } +}); + test("admin model credentials survive a second app instance on the same durable store", async () => { const backing = createMemoryMap(); const first = createModelCredentialStore({ backing, keyMaterial: "shared-model-key" });