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
34 changes: 34 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,37 @@ jobs:
run: python3 scripts/test_run_anvil.py
- name: Check entrypoint syntax
run: sh -n anvil/entrypoint.sh

docker-broker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: tongs/docker-broker/package-lock.json
- name: Install broker deps
run: npm ci
working-directory: tongs/docker-broker
- name: Run broker unit tests
run: npm test
working-directory: tongs/docker-broker

docker-broker-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: tongs/docker-broker/package-lock.json
- name: Install broker deps
run: npm ci
working-directory: tongs/docker-broker
- name: Pre-pull worker image
run: docker pull alpine:3.20
- name: Run broker end-to-end tests
run: npm run test:e2e
working-directory: tongs/docker-broker
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ OPENCODE_CTR ?= opencode-$(PROJECT_NAME)
CLAUDE_IMG ?= claude-code:local
CLAUDE_CTR ?= claude-$(PROJECT_NAME)

BROKER_IMG ?= swarmforge-docker-broker:latest

PROFILE ?=
DATA_DIR ?= $(HOME)/.local/share/opencode
OPENCODE_ARGS ?=
Expand Down Expand Up @@ -59,8 +61,11 @@ SWARMFORGE_USER_ASSETS_DIR ?= $(HOME)/.swarmforge
SWARMFORGE_ORG_ASSETS_DIR ?= $(if $(strip $(SWARMFORGE_ORG_CONFIG_ROOT)),$(SWARMFORGE_ORG_CONFIG_ROOT)/.swarmforge,)
SWARMFORGE_REPO_AGENTS_DIR ?= $(SWARMFORGE_DIR)/agents
# Repo-layer tong definitions, pointed at directly (like SWARMFORGE_REPO_AGENTS_DIR)
# so the rest of the checkout is never read. This repo ships no tongs/ dir, so the
# wildcard guard below leaves the layer absent until one is added.
# so the rest of the checkout is never read. The tongs/ dir ships only the
# reference broker's source under a subdirectory, not a top-level *.yaml, so
# discovery (which reads top-level *.yaml only) finds nothing here until a
# definition is added; the wildcard guard below still skips the layer entirely if
# the dir is ever absent.
SWARMFORGE_REPO_TONGS_DIR ?= $(SWARMFORGE_DIR)/tongs

# Portable skills/commands overlay layers. These follow the harness-neutral
Expand Down Expand Up @@ -138,7 +143,7 @@ CLAUDE_RUN_MOUNTS = \
--tmpfs /home/opencode/.claude/agents \
$(SWARMFORGE_LAYER_MOUNTS)

.PHONY: opencode_network build_opencode update_opencode build_claude update_claude run_opencode stop_opencode run_claude stop_claude run_ollama logs_ollama stop_ollama gpu_stat clean \
.PHONY: opencode_network build_opencode update_opencode build_broker build_claude update_claude run_opencode stop_opencode run_claude stop_claude run_ollama logs_ollama stop_ollama gpu_stat clean \
run_llama_3-1-8b run_gpt-oss-20b run_gpt-oss-120b run_devstral2_small test

define run_agent_container
Expand Down Expand Up @@ -244,6 +249,11 @@ build_opencode:
update_opencode:
$(MAKE) build_opencode OPENCODE_INSTALL_BUST=$(shell date +%s)

# Build the reference docker-task broker image. It is not used until a broker tong
# definition is enabled in a layer (see tongs/docker-broker/docker-broker.tong.yaml).
build_broker:
docker build -t $(BROKER_IMG) "$(SWARMFORGE_DIR)/tongs/docker-broker"

build_claude:
docker build \
--target claude-runtime \
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,38 @@ A **workspace**-sourced tong (from a repo you cloned) could otherwise request yo
- The gate defaults to **No**, and a non-interactive stdin reads as No. A scripted `--no-prompt` run **fails closed** rather than auto-approving.
- Approving `image: foo:latest` approves a moving target; **pinned digests are the recommended convention** for workspace tongs.

### Broker tongs

A **broker** is a tong that holds the docker socket and spawns its own short-lived worker containers on demand, so the anvil can compile, run tests, or do other sandboxed work without ever getting socket access itself.

`tongs/docker-broker/` ships a reference broker: an HTTP MCP server whose verbs are defined by a **declarative config**, not hand-written per project. Each command in `broker.config.yaml` describes the worker container to spawn — reusing the tong definition shape (`image`, `mounts`, `command`, `env`, `resources`, `networks`) — with an MCP surface (`name`, `description`, typed `params`) on top:

```yaml
allowed_images:
- node:24-alpine # the entire image allowlist; nothing else can run
commands:
- name: test # the MCP tool the agent calls
description: Run the project's test suite.
image: node:24-alpine
mounts: [workspace:/work:ro]
workdir: /work
command: [npm, test, --]
params:
- name: suite # exposed as a constrained MCP input
type: enum # boolean | enum
values: [unit, integration, e2e]
append_value: true # the chosen value is appended as one command token
```

The config **is** the broker's allowlist. There is no verb that runs an arbitrary image or mounts an arbitrary host path: a worker may only mount the session `workspace`, and a parameter can only toggle a fixed effect (`boolean`) or pick a value from a fixed set (`enum`) — values are passed as whole argv words to a worker spawned without a shell, so nothing a caller sends can become a flag, path, or shell metacharacter. The launcher hands the broker the workspace's host path as `SWARMFORGE_WORKSPACE_HOST_PATH` so it can mount the workspace into the workers it spawns.

To enable it:

1. `make build_broker` — builds the `swarmforge-docker-broker` image.
2. Copy the example definition into a layer: `cp tongs/docker-broker/docker-broker.tong.yaml ~/.swarmforge/tongs/docker-broker.yaml`.

The example definition is **not** auto-discovered from the checkout (it lives a directory below the layer root, and discovery reads only top-level `*.yaml`), so the broker stays off until you opt in. Because it requests the docker socket, a workspace-sourced copy is always called out in the approval prompt.

## Skill Tests

A lightweight skill test harness runs scenario prompts against a chosen model and verifies expected behavior.
Expand Down
58 changes: 58 additions & 0 deletions scripts/test_tongs.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,15 @@ def test_non_string_mount_rejected(self):
def test_known_mounts_accepted(self):
self.assertEqual(tongs.validate_tong("t", self._base(mounts=["workspace:ro", "docker-socket"])), [])

def test_rw_mount_mode_accepted(self):
self.assertEqual(tongs.validate_tong("t", self._base(mounts=["workspace:rw"])), [])

def test_target_path_mount_rejected(self):
# `workspace:/target` is broker-config only; as a tong mount the launcher
# would forward it as a bogus docker mode.
errors = tongs.validate_tong("t", self._base(mounts=["workspace:/work:ro"]))
self.assertTrue(any("invalid mode" in e for e in errors))

def test_non_string_network_rejected(self):
errors = tongs.validate_tong("t", self._base(networks=[{"name": "x"}]))
self.assertTrue(any("network" in e for e in errors))
Expand Down Expand Up @@ -1022,6 +1031,55 @@ def test_run_argv_does_not_emit_empty_hash_label(self):
argv = tongs.tong_run_argv("g", def_of(NONE_TONG), container_name="c", network="n", alias="g")
self.assertNotIn("swarmforge.tong.config-hash=", " ".join(argv))

def test_run_argv_injects_workspace_host_path_for_socket_tong(self):
# A broker (socket-holding) tong is handed the workspace's host path so it
# can bind-mount the workspace into the workers it spawns.
defn = def_of(NONE_TONG)
defn["mounts"] = ["docker-socket"]
argv = tongs.tong_run_argv(
"broker", defn, container_name="c", network="n", alias="broker",
workspace="/host/ws",
)
self.assertIn("SWARMFORGE_WORKSPACE_HOST_PATH=/host/ws", argv)

def test_run_argv_omits_workspace_host_path_for_non_socket_tong(self):
# Ordinary tongs never see the host path, so the env they get is unchanged.
defn = def_of(NONE_TONG)
defn["mounts"] = ["workspace:ro"]
argv = tongs.tong_run_argv(
"w", defn, container_name="c", network="n", alias="w", workspace="/host/ws",
)
self.assertNotIn("SWARMFORGE_WORKSPACE_HOST_PATH", " ".join(argv))

def test_run_argv_omits_workspace_host_path_when_workspace_unknown(self):
defn = def_of(NONE_TONG)
defn["mounts"] = ["docker-socket"]
argv = tongs.tong_run_argv("broker", defn, container_name="c", network="n", alias="broker")
self.assertNotIn("SWARMFORGE_WORKSPACE_HOST_PATH", " ".join(argv))

def test_run_argv_explicit_workspace_host_path_wins(self):
# A tong that sets the name itself keeps its own value (setdefault).
defn = def_of(NONE_TONG)
defn["mounts"] = ["docker-socket"]
argv = tongs.tong_run_argv(
"broker", defn, container_name="c", network="n", alias="broker",
env={"SWARMFORGE_WORKSPACE_HOST_PATH": "/explicit"}, workspace="/host/ws",
)
self.assertIn("SWARMFORGE_WORKSPACE_HOST_PATH=/explicit", argv)
self.assertNotIn("SWARMFORGE_WORKSPACE_HOST_PATH=/host/ws", argv)

def test_run_argv_omits_workspace_host_path_for_shared_socket_tong(self):
# A `shared` broker is reused across sessions, so it must not receive a
# per-session workspace path.
defn = def_of(NONE_TONG)
defn["mounts"] = ["docker-socket"]
defn["lifecycle"] = "shared"
argv = tongs.tong_run_argv(
"broker", defn, container_name="c", network="n", alias="broker",
workspace="/host/ws",
)
self.assertNotIn("SWARMFORGE_WORKSPACE_HOST_PATH", " ".join(argv))

def test_anvil_option_value_reads_name_and_network(self):
self.assertEqual(tongs.anvil_option_value(ANVIL_ARGV, "--name"), "claude-proj")
self.assertEqual(tongs.anvil_option_value(ANVIL_ARGV, "--network"), "opencode-net")
Expand Down
34 changes: 31 additions & 3 deletions scripts/tongs.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@
# and the broker agree on one spelling.
SOCKET_MOUNT = "docker-socket"

# A broker tong holds the docker socket and spawns its own worker containers. A
# container cannot re-share the bind mounts it received, so a broker that wants
# to mount the session workspace into a worker needs the workspace's *host* path
# (the path the daemon understands), not the in-container mount point. The
# launcher injects it here for socket-holding tongs; non-broker tongs never see
# it, so the passthrough behavior for ordinary tongs is unchanged.
WORKSPACE_HOST_ENV = "SWARMFORGE_WORKSPACE_HOST_PATH"


def warn(message):
print("tongs: %s" % message, file=sys.stderr)
Expand Down Expand Up @@ -296,9 +304,19 @@ def err(msg):
for mount in mounts:
if not isinstance(mount, str):
err("mount entries must be strings, got %r" % (mount,))
elif mount.split(":", 1)[0] not in (WORKSPACE_MOUNT, SOCKET_MOUNT):
continue
word, sep, mode = mount.partition(":")
if word not in (WORKSPACE_MOUNT, SOCKET_MOUNT):
err("unknown mount %r (expected '%s' or '%s')"
% (mount, WORKSPACE_MOUNT, SOCKET_MOUNT))
continue
# `tong_mount_specs` forwards everything after the first colon verbatim
# as the docker mount mode; the `workspace:/target` form is broker-config
# only, not valid in a tong definition.
if sep and mode not in ("ro", "rw"):
err("mount %r has an invalid mode %r (expected 'ro' or 'rw'; the "
"'workspace:/target' form is broker-config only, not a tong mount)"
% (mount, mode))

networks = defn.get("networks")
if isinstance(networks, list):
Expand Down Expand Up @@ -1205,6 +1223,11 @@ def tong_run_argv(
launch can detect a stale `shared` container. `env` (the tong's plain,
non-secret values from `plan_tong_secrets`) is passed as `-e` in sorted order;
resolved secret values never appear here -- they arrive over the FIFO instead.
A socket-holding (broker) `session` tong additionally receives
`SWARMFORGE_WORKSPACE_HOST_PATH` so it can bind-mount the session workspace into
the workers it spawns; a tong that sets that name itself keeps its own value. A
`shared` socket tong does not get it -- its container is reused across sessions,
so a per-session workspace path would be stale (and a leak) for later ones.

When the tong has secret env, the launcher passes `fifo_host_path` (bind-mounted
read-only as the secret channel), `entrypoint` (`/bin/sh`), and `command` (the
Expand All @@ -1225,8 +1248,13 @@ def tong_run_argv(
argv += ["--label", "%s=%s" % (LABEL_CONFIG_HASH, label_hash)]
if fifo_host_path:
argv += ["-v", "%s:%s:ro" % (fifo_host_path, SECRET_FIFO_TARGET)]
for key in sorted(env or {}):
argv += ["-e", "%s=%s" % (key, env[key])]
effective_env = dict(env or {})
# A `shared` container is reused across sessions, so a per-session workspace
# path baked into it would be stale for later ones; only `session` tongs get it.
if workspace and _has_socket_mount(defn) and defn.get("lifecycle") == "session":
effective_env.setdefault(WORKSPACE_HOST_ENV, workspace)
for key in sorted(effective_env):
argv += ["-e", "%s=%s" % (key, effective_env[key])]
for spec in tong_mount_specs(defn, workspace, socket_path=socket_path):
argv += ["-v", spec]
argv += tong_resource_flags(defn)
Expand Down
2 changes: 2 additions & 0 deletions tongs/docker-broker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
30 changes: 30 additions & 0 deletions tongs/docker-broker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
ARG NODE_TAG=24-alpine

# --- build: compile TypeScript to dist/ with the full dev toolchain ----------
FROM node:${NODE_TAG} AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# --- runtime: docker CLI + production deps + compiled output -----------------
FROM node:${NODE_TAG} AS runtime
# The broker spawns worker containers by talking to the host docker socket
# (bind-mounted in by the launcher), so it needs the docker *client* only.
RUN apk add --no-cache docker-cli
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist

# Baked command set. Override BROKER_CONFIG to point at a different (trusted,
# never workspace-writable) path if you mount one instead.
COPY broker.config.yaml /etc/swarmforge/broker.config.yaml
ENV BROKER_CONFIG=/etc/swarmforge/broker.config.yaml
ENV PORT=8080
EXPOSE 8080

CMD ["node", "dist/src/index.js"]
83 changes: 83 additions & 0 deletions tongs/docker-broker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# docker-broker

A reference **broker tong**: an HTTP MCP server that exposes a *configured* set of
narrow docker-task verbs. It holds the docker socket and spawns short-lived worker
containers on demand, so the anvil can compile and test without socket access of
its own.

The verbs are not hand-written per project — they come from a declarative config
(`broker.config.yaml`, baked into the image at `/etc/swarmforge/broker.config.yaml`).
Each command describes the worker container to spawn, reusing the Swarmforge tong
definition shape, with an MCP surface on top.

## Layout

- `broker.config.yaml` — the baked reference command set. Replace it with the
verbs your project needs (or mount your own at a trusted path and point
`BROKER_CONFIG` at it).
- `src/config.ts` — parses and validates the command config, fail-closed.
- `src/commands.ts` — turns a validated command + caller params into a `docker run`
argv and runs the worker. This is the security boundary.
- `src/server.ts` / `src/index.ts` — the MCP tool registration and the HTTP server.
- `docker-broker.tong.yaml` — example tong definition wiring this image into a
Swarmforge layer (not auto-discovered; copy it into a layer to enable).

## Config model

```yaml
name: docker-broker
allowed_images: # the entire image allowlist
- node:24-alpine
commands:
- name: build # MCP tool name
description: ... # shown to the agent
image: node:24-alpine # must be in allowed_images
mounts: [workspace:/work] # only the `workspace` magic word (never the socket)
workdir: /work
command: [npm, run, build] # base argv inside the container
env: { CI: "1" }
resources: { memory: 1g }
params:
- name: production # exposed as an MCP input
type: boolean # toggles a fixed effect when true
when_true:
env: { NODE_ENV: production }
- name: target
type: enum # picks a value from a fixed set
values: [app, lib]
env_var: TARGET # or `append_value: true` to append it as a token
```

Safety rules enforced at load time (the server refuses to start otherwise):

- Every command image must be listed in `allowed_images`; there is no
arbitrary-image verb.
- A worker may only mount `workspace[:<target>][:<mode>]` — never the docker
socket and never a raw host path.
- Parameters are limited to `boolean` (apply a fixed `append_command`/`env`
effect — a boolean param must declare a non-empty `when_true`, so it can never
be a silent no-op) and `enum` (insert a value drawn from a fixed `values` set
via `append_value`/`env_var`). Values reach the worker as whole argv words; the
worker is spawned without a shell.

Notes:

- There is deliberately **no free-form `string` parameter** in v1: an
unconstrained value is exactly the injection surface the broker exists to
avoid. Enumerate the values you want to allow with an `enum` param instead.
- The `workspace:<target>` mount form (a custom mountpoint) is **broker-config
only** — it describes where a *worker* sees the workspace. A Swarmforge tong
definition's own `mounts:` accepts only `workspace[:mode]` and always mounts at
`/workspace`.

## Develop

```sh
npm install
npm test # tsc + node:test over the config + argv-builder suites
npm run build # emit dist/
```

## Build the image

From the repo root: `make build_broker` (tags `swarmforge-docker-broker:latest`).
Loading
Loading