Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/openfactory-sandbox-options.md
Original file line number Diff line number Diff line change
@@ -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`.
76 changes: 71 additions & 5 deletions packages/agents/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,12 +52,40 @@ export interface AgentHandlerResult {
* die with the process, which would leave containers running.
*/
shutdownSandboxes: (() => Promise<void>) | 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<string, string>
extraMounts?: Array<BuiltinDockerSandboxMount>
}

export interface BuiltinAgentHandlerOptions {
agentServerUrl: string
serveEndpoint?: string
Expand All @@ -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 {
Expand Down Expand Up @@ -120,6 +151,7 @@ export async function createBuiltinAgentHandler(
baseSkillsDir: baseSkillsDirOverride,
serverHeaders,
defaultDispatchPolicyForType,
dockerSandbox: dockerSandboxOpts,
} = options

const modelCatalog = await createBuiltinModelCatalog({
Expand Down Expand Up @@ -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,
Expand All @@ -192,6 +224,7 @@ export async function createBuiltinAgentHandler(
typeNames,
skillsRegistry,
shutdownSandboxes,
modelCatalog,
}
}

Expand Down Expand Up @@ -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<string, string>
extraMounts?: Array<BuiltinDockerSandboxMount>
} {
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
Expand All @@ -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<SandboxProfile>
shutdownSandboxes: (() => Promise<void>) | null
}> {
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions packages/agents/test/docker-sandbox-options.test.ts
Original file line number Diff line number Diff line change
@@ -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({})
})
})
Loading