From 8adf13029f580104b8eb7308ddcba1c515f00219 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:07:47 +0000 Subject: [PATCH] fix: route Docker control-plane workers to in-network TRPC URL Self-hosted sandboxes with DOCKER_WORKER_NETWORK blackhole the docker gateway, so public TRPC_URL values fail with fetch failed at boot. --- .../__tests__/spawn-docker-worker.test.ts | 30 ++++++++++++++ .../compute-providers/spawn-docker-worker.ts | 39 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts b/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts index 89e9a5f15..d11f6e892 100644 --- a/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts +++ b/apps/controller/src/compute-providers/__tests__/spawn-docker-worker.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest'; import { processListIncludesDockerWorkerRun } from '../docker-sandbox-security'; import { buildDockerSandboxServerUrl, + DOCKER_CONTROL_PLANE_TRPC_URL, getDockerWorkerCommand, resolveDockerWorkerOwnershipTargetFromLookup, + resolveDockerWorkerTrpcUrl, resumeDockerTaskDaemon, shouldRetryDockerWorkerWithoutDiskLimit, shouldAutoRemoveDockerWorkerContainer, @@ -159,6 +161,34 @@ describe('toContainerReachableUrl', () => { }); }); +describe('resolveDockerWorkerTrpcUrl', () => { + it('uses the in-network API alias when a control network is configured', () => { + expect( + resolveDockerWorkerTrpcUrl({ + trpcUrl: 'https://roomote.example.com/_roomote-api', + controlNetwork: 'roomote_worker', + }), + ).toBe(DOCKER_CONTROL_PLANE_TRPC_URL); + }); + + it('keeps an already in-network api hostname on the control plane', () => { + expect( + resolveDockerWorkerTrpcUrl({ + trpcUrl: 'http://api:3001/', + controlNetwork: 'roomote_worker', + }), + ).toBe('http://api:3001'); + }); + + it('rewrites localhost TRPC URLs for non-control-network Docker workers', () => { + expect( + resolveDockerWorkerTrpcUrl({ + trpcUrl: 'http://localhost:13001', + }), + ).toBe('http://host.docker.internal:13001'); + }); +}); + describe('shouldAutoRemoveDockerWorkerContainer', () => { it('preserves containers in every environment for bounded standby retention', () => { expect(shouldAutoRemoveDockerWorkerContainer('production')).toBe(false); diff --git a/apps/controller/src/compute-providers/spawn-docker-worker.ts b/apps/controller/src/compute-providers/spawn-docker-worker.ts index 2ea6d2c83..704a0ef96 100644 --- a/apps/controller/src/compute-providers/spawn-docker-worker.ts +++ b/apps/controller/src/compute-providers/spawn-docker-worker.ts @@ -371,7 +371,10 @@ export async function spawnDockerWorker( image: config.image, extraEnv: { SANDBOX_TIMEOUT_MS: String(config.dockerTimeoutMs), - TRPC_URL: toContainerReachableUrl(process.env.TRPC_URL ?? Env.TRPC_URL), + TRPC_URL: resolveDockerWorkerTrpcUrl({ + trpcUrl: process.env.TRPC_URL ?? Env.TRPC_URL, + controlNetwork, + }), // Mock-Slack parity: worker-side SlackNotifier calls (question blocks, // reactions) must reach the same mock harness the API uses. ...(process.env.SLACK_API_BASE_URL && { @@ -735,6 +738,40 @@ export function getDockerWorkerCommand( return payloadKind === TaskPayloadKind.SnapshotResume ? 'resume' : 'run'; } +/** + * Docker Compose self-host/prod attaches the `api` service to each task + * network. When a control network is configured, egress policy blackholes the + * docker bridge gateway so sandboxes cannot hairpin through the public edge. + * Workers must call the in-network API alias directly (no `/_roomote-api` + * prefix — that path only exists on the public reverse proxy). + */ +export const DOCKER_CONTROL_PLANE_TRPC_URL = 'http://api:3001'; + +export function resolveDockerWorkerTrpcUrl(params: { + trpcUrl: string | undefined; + controlNetwork?: string; +}): string { + if (params.controlNetwork?.trim()) { + const configured = params.trpcUrl?.trim(); + + if (configured) { + try { + const url = new URL(configured); + + if (url.hostname === 'api') { + return trimTrailingSlash(url.toString()); + } + } catch { + // Fall through to the control-plane default. + } + } + + return DOCKER_CONTROL_PLANE_TRPC_URL; + } + + return toContainerReachableUrl(params.trpcUrl); +} + export function toContainerReachableUrl(value: string | undefined): string { if (!value) { return '';