diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 09:51:00 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 09:51:00 +0900 |
| commit | a980aba97aed34692dd655e716747c4ff53fb186 (patch) | |
| tree | 56b32ed9db8f66f9f6141eda923f5d1f7c1ba6d9 /packages | |
| parent | 52ce1bb8b25cc6ba4ba7d2734c35c95e0a08d723 (diff) | |
| download | dispatch-a980aba97aed34692dd655e716747c4ff53fb186.tar.gz dispatch-a980aba97aed34692dd655e716747c4ff53fb186.zip | |
host-bin: external-extension loader + claude credential wiring
Add loadExternalExtensions(): fault-isolated dynamic import of out-of-repo
extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. main.ts assembles the
credential-store in boot() so a 'claude' credential is registered when an
external anthropic provider is loaded; config.ts surfaces the anthropic model /
credential-key settings those extensions read.
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/host-bin/src/config.ts | 11 | ||||
| -rw-r--r-- | packages/host-bin/src/load-external.test.ts | 47 | ||||
| -rw-r--r-- | packages/host-bin/src/load-external.ts | 47 | ||||
| -rw-r--r-- | packages/host-bin/src/main.ts | 42 |
4 files changed, 142 insertions, 5 deletions
diff --git a/packages/host-bin/src/config.ts b/packages/host-bin/src/config.ts index cf6b0ce..f79ef4b 100644 --- a/packages/host-bin/src/config.ts +++ b/packages/host-bin/src/config.ts @@ -20,6 +20,17 @@ export function envToConfigMap( map["provider.openai-compat.model"] = model; } + // Optional settings consumed by external extensions (e.g. the Claude provider). + const anthropicModel = env.DISPATCH_ANTHROPIC_MODEL; + if (anthropicModel !== undefined) { + map["provider.anthropic.model"] = anthropicModel; + } + + const claudeCredentialKey = env.DISPATCH_CLAUDE_CREDENTIAL_KEY; + if (claudeCredentialKey !== undefined) { + map["claude.credentialKey"] = claudeCredentialKey; + } + const httpPort = env.BACKEND_PORT ?? env.PORT; if (httpPort !== undefined) { const n = Number(httpPort); diff --git a/packages/host-bin/src/load-external.test.ts b/packages/host-bin/src/load-external.test.ts new file mode 100644 index 0000000..38e3622 --- /dev/null +++ b/packages/host-bin/src/load-external.test.ts @@ -0,0 +1,47 @@ +import type { Logger } from "@dispatch/kernel"; +import { describe, expect, it } from "vitest"; +import { loadExternalExtensions } from "./load-external.js"; + +function makeLogger(): Logger { + const noop = () => {}; + const logger = { + debug: noop, + info: noop, + warn: noop, + error: noop, + child: () => logger, + span: () => { + throw new Error("not used"); + }, + } as unknown as Logger; + return logger; +} + +describe("loadExternalExtensions", () => { + it("returns an empty array for no specifiers", async () => { + expect(await loadExternalExtensions([], makeLogger())).toEqual([]); + }); + + it("loads a real extension module by package name", async () => { + // auth-apikey is a bundled extension that exports `extension`; we use it as + // a stand-in for an external module to exercise the dynamic-import path. + const loaded = await loadExternalExtensions(["@dispatch/auth-apikey"], makeLogger()); + expect(loaded).toHaveLength(1); + expect(loaded[0]?.manifest.id).toBe("auth-apikey"); + }); + + it("skips a specifier that cannot be imported without throwing", async () => { + const loaded = await loadExternalExtensions( + ["./does-not-exist-xyz.js", "@dispatch/auth-apikey"], + makeLogger(), + ); + // The bad one is skipped; the good one still loads. + expect(loaded.map((e) => e.manifest.id)).toEqual(["auth-apikey"]); + }); + + it("skips a module that exports no valid extension", async () => { + // `@dispatch/journal-sink` exports factories but no `extension`. + const loaded = await loadExternalExtensions(["@dispatch/journal-sink"], makeLogger()); + expect(loaded).toEqual([]); + }); +}); diff --git a/packages/host-bin/src/load-external.ts b/packages/host-bin/src/load-external.ts new file mode 100644 index 0000000..34b8bce --- /dev/null +++ b/packages/host-bin/src/load-external.ts @@ -0,0 +1,47 @@ +import type { Extension, Logger } from "@dispatch/kernel"; + +/** + * Load external (out-of-repo) extensions by dynamic import. + * + * Each specifier is a module the host imports at boot: an absolute path to a + * built/source entry, or a package name resolvable from the host's working + * directory. The module must expose an `Extension` as `export const extension` + * (or as its default export). The host's own loader (`createHost`) then applies + * apiVersion gating, dependency ordering, the capability gate, and per-extension + * fault isolation — external extensions get exactly the same treatment as + * bundled ones. + * + * Fault-isolated by design: a specifier that fails to import or exports no valid + * extension is logged and SKIPPED — a broken external extension must never crash + * boot (defend faults, not adversaries; never leave the system broken). + */ +export async function loadExternalExtensions( + specifiers: readonly string[], + logger: Logger, +): Promise<Extension[]> { + const loaded: Extension[] = []; + for (const spec of specifiers) { + try { + const mod = (await import(spec)) as Record<string, unknown>; + const candidate = mod.extension ?? mod.default ?? mod; + if (isExtension(candidate)) { + loaded.push(candidate); + logger.info(`Loaded external extension "${candidate.manifest.id}" from ${spec}`); + } else { + logger.warn(`External module "${spec}" has no valid extension export; skipped`); + } + } catch (err) { + logger.error(`Failed to load external extension "${spec}"; skipped`, { err }); + } + } + return loaded; +} + +/** Structural check that a dynamically-imported value is an `Extension`. */ +function isExtension(value: unknown): value is Extension { + if (!value || typeof value !== "object") return false; + const e = value as { manifest?: unknown; activate?: unknown }; + if (typeof e.activate !== "function") return false; + const m = e.manifest as { id?: unknown } | undefined; + return !!m && typeof m.id === "string"; +} diff --git a/packages/host-bin/src/main.ts b/packages/host-bin/src/main.ts index b18f105..ec51d11 100644 --- a/packages/host-bin/src/main.ts +++ b/packages/host-bin/src/main.ts @@ -29,6 +29,7 @@ import { createTransportWsExtension } from "@dispatch/transport-ws"; import type { ChildHandle } from "./collector-supervisor.js"; import { createCollectorSupervisor } from "./collector-supervisor.js"; import { configMapToAccess, envToConfigMap } from "./config.js"; +import { loadExternalExtensions } from "./load-external.js"; function createEmptySecrets(): SecretsAccess { return { @@ -52,16 +53,15 @@ function createNoopEvents(): EventsEmitter { return { emit: () => {} }; } +// Core extensions EXCEPT the credential-store, which is assembled in boot() so +// its credential list can include any credentials backed by external providers +// (e.g. a `claude` credential once the external Anthropic provider is loaded). const CORE_EXTENSIONS: readonly Extension[] = [ storageSqliteExt, conversationStoreExt, authApikeyExt, providerOpenaiCompatExt, toolReadFileExt, - // MVP single hardcoded credential; future work makes it config/TOML-driven. - createCredentialStoreExtension({ - credentials: [{ name: "opencode", providerId: "openai-compat" }], - }), sessionOrchestratorExt, createTransportHttpExtension(), // Surface extensions — dependency order: surface-registry first, then consumers. @@ -70,6 +70,14 @@ const CORE_EXTENSIONS: readonly Extension[] = [ createLoadedExtensionsExtension(), ]; +/** Parse the comma-separated list of external extension module specifiers. */ +function parseExternalSpecifiers(env: Readonly<Record<string, string | undefined>>): string[] { + return (env.DISPATCH_EXTERNAL_EXTENSIONS ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + async function boot(): Promise<void> { const journalPath = process.env.DISPATCH_JOURNAL ?? "./.dispatch/journal/app.ndjson"; mkdirSync(dirname(journalPath), { recursive: true }); @@ -115,7 +123,31 @@ async function boot(): Promise<void> { logDeps, }; - const host = createHost(CORE_EXTENSIONS, deps); + // Load external (out-of-repo) extensions declared via DISPATCH_EXTERNAL_EXTENSIONS. + const externalSpecifiers = parseExternalSpecifiers( + process.env as Readonly<Record<string, string | undefined>>, + ); + const externalExtensions = await loadExternalExtensions(externalSpecifiers, logger); + + // Assemble the credential list. MVP keeps the hardcoded `opencode` credential + // and adds a `claude` credential when an external Anthropic provider is loaded. + const credentials = [{ name: "opencode", providerId: "openai-compat" }]; + const hasAnthropic = externalExtensions.some((e) => + e.manifest.contributes?.providers?.includes("anthropic"), + ); + if (hasAnthropic) { + const claudeName = process.env.DISPATCH_CLAUDE_CREDENTIAL ?? "claude"; + credentials.push({ name: claudeName, providerId: "anthropic" }); + logger.info(`Registered credential "${claudeName}" → anthropic provider`); + } + + const extensions: Extension[] = [ + ...CORE_EXTENSIONS, + createCredentialStoreExtension({ credentials }), + ...externalExtensions, + ]; + + const host = createHost(extensions, deps); await host.activate(); const disabled = host.getDisabled(); |
