From 4f5f399fa054e75cf3c194ae88afc41e4516bc7d Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:02:45 +0000 Subject: [PATCH 1/2] 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. Also fail fast when required launch env is missing and log injected keys. --- .../__tests__/spawn-docker-worker.test.ts | 65 ++++++++++++++++ .../compute-providers/spawn-docker-worker.ts | 77 +++++++++++++++++-- 2 files changed, 136 insertions(+), 6 deletions(-) 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..623dd7c93 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 @@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'; import { processListIncludesDockerWorkerRun } from '../docker-sandbox-security'; import { + assertDockerWorkerLaunchEnv, buildDockerSandboxServerUrl, + buildDockerWorkerExecEnvArgs, + DOCKER_CONTROL_PLANE_TRPC_URL, getDockerWorkerCommand, resolveDockerWorkerOwnershipTargetFromLookup, + resolveDockerWorkerTrpcUrl, resumeDockerTaskDaemon, shouldRetryDockerWorkerWithoutDiskLimit, shouldAutoRemoveDockerWorkerContainer, @@ -159,6 +163,67 @@ 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('strips public-proxy path prefixes from already-in-network api hosts', () => { + expect( + resolveDockerWorkerTrpcUrl({ + trpcUrl: 'http://api:3001/_roomote-api', + controlNetwork: 'roomote_worker', + }), + ).toBe(DOCKER_CONTROL_PLANE_TRPC_URL); + }); + + it('rewrites localhost TRPC URLs for non-control-network Docker workers', () => { + expect( + resolveDockerWorkerTrpcUrl({ + trpcUrl: 'http://localhost:13001', + }), + ).toBe('http://host.docker.internal:13001'); + }); +}); + +describe('assertDockerWorkerLaunchEnv', () => { + it('rejects missing required launch env values', () => { + expect(() => + assertDockerWorkerLaunchEnv({ + AUTH_TOKEN: '', + TRPC_URL: 'http://api:3001', + R_APP_URL: 'https://roomote.example.com', + }), + ).toThrow('AUTH_TOKEN'); + }); + + it('accepts a complete launch env', () => { + expect(() => + assertDockerWorkerLaunchEnv({ + AUTH_TOKEN: 'token', + TRPC_URL: 'http://api:3001', + R_APP_URL: 'https://roomote.example.com', + }), + ).not.toThrow(); + }); +}); + +describe('buildDockerWorkerExecEnvArgs', () => { + it('flattens env into docker exec -e KEY=value pairs', () => { + expect( + buildDockerWorkerExecEnvArgs({ + AUTH_TOKEN: 'token', + TRPC_URL: 'http://api:3001', + }), + ).toEqual(['-e', 'AUTH_TOKEN=token', '-e', 'TRPC_URL=http://api:3001']); + }); +}); + 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..9e48ad43d 100644 --- a/apps/controller/src/compute-providers/spawn-docker-worker.ts +++ b/apps/controller/src/compute-providers/spawn-docker-worker.ts @@ -363,6 +363,10 @@ export async function spawnDockerWorker( field: 'provisionReadyAt', }); + const workerTrpcUrl = resolveDockerWorkerTrpcUrl({ + trpcUrl: process.env.TRPC_URL ?? Env.TRPC_URL, + controlNetwork, + }); const workerEnv = buildDockerWorkerEnv({ authToken, sandboxExpiresAtMs: Date.now() + config.dockerTimeoutMs, @@ -371,7 +375,7 @@ 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: workerTrpcUrl, // 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 && { @@ -406,14 +410,17 @@ export async function spawnDockerWorker( }, }); + assertDockerWorkerLaunchEnv(workerEnv); + const workerCommand = getDockerWorkerCommand(taskRun.payloadKind); + // Inject via `docker exec -e` so the worker process gets AUTH_TOKEN / + // TRPC_URL without baking secrets into `docker inspect` Config.Env. + // Operators checking a later plain `docker exec` shell will not see these + // keys — that is expected and does not mean spawn skipped injection. await docker([ 'exec', '-d', - ...Object.entries(workerEnv).flatMap(([key, value]) => [ - '-e', - `${key}=${value}`, - ]), + ...buildDockerWorkerExecEnvArgs(workerEnv), containerName, 'bash', '-lc', @@ -424,7 +431,12 @@ export async function spawnDockerWorker( console.log( `[spawnDockerWorker] Docker worker launched for task run #${taskRun.id} ${JSON.stringify( - { containerName, containerId }, + { + containerName, + containerId, + trpcUrl: workerTrpcUrl, + envKeys: Object.keys(workerEnv).sort(), + }, )}`, ); @@ -735,6 +747,59 @@ 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 { + // Control-plane isolation only trusts the `api` service on the task + // network at the app origin/root. Public reverse-proxy path prefixes such + // as `/_roomote-api` must not be preserved even when hostname is already + // `api`. + if (params.controlNetwork?.trim()) { + return DOCKER_CONTROL_PLANE_TRPC_URL; + } + + return toContainerReachableUrl(params.trpcUrl); +} + +const REQUIRED_DOCKER_WORKER_LAUNCH_ENV_KEYS = [ + 'AUTH_TOKEN', + 'TRPC_URL', + 'R_APP_URL', +] as const; + +export function assertDockerWorkerLaunchEnv( + workerEnv: Record, +): void { + const missing = REQUIRED_DOCKER_WORKER_LAUNCH_ENV_KEYS.filter( + (key) => !workerEnv[key]?.trim(), + ); + + if (missing.length > 0) { + throw new Error( + `Docker worker launch env missing required value(s): ${missing.join(', ')}`, + ); + } +} + +export function buildDockerWorkerExecEnvArgs( + workerEnv: Record, +): string[] { + return Object.entries(workerEnv).flatMap(([key, value]) => [ + '-e', + `${key}=${value}`, + ]); +} + export function toContainerReachableUrl(value: string | undefined): string { if (!value) { return ''; From f45d9d64a943c9fdaef9e04ec06a1f49f34e8809 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:02:38 +0000 Subject: [PATCH 2/2] fix: sanitize logged Docker worker TRPC_URL and add patch changeset --- .../docker-worker-control-plane-trpc.md | 5 ++++ .../__tests__/spawn-docker-worker.test.ts | 23 +++++++++++++++++++ .../compute-providers/spawn-docker-worker.ts | 20 +++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 .changeset/docker-worker-control-plane-trpc.md diff --git a/.changeset/docker-worker-control-plane-trpc.md b/.changeset/docker-worker-control-plane-trpc.md new file mode 100644 index 000000000..ace1d7691 --- /dev/null +++ b/.changeset/docker-worker-control-plane-trpc.md @@ -0,0 +1,5 @@ +--- +'@roomote/web': patch +--- + +Fix self-hosted Docker tasks stuck at Booting environment when workers cannot reach a public TRPC_URL from the task network. 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 63eb8ab78..3c2ff4aeb 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 @@ -16,6 +16,7 @@ import { resolveDockerSpawnCleanupMode, resolveDockerWorkerOwnershipTargetFromLookup, resolveDockerWorkerTrpcUrl, + sanitizeDockerWorkerTrpcUrlForLog, resumeDockerTaskDaemon, shouldPreserveFailedDockerWorkerContainer, shouldRetryDockerWorkerWithoutDiskLimit, @@ -261,6 +262,28 @@ describe('resolveDockerWorkerTrpcUrl', () => { }); }); +describe('sanitizeDockerWorkerTrpcUrlForLog', () => { + it('strips URL userinfo credentials and query/hash before logging', () => { + expect( + sanitizeDockerWorkerTrpcUrlForLog( + 'https://worker:s3cret@api.example.com:8443/_roomote-api?token=abc#frag', + ), + ).toBe('https://api.example.com:8443/_roomote-api'); + }); + + it('keeps plain in-network control-plane URLs unchanged', () => { + expect( + sanitizeDockerWorkerTrpcUrlForLog(DOCKER_CONTROL_PLANE_TRPC_URL), + ).toBe(DOCKER_CONTROL_PLANE_TRPC_URL); + }); + + it('returns a stable placeholder for unparseable values', () => { + expect(sanitizeDockerWorkerTrpcUrlForLog('not a url')).toBe( + '[invalid-trpc-url]', + ); + }); +}); + describe('assertDockerWorkerLaunchEnv', () => { it('rejects missing required launch env values', () => { expect(() => diff --git a/apps/controller/src/compute-providers/spawn-docker-worker.ts b/apps/controller/src/compute-providers/spawn-docker-worker.ts index 2a758c1d4..d1e8808f9 100644 --- a/apps/controller/src/compute-providers/spawn-docker-worker.ts +++ b/apps/controller/src/compute-providers/spawn-docker-worker.ts @@ -539,7 +539,7 @@ export async function spawnDockerWorker( { containerName, containerId, - trpcUrl: workerTrpcUrl, + trpcUrl: sanitizeDockerWorkerTrpcUrlForLog(workerTrpcUrl), envKeys: Object.keys(workerEnv).sort(), }, )}`, @@ -997,6 +997,24 @@ export function resolveDockerWorkerTrpcUrl(params: { return toContainerReachableUrl(params.trpcUrl); } +/** + * Log-safe view of the worker TRPC URL: drops userinfo credentials and + * query/hash so operator logs never capture embedded secrets while still + * showing host + path for spawn diagnosis. + */ +export function sanitizeDockerWorkerTrpcUrlForLog(trpcUrl: string): string { + try { + const url = new URL(trpcUrl); + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + return trimTrailingSlash(url.toString()); + } catch { + return '[invalid-trpc-url]'; + } +} + const REQUIRED_DOCKER_WORKER_LAUNCH_ENV_KEYS = [ 'AUTH_TOKEN', 'TRPC_URL',