Skip to content

Add a configurable docker-task broker tong#21

Open
CrypticSwarm wants to merge 14 commits into
masterfrom
tongs-phase-9-reference-broker
Open

Add a configurable docker-task broker tong#21
CrypticSwarm wants to merge 14 commits into
masterfrom
tongs-phase-9-reference-broker

Conversation

@CrypticSwarm

Copy link
Copy Markdown
Owner

Adds a reference broker tong: a sidecar that holds the docker socket and
spawns short-lived worker containers on demand, so the anvil can compile, run
tests, or do other sandboxed work without ever getting socket access itself.

Unlike a hand-written, one-off broker, the verbs it exposes are defined by a
declarative config baked into the image. Each command 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) layered on top. The config is the allowlist:
there is no verb that runs an arbitrary image or mounts an arbitrary host path.

What's here

  • A TypeScript HTTP MCP broker under tongs/docker-broker/: config schema +
    fail-closed validation, a docker run argv builder, the MCP server, and a
    multi-stage Dockerfile. The baked reference config ships a build and a test
    verb over a single allowlisted image.
  • The launcher now hands a socket-holding tong the workspace's host path as
    SWARMFORGE_WORKSPACE_HOST_PATH, so the broker can mount the session workspace
    into the workers it spawns (a container can't re-share the bind mounts it was
    given).
  • make build_broker to build the image, and an example tong definition wiring
    it in.

Safety

  • Every worker image must be listed in allowed_images.
  • A worker may only mount the workspace magic word -- never the docker socket,
    never a raw host path.
  • Parameters are limited to booleans (toggling a fixed effect) and enums (a value
    drawn from a fixed set). Values reach the worker as whole argv words and the
    worker is spawned without a shell, so nothing a caller sends can become a flag,
    path, or shell metacharacter. The argv builder re-checks enum values itself, so
    the boundary holds even if the schema layer is bypassed.

A broker tong holds the docker socket and spawns its own short-lived worker
containers. A container cannot re-share the bind mounts it was given, so a
broker that wants to mount the session workspace into a worker needs the
workspace's host path -- the path the daemon resolves -- not the in-container
mount point it sees.

Inject SWARMFORGE_WORKSPACE_HOST_PATH into any socket-holding tong's
environment, reusing the workspace path the launcher already threads through
for the `workspace` mount magic word. Tongs without the docker-socket mount
never receive it, so their run argv is unchanged; a tong that sets the name
itself keeps its own value.
Introduce a broker tong that exposes a *configured* set of narrow docker-task
verbs rather than a fixed, hand-written set. Each command in the broker's
declarative config describes the worker container to spawn -- mirroring the tong
definition shape (image, mounts, command, env, resources, networks) -- with an
MCP surface (name, description, typed params) on top. The set of images named
across the commands, gated by `allowed_images`, is the entire allowlist; there
is no "run an arbitrary container" verb.

This commit lands the security-critical pure core:

- config.ts parses and validates the declarative config fail-closed. A worker may
  only mount the `workspace` magic word (never the docker socket or a raw host
  path), every image must be in the allowlist, and parameters are constrained to
  booleans (toggling a fixed effect) or enums (a value drawn from a fixed set).
- commands.ts turns a validated command plus caller inputs into a concrete
  `docker run` argv. Workers are always spawned with an argv array -- never a
  shell -- and the enum value is re-checked against its set here too, so this
  boundary is safe even if the schema layer is bypassed.

Both are covered by node:test suites asserting the validation rules and that no
parameter value can become a flag, path, or shell metacharacter.
Expose each configured command as an MCP tool. The tool's input schema is derived
from the command's declared parameters: a boolean param becomes an optional
boolean, an enum param becomes a string constrained to its allowed values, so the
harness can only ever send a value the config already sanctioned. A tool call runs
its worker to completion and returns the combined output; errors come back as MCP
tool errors rather than crashing the server.

The entrypoint serves a stateless Streamable-HTTP MCP endpoint at /mcp plus a
/healthz probe for the tong readiness check, loading its command set from the
config path, port, and workspace host path in the environment.
Add a multi-stage Dockerfile: a build stage compiles the TypeScript, and a slim
runtime stage carries only the docker *client* (the broker talks to the host
socket the launcher bind-mounts in), the production dependencies, and the compiled
output. The baked command set lands at /etc/swarmforge/broker.config.yaml.

Ship an illustrative reference config -- a `build` and a `test` verb over a single
allowlisted image -- demonstrating workspace mounts, a boolean effect, and an enum
parameter. Operators replace it with the narrow verbs their project needs.
Add a `make build_broker` target that builds the reference broker image, and
ship an example tong definition that wires it in as a session tong holding the
docker socket.

The example definition lives under tongs/docker-broker/, a directory below the
repo tong layer's root. Discovery reads only top-level *.yaml, so shipping it in
the checkout does not hand every repo a docker-socket broker -- a user opts in by
copying it into a layer. Document the broker, its declarative config model, and
its safety rules in the README, and correct the repo-tongs comment now that the
checkout ships a tongs/ directory (still empty of top-level definitions).
The TypeScript build preserves the source tree under dist/ (src/index.ts ->
dist/src/index.js), so the runtime CMD and the `start` script pointed at a
path that does not exist; the container would have crashed on launch and never
passed its readiness probe. Point both at dist/src/index.js. Verified the image's
entrypoint boots and serves the MCP handshake and /healthz.

Also clarify the docs from review: /healthz is a liveness endpoint (the example
tong gates readiness with a TCP probe), the absence of a free-form string
parameter is a deliberate anti-injection choice, and the custom `workspace:<target>`
mount form is broker-config only (a tong definition always mounts at /workspace).
Reject non-boolean values in boolean params when callers bypass schema
validation, and fail config parsing for workspace mounts with multiple
target paths.
Limit captured stdout and stderr to 1 MiB per stream so a noisy or
looping worker cannot grow broker memory without bound.
A `shared` tong is one long-lived container reused across sessions, so
injecting the current session's SWARMFORGE_WORKSPACE_HOST_PATH into it
would leak that workspace into every later session that reuses the
container -- the same leak unsupported_tong_reasons already refuses for a
shared tong that *mounts* the workspace. Gate the env injection on
`lifecycle == session` so a shared socket broker never receives a
per-session path; a worker it then tries to mount the workspace into
fails closed on the unset var.
The launcher forwards everything after the first colon in a mount spec
verbatim as the docker mount mode, so a tong `mounts:` entry only supports
`workspace[:mode]`/`docker-socket[:mode]`. The `workspace:/target` form is
broker-config only; copied into a tong definition it previously passed
validation (which checked only the word before the first colon) and then
failed at container launch with a cryptic docker error. Validate the mode
is `ro`/`rw` so the mistake is caught at load time with a clear message.
validateEffect returned an empty effect for an omitted when_true while
rejecting an explicit empty one, so a boolean param whose author forgot
when_true validated cleanly and shipped as a silent no-op: the MCP schema
advertised it but setting it did nothing. Refuse an omitted when_true at
load time so a boolean param always has a declared effect.
The broker's TypeScript suite (tongs/docker-broker) was never exercised by
CI -- the Tests workflow ran only the Python launcher suites, so a
regression in the broker's config validation or argv construction could
merge green. Add a docker-broker job that runs `npm ci && npm test`
(tsc + node --test) on Node 24, matching the image's NODE_TAG.
Move the express/MCP wiring out of the process entrypoint into
`createApp(config, workspaceHost, doSpawn?)` so it can be started against
an arbitrary config, workspace, and ephemeral port in tests. index.ts
keeps only the env reading, listen, and shutdown. No behavior change to
the served endpoints.
The unit suite injects a mock Spawn, so nothing exercised the real
request -> container -> workspace-file path. Add a docker-gated e2e test
that drives the broker over real HTTP MCP and spawns real alpine workers:
two verbs each write a marker file into a bind-mounted temp workspace, and
the test asserts both the MCP success response and the file on disk. It
runs the harness on the host (not docker-in-docker) so the workspace host
path needs no translation, and skips cleanly when docker is absent.

Split from the hermetic `npm test` via a separate `test:e2e` script and a
`docker-broker-e2e` CI job that pre-pulls the worker image.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant