@bakers-build/addon-runtime-docker 0.0.0-stage → 0.9.1-next.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-runtime-docker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Start each agent run in its own Docker or Podman container.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://github.com/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,44 @@
1
+ import type { RuntimeLaunch } from '@bakers-build/conductor/contract';
2
+ export interface DockerOptions {
3
+ /** The container CLI: `docker`, or `podman`. */
4
+ docker: string;
5
+ image: string;
6
+ /** Extra host paths, mounted read-only at the same path. */
7
+ mounts: string[];
8
+ network: 'host' | 'allowlist';
9
+ /** The conductor-specific internal network used in allowlist mode. */
10
+ runNetwork: string;
11
+ /** Allowlist mode: the conductor's proxy sidecar name. */
12
+ proxy?: string;
13
+ }
14
+ /** Host facts the launch depends on, resolved by the backend. */
15
+ export interface HostPaths {
16
+ /** The run clone's Git directory, mounted as `<workspace>/.git`. */
17
+ runGitDir?: string;
18
+ /** A `shared` clone's parent object store, mounted read-only at its own path. */
19
+ sharedObjects?: string;
20
+ /** The per-run home, mounted at its host path. */
21
+ runHome: string;
22
+ /** The bakers CLI build, mounted read-only at its host path. */
23
+ cliRoot: string;
24
+ uid: number;
25
+ gid: number;
26
+ }
27
+ /** The image's PATH, behind the run home's `bin` (the `bakers` shim lives there). */
28
+ export declare const RUN_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
29
+ export declare const PROXY_PORTS: {
30
+ readonly egress: 3128;
31
+ readonly api: 8081;
32
+ readonly tools: 8082;
33
+ };
34
+ /** What the run sends as its API key in allowlist mode; the proxy swaps in the real one. */
35
+ export declare const API_KEY_PLACEHOLDER = "bb-runtime-placeholder";
36
+ /**
37
+ * The `docker run` argv for one run. Pure: every secret travels as `-e KEY`
38
+ * with its value in the returned env, which the executor hands the docker
39
+ * client; nothing of the operator home is mounted beyond the list below.
40
+ */
41
+ export declare function dockerArgs(input: RuntimeLaunch, opts: DockerOptions, where: HostPaths): {
42
+ argv: string[];
43
+ env: Record<string, string>;
44
+ };
@@ -0,0 +1,75 @@
1
+ /** The image's PATH, behind the run home's `bin` (the `bakers` shim lives there). */
2
+ export const RUN_PATH = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`;
3
+ export const PROXY_PORTS = { egress: 3128, api: 8081, tools: 8082 };
4
+ /** What the run sends as its API key in allowlist mode; the proxy swaps in the real one. */
5
+ export const API_KEY_PLACEHOLDER = 'bb-runtime-placeholder';
6
+ /** Keys whose host value means nothing inside the container. */
7
+ const HOST_ONLY = (key) => key === 'PATH' ||
8
+ key === 'HOME' ||
9
+ key === 'TMPDIR' ||
10
+ key.startsWith('XDG_');
11
+ /**
12
+ * The `docker run` argv for one run. Pure: every secret travels as `-e KEY`
13
+ * with its value in the returned env, which the executor hands the docker
14
+ * client; nothing of the operator home is mounted beyond the list below.
15
+ */
16
+ export function dockerArgs(input, opts, where) {
17
+ const ws = input.workspacePath;
18
+ const api = input.modelApi;
19
+ const bind = (from, to = from, mode = '') => [
20
+ '-v',
21
+ `${from}:${to}${mode}`,
22
+ ];
23
+ const env = { ...input.env };
24
+ const computed = [
25
+ `HOME=${where.runHome}`,
26
+ `PATH=${where.runHome}/bin:${RUN_PATH}`,
27
+ ];
28
+ const allowlist = opts.network === 'allowlist';
29
+ if (allowlist && api) {
30
+ const proxy = `http://${opts.proxy}:${PROXY_PORTS.egress}`;
31
+ computed.push(`HTTPS_PROXY=${proxy}`, `HTTP_PROXY=${proxy}`, `NO_PROXY=${opts.proxy}`, `${api.baseUrlEnv}=http://${opts.proxy}:${PROXY_PORTS.api}`, `${api.keyEnv}=${API_KEY_PLACEHOLDER}`);
32
+ }
33
+ const overridden = new Set(computed.map((c) => c.slice(0, c.indexOf('='))));
34
+ const credentials = Object.keys(api?.auth ?? {});
35
+ const forwarded = Object.keys(env).filter((k) => !HOST_ONLY(k) &&
36
+ !overridden.has(k) &&
37
+ // Allowlist mode keeps the model credential in the proxy only.
38
+ !(allowlist && credentials.includes(k)));
39
+ const ro = (path) => bind(path, path, ':ro');
40
+ const argv = [
41
+ opts.docker,
42
+ 'run',
43
+ '--rm',
44
+ '-it',
45
+ '--name',
46
+ `bb-run-${input.run.uuid}`,
47
+ '--label',
48
+ `build.bakers.run=${input.run.uuid}`,
49
+ '--network',
50
+ allowlist ? opts.runNetwork : 'host',
51
+ '--user',
52
+ `${where.uid}:${where.gid}`,
53
+ ...(opts.docker.endsWith('podman') ? ['--userns=keep-id'] : []),
54
+ ...computed.flatMap((c) => ['-e', c]),
55
+ ...forwarded.flatMap((k) => ['-e', k]),
56
+ ...bind(ws),
57
+ // The only Git the run sees; the parent repository is not mounted.
58
+ ...(where.runGitDir ? bind(where.runGitDir, `${ws}/.git`) : []),
59
+ ...(where.sharedObjects ? ro(where.sharedObjects) : []),
60
+ ...bind(where.cliRoot, undefined, ':ro'),
61
+ ...opts.mounts.flatMap((m) => bind(m, m, ':ro')),
62
+ ...bind(where.runHome),
63
+ ...(input.readPaths ?? []).flatMap((path) => ro(path)),
64
+ ...(input.writePaths ?? []).flatMap((path) => bind(path)),
65
+ // Shared host files over their placeholders in the run home.
66
+ ...(input.bindPaths ?? []).flatMap(({ source, target }) => bind(source, target)),
67
+ '-w',
68
+ ws,
69
+ opts.image,
70
+ 'sh',
71
+ '-c',
72
+ input.command,
73
+ ];
74
+ return { argv, env };
75
+ }
@@ -0,0 +1,35 @@
1
+ import type { BbRuntime } from '@bakers-build/conductor/contract';
2
+ import { type ConductorLogger } from '@bakers-build/core';
3
+ /** The addon's `with` options. */
4
+ export interface RuntimeDockerOptions {
5
+ /** The container CLI; `podman` works too. */
6
+ docker?: string;
7
+ /** An operator image; never built. Default: the shipped image. */
8
+ image?: string;
9
+ /** Extra host paths mounted read-only. */
10
+ mounts?: string[];
11
+ network?: 'host' | 'allowlist';
12
+ /** Allowlist mode: hosts the run may reach, exact or `*.suffix`. */
13
+ allow?: string[];
14
+ }
15
+ export interface BackendDeps {
16
+ exec(file: string, args: string[]): Promise<{
17
+ stdout: string;
18
+ }>;
19
+ /** The conductor's own environment (the model credential lives here). */
20
+ env: Record<string, string | undefined>;
21
+ logger: ConductorLogger;
22
+ /** The bakers CLI build, mounted read-only at its host path. */
23
+ cliRoot: string;
24
+ /** This package's directory; its `image/` holds the Dockerfile. */
25
+ packageDir: string;
26
+ version: string;
27
+ /** The running bakers entry script; the run calls it as `bakers`. Absent: no shim. */
28
+ bakersEntry?: string;
29
+ /** Names this conductor's proxy sidecar, so conductors on one host never share one. */
30
+ conductorKey?: string;
31
+ }
32
+ /** The first 12 hex chars of the sha256 of `text`. */
33
+ export declare const shortHash: (text: string) => string;
34
+ export declare const realExec: BackendDeps['exec'];
35
+ export declare function dockerRuntime(options: RuntimeDockerOptions, deps: BackendDeps): BbRuntime;
@@ -0,0 +1,275 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync, } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ import { shellQuote } from '@bakers-build/core';
8
+ import { API_KEY_PLACEHOLDER, dockerArgs, PROXY_PORTS, } from './args.js';
9
+ const EGRESS_NETWORK = 'bb-runtime-egress';
10
+ /** The first 12 hex chars of the sha256 of `text`. */
11
+ export const shortHash = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
12
+ const run = promisify(execFile);
13
+ export const realExec = (file, args) => run(file, args);
14
+ export function dockerRuntime(options, deps) {
15
+ const docker = options.docker ?? 'docker';
16
+ const opts = {
17
+ docker,
18
+ mounts: options.mounts ?? [],
19
+ network: options.network ?? 'host',
20
+ runNetwork: `bb-runtime-${deps.conductorKey ?? 'default'}`,
21
+ };
22
+ const allowlist = opts.network === 'allowlist';
23
+ const proxyName = `bb-runtime-proxy-${deps.conductorKey ?? 'default'}`;
24
+ if (allowlist)
25
+ opts.proxy = proxyName;
26
+ let gateway;
27
+ let toolsPort;
28
+ let proxyReady;
29
+ const cacheHome = deps.env.XDG_CACHE_HOME || join(deps.env.HOME || homedir(), '.cache');
30
+ // The uuid names a container filter and a directory removed recursively.
31
+ const plainName = (uuid) => /^[A-Za-z0-9-]+$/.test(uuid);
32
+ const runHomeOf = (uuid) => {
33
+ if (!plainName(uuid))
34
+ throw new Error(`runtime-docker: invalid run uuid ${JSON.stringify(uuid)}`);
35
+ return join(cacheHome, 'bakers/runtime', uuid);
36
+ };
37
+ // One image per agent install; built once each.
38
+ const imagesReady = new Map();
39
+ const ensureImage = async (image, install) => {
40
+ try {
41
+ await deps.exec(docker, ['image', 'inspect', image]);
42
+ }
43
+ catch (err) {
44
+ if (options.image !== undefined)
45
+ throw new Error(`runtime-docker: image ${image} is not available (${String(err)}); pull or build it first`);
46
+ deps.logger.info({ image }, 'building the run image');
47
+ await deps.exec(docker, [
48
+ 'build',
49
+ '-t',
50
+ image,
51
+ '--build-arg',
52
+ `AGENT_INSTALL=${install}`,
53
+ join(deps.packageDir, 'image'),
54
+ ]);
55
+ }
56
+ };
57
+ const ensureNetwork = async (name, internal) => {
58
+ try {
59
+ await deps.exec(docker, ['network', 'inspect', name]);
60
+ }
61
+ catch {
62
+ await deps.exec(docker, [
63
+ 'network',
64
+ 'create',
65
+ ...(internal ? ['--internal'] : []),
66
+ name,
67
+ ]);
68
+ }
69
+ };
70
+ // One sidecar per conductor; recreated only when what it relays changed.
71
+ // shortcut: one model API per sidecar; agents of different APIs on one conductor recreate it, give each its own when that is needed.
72
+ const startProxy = async (hash, credentials, api, image) => {
73
+ const current = await deps
74
+ .exec(docker, [
75
+ 'inspect',
76
+ '-f',
77
+ '{{index .Config.Labels "build.bakers.proxy"}}',
78
+ proxyName,
79
+ ])
80
+ .then((r) => r.stdout.trim(), () => undefined);
81
+ if (current === hash)
82
+ return;
83
+ if (current !== undefined)
84
+ await deps.exec(docker, ['rm', '-f', proxyName]);
85
+ await deps.exec(docker, [
86
+ 'run',
87
+ '-d',
88
+ '--restart',
89
+ 'unless-stopped',
90
+ '--name',
91
+ proxyName,
92
+ '--label',
93
+ `build.bakers.proxy=${hash}`,
94
+ '--network',
95
+ EGRESS_NETWORK,
96
+ ...credentials.flatMap((k) => ['-e', k]),
97
+ '-e',
98
+ `BB_ALLOW=${(options.allow ?? []).join(',')}`,
99
+ '-e',
100
+ `BB_TOOLS=${gateway}:${toolsPort}`,
101
+ '-e',
102
+ `BB_API_TARGET=${api.target}`,
103
+ '-e',
104
+ `BB_API_AUTH=${JSON.stringify(api.auth)}`,
105
+ image,
106
+ 'node',
107
+ '/opt/bb-runtime/proxy.mjs',
108
+ ]);
109
+ await deps.exec(docker, ['network', 'connect', opts.runNetwork, proxyName]);
110
+ };
111
+ const credentialsFor = (agent) => {
112
+ const api = agent.modelApi;
113
+ if (allowlist && !api)
114
+ throw new Error('runtime-docker: allowlist mode requires model API metadata');
115
+ const credentials = Object.keys(api?.auth ?? {}).filter((k) => deps.env[k]);
116
+ if (api && credentials.length === 0)
117
+ throw new Error(`runtime-docker: ${api.credentialHint}`);
118
+ return credentials;
119
+ };
120
+ const runtime = {
121
+ id: 'docker',
122
+ async prepareRun(run, agent) {
123
+ const credentials = credentialsFor(agent);
124
+ const homePath = runHomeOf(run.uuid);
125
+ mkdirSync(homePath, { recursive: true, mode: 0o700 });
126
+ const env = allowlist
127
+ ? { [agent.modelApi.keyEnv]: API_KEY_PLACEHOLDER }
128
+ : Object.fromEntries(credentials.map((k) => [k, deps.env[k]]));
129
+ return { homePath, env };
130
+ },
131
+ async wrap(launch) {
132
+ const runHome = runHomeOf(launch.run.uuid);
133
+ if (launch.homePath !== runHome)
134
+ throw new Error('runtime-docker: private run home was not prepared');
135
+ const api = launch.modelApi;
136
+ const credentials = credentialsFor(launch);
137
+ if (!options.image && !launch.installCommand)
138
+ throw new Error(`runtime-docker: agent ${launch.harness} has no installCommand; configure an image`);
139
+ const image = options.image ??
140
+ `bakers-runtime:${deps.version}-${shortHash(launch.installCommand ?? '')}`;
141
+ if (!imagesReady.has(image))
142
+ imagesReady.set(image, ensureImage(image, launch.installCommand ?? '').catch((err) => {
143
+ imagesReady.delete(image);
144
+ throw err;
145
+ }));
146
+ await imagesReady.get(image);
147
+ if (allowlist) {
148
+ if (!gateway || !toolsPort)
149
+ throw new Error('runtime-docker: allowlist mode needs the conductor tool server; it was not started');
150
+ const hash = shortHash(JSON.stringify([
151
+ gateway,
152
+ toolsPort,
153
+ options.allow ?? [],
154
+ image,
155
+ api,
156
+ opts.runNetwork,
157
+ ]));
158
+ if (proxyReady?.hash !== hash)
159
+ proxyReady = {
160
+ hash,
161
+ ready: startProxy(hash, credentials, api, image).catch((err) => {
162
+ proxyReady = undefined;
163
+ throw err;
164
+ }),
165
+ };
166
+ await proxyReady.ready;
167
+ }
168
+ const ws = launch.workspacePath;
169
+ const runGitDir = launch.gitDir;
170
+ const within = (path, root) => path === root || path.startsWith(root + sep);
171
+ if (within(realpathSync(dirname(runHome)), realpathSync(ws)))
172
+ throw new Error('runtime-docker: private state must stay outside the worktree');
173
+ if (runGitDir && within(realpathSync(runGitDir), realpathSync(ws)))
174
+ throw new Error('runtime-docker: the run Git directory must stay outside the workspace');
175
+ const canonical = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
176
+ const binds = launch.bindPaths ?? [];
177
+ for (const { target } of binds)
178
+ if (target.includes(':') || !within(resolve(target), resolve(runHome)))
179
+ throw new Error(`runtime-docker: shared file target outside the run home ${target}`);
180
+ for (const [paths, write] of [
181
+ [launch.readPaths ?? [], false],
182
+ [launch.writePaths ?? [], true],
183
+ [binds.map(({ source }) => source), true],
184
+ ]) {
185
+ for (const path of paths) {
186
+ if (!isAbsolute(path) || path.includes(':') || !existsSync(path))
187
+ throw new Error(`runtime-docker: invalid prepared path ${path}`);
188
+ const actual = canonical(path);
189
+ if (actual === '/' ||
190
+ (deps.env.HOME && within(canonical(deps.env.HOME), actual)))
191
+ throw new Error(`runtime-docker: refusing broad path ${path}`);
192
+ if (write &&
193
+ [
194
+ ...(runGitDir ? [runGitDir] : []),
195
+ ...(launch.sharedObjects ? [launch.sharedObjects] : []),
196
+ dirname(runHome),
197
+ join(ws, '.git'),
198
+ deps.cliRoot,
199
+ ...(launch.readPaths ?? []),
200
+ ...opts.mounts,
201
+ ].some((root) => within(actual, canonical(root)) ||
202
+ within(canonical(root), actual)))
203
+ throw new Error(`runtime-docker: protected write path ${path}`);
204
+ }
205
+ }
206
+ // The CLI build is mounted read-only; this puts the conductor's own CLI on PATH.
207
+ if (deps.bakersEntry) {
208
+ mkdirSync(join(runHome, 'bin'), { recursive: true });
209
+ writeFileSync(join(runHome, 'bin/bakers'), `#!/bin/sh\nexec node ${shellQuote(deps.bakersEntry)} "$@"\n`, { mode: 0o755 });
210
+ }
211
+ const env = { ...launch.env };
212
+ // Allowlist mode: the credential stays with the proxy sidecar.
213
+ if (!allowlist)
214
+ for (const k of credentials)
215
+ env[k] = deps.env[k];
216
+ const { argv, env: runEnv } = dockerArgs({ ...launch, env }, { ...opts, image }, {
217
+ ...(runGitDir ? { runGitDir } : {}),
218
+ ...(launch.sharedObjects
219
+ ? { sharedObjects: launch.sharedObjects }
220
+ : {}),
221
+ runHome,
222
+ cliRoot: deps.cliRoot,
223
+ uid: process.getuid?.() ?? 1000,
224
+ gid: process.getgid?.() ?? 1000,
225
+ });
226
+ return { command: argv.map(shellQuote).join(' '), env: runEnv };
227
+ },
228
+ async cleanup(r) {
229
+ if (!plainName(r.uuid)) {
230
+ deps.logger.warn({ run: r.uuid }, 'runtime cleanup skipped: invalid run uuid');
231
+ return;
232
+ }
233
+ try {
234
+ const { stdout } = await deps.exec(docker, [
235
+ 'ps',
236
+ '-aq',
237
+ '--filter',
238
+ `name=^bb-run-${r.uuid}$`,
239
+ '--filter',
240
+ `label=build.bakers.run=${r.uuid}`,
241
+ ]);
242
+ const ids = stdout.split('\n').filter(Boolean);
243
+ if (ids.length > 0)
244
+ await deps.exec(docker, ['rm', '-f', ...ids]);
245
+ }
246
+ catch (err) {
247
+ deps.logger.warn({ run: r.uuid, err: String(err) }, 'runtime container cleanup failed');
248
+ return;
249
+ }
250
+ rmSync(runHomeOf(r.uuid), { recursive: true, force: true });
251
+ },
252
+ };
253
+ if (allowlist) {
254
+ runtime.toolsUrl = (hostUrl) => {
255
+ const u = new URL(hostUrl);
256
+ toolsPort = u.port;
257
+ const path = u.pathname === '/' ? '' : u.pathname;
258
+ return `http://${proxyName}:${PROXY_PORTS.tools}${path}${u.search}`;
259
+ };
260
+ runtime.toolServerHosts = async () => {
261
+ await ensureNetwork(opts.runNetwork, true);
262
+ await ensureNetwork(EGRESS_NETWORK, false);
263
+ const { stdout } = await deps.exec(docker, [
264
+ 'network',
265
+ 'inspect',
266
+ '-f',
267
+ '{{range .IPAM.Config}}{{.Gateway}}{{end}}',
268
+ EGRESS_NETWORK,
269
+ ]);
270
+ gateway = stdout.trim();
271
+ return [gateway];
272
+ };
273
+ }
274
+ return runtime;
275
+ }
@@ -0,0 +1,17 @@
1
+ import type { RuntimePlugin } from '@bakers-build/conductor/contract';
2
+ import { type RuntimeDockerOptions } from './backend.js';
3
+ export * from './args.js';
4
+ export { type BackendDeps, dockerRuntime, type RuntimeDockerOptions, } from './backend.js';
5
+ /**
6
+ * This package's directory, and the CLI build a run mounts read-only: the
7
+ * common ancestor of this package and the conductor, widened to the outermost
8
+ * `node_modules` of an installed CLI so its hoisted dependencies come along.
9
+ */
10
+ export declare function pluginPaths(): {
11
+ cliRoot: string;
12
+ packageDir: string;
13
+ };
14
+ export { cliRootOf } from '@bakers-build/core';
15
+ /** The shipped image's tag: the package version plus a hash of `image/`, so any change rebuilds it. */
16
+ export declare function imageVersion(packageDir: string, version: string): string;
17
+ export declare function runtimeDocker(options?: RuntimeDockerOptions): RuntimePlugin;
@@ -0,0 +1,56 @@
1
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { cliRootOf } from '@bakers-build/core';
6
+ import { dockerRuntime, realExec, shortHash, } from './backend.js';
7
+ export * from './args.js';
8
+ export { dockerRuntime, } from './backend.js';
9
+ /** The nearest directory at or above `file` holding a package.json. */
10
+ function packageOf(file) {
11
+ let dir = dirname(realpathSync(file));
12
+ while (!existsSync(join(dir, 'package.json')) && dirname(dir) !== dir)
13
+ dir = dirname(dir);
14
+ return dir;
15
+ }
16
+ /**
17
+ * This package's directory, and the CLI build a run mounts read-only: the
18
+ * common ancestor of this package and the conductor, widened to the outermost
19
+ * `node_modules` of an installed CLI so its hoisted dependencies come along.
20
+ */
21
+ export function pluginPaths() {
22
+ const packageDir = packageOf(fileURLToPath(import.meta.url));
23
+ const conductor = packageOf(fileURLToPath(import.meta.resolve('@bakers-build/conductor/contract')));
24
+ return { cliRoot: cliRootOf(packageDir, conductor, homedir()), packageDir };
25
+ }
26
+ export { cliRootOf } from '@bakers-build/core';
27
+ /** The shipped image's tag: the package version plus a hash of `image/`, so any change rebuilds it. */
28
+ export function imageVersion(packageDir, version) {
29
+ const dir = join(packageDir, 'image');
30
+ const files = readdirSync(dir).sort();
31
+ return `${version}-${shortHash(files.map((f) => f + readFileSync(join(dir, f), 'utf8')).join('\0'))}`;
32
+ }
33
+ export function runtimeDocker(options = {}) {
34
+ return {
35
+ kind: 'runtime',
36
+ id: 'docker',
37
+ isolates: true,
38
+ requiredModules: [],
39
+ createRuntime: async (config, { logger }) => {
40
+ const { cliRoot, packageDir } = pluginPaths();
41
+ const { version } = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
42
+ return dockerRuntime(options, {
43
+ exec: realExec,
44
+ env: process.env,
45
+ logger,
46
+ cliRoot,
47
+ packageDir,
48
+ version: imageVersion(packageDir, version),
49
+ conductorKey: shortHash(config.config_path),
50
+ ...(process.argv[1]
51
+ ? { bakersEntry: realpathSync(process.argv[1]) }
52
+ : {}),
53
+ });
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,3 @@
1
+ import type { Preset } from '@bakers-build/conductor/contract';
2
+ export declare const scopes: Preset['scopes'];
3
+ export declare const runtimes: Preset['runtimes'];
@@ -0,0 +1,6 @@
1
+ import { runtimeDocker } from './index.js';
2
+ export const scopes = ['machine'];
3
+ export const runtimes = (acc, options) => [
4
+ ...acc,
5
+ runtimeDocker(options),
6
+ ];
@@ -0,0 +1,11 @@
1
+ # One container per agent run (@bakers-build/addon-runtime-docker). The bakers CLI
2
+ # is not baked in: each run mounts the host build read-only at its host path,
3
+ # because the agent command names the CLI's scripts by that path.
4
+ FROM node:22-slim
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends git ca-certificates \
7
+ && rm -rf /var/lib/apt/lists/*
8
+ # The agent's own install (its `installCommand`); one image per agent.
9
+ ARG AGENT_INSTALL
10
+ RUN test -n "$AGENT_INSTALL" && sh -c "$AGENT_INSTALL"
11
+ COPY proxy.mjs /opt/bb-runtime/proxy.mjs
@@ -0,0 +1,157 @@
1
+ // The egress sidecar of @bakers-build/addon-runtime-docker's allowlist mode.
2
+ // It sits on the internal run network and a normal bridge, and is the run's
3
+ // only way out:
4
+ // :3128 HTTP CONNECT proxy, to allowed hosts only;
5
+ // :8081 the model API, with the real credential added here (the run holds a placeholder);
6
+ // :8082 a TCP relay to the conductor's tool server.
7
+ import { createServer, request as httpRequest } from 'node:http';
8
+ import { request as httpsRequest } from 'node:https';
9
+ import { connect, createServer as createTcpServer } from 'node:net';
10
+
11
+ /** Exact host, or `*.suffix` for any subdomain; case-insensitive. */
12
+ export function allowed(host, patterns) {
13
+ const h = String(host).toLowerCase();
14
+ return patterns.some((p) => {
15
+ const q = String(p).toLowerCase();
16
+ return q.startsWith('*.') ? h.endsWith(q.slice(1)) : h === q;
17
+ });
18
+ }
19
+
20
+ function listen(server, port, host) {
21
+ return new Promise((resolve, reject) => {
22
+ server.once('error', reject);
23
+ server.listen(port, host, () => resolve(server.address().port));
24
+ });
25
+ }
26
+
27
+ function splitHostPort(target) {
28
+ const at = target.lastIndexOf(':');
29
+ return {
30
+ host: target.slice(0, at).replace(/^\[|\]$/g, ''),
31
+ port: Number(target.slice(at + 1)),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * `auth` maps each credential name, preferred first, to the headers sent with
37
+ * it: `set` replaces a header (`{value}` is the secret), `append` adds a
38
+ * comma-separated value. `secrets` holds the credential values (the sidecar's env).
39
+ * @param {{ allow: string[], host?: string, apiTarget?: string,
40
+ * auth?: Record<string, { set: Record<string, string>, append?: Record<string, string> }>,
41
+ * secrets?: Record<string, string | undefined>, tools?: string,
42
+ * ports?: { egress?: number, api?: number, tools?: number } }} o
43
+ */
44
+ export async function startProxy(o) {
45
+ const bind = o.host ?? '0.0.0.0';
46
+ const servers = [];
47
+
48
+ const egress = createServer((_req, res) => {
49
+ // shortcut: plain-HTTP proxying is refused; allow it when an allowed host needs http://.
50
+ res.writeHead(403).end('only HTTPS (CONNECT) egress is allowed\n');
51
+ });
52
+ egress.on('connect', (req, socket, head) => {
53
+ // A client may reset any tunnel, refused ones included; unhandled, that kills every stream.
54
+ let upstream;
55
+ socket.on('error', () => upstream?.destroy());
56
+ const { host, port } = splitHostPort(req.url ?? '');
57
+ if (
58
+ !host ||
59
+ !Number.isInteger(port) ||
60
+ port < 1 ||
61
+ port > 65535 ||
62
+ !allowed(host, o.allow)
63
+ ) {
64
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
65
+ return;
66
+ }
67
+ upstream = connect(port, host, () => {
68
+ socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
69
+ if (head.length) upstream.write(head);
70
+ upstream.pipe(socket).pipe(upstream);
71
+ });
72
+ upstream.on('error', () => socket.end('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
73
+ });
74
+ servers.push(egress);
75
+ const ports = { egress: await listen(egress, o.ports?.egress ?? 0, bind) };
76
+
77
+ const auth = o.auth ?? {};
78
+ const name = Object.keys(auth).find((k) => o.secrets?.[k]);
79
+ if (o.apiTarget && name) {
80
+ const value = String(o.secrets[name]);
81
+ const { set, append = {} } = auth[name];
82
+ // The run's placeholder may sit in any credential header.
83
+ const strip = Object.values(auth).flatMap((a) =>
84
+ Object.keys(a.set).map((h) => h.toLowerCase()),
85
+ );
86
+ const target = new URL(o.apiTarget);
87
+ const send = target.protocol === 'https:' ? httpsRequest : httpRequest;
88
+ const api = createServer((req, res) => {
89
+ const headers = { ...req.headers, host: target.host };
90
+ for (const h of strip) delete headers[h];
91
+ for (const [h, v] of Object.entries(set))
92
+ headers[h.toLowerCase()] = v.replaceAll('{value}', value);
93
+ for (const [h, v] of Object.entries(append)) {
94
+ const k = h.toLowerCase();
95
+ const list = String(headers[k] ?? '')
96
+ .split(',')
97
+ .map((b) => b.trim())
98
+ .filter(Boolean);
99
+ if (!list.includes(v)) list.push(v);
100
+ headers[k] = list.join(',');
101
+ }
102
+ const up = send(
103
+ {
104
+ protocol: target.protocol,
105
+ hostname: target.hostname,
106
+ port: target.port,
107
+ method: req.method,
108
+ path: req.url,
109
+ headers,
110
+ },
111
+ (r) => {
112
+ res.writeHead(r.statusCode ?? 502, r.headers);
113
+ r.pipe(res);
114
+ },
115
+ );
116
+ up.on('error', () =>
117
+ res.headersSent ? res.destroy() : res.writeHead(502).end(),
118
+ );
119
+ req.pipe(up);
120
+ });
121
+ servers.push(api);
122
+ ports.api = await listen(api, o.ports?.api ?? 0, bind);
123
+ }
124
+
125
+ if (o.tools) {
126
+ const { host, port } = splitHostPort(o.tools);
127
+ const relay = createTcpServer((socket) => {
128
+ const up = connect(port, host);
129
+ socket.pipe(up).pipe(socket);
130
+ up.on('error', () => socket.destroy());
131
+ socket.on('error', () => up.destroy());
132
+ });
133
+ servers.push(relay);
134
+ ports.tools = await listen(relay, o.ports?.tools ?? 0, bind);
135
+ }
136
+
137
+ return {
138
+ ports,
139
+ close: () =>
140
+ Promise.all(
141
+ servers.map((s) => new Promise((r) => s.close(() => r()))),
142
+ ).then(() => {}),
143
+ };
144
+ }
145
+
146
+ // Run as the sidecar: configuration comes from the environment.
147
+ if (import.meta.url === `file://${process.argv[1]}`) {
148
+ const p = await startProxy({
149
+ allow: (process.env.BB_ALLOW ?? '').split(',').filter(Boolean),
150
+ apiTarget: process.env.BB_API_TARGET,
151
+ auth: JSON.parse(process.env.BB_API_AUTH ?? '{}'),
152
+ secrets: process.env,
153
+ ...(process.env.BB_TOOLS ? { tools: process.env.BB_TOOLS } : {}),
154
+ ports: { egress: 3128, api: 8081, tools: 8082 },
155
+ });
156
+ console.log(`bb-runtime proxy listening ${JSON.stringify(p.ports)}`);
157
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://bakers.build/schema/addon-runtime-docker/options.schema.json",
4
+ "title": "@bakers-build/addon-runtime-docker options",
5
+ "description": "The runtime-docker entry's with.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "docker": {
10
+ "type": "string",
11
+ "minLength": 1,
12
+ "default": "docker",
13
+ "description": "The container CLI; podman also works."
14
+ },
15
+ "image": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Your image, used for every agent and never built. Default: the shipped image, built on first use."
19
+ },
20
+ "mounts": {
21
+ "type": "array",
22
+ "items": { "type": "string", "pattern": "^/" },
23
+ "default": [],
24
+ "description": "Extra absolute host paths, mounted read-only at the same path."
25
+ },
26
+ "network": {
27
+ "enum": ["host", "allowlist"],
28
+ "default": "host",
29
+ "description": "host shares the host network; allowlist routes the run through the conductor's proxy."
30
+ },
31
+ "allow": {
32
+ "type": "array",
33
+ "items": { "type": "string", "minLength": 1 },
34
+ "default": [],
35
+ "description": "Allowlist mode: hosts the run may reach, exact or *.suffix."
36
+ }
37
+ }
38
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-runtime-docker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.1-next.6",
4
+ "description": "Start each agent run in its own Docker or Podman container.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/index.js",
9
+ "./preset": "./dist/src/preset.js",
10
+ "./options.schema.json": "./options.schema.json"
11
+ },
12
+ "files": [
13
+ "dist/src",
14
+ "image",
15
+ "options.schema.json"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/bakers-build/bakers-build.git",
23
+ "directory": "bakers-cli/addons/runtime-docker"
24
+ },
25
+ "peerDependencies": {
26
+ "@bakers-build/conductor": "^0.9.1-next.6",
27
+ "@bakers-build/core": "^0.9.1-next.6"
28
+ },
29
+ "bakersBuild": {
30
+ "commit": "e7d9921d51d5c045f29ca7e35d1e6a348e6ddf08",
31
+ "channel": "next"
32
+ }
33
+ }