From 52d76c304aebed71e2c8fddc61ca1dc07f4e6ebb Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 28 Jun 2026 12:23:37 -0500 Subject: [PATCH 01/14] Hand broker tongs the workspace host path 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. --- scripts/test_tongs.py | 37 +++++++++++++++++++++++++++++++++++++ scripts/tongs.py | 18 ++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/scripts/test_tongs.py b/scripts/test_tongs.py index e5a39a3..6602df8 100644 --- a/scripts/test_tongs.py +++ b/scripts/test_tongs.py @@ -1022,6 +1022,43 @@ 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_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") diff --git a/scripts/tongs.py b/scripts/tongs.py index 1177439..fcde8a4 100644 --- a/scripts/tongs.py +++ b/scripts/tongs.py @@ -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) @@ -1205,6 +1213,9 @@ 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) 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. 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 @@ -1225,8 +1236,11 @@ 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 {}) + if workspace and _has_socket_mount(defn): + 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) From ba53be61f8f6483e2a9862ad010416a9a5f52411 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 28 Jun 2026 12:29:01 -0500 Subject: [PATCH 02/14] Add the configurable docker-task broker core 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. --- tongs/docker-broker/.gitignore | 2 + tongs/docker-broker/package-lock.json | 1739 +++++++++++++++++++++ tongs/docker-broker/package.json | 24 + tongs/docker-broker/src/commands.ts | 140 ++ tongs/docker-broker/src/config.ts | 283 ++++ tongs/docker-broker/test/commands.test.ts | 146 ++ tongs/docker-broker/test/config.test.ts | 166 ++ tongs/docker-broker/tsconfig.json | 15 + 8 files changed, 2515 insertions(+) create mode 100644 tongs/docker-broker/.gitignore create mode 100644 tongs/docker-broker/package-lock.json create mode 100644 tongs/docker-broker/package.json create mode 100644 tongs/docker-broker/src/commands.ts create mode 100644 tongs/docker-broker/src/config.ts create mode 100644 tongs/docker-broker/test/commands.test.ts create mode 100644 tongs/docker-broker/test/config.test.ts create mode 100644 tongs/docker-broker/tsconfig.json diff --git a/tongs/docker-broker/.gitignore b/tongs/docker-broker/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/tongs/docker-broker/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tongs/docker-broker/package-lock.json b/tongs/docker-broker/package-lock.json new file mode 100644 index 0000000..7fd7694 --- /dev/null +++ b/tongs/docker-broker/package-lock.json @@ -0,0 +1,1739 @@ +{ + "name": "swarmforge-docker-broker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "swarmforge-docker-broker", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "express": "^4.21.2", + "js-yaml": "^4.1.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/tongs/docker-broker/package.json b/tongs/docker-broker/package.json new file mode 100644 index 0000000..211f3ec --- /dev/null +++ b/tongs/docker-broker/package.json @@ -0,0 +1,24 @@ +{ + "name": "swarmforge-docker-broker", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "A Swarmforge broker tong: exposes a configured set of narrow docker-task verbs as an HTTP MCP server.", + "scripts": { + "build": "tsc", + "test": "tsc && node --test \"dist/test/**/*.test.js\"", + "start": "node dist/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.4", + "express": "^4.21.2", + "js-yaml": "^4.1.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.10.2", + "typescript": "^5.7.2" + } +} diff --git a/tongs/docker-broker/src/commands.ts b/tongs/docker-broker/src/commands.ts new file mode 100644 index 0000000..264d7d7 --- /dev/null +++ b/tongs/docker-broker/src/commands.ts @@ -0,0 +1,140 @@ +// Turning a validated command + caller-supplied parameters into a concrete +// `docker run` invocation. This is the broker's security boundary, so it trusts +// nothing: images are fixed by config, mounts resolve only the `workspace` magic +// word against the host path the launcher injected, and parameters can only +// append config-defined tokens or insert an enum value re-checked against its +// allowed set. Workers are always spawned with an argv array -- never a shell -- +// so no parameter value can be interpreted as a flag, path, or shell metacharacter. + +import { spawn } from "node:child_process"; +import type { CommandDef } from "./config.js"; + +export class BrokerError extends Error {} + +export type RunResult = { + exitCode: number; + stdout: string; + stderr: string; +}; + +export type Spawn = (command: string, args: string[]) => Promise; + +// Resolve a validated `workspace[:][:]` spec to a docker `-v` value +// against the host workspace path. +export function resolveWorkspaceMount(spec: string, workspaceHost: string): string { + const parts = spec.split(":"); + let target = "/workspace"; + let mode: string | undefined; + for (let i = 1; i < parts.length; i++) { + const field = parts[i]; + if (field.startsWith("/")) target = field; + else mode = field; + } + return mode ? `${workspaceHost}:${target}:${mode}` : `${workspaceHost}:${target}`; +} + +// Map caller inputs onto the additive effects the config permits: tokens appended +// to the worker command and fixed environment overrides. Inputs are applied in +// declaration order so the resulting argv is deterministic. +export function applyParams( + command: CommandDef, + inputs: Record, +): { append: string[]; env: Record } { + const append: string[] = []; + const env: Record = {}; + for (const param of command.params) { + if (param.type === "boolean") { + const value = param.name in inputs ? Boolean(inputs[param.name]) : param.default; + if (value) { + append.push(...param.whenTrue.appendCommand); + Object.assign(env, param.whenTrue.env); + } + continue; + } + // enum + const provided = param.name in inputs ? inputs[param.name] : undefined; + const selected = provided === undefined ? param.default : String(provided); + if (selected === undefined) { + if (param.required) throw new BrokerError(`missing required parameter '${param.name}'`); + continue; + } + // Re-check against the allowed set even though the MCP schema already enforces + // it: this function must be safe on its own, never trusting the caller. + if (!param.values.includes(selected)) { + throw new BrokerError(`'${selected}' is not an allowed value for parameter '${param.name}'`); + } + if (param.target.kind === "append") append.push(selected); + else env[param.target.var] = selected; + } + return { append, env }; +} + +// Build the argv passed to `docker` (i.e. starting at `run`). Pure and total for a +// validated command; throws only when a workspace mount is requested but the host +// path was not injected. +export function buildWorkerArgv( + command: CommandDef, + inputs: Record, + workspaceHost: string | undefined, +): string[] { + const { append, env } = applyParams(command, inputs); + const argv = ["run", "--rm"]; + if (command.entrypoint) argv.push("--entrypoint", command.entrypoint); + if (command.workdir) argv.push("-w", command.workdir); + for (const network of command.networks) argv.push("--network", network); + + const mergedEnv = { ...command.env, ...env }; + for (const key of Object.keys(mergedEnv).sort()) argv.push("-e", `${key}=${mergedEnv[key]}`); + + for (const spec of command.mounts) { + if (!workspaceHost) { + throw new BrokerError( + `command '${command.name}' mounts the workspace but SWARMFORGE_WORKSPACE_HOST_PATH is unset`, + ); + } + argv.push("-v", resolveWorkspaceMount(spec, workspaceHost)); + } + + if (command.resources.memory) argv.push("--memory", command.resources.memory); + if (command.resources.cpus) argv.push("--cpus", command.resources.cpus); + if (command.resources.gpus) argv.push("--gpus", command.resources.gpus); + + argv.push(command.image); + argv.push(...command.command, ...append); + return argv; +} + +const realSpawn: Spawn = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr })); + }); + +// Run one worker to completion, capturing its output. The container is `--rm`, so +// nothing is left behind once it exits. +export function runWorker( + command: CommandDef, + inputs: Record, + workspaceHost: string | undefined, + doSpawn: Spawn = realSpawn, +): Promise { + const argv = buildWorkerArgv(command, inputs, workspaceHost); + return doSpawn("docker", argv); +} + +export function formatResult(label: string, result: RunResult): string { + const ok = result.exitCode === 0; + const lines = [`${label}: ${ok ? "success" : `failed (exit ${result.exitCode})`}`]; + if (result.stdout.trim()) lines.push("--- stdout ---", result.stdout.trimEnd()); + if (result.stderr.trim()) lines.push("--- stderr ---", result.stderr.trimEnd()); + return lines.join("\n"); +} diff --git a/tongs/docker-broker/src/config.ts b/tongs/docker-broker/src/config.ts new file mode 100644 index 0000000..7546969 --- /dev/null +++ b/tongs/docker-broker/src/config.ts @@ -0,0 +1,283 @@ +// Declarative broker configuration: the set of narrow docker-task verbs this +// broker exposes. The shape deliberately mirrors a Swarmforge tong definition -- +// each command describes the *worker* container to spawn (image, mounts, command, +// env, resources, networks) -- with an MCP surface (name, description, typed +// params) layered on top. There is no "run an arbitrary container" verb: the set +// of images named across the commands, gated by `allowed_images`, is the entire +// allowlist. +// +// Loading is fail-closed. Any structural problem throws ConfigError and the broker +// refuses to start, so a misconfigured allowlist or an injectable parameter can +// never reach the docker socket. + +import { readFileSync } from "node:fs"; +import yaml from "js-yaml"; + +export class ConfigError extends Error {} + +export type Effect = { + // Tokens appended (as whole argv words, never shell-split) to the worker command. + appendCommand: string[]; + // Fixed environment overrides applied to the worker. + env: Record; +}; + +export type BooleanParam = { + name: string; + type: "boolean"; + description: string; + default: boolean; + // Applied verbatim when the caller passes `true`. Drawn entirely from config; + // the caller chooses only whether to apply it. + whenTrue: Effect; +}; + +export type EnumParam = { + name: string; + type: "enum"; + description: string; + values: string[]; + required: boolean; + default?: string; + // Where the chosen value (always one of `values`) goes: appended as a single + // command token, or set as the value of a fixed env var. + target: { kind: "append" } | { kind: "env"; var: string }; +}; + +export type Param = BooleanParam | EnumParam; + +export type Resources = { + memory?: string; + cpus?: string; + gpus?: string; +}; + +export type CommandDef = { + name: string; + description: string; + image: string; + // Magic-word mount specs (only `workspace[:][:]`); never raw host + // paths and never the docker socket. + mounts: string[]; + workdir?: string; + entrypoint?: string; + command: string[]; + env: Record; + networks: string[]; + resources: Resources; + params: Param[]; +}; + +export type BrokerConfig = { + name: string; + description: string; + allowedImages: string[]; + commands: CommandDef[]; +}; + +const NAME_RE = /^[a-zA-Z0-9_-]+$/; +const PARAM_NAME_RE = /^[a-zA-Z0-9_]+$/; +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown, where: string): string { + if (typeof value !== "string") { + throw new ConfigError(`${where} must be a string`); + } + return value; +} + +function asNonEmptyString(value: unknown, where: string): string { + const s = asString(value, where); + if (s.trim() === "") throw new ConfigError(`${where} must not be empty`); + return s; +} + +function asStringArray(value: unknown, where: string): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) throw new ConfigError(`${where} must be a list`); + return value.map((item, i) => asString(item, `${where}[${i}]`)); +} + +function asStringMap(value: unknown, where: string): Record { + if (value === undefined) return {}; + if (!isPlainObject(value)) throw new ConfigError(`${where} must be a mapping`); + const out: Record = {}; + for (const [key, raw] of Object.entries(value)) { + if (!ENV_KEY_RE.test(key)) { + throw new ConfigError(`${where}: '${key}' is not a valid environment variable name`); + } + out[key] = asString(raw, `${where}.${key}`); + } + return out; +} + +// `workspace`, `workspace:ro`, `workspace:/code`, `workspace:/code:ro` -- the +// only legal mount. A worker never receives `docker-socket` (that would re-hand +// out the broker's own privilege) or a raw host path. +function validateMount(raw: unknown, where: string): string { + const spec = asString(raw, where); + const parts = spec.split(":"); + if (parts[0] === "docker-socket") { + throw new ConfigError(`${where}: a worker may not mount the docker socket`); + } + if (parts[0] !== "workspace") { + throw new ConfigError(`${where}: '${spec}' is not a recognized mount (only 'workspace')`); + } + if (parts.length > 3) { + throw new ConfigError(`${where}: '${spec}' has too many ':'-separated fields`); + } + for (let i = 1; i < parts.length; i++) { + const field = parts[i]; + const looksLikePath = field.startsWith("/"); + const looksLikeMode = field === "ro" || field === "rw"; + if (!looksLikePath && !looksLikeMode) { + throw new ConfigError(`${where}: '${field}' is neither a target path nor an access mode`); + } + // A mode may only appear last. + if (looksLikeMode && i !== parts.length - 1) { + throw new ConfigError(`${where}: access mode '${field}' must be the final field`); + } + } + return spec; +} + +function validateResources(value: unknown, where: string): Resources { + if (value === undefined) return {}; + if (!isPlainObject(value)) throw new ConfigError(`${where} must be a mapping`); + const out: Resources = {}; + for (const key of ["memory", "cpus", "gpus"] as const) { + if (value[key] !== undefined) out[key] = asNonEmptyString(value[key], `${where}.${key}`); + } + const unknown = Object.keys(value).filter((k) => !["memory", "cpus", "gpus"].includes(k)); + if (unknown.length) throw new ConfigError(`${where}: unknown resource(s) ${unknown.join(", ")}`); + return out; +} + +function validateEffect(value: unknown, where: string): Effect { + if (value === undefined) return { appendCommand: [], env: {} }; + if (!isPlainObject(value)) throw new ConfigError(`${where} must be a mapping`); + const effect: Effect = { + appendCommand: asStringArray(value.append_command, `${where}.append_command`), + env: asStringMap(value.env, `${where}.env`), + }; + if (effect.appendCommand.length === 0 && Object.keys(effect.env).length === 0) { + throw new ConfigError(`${where} has no effect (set append_command and/or env)`); + } + return effect; +} + +function validateParam(value: unknown, where: string): Param { + if (!isPlainObject(value)) throw new ConfigError(`${where} must be a mapping`); + const name = asNonEmptyString(value.name, `${where}.name`); + if (!PARAM_NAME_RE.test(name)) { + throw new ConfigError(`${where}.name '${name}' must match ${PARAM_NAME_RE}`); + } + const description = value.description === undefined ? "" : asString(value.description, `${where}.description`); + const type = asString(value.type, `${where}.type`); + + if (type === "boolean") { + const dflt = value.default === undefined ? false : value.default; + if (typeof dflt !== "boolean") throw new ConfigError(`${where}.default must be a boolean`); + return { + name, + type: "boolean", + description, + default: dflt, + whenTrue: validateEffect(value.when_true, `${where}.when_true`), + }; + } + + if (type === "enum") { + const values = asStringArray(value.values, `${where}.values`); + if (values.length === 0) throw new ConfigError(`${where}.values must be non-empty`); + if (new Set(values).size !== values.length) throw new ConfigError(`${where}.values has duplicates`); + const required = value.required === undefined ? false : value.required; + if (typeof required !== "boolean") throw new ConfigError(`${where}.required must be a boolean`); + let dflt: string | undefined; + if (value.default !== undefined) { + if (required) throw new ConfigError(`${where}: a required param cannot also have a default`); + dflt = asString(value.default, `${where}.default`); + if (!values.includes(dflt)) throw new ConfigError(`${where}.default '${dflt}' is not one of values`); + } + const hasAppend = value.append_value === true; + const envVar = value.env_var === undefined ? undefined : asNonEmptyString(value.env_var, `${where}.env_var`); + if (hasAppend === (envVar !== undefined)) { + throw new ConfigError(`${where} must set exactly one of append_value: true or env_var`); + } + if (envVar !== undefined && !ENV_KEY_RE.test(envVar)) { + throw new ConfigError(`${where}.env_var '${envVar}' is not a valid environment variable name`); + } + const target: EnumParam["target"] = hasAppend ? { kind: "append" } : { kind: "env", var: envVar! }; + return { name, type: "enum", description, values, required, default: dflt, target }; + } + + throw new ConfigError(`${where}.type '${type}' must be 'boolean' or 'enum'`); +} + +function validateCommand(value: unknown, where: string, allowedImages: string[]): CommandDef { + if (!isPlainObject(value)) throw new ConfigError(`${where} must be a mapping`); + const name = asNonEmptyString(value.name, `${where}.name`); + if (!NAME_RE.test(name)) throw new ConfigError(`${where}.name '${name}' must match ${NAME_RE}`); + const image = asNonEmptyString(value.image, `${where}.image`); + if (!allowedImages.includes(image)) { + throw new ConfigError(`${where}.image '${image}' is not in allowed_images`); + } + const mounts = (value.mounts === undefined ? [] : asStringArray(value.mounts, `${where}.mounts`)).map( + (m, i) => validateMount(m, `${where}.mounts[${i}]`), + ); + const params = (value.params === undefined ? [] : (value.params as unknown[])).map((p, i) => + validateParam(p, `${where}.params[${i}]`), + ); + const seen = new Set(); + for (const p of params) { + if (seen.has(p.name)) throw new ConfigError(`${where}: duplicate param '${p.name}'`); + seen.add(p.name); + } + return { + name, + description: asNonEmptyString(value.description, `${where}.description`), + image, + mounts, + workdir: value.workdir === undefined ? undefined : asNonEmptyString(value.workdir, `${where}.workdir`), + entrypoint: value.entrypoint === undefined ? undefined : asNonEmptyString(value.entrypoint, `${where}.entrypoint`), + command: asStringArray(value.command, `${where}.command`), + env: asStringMap(value.env, `${where}.env`), + networks: asStringArray(value.networks, `${where}.networks`), + resources: validateResources(value.resources, `${where}.resources`), + params, + }; +} + +export function parseConfig(doc: unknown): BrokerConfig { + if (!isPlainObject(doc)) throw new ConfigError("config root must be a mapping"); + const name = asNonEmptyString(doc.name, "name"); + const description = doc.description === undefined ? "" : asString(doc.description, "description"); + const allowedImages = asStringArray(doc.allowed_images, "allowed_images"); + if (allowedImages.length === 0) { + throw new ConfigError("allowed_images must list at least one image (the broker refuses to run with no allowlist)"); + } + if (!Array.isArray(doc.commands) || doc.commands.length === 0) { + throw new ConfigError("commands must be a non-empty list"); + } + const commands = doc.commands.map((c, i) => validateCommand(c, `commands[${i}]`, allowedImages)); + const names = new Set(); + for (const c of commands) { + if (names.has(c.name)) throw new ConfigError(`duplicate command name '${c.name}'`); + names.add(c.name); + } + return { name, description, allowedImages, commands }; +} + +export function loadConfig(path: string): BrokerConfig { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (err) { + throw new ConfigError(`cannot read broker config at ${path}: ${(err as Error).message}`); + } + return parseConfig(yaml.load(raw)); +} diff --git a/tongs/docker-broker/test/commands.test.ts b/tongs/docker-broker/test/commands.test.ts new file mode 100644 index 0000000..c0f2a2d --- /dev/null +++ b/tongs/docker-broker/test/commands.test.ts @@ -0,0 +1,146 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseConfig, type CommandDef } from "../src/config.js"; +import { buildWorkerArgv, applyParams, resolveWorkspaceMount, BrokerError } from "../src/commands.js"; + +function commandFrom(command: Record): CommandDef { + const cfg = parseConfig({ + name: "b", + allowed_images: ["img@sha256:abc"], + commands: [{ image: "img@sha256:abc", ...command }], + }); + return cfg.commands[0]; +} + +test("a base command builds run --rm ", () => { + const cmd = commandFrom({ name: "build", description: "d", command: ["make", "build"] }); + assert.deepEqual(buildWorkerArgv(cmd, {}, undefined), [ + "run", + "--rm", + "img@sha256:abc", + "make", + "build", + ]); +}); + +test("a boolean param appends tokens and env only when true", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + command: ["start"], + params: [{ name: "local", type: "boolean", when_true: { append_command: ["--local"], env: { LOCAL: "1" } } }], + }); + assert.deepEqual(applyParams(cmd, { local: true }), { append: ["--local"], env: { LOCAL: "1" } }); + assert.deepEqual(applyParams(cmd, { local: false }), { append: [], env: {} }); + assert.deepEqual(applyParams(cmd, {}), { append: [], env: {} }); +}); + +test("a boolean default applies when the arg is absent", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + command: ["go"], + params: [{ name: "fast", type: "boolean", default: true, when_true: { append_command: ["fast"] } }], + }); + assert.deepEqual(buildWorkerArgv(cmd, {}, undefined), ["run", "--rm", "img@sha256:abc", "go", "fast"]); +}); + +test("an enum append target appends the selected value as one token", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + command: ["run.sh"], + params: [{ name: "suite", type: "enum", values: ["unit", "e2e"], append_value: true }], + }); + assert.deepEqual(buildWorkerArgv(cmd, { suite: "e2e" }, undefined), [ + "run", + "--rm", + "img@sha256:abc", + "run.sh", + "e2e", + ]); +}); + +test("an enum env target sets the env var", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + params: [{ name: "target", type: "enum", values: ["app", "lib"], env_var: "TARGET" }], + }); + assert.deepEqual(applyParams(cmd, { target: "lib" }), { append: [], env: { TARGET: "lib" } }); +}); + +test("an enum value outside its set is rejected even if the schema is bypassed", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + params: [{ name: "t", type: "enum", values: ["a", "b"], append_value: true }], + }); + assert.throws(() => applyParams(cmd, { t: "; rm -rf /" }), BrokerError); +}); + +test("a missing required enum is rejected", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + params: [{ name: "t", type: "enum", values: ["a", "b"], append_value: true, required: true }], + }); + assert.throws(() => applyParams(cmd, {}), /missing required parameter/); +}); + +test("workspace mounts resolve against the host path", () => { + assert.equal(resolveWorkspaceMount("workspace", "/host/ws"), "/host/ws:/workspace"); + assert.equal(resolveWorkspaceMount("workspace:ro", "/host/ws"), "/host/ws:/workspace:ro"); + assert.equal(resolveWorkspaceMount("workspace:/code", "/host/ws"), "/host/ws:/code"); + assert.equal(resolveWorkspaceMount("workspace:/code:ro", "/host/ws"), "/host/ws:/code:ro"); +}); + +test("a workspace mount without a host path is an error", () => { + const cmd = commandFrom({ name: "x", description: "d", mounts: ["workspace"], command: ["go"] }); + assert.throws(() => buildWorkerArgv(cmd, {}, undefined), /SWARMFORGE_WORKSPACE_HOST_PATH is unset/); + assert.deepEqual(buildWorkerArgv(cmd, {}, "/host/ws"), [ + "run", + "--rm", + "-v", + "/host/ws:/workspace", + "img@sha256:abc", + "go", + ]); +}); + +test("env is emitted sorted and param env overrides static env", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + env: { B: "static", A: "1" }, + params: [{ name: "p", type: "enum", values: ["override"], env_var: "B" }], + }); + const argv = buildWorkerArgv(cmd, { p: "override" }, undefined); + // -e A=1 comes before -e B=override, and B is the param's value, not "static". + assert.deepEqual(argv, ["run", "--rm", "-e", "A=1", "-e", "B=override", "img@sha256:abc"]); +}); + +test("appended values stay single argv elements (no shell splitting)", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + command: ["echo"], + params: [{ name: "msg", type: "enum", values: ["a b c"], append_value: true }], + }); + const argv = buildWorkerArgv(cmd, { msg: "a b c" }, undefined); + assert.equal(argv[argv.length - 1], "a b c"); +}); + +test("resources and networks render as docker flags", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + networks: ["build-net"], + resources: { memory: "512m", cpus: "2", gpus: "all" }, + command: ["go"], + }); + const argv = buildWorkerArgv(cmd, {}, undefined); + assert.ok(argv.includes("--network") && argv[argv.indexOf("--network") + 1] === "build-net"); + assert.ok(argv.includes("--memory") && argv[argv.indexOf("--memory") + 1] === "512m"); + assert.ok(argv.includes("--gpus") && argv[argv.indexOf("--gpus") + 1] === "all"); +}); diff --git a/tongs/docker-broker/test/config.test.ts b/tongs/docker-broker/test/config.test.ts new file mode 100644 index 0000000..513128e --- /dev/null +++ b/tongs/docker-broker/test/config.test.ts @@ -0,0 +1,166 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseConfig, ConfigError, type BrokerConfig } from "../src/config.js"; + +function base(overrides: Record = {}): Record { + return { + name: "demo-broker", + allowed_images: ["toolchain@sha256:abc"], + commands: [ + { name: "build", description: "Build it", image: "toolchain@sha256:abc", command: ["make", "build"] }, + ], + ...overrides, + }; +} + +test("a minimal config parses and normalizes defaults", () => { + const cfg: BrokerConfig = parseConfig(base()); + assert.equal(cfg.name, "demo-broker"); + assert.equal(cfg.commands.length, 1); + const cmd = cfg.commands[0]; + assert.deepEqual(cmd.command, ["make", "build"]); + assert.deepEqual(cmd.mounts, []); + assert.deepEqual(cmd.env, {}); + assert.deepEqual(cmd.params, []); +}); + +test("an empty allowlist is refused", () => { + assert.throws(() => parseConfig(base({ allowed_images: [] })), ConfigError); +}); + +test("a command image outside the allowlist is refused", () => { + const doc = base({ + commands: [{ name: "x", description: "d", image: "other:latest" }], + }); + assert.throws(() => parseConfig(doc), /not in allowed_images/); +}); + +test("duplicate command names are refused", () => { + const doc = base({ + commands: [ + { name: "dup", description: "d", image: "toolchain@sha256:abc" }, + { name: "dup", description: "d", image: "toolchain@sha256:abc" }, + ], + }); + assert.throws(() => parseConfig(doc), /duplicate command/); +}); + +test("a worker may not mount the docker socket", () => { + const doc = base({ + commands: [{ name: "x", description: "d", image: "toolchain@sha256:abc", mounts: ["docker-socket"] }], + }); + assert.throws(() => parseConfig(doc), /may not mount the docker socket/); +}); + +test("an unrecognized mount magic word is refused", () => { + const doc = base({ + commands: [{ name: "x", description: "d", image: "toolchain@sha256:abc", mounts: ["/etc:/etc"] }], + }); + assert.throws(() => parseConfig(doc), /not a recognized mount/); +}); + +test("a mount access mode must come last", () => { + const doc = base({ + commands: [{ name: "x", description: "d", image: "toolchain@sha256:abc", mounts: ["workspace:ro:/code"] }], + }); + assert.throws(() => parseConfig(doc), /must be the final field/); +}); + +test("a boolean param with no effect is refused", () => { + const doc = base({ + commands: [ + { + name: "x", + description: "d", + image: "toolchain@sha256:abc", + params: [{ name: "flag", type: "boolean", when_true: {} }], + }, + ], + }); + assert.throws(() => parseConfig(doc), /has no effect/); +}); + +test("an enum param needs exactly one target", () => { + const both = base({ + commands: [ + { + name: "x", + description: "d", + image: "toolchain@sha256:abc", + params: [{ name: "t", type: "enum", values: ["a", "b"], append_value: true, env_var: "T" }], + }, + ], + }); + assert.throws(() => parseConfig(both), /exactly one of/); + + const neither = base({ + commands: [ + { + name: "x", + description: "d", + image: "toolchain@sha256:abc", + params: [{ name: "t", type: "enum", values: ["a", "b"] }], + }, + ], + }); + assert.throws(() => parseConfig(neither), /exactly one of/); +}); + +test("an enum default outside its values is refused", () => { + const doc = base({ + commands: [ + { + name: "x", + description: "d", + image: "toolchain@sha256:abc", + params: [{ name: "t", type: "enum", values: ["a", "b"], append_value: true, default: "z" }], + }, + ], + }); + assert.throws(() => parseConfig(doc), /is not one of values/); +}); + +test("a required enum cannot also carry a default", () => { + const doc = base({ + commands: [ + { + name: "x", + description: "d", + image: "toolchain@sha256:abc", + params: [{ name: "t", type: "enum", values: ["a", "b"], append_value: true, required: true, default: "a" }], + }, + ], + }); + assert.throws(() => parseConfig(doc), /cannot also have a default/); +}); + +test("a full command with params normalizes cleanly", () => { + const doc = base({ + description: "demo", + commands: [ + { + name: "compile", + description: "Compile", + image: "toolchain@sha256:abc", + mounts: ["workspace:/code"], + workdir: "/code", + command: ["make"], + env: { CI: "1" }, + resources: { memory: "512m" }, + params: [ + { name: "fast", type: "boolean", default: true, when_true: { append_command: ["build_fast"] } }, + { name: "target", type: "enum", values: ["app", "lib"], env_var: "TARGET" }, + ], + }, + ], + }); + const cfg = parseConfig(doc); + const cmd = cfg.commands[0]; + assert.deepEqual(cmd.mounts, ["workspace:/code"]); + assert.equal(cmd.resources.memory, "512m"); + assert.equal(cmd.params.length, 2); + const fast = cmd.params[0]; + assert.equal(fast.type === "boolean" && fast.default, true); + const target = cmd.params[1]; + assert.ok(target.type === "enum" && target.target.kind === "env" && target.target.var === "TARGET"); +}); diff --git a/tongs/docker-broker/tsconfig.json b/tongs/docker-broker/tsconfig.json new file mode 100644 index 0000000..109c5b2 --- /dev/null +++ b/tongs/docker-broker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 4f7377236a97071e1cdaad1ead1446d227d28aa7 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 28 Jun 2026 12:29:06 -0500 Subject: [PATCH 03/14] Serve the broker over HTTP MCP 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. --- tongs/docker-broker/src/index.ts | 74 +++++++++++++++++++++++++++++++ tongs/docker-broker/src/server.ts | 70 +++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 tongs/docker-broker/src/index.ts create mode 100644 tongs/docker-broker/src/server.ts diff --git a/tongs/docker-broker/src/index.ts b/tongs/docker-broker/src/index.ts new file mode 100644 index 0000000..d86343a --- /dev/null +++ b/tongs/docker-broker/src/index.ts @@ -0,0 +1,74 @@ +// HTTP entrypoint for the broker. Loads the baked command config, then serves a +// stateless Streamable-HTTP MCP endpoint at /mcp (a fresh server per request, as +// the SDK's stateless example does) plus a /healthz probe for the tong readiness +// check. The config path, port, and workspace host path all come from the +// environment so the same image serves any baked or mounted configuration. + +import express, { type Request, type Response } from "express"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { loadConfig } from "./config.js"; +import { buildServer } from "./server.js"; + +const port = Number(process.env.PORT ?? 3000); +const configPath = process.env.BROKER_CONFIG ?? "/etc/swarmforge/broker.config.yaml"; +const workspaceHost = process.env.SWARMFORGE_WORKSPACE_HOST_PATH; + +const config = (() => { + try { + return loadConfig(configPath); + } catch (err) { + console.error(`broker config error: ${(err as Error).message}`); + process.exit(1); + } +})(); + +const app = express(); +app.use(express.json()); + +app.post("/mcp", async (req: Request, res: Response) => { + const server = buildServer(config, workspaceHost); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { + transport.close(); + server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error("mcp POST failed", err); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +}); + +function methodNotAllowed(_req: Request, res: Response): void { + res.status(405).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Method not allowed." }, + id: null, + }); +} + +app.get("/mcp", methodNotAllowed); +app.delete("/mcp", methodNotAllowed); + +app.get("/healthz", (_req, res) => { + res.json({ ok: true }); +}); + +const httpServer = app.listen(port, () => { + console.log(`${config.name} broker listening on :${port} (${config.commands.length} verb(s))`); +}); + +function shutdown(signal: string): void { + console.log(`received ${signal}, shutting down`); + httpServer.close(() => process.exit(0)); +} +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/tongs/docker-broker/src/server.ts b/tongs/docker-broker/src/server.ts new file mode 100644 index 0000000..1c72bde --- /dev/null +++ b/tongs/docker-broker/src/server.ts @@ -0,0 +1,70 @@ +// Maps a validated broker config onto an MCP server: one tool per command, whose +// input schema is derived from the command's declared parameters. A boolean param +// becomes an optional boolean; an enum param becomes a (required or optional) +// string constrained to its allowed values, so the harness can only ever send a +// value the config already sanctioned. + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import type { BrokerConfig, CommandDef } from "./config.js"; +import { formatResult, runWorker, type Spawn } from "./commands.js"; + +function buildInputSchema(command: CommandDef): Record { + const shape: Record = {}; + for (const param of command.params) { + if (param.type === "boolean") { + shape[param.name] = z + .boolean() + .optional() + .describe(param.description || `When true, apply the '${param.name}' option.`); + } else { + const base = z.enum(param.values as [string, ...string[]]); + const described = (param.required ? base : base.optional()).describe( + param.description || `One of: ${param.values.join(", ")}.`, + ); + shape[param.name] = described; + } + } + return shape; +} + +function serverInstructions(config: BrokerConfig): string { + const header = + config.description || + "A Swarmforge broker: spawns a fixed set of narrow worker containers on demand."; + const verbs = config.commands.map((c) => ` - ${c.name} -- ${c.description}`).join("\n"); + return `${header}\n\nExposed verbs:\n${verbs}\n\nEach verb runs a pre-defined container to completion and returns its combined output; no arbitrary images or mounts can be requested.`; +} + +export function buildServer( + config: BrokerConfig, + workspaceHost: string | undefined, + doSpawn?: Spawn, +): McpServer { + const server = new McpServer( + { name: config.name, version: "0.1.0" }, + { instructions: serverInstructions(config) }, + ); + for (const command of config.commands) { + server.registerTool( + command.name, + { + title: command.name, + description: command.description, + inputSchema: buildInputSchema(command), + }, + async (args: Record) => { + try { + const result = await runWorker(command, args ?? {}, workspaceHost, doSpawn); + return { content: [{ type: "text" as const, text: formatResult(command.name, result) }] }; + } catch (err) { + return { + content: [{ type: "text" as const, text: `${command.name}: error: ${(err as Error).message}` }], + isError: true, + }; + } + }, + ); + } + return server; +} From 2fcb91f62a95536b32eb4c12053a70b61922f195 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 28 Jun 2026 12:30:09 -0500 Subject: [PATCH 04/14] Package the broker image with a reference command set 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. --- tongs/docker-broker/Dockerfile | 30 +++++++++++++++++ tongs/docker-broker/broker.config.yaml | 46 ++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tongs/docker-broker/Dockerfile create mode 100644 tongs/docker-broker/broker.config.yaml diff --git a/tongs/docker-broker/Dockerfile b/tongs/docker-broker/Dockerfile new file mode 100644 index 0000000..c552210 --- /dev/null +++ b/tongs/docker-broker/Dockerfile @@ -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/index.js"] diff --git a/tongs/docker-broker/broker.config.yaml b/tongs/docker-broker/broker.config.yaml new file mode 100644 index 0000000..5054b0f --- /dev/null +++ b/tongs/docker-broker/broker.config.yaml @@ -0,0 +1,46 @@ +# Reference broker command set. This is illustrative: replace the images and +# commands with the narrow, task-shaped verbs your project actually needs, and +# pin image digests for anything you depend on. +# +# Every image a command may run must appear in `allowed_images` -- that list is +# the broker's entire 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 parameters can only toggle a fixed effect (boolean) or pick a value from a +# fixed set (enum). +name: docker-broker +description: >- + Reference Swarmforge broker. Spawns short-lived worker containers on demand so + the anvil can compile and test without holding the docker socket itself. + +allowed_images: + - node:24-alpine + +commands: + - name: build + description: Run `npm run build` against the project workspace. + image: node:24-alpine + mounts: [workspace:/work] + workdir: /work + command: [npm, run, build] + resources: + memory: 1g + params: + - name: production + type: boolean + description: Build in production mode (sets NODE_ENV=production). + when_true: + env: + NODE_ENV: production + + - name: test + description: Run the project's test suite, optionally narrowed to one suite. + image: node:24-alpine + mounts: [workspace:/work:ro] + workdir: /work + command: [npm, test, --] + params: + - name: suite + type: enum + description: Which test suite to run; omit to run them all. + values: [unit, integration, e2e] + append_value: true From 0608f0070562f0848eced8129d56b7b9050069cc Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 28 Jun 2026 12:32:39 -0500 Subject: [PATCH 05/14] Wire the broker into the build and document enabling it 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). --- Makefile | 16 ++++- README.md | 32 ++++++++++ tongs/docker-broker/README.md | 71 +++++++++++++++++++++ tongs/docker-broker/docker-broker.tong.yaml | 24 +++++++ 4 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 tongs/docker-broker/README.md create mode 100644 tongs/docker-broker/docker-broker.tong.yaml diff --git a/Makefile b/Makefile index cc33918..cff77af 100644 --- a/Makefile +++ b/Makefile @@ -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 ?= @@ -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 @@ -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 @@ -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 \ diff --git a/README.md b/README.md index bd4baa9..4d61740 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tongs/docker-broker/README.md b/tongs/docker-broker/README.md new file mode 100644 index 0000000..8c4be11 --- /dev/null +++ b/tongs/docker-broker/README.md @@ -0,0 +1,71 @@ +# 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[:][:]` — never the docker + socket and never a raw host path. +- Parameters are limited to `boolean` (apply a fixed `append_command`/`env` + effect) and `enum` (a value drawn from a fixed `values` set). Values reach the + worker as whole argv words; the worker is spawned without a shell. + +## 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`). diff --git a/tongs/docker-broker/docker-broker.tong.yaml b/tongs/docker-broker/docker-broker.tong.yaml new file mode 100644 index 0000000..8076922 --- /dev/null +++ b/tongs/docker-broker/docker-broker.tong.yaml @@ -0,0 +1,24 @@ +# Example tong definition for the reference docker-task broker. +# +# This file is deliberately NOT auto-discovered. Tong discovery only reads +# top-level *.yaml in a layer's tongs/ directory, and this lives a level down, so +# shipping it in the checkout does not hand every repo a docker-socket broker. +# +# To enable the broker: +# 1. make build_broker # build the swarmforge-docker-broker image +# 2. cp tongs/docker-broker/docker-broker.tong.yaml ~/.swarmforge/tongs/docker-broker.yaml +# +# Because it holds the docker socket, a workspace-sourced copy of this definition +# is always called out in the first-run approval prompt before it can start. +description: Spawns narrow docker-task workers on demand so the anvil never holds the docker socket itself. +lifecycle: session +image: swarmforge-docker-broker:latest +interface: + kind: mcp + transport: http + port: 8080 + name: docker-broker +mounts: + - docker-socket +readiness: + mode: tcp From 973ac7e58af776e2f25b45c5035faa58c8d16171 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sun, 28 Jun 2026 12:38:47 -0500 Subject: [PATCH 06/14] Fix the broker container start path and clarify docs 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:` mount form is broker-config only (a tong definition always mounts at /workspace). --- tongs/docker-broker/Dockerfile | 2 +- tongs/docker-broker/README.md | 15 +++++++++++++-- tongs/docker-broker/package.json | 2 +- tongs/docker-broker/src/index.ts | 8 +++++--- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/tongs/docker-broker/Dockerfile b/tongs/docker-broker/Dockerfile index c552210..225c936 100644 --- a/tongs/docker-broker/Dockerfile +++ b/tongs/docker-broker/Dockerfile @@ -27,4 +27,4 @@ ENV BROKER_CONFIG=/etc/swarmforge/broker.config.yaml ENV PORT=8080 EXPOSE 8080 -CMD ["node", "dist/index.js"] +CMD ["node", "dist/src/index.js"] diff --git a/tongs/docker-broker/README.md b/tongs/docker-broker/README.md index 8c4be11..fa4d36d 100644 --- a/tongs/docker-broker/README.md +++ b/tongs/docker-broker/README.md @@ -55,8 +55,19 @@ Safety rules enforced at load time (the server refuses to start otherwise): - A worker may only mount `workspace[:][:]` — never the docker socket and never a raw host path. - Parameters are limited to `boolean` (apply a fixed `append_command`/`env` - effect) and `enum` (a value drawn from a fixed `values` set). Values reach the - worker as whole argv words; the worker is spawned without a shell. + effect) 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:` 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 diff --git a/tongs/docker-broker/package.json b/tongs/docker-broker/package.json index 211f3ec..89cb691 100644 --- a/tongs/docker-broker/package.json +++ b/tongs/docker-broker/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "tsc", "test": "tsc && node --test \"dist/test/**/*.test.js\"", - "start": "node dist/index.js" + "start": "node dist/src/index.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.4", diff --git a/tongs/docker-broker/src/index.ts b/tongs/docker-broker/src/index.ts index d86343a..cb7c42c 100644 --- a/tongs/docker-broker/src/index.ts +++ b/tongs/docker-broker/src/index.ts @@ -1,8 +1,10 @@ // HTTP entrypoint for the broker. Loads the baked command config, then serves a // stateless Streamable-HTTP MCP endpoint at /mcp (a fresh server per request, as -// the SDK's stateless example does) plus a /healthz probe for the tong readiness -// check. The config path, port, and workspace host path all come from the -// environment so the same image serves any baked or mounted configuration. +// the SDK's stateless example does) plus a /healthz liveness endpoint (the +// example tong gates readiness with a TCP probe; /healthz is available for a +// `healthcheck`-mode readiness if one is configured). The config path, port, and +// workspace host path all come from the environment so the same image serves any +// baked or mounted configuration. import express, { type Request, type Response } from "express"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; From 98ca5e0f26d0d6e695242daaaee0c1610900e0ac Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 10 Jul 2026 00:59:10 -0500 Subject: [PATCH 07/14] Harden broker input validation Reject non-boolean values in boolean params when callers bypass schema validation, and fail config parsing for workspace mounts with multiple target paths. --- tongs/docker-broker/src/commands.ts | 5 ++++- tongs/docker-broker/src/config.ts | 7 +++++++ tongs/docker-broker/test/commands.test.ts | 10 ++++++++++ tongs/docker-broker/test/config.test.ts | 7 +++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tongs/docker-broker/src/commands.ts b/tongs/docker-broker/src/commands.ts index 264d7d7..210d413 100644 --- a/tongs/docker-broker/src/commands.ts +++ b/tongs/docker-broker/src/commands.ts @@ -44,7 +44,10 @@ export function applyParams( const env: Record = {}; for (const param of command.params) { if (param.type === "boolean") { - const value = param.name in inputs ? Boolean(inputs[param.name]) : param.default; + const value = param.name in inputs ? inputs[param.name] : param.default; + if (typeof value !== "boolean") { + throw new BrokerError(`parameter '${param.name}' must be a boolean`); + } if (value) { append.push(...param.whenTrue.appendCommand); Object.assign(env, param.whenTrue.env); diff --git a/tongs/docker-broker/src/config.ts b/tongs/docker-broker/src/config.ts index 7546969..cae5216 100644 --- a/tongs/docker-broker/src/config.ts +++ b/tongs/docker-broker/src/config.ts @@ -130,6 +130,7 @@ function validateMount(raw: unknown, where: string): string { if (parts.length > 3) { throw new ConfigError(`${where}: '${spec}' has too many ':'-separated fields`); } + let seenTarget = false; for (let i = 1; i < parts.length; i++) { const field = parts[i]; const looksLikePath = field.startsWith("/"); @@ -141,6 +142,12 @@ function validateMount(raw: unknown, where: string): string { if (looksLikeMode && i !== parts.length - 1) { throw new ConfigError(`${where}: access mode '${field}' must be the final field`); } + if (looksLikePath) { + if (seenTarget) { + throw new ConfigError(`${where}: '${spec}' has multiple target paths`); + } + seenTarget = true; + } } return spec; } diff --git a/tongs/docker-broker/test/commands.test.ts b/tongs/docker-broker/test/commands.test.ts index c0f2a2d..b89f29f 100644 --- a/tongs/docker-broker/test/commands.test.ts +++ b/tongs/docker-broker/test/commands.test.ts @@ -35,6 +35,16 @@ test("a boolean param appends tokens and env only when true", () => { assert.deepEqual(applyParams(cmd, {}), { append: [], env: {} }); }); +test("a boolean param rejects non-booleans if schema validation is bypassed", () => { + const cmd = commandFrom({ + name: "x", + description: "d", + command: ["start"], + params: [{ name: "local", type: "boolean", when_true: { append_command: ["--local"] } }], + }); + assert.throws(() => applyParams(cmd, { local: "false" }), BrokerError); +}); + test("a boolean default applies when the arg is absent", () => { const cmd = commandFrom({ name: "x", diff --git a/tongs/docker-broker/test/config.test.ts b/tongs/docker-broker/test/config.test.ts index 513128e..707322c 100644 --- a/tongs/docker-broker/test/config.test.ts +++ b/tongs/docker-broker/test/config.test.ts @@ -66,6 +66,13 @@ test("a mount access mode must come last", () => { assert.throws(() => parseConfig(doc), /must be the final field/); }); +test("a workspace mount may only specify one target path", () => { + const doc = base({ + commands: [{ name: "x", description: "d", image: "toolchain@sha256:abc", mounts: ["workspace:/code:/other"] }], + }); + assert.throws(() => parseConfig(doc), /multiple target paths/); +}); + test("a boolean param with no effect is refused", () => { const doc = base({ commands: [ From 81152401be384e008532b11c5d24ba753ca9172c Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 10 Jul 2026 00:59:16 -0500 Subject: [PATCH 08/14] Cap broker worker output Limit captured stdout and stderr to 1 MiB per stream so a noisy or looping worker cannot grow broker memory without bound. --- tongs/docker-broker/src/commands.ts | 40 +++++++++++++++++++---- tongs/docker-broker/test/commands.test.ts | 21 +++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/tongs/docker-broker/src/commands.ts b/tongs/docker-broker/src/commands.ts index 210d413..81114af 100644 --- a/tongs/docker-broker/src/commands.ts +++ b/tongs/docker-broker/src/commands.ts @@ -19,6 +19,8 @@ export type RunResult = { export type Spawn = (command: string, args: string[]) => Promise; +export const MAX_WORKER_OUTPUT_BYTES = 1024 * 1024; + // Resolve a validated `workspace[:][:]` spec to a docker `-v` value // against the host workspace path. export function resolveWorkspaceMount(spec: string, workspaceHost: string): string { @@ -110,18 +112,40 @@ export function buildWorkerArgv( const realSpawn: Spawn = (command, args) => new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + const appendBounded = (chunks: Buffer[], currentBytes: number, chunk: Buffer | string): number => { + if (currentBytes >= MAX_WORKER_OUTPUT_BYTES) return currentBytes; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = MAX_WORKER_OUTPUT_BYTES - currentBytes; + const next = buffer.length > remaining ? buffer.subarray(0, remaining) : buffer; + chunks.push(next); + return currentBytes + next.length; + }; child.stdout.on("data", (chunk) => { - stdout += chunk.toString(); + stdoutBytes = appendBounded(stdoutChunks, stdoutBytes, chunk); }); child.stderr.on("data", (chunk) => { - stderr += chunk.toString(); + stderrBytes = appendBounded(stderrChunks, stderrBytes, chunk); }); child.on("error", reject); - child.on("close", (code) => resolve({ exitCode: code ?? 0, stdout, stderr })); + child.on("close", (code) => + resolve({ + exitCode: code ?? 0, + stdout: Buffer.concat(stdoutChunks, stdoutBytes).toString(), + stderr: Buffer.concat(stderrChunks, stderrBytes).toString(), + }), + ); }); +function capOutput(value: string): string { + const bytes = Buffer.byteLength(value); + if (bytes <= MAX_WORKER_OUTPUT_BYTES) return value; + return Buffer.from(value).subarray(0, MAX_WORKER_OUTPUT_BYTES).toString(); +} + // Run one worker to completion, capturing its output. The container is `--rm`, so // nothing is left behind once it exits. export function runWorker( @@ -131,7 +155,11 @@ export function runWorker( doSpawn: Spawn = realSpawn, ): Promise { const argv = buildWorkerArgv(command, inputs, workspaceHost); - return doSpawn("docker", argv); + return doSpawn("docker", argv).then((result) => ({ + ...result, + stdout: capOutput(result.stdout), + stderr: capOutput(result.stderr), + })); } export function formatResult(label: string, result: RunResult): string { diff --git a/tongs/docker-broker/test/commands.test.ts b/tongs/docker-broker/test/commands.test.ts index b89f29f..defc8fa 100644 --- a/tongs/docker-broker/test/commands.test.ts +++ b/tongs/docker-broker/test/commands.test.ts @@ -1,7 +1,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseConfig, type CommandDef } from "../src/config.js"; -import { buildWorkerArgv, applyParams, resolveWorkspaceMount, BrokerError } from "../src/commands.js"; +import { + buildWorkerArgv, + applyParams, + resolveWorkspaceMount, + BrokerError, + runWorker, + MAX_WORKER_OUTPUT_BYTES, +} from "../src/commands.js"; function commandFrom(command: Record): CommandDef { const cfg = parseConfig({ @@ -154,3 +161,15 @@ test("resources and networks render as docker flags", () => { assert.ok(argv.includes("--memory") && argv[argv.indexOf("--memory") + 1] === "512m"); assert.ok(argv.includes("--gpus") && argv[argv.indexOf("--gpus") + 1] === "all"); }); + +test("worker output capture is capped", async () => { + const cmd = commandFrom({ name: "x", description: "d", command: ["go"] }); + const result = await runWorker(cmd, {}, undefined, async () => ({ + exitCode: 0, + stdout: "x".repeat(MAX_WORKER_OUTPUT_BYTES + 1), + stderr: "y".repeat(MAX_WORKER_OUTPUT_BYTES + 1), + })); + + assert.equal(result.stdout.length, MAX_WORKER_OUTPUT_BYTES); + assert.equal(result.stderr.length, MAX_WORKER_OUTPUT_BYTES); +}); From b4a25e2a3a1b52faf456dd6162a2e32606f951c1 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 10 Jul 2026 10:19:57 -0500 Subject: [PATCH 09/14] Restrict broker workspace host path to session tongs 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. --- scripts/test_tongs.py | 12 ++++++++++++ scripts/tongs.py | 10 +++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/test_tongs.py b/scripts/test_tongs.py index 6602df8..9bc0984 100644 --- a/scripts/test_tongs.py +++ b/scripts/test_tongs.py @@ -1059,6 +1059,18 @@ def test_run_argv_explicit_workspace_host_path_wins(self): 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") diff --git a/scripts/tongs.py b/scripts/tongs.py index fcde8a4..a125d04 100644 --- a/scripts/tongs.py +++ b/scripts/tongs.py @@ -1213,9 +1213,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) tong additionally receives + 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. + 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 @@ -1237,7 +1239,9 @@ def tong_run_argv( if fifo_host_path: argv += ["-v", "%s:%s:ro" % (fifo_host_path, SECRET_FIFO_TARGET)] effective_env = dict(env or {}) - if workspace and _has_socket_mount(defn): + # 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])] From f1e38e8b0ea5b2efc8b54c8fe0ada001b8fa1613 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 10 Jul 2026 10:20:09 -0500 Subject: [PATCH 10/14] Validate tong mount modes, rejecting broker-config target paths 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. --- scripts/test_tongs.py | 9 +++++++++ scripts/tongs.py | 12 +++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/test_tongs.py b/scripts/test_tongs.py index 9bc0984..ca70f8e 100644 --- a/scripts/test_tongs.py +++ b/scripts/test_tongs.py @@ -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)) diff --git a/scripts/tongs.py b/scripts/tongs.py index a125d04..6e994ab 100644 --- a/scripts/tongs.py +++ b/scripts/tongs.py @@ -304,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): From 51f393ffe9fd78f69f4110af0aa64f31e9a67171 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Fri, 10 Jul 2026 10:20:17 -0500 Subject: [PATCH 11/14] Require when_true on broker boolean params 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. --- tongs/docker-broker/README.md | 5 +++-- tongs/docker-broker/src/config.ts | 5 ++++- tongs/docker-broker/test/config.test.ts | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tongs/docker-broker/README.md b/tongs/docker-broker/README.md index fa4d36d..a9ee92a 100644 --- a/tongs/docker-broker/README.md +++ b/tongs/docker-broker/README.md @@ -55,8 +55,9 @@ Safety rules enforced at load time (the server refuses to start otherwise): - A worker may only mount `workspace[:][:]` — never the docker socket and never a raw host path. - Parameters are limited to `boolean` (apply a fixed `append_command`/`env` - effect) 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 + 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: diff --git a/tongs/docker-broker/src/config.ts b/tongs/docker-broker/src/config.ts index cae5216..88408d3 100644 --- a/tongs/docker-broker/src/config.ts +++ b/tongs/docker-broker/src/config.ts @@ -165,7 +165,6 @@ function validateResources(value: unknown, where: string): Resources { } function validateEffect(value: unknown, where: string): Effect { - if (value === undefined) return { appendCommand: [], env: {} }; if (!isPlainObject(value)) throw new ConfigError(`${where} must be a mapping`); const effect: Effect = { appendCommand: asStringArray(value.append_command, `${where}.append_command`), @@ -189,6 +188,10 @@ function validateParam(value: unknown, where: string): Param { if (type === "boolean") { const dflt = value.default === undefined ? false : value.default; if (typeof dflt !== "boolean") throw new ConfigError(`${where}.default must be a boolean`); + // An omitted when_true would advertise a parameter that does nothing when set. + if (value.when_true === undefined) { + throw new ConfigError(`${where}.when_true is required (a boolean param must declare its effect)`); + } return { name, type: "boolean", diff --git a/tongs/docker-broker/test/config.test.ts b/tongs/docker-broker/test/config.test.ts index 707322c..5f9201a 100644 --- a/tongs/docker-broker/test/config.test.ts +++ b/tongs/docker-broker/test/config.test.ts @@ -87,6 +87,20 @@ test("a boolean param with no effect is refused", () => { assert.throws(() => parseConfig(doc), /has no effect/); }); +test("a boolean param that omits when_true is refused", () => { + const doc = base({ + commands: [ + { + name: "x", + description: "d", + image: "toolchain@sha256:abc", + params: [{ name: "flag", type: "boolean" }], + }, + ], + }); + assert.throws(() => parseConfig(doc), /when_true is required/); +}); + test("an enum param needs exactly one target", () => { const both = base({ commands: [ From 2ce34b56f66b2de8d239027fc291babe3e53a125 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sat, 11 Jul 2026 12:13:25 -0500 Subject: [PATCH 12/14] Run broker unit tests in CI 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. --- .github/workflows/tests.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24494f0..8d4f623 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,3 +18,19 @@ 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 From ac4f323cf83eb37e153cab281d1a94cf6e718e95 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sat, 11 Jul 2026 12:32:27 -0500 Subject: [PATCH 13/14] Extract broker HTTP app into a testable factory 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. --- tongs/docker-broker/src/app.ts | 58 ++++++++++++++++++++++++++++++++ tongs/docker-broker/src/index.ts | 56 +++--------------------------- 2 files changed, 63 insertions(+), 51 deletions(-) create mode 100644 tongs/docker-broker/src/app.ts diff --git a/tongs/docker-broker/src/app.ts b/tongs/docker-broker/src/app.ts new file mode 100644 index 0000000..86e292b --- /dev/null +++ b/tongs/docker-broker/src/app.ts @@ -0,0 +1,58 @@ +// The broker's HTTP surface, split from the process entrypoint so it can be +// started against an arbitrary config/workspace/port in tests. Serves a stateless +// Streamable-HTTP MCP endpoint at /mcp (a fresh server per request, as the SDK's +// stateless example does) plus a /healthz liveness endpoint. + +import express, { type Express, type Request, type Response } from "express"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { BrokerConfig } from "./config.js"; +import { buildServer } from "./server.js"; +import type { Spawn } from "./commands.js"; + +function methodNotAllowed(_req: Request, res: Response): void { + res.status(405).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Method not allowed." }, + id: null, + }); +} + +export function createApp( + config: BrokerConfig, + workspaceHost: string | undefined, + doSpawn?: Spawn, +): Express { + const app = express(); + app.use(express.json()); + + app.post("/mcp", async (req: Request, res: Response) => { + const server = buildServer(config, workspaceHost, doSpawn); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { + transport.close(); + server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + console.error("mcp POST failed", err); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + + app.get("/mcp", methodNotAllowed); + app.delete("/mcp", methodNotAllowed); + + app.get("/healthz", (_req, res) => { + res.json({ ok: true }); + }); + + return app; +} diff --git a/tongs/docker-broker/src/index.ts b/tongs/docker-broker/src/index.ts index cb7c42c..098aa08 100644 --- a/tongs/docker-broker/src/index.ts +++ b/tongs/docker-broker/src/index.ts @@ -1,15 +1,9 @@ -// HTTP entrypoint for the broker. Loads the baked command config, then serves a -// stateless Streamable-HTTP MCP endpoint at /mcp (a fresh server per request, as -// the SDK's stateless example does) plus a /healthz liveness endpoint (the -// example tong gates readiness with a TCP probe; /healthz is available for a -// `healthcheck`-mode readiness if one is configured). The config path, port, and -// workspace host path all come from the environment so the same image serves any -// baked or mounted configuration. +// HTTP entrypoint for the broker. Loads the baked command config and serves the +// MCP app (see app.ts). The config path, port, and workspace host path all come +// from the environment so the same image serves any baked or mounted config. -import express, { type Request, type Response } from "express"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { loadConfig } from "./config.js"; -import { buildServer } from "./server.js"; +import { createApp } from "./app.js"; const port = Number(process.env.PORT ?? 3000); const configPath = process.env.BROKER_CONFIG ?? "/etc/swarmforge/broker.config.yaml"; @@ -24,47 +18,7 @@ const config = (() => { } })(); -const app = express(); -app.use(express.json()); - -app.post("/mcp", async (req: Request, res: Response) => { - const server = buildServer(config, workspaceHost); - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - res.on("close", () => { - transport.close(); - server.close(); - }); - try { - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - } catch (err) { - console.error("mcp POST failed", err); - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: "2.0", - error: { code: -32603, message: "Internal server error" }, - id: null, - }); - } - } -}); - -function methodNotAllowed(_req: Request, res: Response): void { - res.status(405).json({ - jsonrpc: "2.0", - error: { code: -32000, message: "Method not allowed." }, - id: null, - }); -} - -app.get("/mcp", methodNotAllowed); -app.delete("/mcp", methodNotAllowed); - -app.get("/healthz", (_req, res) => { - res.json({ ok: true }); -}); - -const httpServer = app.listen(port, () => { +const httpServer = createApp(config, workspaceHost).listen(port, () => { console.log(`${config.name} broker listening on :${port} (${config.commands.length} verb(s))`); }); From b5f555d9eb969babaaf42064376964bc6801ace6 Mon Sep 17 00:00:00 2001 From: CrypticSwarm Date: Sat, 11 Jul 2026 12:32:34 -0500 Subject: [PATCH 14/14] Add broker end-to-end round-trip test and CI job 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. --- .github/workflows/tests.yml | 18 ++++ tongs/docker-broker/package.json | 3 +- .../docker-broker/test/e2e/roundtrip.test.ts | 102 ++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 tongs/docker-broker/test/e2e/roundtrip.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8d4f623..f97f72b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -34,3 +34,21 @@ jobs: - 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 diff --git a/tongs/docker-broker/package.json b/tongs/docker-broker/package.json index 89cb691..9b50965 100644 --- a/tongs/docker-broker/package.json +++ b/tongs/docker-broker/package.json @@ -6,7 +6,8 @@ "description": "A Swarmforge broker tong: exposes a configured set of narrow docker-task verbs as an HTTP MCP server.", "scripts": { "build": "tsc", - "test": "tsc && node --test \"dist/test/**/*.test.js\"", + "test": "tsc && node --test \"dist/test/*.test.js\"", + "test:e2e": "tsc && node --test \"dist/test/e2e/*.test.js\"", "start": "node dist/src/index.js" }, "dependencies": { diff --git a/tongs/docker-broker/test/e2e/roundtrip.test.ts b/tongs/docker-broker/test/e2e/roundtrip.test.ts new file mode 100644 index 0000000..aebc157 --- /dev/null +++ b/tongs/docker-broker/test/e2e/roundtrip.test.ts @@ -0,0 +1,102 @@ +// End-to-end: drive the real broker over HTTP MCP and let it spawn real worker +// containers, then assert the workers actually touched the mounted workspace. +// +// Unlike the unit tests (which inject a mock Spawn), this exercises the whole +// path -- MCP client -> HTTP app -> buildServer -> runWorker -> `docker run` -> a +// container writing into the bind-mounted workspace -> the file on disk. It runs +// the harness directly on the host (not docker-in-docker) so the workspace host +// path is just a local temp dir the daemon can bind-mount without translation. +// +// Skipped when docker is unavailable so `npm run test:e2e` degrades gracefully +// off CI. The worker runs as root, so the files it leaves are root-owned but +// world-readable -- fine to stat and read back here. + +import { after, before, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { parseConfig } from "../../src/config.js"; +import { createApp } from "../../src/app.js"; + +const WORKER_IMAGE = "alpine:3.20"; + +function dockerUnavailableReason(): string | false { + try { + execFileSync("docker", ["version"], { stdio: "ignore" }); + return false; + } catch { + return "docker is not available"; + } +} + +describe("broker end-to-end round trip", { skip: dockerUnavailableReason() }, () => { + let workspace: string; + let httpServer: Server; + let client: Client; + + before(async () => { + workspace = mkdtempSync(join(tmpdir(), "broker-e2e-")); + const config = parseConfig({ + name: "e2e-broker", + description: "End-to-end fixture broker.", + allowed_images: [WORKER_IMAGE], + commands: [ + { + name: "write_a", + description: "Write endpoint A's marker file into the workspace.", + image: WORKER_IMAGE, + mounts: ["workspace:/work"], + workdir: "/work", + command: ["sh", "-c", "echo A > /work/a.txt"], + }, + { + name: "write_b", + description: "Write endpoint B's marker file into the workspace.", + image: WORKER_IMAGE, + mounts: ["workspace:/work"], + workdir: "/work", + command: ["sh", "-c", "echo B > /work/b.txt"], + }, + ], + }); + + httpServer = await new Promise((resolve) => { + const s = createApp(config, workspace).listen(0, () => resolve(s)); + }); + const { port } = httpServer.address() as AddressInfo; + + client = new Client({ name: "e2e-client", version: "0.0.0" }); + await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`))); + }); + + after(async () => { + await client?.close(); + httpServer?.close(); + if (workspace) rmSync(workspace, { recursive: true, force: true }); + }); + + // Each endpoint: call the verb over MCP, confirm the broker reports success, + // and confirm the worker container actually wrote its file into the workspace. + for (const { verb, file, marker } of [ + { verb: "write_a", file: "a.txt", marker: "A" }, + { verb: "write_b", file: "b.txt", marker: "B" }, + ]) { + test(`${verb} runs a worker that writes ${file}`, { timeout: 120_000 }, async () => { + const result = (await client.callTool({ name: verb, arguments: {} })) as { + isError?: boolean; + content: Array<{ type: string; text: string }>; + }; + + assert.ok(!result.isError, `expected ${verb} to succeed, got: ${result.content?.[0]?.text}`); + assert.match(result.content[0].text, /success/); + assert.equal(readFileSync(join(workspace, file), "utf8").trim(), marker); + }); + } +});