summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 09:51:00 +0900
committerAdam Malczewski <[email protected]>2026-06-10 09:51:00 +0900
commita980aba97aed34692dd655e716747c4ff53fb186 (patch)
tree56b32ed9db8f66f9f6141eda923f5d1f7c1ba6d9 /packages
parent52ce1bb8b25cc6ba4ba7d2734c35c95e0a08d723 (diff)
downloaddispatch-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.ts11
-rw-r--r--packages/host-bin/src/load-external.test.ts47
-rw-r--r--packages/host-bin/src/load-external.ts47
-rw-r--r--packages/host-bin/src/main.ts42
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();