diff options
| author | Adam Malczewski <[email protected]> | 2026-06-05 00:50:05 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-05 00:50:05 +0900 |
| commit | ab74946c3b6e1c2e55e47690d11ad838468d5c2f (patch) | |
| tree | 7c12ea79b1817dbff939d85d1690866313d4b0f9 | |
| parent | efb4737fd22572f757e7a9eb1034dd96ae1b6593 (diff) | |
| download | dispatch-ab74946c3b6e1c2e55e47690d11ad838468d5c2f.tar.gz dispatch-ab74946c3b6e1c2e55e47690d11ad838468d5c2f.zip | |
feat(auth): provider resolves credentials via AuthContract, not config
- kernel HostAPI: add getAuthProviders()/getAuthProvider(id) read-views (mirrors getProviders)
- provider-openai-compat: activate() resolves creds via host.getAuthProvider("apikey").resolve(); dependsOn auth-apikey; model stays config-driven
- host-bin: mirror the new getters in post-activation HostAPI stub
- auth-apikey is no longer vestigial; auth seam exercised end-to-end
- 185 tests pass; typecheck + biome clean; verified live (curl returns real response)
| -rw-r--r-- | packages/host-bin/src/main.ts | 5 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/extension.ts | 6 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.test.ts | 27 | ||||
| -rw-r--r-- | packages/kernel/src/host/host.ts | 6 | ||||
| -rw-r--r-- | packages/provider-openai-compat/src/extension.test.ts | 99 | ||||
| -rw-r--r-- | packages/provider-openai-compat/src/extension.ts | 35 | ||||
| -rw-r--r-- | tasks.md | 30 |
7 files changed, 195 insertions, 13 deletions
diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index a3eb164..fe36e80 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -57,6 +57,8 @@ function buildPostActivationHostAPI( host: { getProviders: () => ReadonlyMap<string, unknown>; getTools: () => ReadonlyMap<string, unknown>; + getAuthProviders: () => ReadonlyMap<string, unknown>; + getAuthProvider: (id: string) => unknown; }, deps: HostDeps, ): HostAPI { @@ -79,6 +81,9 @@ function buildPostActivationHostAPI( logger: deps.logger, getProviders: () => host.getProviders() as ReturnType<HostAPI["getProviders"]>, getTools: () => host.getTools() as ReturnType<HostAPI["getTools"]>, + getAuthProviders: () => host.getAuthProviders() as ReturnType<HostAPI["getAuthProviders"]>, + getAuthProvider: (id: string) => + host.getAuthProvider(id) as ReturnType<HostAPI["getAuthProvider"]>, scheduler: { register: (job: ScheduledJob) => deps.scheduler.register(job) }, }; } diff --git a/packages/kernel/src/contracts/extension.ts b/packages/kernel/src/contracts/extension.ts index 7078a9a..424f714 100644 --- a/packages/kernel/src/contracts/extension.ts +++ b/packages/kernel/src/contracts/extension.ts @@ -230,6 +230,12 @@ export interface HostAPI { /** Read-only view of all registered tools. */ readonly getTools: () => ReadonlyMap<string, ToolContract>; + /** Read-only view of all registered auth providers. */ + readonly getAuthProviders: () => ReadonlyMap<string, AuthContract>; + + /** Look up a single auth provider by id. */ + readonly getAuthProvider: (id: string) => AuthContract | undefined; + /** Register a scheduled job with the host's scheduler. */ readonly scheduler: { readonly register: (job: ScheduledJob) => void; diff --git a/packages/kernel/src/host/host.test.ts b/packages/kernel/src/host/host.test.ts index f7b7eba..82d5177 100644 --- a/packages/kernel/src/host/host.test.ts +++ b/packages/kernel/src/host/host.test.ts @@ -632,6 +632,33 @@ describe("createHost", () => { expect(capturedProviders?.size).toBe(1); expect(capturedProviders?.get("anthropic")).toBe(provider); }); + + it("getAuthProviders/getAuthProvider returns registered auth via HostAPI", async () => { + const auth = createFakeAuth("apikey"); + let capturedAuth: ReadonlyMap<string, AuthContract> | undefined; + let capturedSingle: AuthContract | undefined; + + const producer = createExtension("auth-apikey", { + activate: (host) => { + host.defineAuth(auth); + }, + }); + const consumer = createExtension("consumer", { + dependsOn: ["auth-apikey"], + activate: (host) => { + capturedAuth = host.getAuthProviders(); + capturedSingle = host.getAuthProvider("apikey"); + }, + }); + + const host = createHost([producer, consumer], deps); + await host.activate(); + + expect(capturedAuth).toBeDefined(); + expect(capturedAuth?.size).toBe(1); + expect(capturedAuth?.get("apikey")).toBe(auth); + expect(capturedSingle).toBe(auth); + }); }); describe("DAG errors", () => { diff --git a/packages/kernel/src/host/host.ts b/packages/kernel/src/host/host.ts index e0ca5d5..8592a16 100644 --- a/packages/kernel/src/host/host.ts +++ b/packages/kernel/src/host/host.ts @@ -132,6 +132,12 @@ export function createHost(extensions: readonly Extension[], deps: HostDeps): Ho getTools() { return tools; }, + getAuthProviders() { + return authProviders; + }, + getAuthProvider(id: string) { + return authProviders.get(id); + }, scheduler: { register(job: ScheduledJob) { scheduledJobs.push(job); diff --git a/packages/provider-openai-compat/src/extension.test.ts b/packages/provider-openai-compat/src/extension.test.ts new file mode 100644 index 0000000..6dc409a --- /dev/null +++ b/packages/provider-openai-compat/src/extension.test.ts @@ -0,0 +1,99 @@ +import type { ApiKeyCredentials, AuthContract, HostAPI } from "@dispatch/kernel"; +import { describe, expect, it, vi } from "vitest"; +import { activate, manifest } from "./extension.js"; + +function makeFakeHost(overrides: { + getAuthProvider?: (id: string) => AuthContract | undefined; + configGet?: (key: string) => unknown; +}): { host: HostAPI; defineProvider: ReturnType<typeof vi.fn> } { + const defineProvider = vi.fn(); + const warn = vi.fn(); + const info = vi.fn(); + const host = { + defineProvider, + config: { get: overrides.configGet ?? (() => undefined) }, + logger: { debug: vi.fn(), info, warn, error: vi.fn() }, + getAuthProvider: overrides.getAuthProvider ?? (() => undefined), + } as unknown as HostAPI; + return { host, defineProvider }; +} + +describe("provider-openai-compat activation", () => { + it("registers provider when auth resolves ApiKeyCredentials", async () => { + const fakeCreds: ApiKeyCredentials = { type: "api-key", apiKey: "sk-test" }; + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => fakeCreds, + }; + + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + }); + + await activate(host); + + expect(defineProvider).toHaveBeenCalledTimes(1); + expect(defineProvider.mock.calls[0]?.[0]?.id).toBe("openai-compat"); + }); + + it("does not register provider when getAuthProvider returns undefined", async () => { + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: () => undefined, + }); + + await activate(host); + + expect(defineProvider).not.toHaveBeenCalled(); + }); + + it("does not register provider when resolve returns null", async () => { + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => null, + }; + + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + }); + + await activate(host); + + expect(defineProvider).not.toHaveBeenCalled(); + }); + + it("does not register provider when credentials are not api-key type", async () => { + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => ({ type: "bearer-token", token: "tok-123" }), + }; + + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + }); + + await activate(host); + + expect(defineProvider).not.toHaveBeenCalled(); + }); + + it("uses default model when config returns undefined", async () => { + const fakeCreds: ApiKeyCredentials = { type: "api-key", apiKey: "sk-test" }; + const fakeAuth: AuthContract = { + id: "apikey", + resolve: async () => fakeCreds, + }; + + const { host, defineProvider } = makeFakeHost({ + getAuthProvider: (id) => (id === "apikey" ? fakeAuth : undefined), + configGet: () => undefined, + }); + + await activate(host); + + expect(defineProvider).toHaveBeenCalledTimes(1); + }); + + it("declares dependsOn auth-apikey", () => { + expect(manifest.dependsOn).toEqual(["auth-apikey"]); + }); +}); diff --git a/packages/provider-openai-compat/src/extension.ts b/packages/provider-openai-compat/src/extension.ts index 1026381..4a580d3 100644 --- a/packages/provider-openai-compat/src/extension.ts +++ b/packages/provider-openai-compat/src/extension.ts @@ -1,4 +1,4 @@ -import type { ApiKeyCredentials, Extension, HostAPI, Manifest } from "@dispatch/kernel"; +import type { Extension, HostAPI, Manifest } from "@dispatch/kernel"; import { createOpenAICompatProvider } from "./provider.js"; export const manifest: Manifest = { @@ -6,31 +6,40 @@ export const manifest: Manifest = { name: "OpenAI-Compatible Provider", version: "0.0.0", apiVersion: "^0.1.0", + dependsOn: ["auth-apikey"], trust: "bundled", activation: "eager", capabilities: { network: true }, contributes: { providers: ["openai-compat"] }, }; -export function activate(host: HostAPI): void { - const apiKey = host.config.get<string>("provider.openai-compat.apiKey"); - const baseURL = host.config.get<string>("provider.openai-compat.baseURL"); - const model = host.config.get<string>("provider.openai-compat.model") ?? "deepseek-v4-flash"; +export async function activate(host: HostAPI): Promise<void> { + const auth = host.getAuthProvider("apikey"); + if (!auth) { + host.logger.warn( + "provider-openai-compat: auth-apikey extension not available. Provider not registered.", + ); + return; + } - if (!apiKey) { + const creds = await auth.resolve(); + if (!creds) { host.logger.warn( - "provider-openai-compat: no API key configured (provider.openai-compat.apiKey). Provider not registered.", + "provider-openai-compat: no credentials resolved from auth-apikey. Provider not registered.", ); return; } - const credentials: ApiKeyCredentials = { - type: "api-key", - apiKey, - ...(baseURL !== undefined ? { baseURL } : {}), - }; + if (creds.type !== "api-key") { + host.logger.warn( + `provider-openai-compat: expected api-key credentials but got "${creds.type}". Provider not registered.`, + ); + return; + } + + const model = host.config.get<string>("provider.openai-compat.model") ?? "deepseek-v4-flash"; - const provider = createOpenAICompatProvider({ credentials, model }); + const provider = createOpenAICompatProvider({ credentials: creds, model }); host.defineProvider(provider); host.logger.info(`provider-openai-compat: registered (model=${model})`); } @@ -54,3 +54,33 @@ drive fan-out); extension **loading is dynamic** (manifests via the host). - host + conversation-store ran in parallel (disjoint). ✓ - session-orchestrator + transport-http ran in parallel (disjoint). ✓ - kernel-crs solo (touched shared contracts/extension.ts). + +--- + +## Post-MVP backlog (orchestrator: mimo-v2.5-pro summons) + +### Step 1 — Wire auth → provider properly [x] DONE (verified live) +Design: provider self-registers in `activate` (full-fidelity), so it must reach +the `AuthContract` via the HostAPI. HostAPI exposes `getProviders`/`getTools` but +NOT auth. Fix = small contract addition mirroring the existing provider/tool +precedent. +- **kernel-host** (owner of `host.ts`/`extension.ts`/`host.test.ts`): add + `getAuthProviders()`/`getAuthProvider(id)` to `HostAPI` + implement in + `buildHostAPI()` + tests. `lsp references` drives fan-out. +- **provider-openai-compat** (owner): in `activate`, resolve creds via + `host.getAuthProvider("apikey").resolve()` (ApiKeyCredentials → baseURL/apiKey) + instead of reading `host.config` apiKey/baseURL. Add `dependsOn:["auth-apikey"]`. + Model stays config-driven (not a credential). +- **host-bin** (orchestrator CR wiring): mirror the two getters in + `buildPostActivationHostAPI`. +Sequence: kernel-host FIRST (adds contract surface), THEN provider (consumes it), +THEN host-bin wiring. NOT parallel (provider depends on kernel surface). + +**Step 1 RESULT:** done + verified. kernel-host added `getAuthProviders`/ +`getAuthProvider` to HostAPI; provider-openai-compat `activate` now resolves creds +via `host.getAuthProvider("apikey").resolve()` (`dependsOn:["auth-apikey"]`); +host-bin stub mirrors the getters. 185 tests, typecheck+biome clean. Live curl +returned a real response with auth-apikey on the path (boot log shows auth-apikey +activates before provider; provider registered = creds resolved through contract). +NOTE: host-bin's `buildPostActivationHostAPI` stub is slated for removal in Step 3 +(host CR-1). Summons: prompts/step1-kernel-host.md, prompts/step1-provider.md (mimo-v2.5-pro). |
