Merge pull request #1814 from qwibitai/refactor/provider-barrel-v2
refactor(v2/providers): self-registration barrel + host container-config registry
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
* - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db)
|
* - 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_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db)
|
||||||
* - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat)
|
* - 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_ASSISTANT_NAME: assistant name for transcript archiving
|
||||||
* - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands
|
* - 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 { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import { buildSystemPromptAddendum } from './destinations.js';
|
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 { createProvider, type ProviderName } from './providers/factory.js';
|
||||||
import { runPollLoop } from './poll-loop.js';
|
import { runPollLoop } from './poll-loop.js';
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
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';
|
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||||
|
|
||||||
function log(msg: string): void {
|
function log(msg: string): void {
|
||||||
@@ -271,3 +272,5 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerProvider('claude', (opts) => new ClaudeProvider(opts));
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import type { AgentProvider, ProviderOptions } from './types.js';
|
import type { AgentProvider, ProviderOptions } from './types.js';
|
||||||
import { ClaudeProvider } from './claude.js';
|
import { getProviderFactory } from './provider-registry.js';
|
||||||
import { MockProvider } from './mock.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 {
|
export function createProvider(name: ProviderName, options: ProviderOptions = {}): AgentProvider {
|
||||||
switch (name) {
|
return getProviderFactory(name)(options);
|
||||||
case 'claude':
|
|
||||||
return new ClaudeProvider(options);
|
|
||||||
case 'mock':
|
|
||||||
return new MockProvider(options);
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown provider: ${name}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
6
container/agent-runner/src/providers/index.ts
Normal file
6
container/agent-runner/src/providers/index.ts
Normal file
@@ -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';
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { registerProvider } from './provider-registry.js';
|
||||||
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.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));
|
||||||
|
|||||||
33
container/agent-runner/src/providers/provider-registry.ts
Normal file
33
container/agent-runner/src/providers/provider-registry.ts
Normal file
@@ -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<string, ProviderFactory>();
|
||||||
|
|
||||||
|
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()];
|
||||||
|
}
|
||||||
@@ -18,6 +18,14 @@ import { initGroupFilesystem } from './group-init.js';
|
|||||||
import { stopTypingRefresh } from './delivery.js';
|
import { stopTypingRefresh } from './delivery.js';
|
||||||
import { log } from './log.js';
|
import { log } from './log.js';
|
||||||
import { validateAdditionalMounts } from './mount-security.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 {
|
import {
|
||||||
markContainerIdle,
|
markContainerIdle,
|
||||||
markContainerRunning,
|
markContainerRunning,
|
||||||
@@ -30,12 +38,6 @@ import type { AgentGroup, Session } from './types.js';
|
|||||||
|
|
||||||
const onecli = new OneCLI({ url: ONECLI_URL });
|
const onecli = new OneCLI({ url: ONECLI_URL });
|
||||||
|
|
||||||
interface VolumeMount {
|
|
||||||
hostPath: string;
|
|
||||||
containerPath: string;
|
|
||||||
readonly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Active containers tracked by session ID. */
|
/** Active containers tracked by session ID. */
|
||||||
const activeContainers = new Map<string, { process: ChildProcess; containerName: string }>();
|
const activeContainers = new Map<string, { process: ChildProcess; containerName: string }>();
|
||||||
|
|
||||||
@@ -92,12 +94,25 @@ async function spawnContainer(session: Session): Promise<void> {
|
|||||||
writeDestinations(agentGroup.id, session.id);
|
writeDestinations(agentGroup.id, session.id);
|
||||||
writeSessionRouting(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()}`;
|
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
|
||||||
// OneCLI agent identifier is always the agent group id — stable across
|
// OneCLI agent identifier is always the agent group id — stable across
|
||||||
// sessions and reversible via getAgentGroup() for approval routing.
|
// sessions and reversible via getAgentGroup() for approval routing.
|
||||||
const agentIdentifier = agentGroup.id;
|
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 });
|
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
|
// Per-group filesystem state lives forever after first creation. Init is
|
||||||
// idempotent: it only writes paths that don't already exist, so this call
|
// 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
|
// 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);
|
mounts.push(...validated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provider-contributed mounts (e.g. opencode-xdg)
|
||||||
|
if (providerContribution.mounts) {
|
||||||
|
mounts.push(...providerContribution.mounts);
|
||||||
|
}
|
||||||
|
|
||||||
return mounts;
|
return mounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,13 +256,15 @@ async function buildContainerArgs(
|
|||||||
containerName: string,
|
containerName: string,
|
||||||
session: Session,
|
session: Session,
|
||||||
agentGroup: AgentGroup,
|
agentGroup: AgentGroup,
|
||||||
|
provider: string,
|
||||||
|
providerContribution: ProviderContainerContribution,
|
||||||
agentIdentifier?: string,
|
agentIdentifier?: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const args: string[] = ['run', '--rm', '--name', containerName];
|
const args: string[] = ['run', '--rm', '--name', containerName];
|
||||||
|
|
||||||
// Environment
|
// Environment
|
||||||
args.push('-e', `TZ=${TIMEZONE}`);
|
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
|
// Two-DB split: container reads inbound.db, writes outbound.db
|
||||||
args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db');
|
args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db');
|
||||||
args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.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_ID=${agentGroup.id}`);
|
||||||
args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`);
|
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.
|
// Users allowed to run admin commands (e.g. /clear) inside this container.
|
||||||
// Computed at wake time: owners + global admins + admins scoped to this
|
// Computed at wake time: owners + global admins + admins scoped to this
|
||||||
// agent group. Role changes take effect on next container spawn.
|
// agent group. Role changes take effect on next container spawn.
|
||||||
|
|||||||
6
src/providers/index.ts
Normal file
6
src/providers/index.ts
Normal file
@@ -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.
|
||||||
58
src/providers/provider-container-registry.ts
Normal file
58
src/providers/provider-container-registry.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* 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/<name>.ts`
|
||||||
|
* with a top-level `registerProviderContainerConfig(...)` call, then appending
|
||||||
|
* `import './<name>.js';` to `src/providers/index.ts` (the barrel).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VolumeMount {
|
||||||
|
hostPath: string;
|
||||||
|
containerPath: string;
|
||||||
|
readonly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderContainerContext {
|
||||||
|
/** Per-session host directory: `<DATA_DIR>/v2-sessions/<session_id>`. */
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
|
||||||
|
|
||||||
|
const registry = new Map<string, ProviderContainerConfigFn>();
|
||||||
|
|
||||||
|
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()];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user