From 1f3b023a5a0c80bcac5840c87df66f407d222c95 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 17 Apr 2026 11:34:29 +0300 Subject: [PATCH 1/2] refactor(v2/providers): self-registration barrel + host container-config registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Providers now mirror the channels pattern: each module calls registerProvider() at top level, and providers/index.ts is a barrel of side-effect imports. createProvider() becomes a thin registry lookup; the closed ProviderName union is gone (now a string alias, since the env var is a runtime string anyway). Also adds a host-side provider-container-registry so providers can declare their own mounts and env passthrough in src/providers/.ts instead of the container-runner having to know about each one. The resolver runs once per spawn and threads provider + contribution through buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire exactly once. Both barrels are append-only — adding a new provider is a new file + one import line per barrel, no edits to existing files. The built-in providers (claude, mock) don't need host-side config, so src/providers/ ships with an empty barrel; the container-side barrel imports both. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/index.ts | 6 +- .../agent-runner/src/providers/claude.ts | 3 + .../agent-runner/src/providers/factory.ts | 19 +++-- container/agent-runner/src/providers/index.ts | 6 ++ container/agent-runner/src/providers/mock.ts | 3 + .../src/providers/provider-registry.ts | 33 +++++++++ src/container-runner.ts | 69 ++++++++++++++++--- src/providers/index.ts | 6 ++ src/providers/provider-container-registry.ts | 63 +++++++++++++++++ 9 files changed, 186 insertions(+), 22 deletions(-) create mode 100644 container/agent-runner/src/providers/index.ts create mode 100644 container/agent-runner/src/providers/provider-registry.ts create mode 100644 src/providers/index.ts create mode 100644 src/providers/provider-container-registry.ts diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 4cf3bc3..5c73dfb 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -8,7 +8,8 @@ * - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db) * - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db) * - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat) - * - AGENT_PROVIDER: 'claude' | 'mock' (default: claude) + * - AGENT_PROVIDER: any registered provider name (default: claude). The + * set of registered providers is whatever `providers/index.ts` imports. * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving * - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands * @@ -27,6 +28,9 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { buildSystemPromptAddendum } from './destinations.js'; +// Providers barrel — each enabled provider self-registers on import. +// Provider skills append imports to providers/index.ts. +import './providers/index.js'; import { createProvider, type ProviderName } from './providers/factory.js'; import { runPollLoop } from './poll-loop.js'; diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 699a93b..97fe44a 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -3,6 +3,7 @@ import path from 'path'; import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { registerProvider } from './provider-registry.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; function log(msg: string): void { @@ -271,3 +272,5 @@ export class ClaudeProvider implements AgentProvider { }; } } + +registerProvider('claude', (opts) => new ClaudeProvider(opts)); diff --git a/container/agent-runner/src/providers/factory.ts b/container/agent-runner/src/providers/factory.ts index cf20b45..8a14da9 100644 --- a/container/agent-runner/src/providers/factory.ts +++ b/container/agent-runner/src/providers/factory.ts @@ -1,16 +1,13 @@ import type { AgentProvider, ProviderOptions } from './types.js'; -import { ClaudeProvider } from './claude.js'; -import { MockProvider } from './mock.js'; +import { getProviderFactory } from './provider-registry.js'; -export type ProviderName = 'claude' | 'mock'; +/** + * Any registered provider name. Kept as a named alias for readability; the + * set of valid names is open and determined at runtime by whichever provider + * modules the `providers/index.ts` barrel imports. + */ +export type ProviderName = string; export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider { - switch (name) { - case 'claude': - return new ClaudeProvider(options); - case 'mock': - return new MockProvider(options); - default: - throw new Error(`Unknown provider: ${name}`); - } + return getProviderFactory(name)(options); } diff --git a/container/agent-runner/src/providers/index.ts b/container/agent-runner/src/providers/index.ts new file mode 100644 index 0000000..70497cf --- /dev/null +++ b/container/agent-runner/src/providers/index.ts @@ -0,0 +1,6 @@ +// Provider self-registration barrel. +// Each import triggers the provider module's registerProvider() call at top +// level. Skills add a new provider by appending one import line below. + +import './claude.js'; +import './mock.js'; diff --git a/container/agent-runner/src/providers/mock.ts b/container/agent-runner/src/providers/mock.ts index d283957..f941f09 100644 --- a/container/agent-runner/src/providers/mock.ts +++ b/container/agent-runner/src/providers/mock.ts @@ -1,3 +1,4 @@ +import { registerProvider } from './provider-registry.js'; import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; /** @@ -72,3 +73,5 @@ export class MockProvider implements AgentProvider { }; } } + +registerProvider('mock', (opts) => new MockProvider(opts)); diff --git a/container/agent-runner/src/providers/provider-registry.ts b/container/agent-runner/src/providers/provider-registry.ts new file mode 100644 index 0000000..250cf72 --- /dev/null +++ b/container/agent-runner/src/providers/provider-registry.ts @@ -0,0 +1,33 @@ +/** + * Provider self-registration registry. + * + * Mirrors `src/channels/channel-registry.ts` on the host. Each provider module + * calls `registerProvider()` at top level; the barrel (`providers/index.ts`) + * imports every provider module for its side effect so registrations fire + * before `createProvider()` is called. + */ +import type { AgentProvider, ProviderOptions } from './types.js'; + +export type ProviderFactory = (options: ProviderOptions) => AgentProvider; + +const registry = new Map(); + +export function registerProvider(name: string, factory: ProviderFactory): void { + if (registry.has(name)) { + throw new Error(`Provider already registered: ${name}`); + } + registry.set(name, factory); +} + +export function getProviderFactory(name: string): ProviderFactory { + const factory = registry.get(name); + if (!factory) { + const known = [...registry.keys()].join(', ') || '(none)'; + throw new Error(`Unknown provider: ${name}. Registered: ${known}`); + } + return factory; +} + +export function listProviderNames(): string[] { + return [...registry.keys()]; +} diff --git a/src/container-runner.ts b/src/container-runner.ts index b8c6dc9..1cce1b3 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -18,6 +18,14 @@ import { initGroupFilesystem } from './group-init.js'; import { stopTypingRefresh } from './delivery.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; +// Provider host-side config barrel — each provider that needs host-side +// container setup self-registers on import. +import './providers/index.js'; +import { + getProviderContainerConfig, + type ProviderContainerContribution, + type VolumeMount, +} from './providers/provider-container-registry.js'; import { markContainerIdle, markContainerRunning, @@ -30,12 +38,6 @@ import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL }); -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - /** Active containers tracked by session ID. */ const activeContainers = new Map(); @@ -92,12 +94,25 @@ async function spawnContainer(session: Session): Promise { writeDestinations(agentGroup.id, session.id); writeSessionRouting(agentGroup.id, session.id); - const mounts = buildMounts(agentGroup, session); + // Resolve the effective provider + any host-side contribution it declares + // (extra mounts, env passthrough). Computed once and threaded through both + // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once. + const { provider, contribution } = resolveProviderContribution(session, agentGroup); + + const mounts = buildMounts(agentGroup, session, contribution); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; // OneCLI agent identifier is always the agent group id — stable across // sessions and reversible via getAgentGroup() for approval routing. const agentIdentifier = agentGroup.id; - const args = await buildContainerArgs(mounts, containerName, session, agentGroup, agentIdentifier); + const args = await buildContainerArgs( + mounts, + containerName, + session, + agentGroup, + provider, + contribution, + agentIdentifier, + ); log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); @@ -166,7 +181,27 @@ export function killContainer(sessionId: string, reason: string): void { } } -function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { +function resolveProviderContribution( + session: Session, + agentGroup: AgentGroup, +): { provider: string; contribution: ProviderContainerContribution } { + const provider = (session.agent_provider || agentGroup.agent_provider || 'claude').toLowerCase(); + const fn = getProviderContainerConfig(provider); + const contribution = fn + ? fn({ + sessionDir: sessionDir(agentGroup.id, session.id), + agentGroupId: agentGroup.id, + hostEnv: process.env, + }) + : {}; + return { provider, contribution }; +} + +function buildMounts( + agentGroup: AgentGroup, + session: Session, + providerContribution: ProviderContainerContribution, +): VolumeMount[] { // Per-group filesystem state lives forever after first creation. Init is // idempotent: it only writes paths that don't already exist, so this call // is a no-op for groups that have spawned before. Pulling in upstream @@ -208,6 +243,11 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { mounts.push(...validated); } + // Provider-contributed mounts (e.g. opencode-xdg) + if (providerContribution.mounts) { + mounts.push(...providerContribution.mounts); + } + return mounts; } @@ -216,13 +256,15 @@ async function buildContainerArgs( containerName: string, session: Session, agentGroup: AgentGroup, + provider: string, + providerContribution: ProviderContainerContribution, agentIdentifier?: string, ): Promise { const args: string[] = ['run', '--rm', '--name', containerName]; // Environment args.push('-e', `TZ=${TIMEZONE}`); - args.push('-e', `AGENT_PROVIDER=${session.agent_provider || agentGroup.agent_provider || 'claude'}`); + args.push('-e', `AGENT_PROVIDER=${provider}`); // Two-DB split: container reads inbound.db, writes outbound.db args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db'); args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db'); @@ -234,6 +276,13 @@ async function buildContainerArgs( args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`); args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`); + // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). + if (providerContribution.env) { + for (const [key, value] of Object.entries(providerContribution.env)) { + args.push('-e', `${key}=${value}`); + } + } + // Users allowed to run admin commands (e.g. /clear) inside this container. // Computed at wake time: owners + global admins + admins scoped to this // agent group. Role changes take effect on next container spawn. diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..3ec9512 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,6 @@ +// Host-side provider container-config barrel. +// Providers that need host-side container setup (extra mounts, env passthrough, +// per-session directories) self-register on import. Providers with no host +// needs (claude, mock) don't appear here. +// +// Skills add a new provider by appending one import line below. diff --git a/src/providers/provider-container-registry.ts b/src/providers/provider-container-registry.ts new file mode 100644 index 0000000..dbddec6 --- /dev/null +++ b/src/providers/provider-container-registry.ts @@ -0,0 +1,63 @@ +/** + * Host-side provider container-config registry. + * + * Providers that need per-spawn host-side setup (extra volume mounts, env var + * passthrough, per-session directories) register a function here. The + * container-runner resolves the session's effective provider name, looks up + * the registered config fn, and merges the returned mounts/env into the spawn + * args. + * + * Providers without host-side needs (e.g. `claude`, `mock`) don't appear in + * this registry at all — the lookup returns `undefined` and the spawn path + * proceeds with only the default mounts and env. + * + * Skills add a new provider's host config by creating `src/providers/.ts` + * with a top-level `registerProviderContainerConfig(...)` call, then appending + * `import './.js';` to `src/providers/index.ts` (the barrel). + */ + +export interface VolumeMount { + hostPath: string; + containerPath: string; + readonly: boolean; +} + +export interface ProviderContainerContext { + /** Per-session host directory: `/v2-sessions/`. */ + sessionDir: string; + /** Agent group ID, for any per-group logic. */ + agentGroupId: string; + /** `process.env` at spawn time — pull passthrough values from here. */ + hostEnv: NodeJS.ProcessEnv; +} + +export interface ProviderContainerContribution { + /** Extra volume mounts (merged with the default session/group/agent-runner mounts). */ + mounts?: VolumeMount[]; + /** Extra env vars to pass to the container (`-e KEY=VALUE`). */ + env?: Record; +} + +export type ProviderContainerConfigFn = ( + ctx: ProviderContainerContext, +) => ProviderContainerContribution; + +const registry = new Map(); + +export function registerProviderContainerConfig( + name: string, + fn: ProviderContainerConfigFn, +): void { + if (registry.has(name)) { + throw new Error(`Provider container config already registered: ${name}`); + } + registry.set(name, fn); +} + +export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined { + return registry.get(name); +} + +export function listProviderContainerConfigNames(): string[] { + return [...registry.keys()]; +} From 7639f7b1bba77850e708673029d423dedf0a9b9f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 17 Apr 2026 11:50:12 +0300 Subject: [PATCH 2/2] style(v2/providers): prettier reflow of provider-container-registry signatures Co-Authored-By: Claude Opus 4.7 (1M context) --- src/providers/provider-container-registry.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/providers/provider-container-registry.ts b/src/providers/provider-container-registry.ts index dbddec6..1fe03e6 100644 --- a/src/providers/provider-container-registry.ts +++ b/src/providers/provider-container-registry.ts @@ -38,16 +38,11 @@ export interface ProviderContainerContribution { env?: Record; } -export type ProviderContainerConfigFn = ( - ctx: ProviderContainerContext, -) => ProviderContainerContribution; +export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution; const registry = new Map(); -export function registerProviderContainerConfig( - name: string, - fn: ProviderContainerConfigFn, -): void { +export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void { if (registry.has(name)) { throw new Error(`Provider container config already registered: ${name}`); }