diff --git a/.changeset/openfactory-sandbox-options.md b/.changeset/openfactory-sandbox-options.md new file mode 100644 index 0000000000..b5d3434725 --- /dev/null +++ b/.changeset/openfactory-sandbox-options.md @@ -0,0 +1,9 @@ +--- +'@electric-ax/agents': minor +--- + +Embedder customization hooks for the built-in agents: + +- `BuiltinAgentHandlerOptions.dockerSandbox` ({ image, allowFloatingTag, env, extraMounts }) threads into the built-in `docker` sandbox profile. These are embedder/operator-trust inputs: `extraMounts` is subject to the runtime's docker-socket guard and `env` is passed verbatim into the container. +- `AgentHandlerResult.modelCatalog` exposes the resolved model catalog so embedders can register sibling agent types with the same model resolution. +- New exports: `resolveBuiltinModelConfig`, `resolveDockerSandboxOpts`, and types `BuiltinModelCatalog`, `BuiltinAgentModelConfig`, `BuiltinDockerSandboxOptions`, `BuiltinDockerSandboxMount`. diff --git a/packages/agents/src/bootstrap.ts b/packages/agents/src/bootstrap.ts index 236224b542..9e5839bbb3 100644 --- a/packages/agents/src/bootstrap.ts +++ b/packages/agents/src/bootstrap.ts @@ -23,6 +23,7 @@ import { serverLog } from './log' import { registerHorton } from './agents/horton' import { registerWorker } from './agents/worker' import { createBuiltinModelCatalog } from './model-catalog' +import type { BuiltinModelCatalog } from './model-catalog' import { createSkillsRegistry } from '@electric-ax/agents-runtime' import type { AgentTool, @@ -51,12 +52,40 @@ export interface AgentHandlerResult { * die with the process, which would leave containers running. */ shutdownSandboxes: (() => Promise) | null + /** + * Model catalog the built-in agents resolve `model` args against — lets + * embedders register sibling agent types with the same model resolution. + */ + modelCatalog: BuiltinModelCatalog } export type BuiltinElectricToolsFactory = NonNullable< ProcessWakeConfig[`createElectricTools`] > +/** Mount spec mirroring `DockerSandboxOpts['extraMounts']` items. */ +export interface BuiltinDockerSandboxMount { + hostPath: string + containerPath: string + readOnly?: boolean +} + +/** + * Embedder customization for the built-in `docker` sandbox profile. + * Threads straight into `dockerSandbox()` (which already supports these); + * custom `extraMounts` are appended after the working-directory mount. + * These are embedder/operator-trust inputs: `extraMounts` is subject to the + * runtime's docker-socket guard, and `env` is passed verbatim into the + * container. + */ +export interface BuiltinDockerSandboxOptions { + /** Digest-pinned image unless `allowFloatingTag` is set. */ + image?: string + allowFloatingTag?: boolean + env?: Record + extraMounts?: Array +} + export interface BuiltinAgentHandlerOptions { agentServerUrl: string serveEndpoint?: string @@ -72,6 +101,8 @@ export interface BuiltinAgentHandlerOptions { typeName: string ) => DispatchPolicy | undefined createElectricTools?: BuiltinElectricToolsFactory + /** Customize the built-in `docker` sandbox profile (image, env, mounts). */ + dockerSandbox?: BuiltinDockerSandboxOptions } function toolName(tool: AgentTool): string | null { @@ -120,6 +151,7 @@ export async function createBuiltinAgentHandler( baseSkillsDir: baseSkillsDirOverride, serverHeaders, defaultDispatchPolicyForType, + dockerSandbox: dockerSandboxOpts, } = options const modelCatalog = await createBuiltinModelCatalog({ @@ -169,7 +201,7 @@ export async function createBuiltinAgentHandler( typeNames.push(`worker`) const { profiles: sandboxProfiles, shutdownSandboxes } = - await buildBuiltinSandboxProfiles(cwd) + await buildBuiltinSandboxProfiles(cwd, dockerSandboxOpts) const runtime = createRuntimeHandler({ baseUrl: agentServerUrl, @@ -192,6 +224,7 @@ export async function createBuiltinAgentHandler( typeNames, skillsRegistry, shutdownSandboxes, + modelCatalog, } } @@ -255,6 +288,33 @@ function sweepOrphanedDockerSandboxesOnce( return dockerBootSweep } +/** + * Merge the profile's working-directory mount with embedder docker options + * into the option fragment spread into `dockerSandbox()`. Exported for tests. + */ +export function resolveDockerSandboxOpts( + cwdMount: BuiltinDockerSandboxMount | undefined, + custom: BuiltinDockerSandboxOptions | undefined +): { + image?: string + allowFloatingTag?: boolean + env?: Record + extraMounts?: Array +} { + const extraMounts = [ + ...(cwdMount ? [cwdMount] : []), + ...(custom?.extraMounts ?? []), + ] + return { + ...(custom?.image !== undefined && { image: custom.image }), + ...(custom?.allowFloatingTag !== undefined && { + allowFloatingTag: custom.allowFloatingTag, + }), + ...(custom?.env !== undefined && { env: custom.env }), + ...(extraMounts.length > 0 && { extraMounts }), + } +} + /** * Built-in sandbox profiles. `local` is always available. `docker` is * gated on Docker being reachable so a user without Docker installed @@ -265,7 +325,10 @@ function sweepOrphanedDockerSandboxesOnce( * server must run on shutdown (the providers' debounced idle teardowns die * with the process). */ -async function buildBuiltinSandboxProfiles(workingDirectory: string): Promise<{ +async function buildBuiltinSandboxProfiles( + workingDirectory: string, + dockerOpts?: BuiltinDockerSandboxOptions +): Promise<{ profiles: Array shutdownSandboxes: (() => Promise) | null }> { @@ -327,9 +390,12 @@ async function buildBuiltinSandboxProfiles(workingDirectory: string): Promise<{ // fully-isolated rather than splitting into `docker-permissive` // / `docker-isolated`. initialNetworkPolicy: { mode: `allow-all` }, - extraMounts: cwd - ? [{ hostPath: cwd, containerPath: `/work`, readOnly: false }] - : undefined, + ...resolveDockerSandboxOpts( + cwd + ? { hostPath: cwd, containerPath: `/work`, readOnly: false } + : undefined, + dockerOpts + ), // The container is always named-by-key and reattachable; // `persistent` chooses idle teardown (stop vs remove) and // `owner` gates creation (an attacher reattaches only). All diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 4c80b1537c..29308328a1 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -5,11 +5,14 @@ export { createAgentHandler, registerBuiltinAgentTypes, registerAgentTypes, + resolveDockerSandboxOpts, } from './bootstrap.js' export type { AgentHandlerResult, BuiltinElectricToolsFactory, BuiltinAgentHandlerOptions, + BuiltinDockerSandboxMount, + BuiltinDockerSandboxOptions, } from './bootstrap.js' export { BuiltinAgentsServer } from './server.js' @@ -47,11 +50,14 @@ export { export { builtinModelProviderLabel, listBuiltinModelChoices, + resolveBuiltinModelConfig, } from './model-catalog.js' export type { BuiltinModelCatalogOptions, BuiltinModelChoice, BuiltinModelProvider, + BuiltinAgentModelConfig, + BuiltinModelCatalog, } from './model-catalog.js' export { registerWorker } from './agents/worker.js' export { diff --git a/packages/agents/test/docker-sandbox-options.test.ts b/packages/agents/test/docker-sandbox-options.test.ts new file mode 100644 index 0000000000..bfe908d9bc --- /dev/null +++ b/packages/agents/test/docker-sandbox-options.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { resolveDockerSandboxOpts } from '../src/bootstrap' + +const cwdMount = { + hostPath: `/home/u/project`, + containerPath: `/work`, + readOnly: false, +} + +describe(`resolveDockerSandboxOpts`, () => { + it(`returns only the cwd mount when no custom options are given`, () => { + expect(resolveDockerSandboxOpts(cwdMount, undefined)).toEqual({ + extraMounts: [cwdMount], + }) + }) + + it(`threads image, allowFloatingTag, and env through`, () => { + expect( + resolveDockerSandboxOpts(undefined, { + image: `ghcr.io/acme/sandbox@sha256:abc`, + allowFloatingTag: false, + env: { FOO: `bar` }, + }) + ).toEqual({ + image: `ghcr.io/acme/sandbox@sha256:abc`, + allowFloatingTag: false, + env: { FOO: `bar` }, + }) + }) + + it(`appends custom mounts after the cwd mount`, () => { + const custom = { + extraMounts: [ + { + hostPath: `/secrets/key.pem`, + containerPath: `/secrets/key.pem`, + readOnly: true, + }, + ], + } + expect(resolveDockerSandboxOpts(cwdMount, custom)).toEqual({ + extraMounts: [cwdMount, custom.extraMounts[0]], + }) + }) + + it(`returns an empty object when there is nothing to apply`, () => { + expect(resolveDockerSandboxOpts(undefined, undefined)).toEqual({}) + }) +})