Add a configurable docker-task broker tong#21
Open
CrypticSwarm wants to merge 14 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, typedparams) 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
tongs/docker-broker/: config schema +fail-closed validation, a
docker runargv builder, the MCP server, and amulti-stage Dockerfile. The baked reference config ships a
buildand atestverb over a single allowlisted image.
SWARMFORGE_WORKSPACE_HOST_PATH, so the broker can mount the session workspaceinto the workers it spawns (a container can't re-share the bind mounts it was
given).
make build_brokerto build the image, and an example tong definition wiringit in.
Safety
allowed_images.workspacemagic word -- never the docker socket,never a raw host path.
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.