# connector-deployer — RPC contract (SEC-H-02) The `connector-deployer` is the small, trusted, no-LLM service that holds `/var/run/docker.sock`. `tool-generator` (which runs LLM/user-influenced connector code) drops root + the socket and calls this service over an authenticated internal RPC. The security property lives in `internal/spec` (allow-by-construction `HostConfig`); this document is the operational contract the server implements. ## Config (process env — the deployer's OWN trusted settings; a caller influences none of it) | Env | Default | Meaning | |---|---|---| | `PORT` | `5011` | deployer HTTP listen port | | `ENVIRONMENT` | `development` | `production`/`prod` ⇒ auth fail-closed | | `INTERNAL_SERVICE_SECRET` | (unset) | S2S shared secret; **required in prod** | | `DEPLOYER_DOCKER_NETWORK` | `rsync-ai-mcp` | the pinned connector network (NetworkMode) | | `MCP_SHARED_NETWORK` | `rsync-ai-mcp` | "also-join" net for sink reachability (usually == above ⇒ no-op) | | `OAUTH_TOKENS_VOLUME_NAME` | `rsync-ai-oauth-tokens` | named volume for OAuth TokenManager; empty disables | | `OAUTH_TOKENS_TARGET` | `/root/.rsync-ai` | mount target for the above | | `TOOLS_DIR` | `/app/shared/mcp-connectors` | connector-artifacts root (mounted READ-ONLY) | | `DOCKER_HOST` | `unix:///var/run/docker.sock` | daemon socket | | `STACK_PREFIX` | `rsync-ai` | container-name prefix, `--vX-Y-Z-mcp`; must match the orchestrator's so an isolated stack's compose-managed connectors are recognised as protected | | `BUILDKIT_HOST` | (unset) | rootless buildkitd gRPC addr (`buildctl --addr`). **Set ⇒ BUILD runs OFF the daemon** (increment 2); unset ⇒ legacy in-daemon `docker build` (rollback valve) | | `DEPLOYER_REGISTRY_PUSH` | `mcp-registry:5000` | registry buildkitd pushes to (compose DNS); used only when `BUILDKIT_HOST` set | | `DEPLOYER_REGISTRY_PULL` | `127.0.0.1:5000` | registry the HOST daemon pulls from (loopback ⇒ auto-insecure, no daemon.json change); used only when `BUILDKIT_HOST` set | ## Auth Every `/v1/*` request must carry `X-Internal-Secret: `. Mirror `tool_generator/deployment/routes.py::require_internal_secret`: - secret set + header matches ⇒ allow; mismatch/absent ⇒ `401`. - secret **unset**: allow (log a warning) only when `ENVIRONMENT` is exactly `development` or `dev` (trimmed, any case); every other value, unset included ⇒ `503 internal_secret_not_configured`. ## POST /v1/deploy — build-if-missing + run (the JIT connector spawn) Untrusted request body — **DATA only** (no HostConfig-adjacent fields): ```json { "connector_id": "postgresql", "version": "v1.0.0", "context_subdir": "public/database/postgresql/versions/v1.0.0", "container_name": "rsync-ai-postgresql-mcp", "aliases": ["rsync-ai-postgresql-mcp", "postgresql-mcp"], "env": {"PORT": "8000"}, "port": 0, "recreate": false, "build_args": {} } ``` Validation (reject `400` on any failure): `connector_id` `^[a-z0-9][a-z0-9-]{0,62}$`; `version` `^[A-Za-z0-9_.-]{1,64}$`; `container_name`/`aliases`/`env`/`port` per `internal/spec` rules; `context_subdir` MUST contain no `..` segment, MUST resolve inside `TOOLS_DIR`, and MUST contain a `Dockerfile`. The image ref is DERIVED as `mcp-:` — never taken from the request. Behavior (mirror `docker_builder.py::start_container` semantics exactly): 1. **Protected compose containers** — if `container_name` is a compose-managed connector (port the `_is_protected_compose_container` check): running ⇒ return ok (skip); stopped ⇒ start it (never rebuild/replace — it may serve a live CDC stream); missing ⇒ `409` with a "run docker compose up" message. 2. **Reuse running** — if a container of that name exists, is `running`, and `recreate` is false ⇒ return ok (no rebuild). 3. Otherwise remove a stale/stopped container of that name if present. 4. **Ensure image** — if `mcp-:` is absent (or `recreate`): build it, supplying the `shared` named context = the `public/` ancestor dir of `context_dir` (same resolution as the Python). Two paths, selected by `BUILDKIT_HOST` (§ Config); both produce a local `mcp-:` image with the SAME six labels (`com.docker.compose.project=rsync-ai-mcp`, `com.docker.compose.service=`, `mcp.connector.version/name`, `mcp.managed=true`, `mcp.auto.generated=true`), a 900s timeout, and the last ~1500 chars of output on failure: - **`BUILDKIT_HOST` unset (legacy / rollback valve)** — the connector's Dockerfile RUN steps build in the HOST daemon's BuildKit, mirroring `_cli_build_with_shared_context`: `DOCKER_BUILDKIT=1 docker build --build-context shared= -t mcp-: <--build-arg k=v...> `. - **`BUILDKIT_HOST` set (SEC-H-02 increment 2 — build OFF the daemon)** — the RUN steps build in the ROOTLESS buildkitd sidecar (which holds no docker.sock) and are pushed to the local registry: `buildctl --addr $BUILDKIT_HOST build --frontend dockerfile.v0 --local context= --local dockerfile= --opt filename=Dockerfile --opt context:shared=local:shared --local shared= <--opt label:...> <--opt build-arg:k=v...> --output type=image,name=$DEPLOYER_REGISTRY_PUSH/mcp-:,push=true,registry.insecure=true`. The HOST daemon then **pulls** `$DEPLOYER_REGISTRY_PULL/mcp-:` (drained to EOF) and **re-tags** it to the bare `mcp-:` — so steps 5/6, the labels, and the response are byte-identical to the legacy path. The daemon fetches finished layers only; it never runs untrusted build steps. 5. **Build the create payload** via `spec.BuildContainerSpec(req, cfg)` then **call `spec.ValidateHostConfigSafe(hc, cfg)` and REFUSE with `500` if it errors** (defense-in-depth — never create an unvalidated container). Also copy the JIT labels onto `container.Config.Labels` (the `com.docker.compose.*` / `rsync-ai.*` / `mcp.*` discovery labels from `start_container`, lines ~603-619) so api-gateway's catalog and the compose-group view still see the container. 6. `ContainerCreate(cfg, hc, netCfg, name)` → `ContainerStart`. Then attach aliases and also-join `MCP_SHARED_NETWORK` if it differs from the primary network (mirror `start_container` lines ~654-689; non-fatal on alias failure). Response `200`: `{"ok":true,"container_id":"","status":"running","image":"mcp-:","built":true|false}`. On build failure: `500` with the last ~1500 chars of build output (already scrubbed by the connector log scrubber upstream; do not add PII here). ## POST /v1/undeploy — `{"container_name": "..."}` Protected-compose ⇒ `409` (never tear down a compose-managed connector). Else `stop(timeout=10)` + `remove(force)`. `200 {"ok":true}`. Missing container ⇒ `200` (idempotent). ## GET /v1/status?name= `200 {"exists":bool,"status":"running|exited|created|...","container_id":""}`. ## GET /healthz `200 {"ok":true}` after a successful daemon `Ping`; `503` if the daemon is unreachable. ## Build off the daemon (SEC-H-02 increment 2) Increment 2 landed the rootless-build path in step 4: when `BUILDKIT_HOST` is set the connector's (untrusted) Dockerfile RUN/FROM steps run in a rootless buildkitd sidecar (no docker.sock, unprivileged uid in a userns) and are pushed to a local `registry:2`; the host daemon only pulls the finished layers and CREATE/STARTs the container — so the un-body-filterable build endpoint is off the trusted (socket-holding) surface. **The path is opt-in everywhere, including prod** — `docker-compose.prod.yml` passes `BUILDKIT_HOST=${DEPLOYER_BUILDKIT_HOST-}`, so the flag is unset unless an operator sets `DEPLOYER_BUILDKIT_HOST=tcp://buildkitd:1234` in the prod `.env`; base/OSS/quickstart/CI keep the legacy in-daemon build the same way. (Until 2026-07-29 the prod compose default was `tcp://buildkitd:1234`, which armed the path on every prod bring-up despite the cutover being documented as pending; arming is now the deliberate act and clearing the var is still the instant revert.) See `docs/internal/jit-connector-sandboxing.md` §4/§6. ## Non-goals (future) Opting OSS/quickstart into the rootless build (they keep the legacy socket-build today, which still benefits from increment 1's tool-generator socket drop); mTLS on the buildkitd gRPC endpoint (currently isolated on the private `rsync-build` network, reachable only by the deployer); and automatic registry GC/retention.