From 30f2433a7695d774ad792526ac18b3efa2f4217c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:05:28 -0500 Subject: [PATCH 1/2] Answer the Docker socket on loopback, not on every address the host has The supervisor's port was published with no interface in front of it, so it bound every address the machine had. That is the wrong default anywhere and worst here: this is the service that mounts /var/run/docker.sock, so reaching it is root on the host by way of four verbs, and SUPERVISOR_TOKEN is a shared secret rather than a network boundary. The computer already says exactly this about its own port, a few lines up, and binds 127.0.0.1 for it. So do the Bots. The supervisor and PostgreSQL were the two that did not, and the supervisor is the one holding the socket. Only the published mapping changes. The process still listens on every interface inside its own container, which is what a deployment running the server inside the network depends on: it sets COMPUTER_NETWORK and reaches this as supervisor:4300, never through the host mapping. Binding the process itself to loopback would have broken both paths, because Docker forwards to the container's interface address rather than its loopback. Verified against docker 29.1.3 before and after: published without a prefix answers on loopback and on the host's routable address; with the prefix it answers on loopback and refuses the routable one; a container on the same network still reaches it by service name. The test pins every service that holds a secret, not just this one, and names them rather than applying a blanket rule, so adding a service stays a decision about where it should answer. It fails on the unprefixed port for each of the four independently. --- docker-compose.yml | 11 ++++++++++- tests/compose.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6fcd2a52..a832ffc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -147,7 +147,16 @@ services: SPIRE_AGENT_SOCKET_VOLUME: ${COMPOSE_PROJECT_NAME:-openbot}_spire-agent-socket ports: # For the server on the host to ask for a Bot's computer. - - "${SUPERVISOR_PORT:-4500}:4300" + # + # Loopback only, like the computer's own port and for a stronger version of the same reason. + # This process holds the Docker socket, so reaching it is root on the host through four verbs, + # and SUPERVISOR_TOKEN is a shared secret rather than a network boundary. Published without an + # interface in front of it this answered every address the host has. + # + # A deployment running the server inside this network does not use this mapping at all: it sets + # COMPUTER_NETWORK and reaches the supervisor as `supervisor:4300`, which is unaffected because + # the process still listens on every interface inside its own container. + - "127.0.0.1:${SUPERVISOR_PORT:-4500}:4300" volumes: # Read-only because this service only ever needs to ask; it is still root-equivalent, which is # the whole reason nothing else here gets it. diff --git a/tests/compose.test.ts b/tests/compose.test.ts index c80b6eba..ac18455f 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -38,6 +38,39 @@ test("publishes every service on a settable port with the documented default", ( } }); +/** + * The services that answer to a secret are published to the host's loopback and no further. + * + * A published port with no interface in front of it binds every address the host has, so the + * service answers anything that can route to the machine. That is the wrong default for all of + * these and worst for the supervisor, which holds the Docker socket: reaching it is root on the + * host by way of four verbs, and `SUPERVISOR_TOKEN` is a shared secret rather than a network + * boundary. The computer says the same thing about itself in a comment beside its own port, and + * this is that reasoning applied to every service that has one. + * + * Named ports rather than a blanket rule, so adding a service is a decision about where it should + * answer rather than something this test quietly grants. + */ +test("publishes every service that holds a secret on loopback only", () => { + const compose = readFileSync( + join(import.meta.dir, "..", "docker-compose.yml"), + "utf8", + ); + + for (const name of [ + "SUPERVISOR_PORT", + "COMPUTER_PORT", + "BOT_PORT", + "LANGGRAPH_PORT", + ]) { + const published = compose.match( + new RegExp(`^\\s*- "(.*)\\$\\{${name}:-\\d+\\}:\\d+"`, "m"), + ); + expect(published).not.toBeNull(); + expect(published?.[1]).toBe("127.0.0.1:"); + } +}); + /** * Both Bots are reachable at whatever `OPENAI_BASE_URL` names. * From 28b5d7cd86633a0ae3f2d8b0b450e1c591c9acb2 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:13:25 -0500 Subject: [PATCH 2/2] Say where the supervisor answers, everywhere that already says it for the computer The port change on its own leaves four places describing the old behaviour, and one of them was already describing the behaviour we wanted rather than the one the compose file had. docs/architecture.md said "do not expose it outside the deployment network" and stopped there, which is the instruction the published port was breaking. It now says where Compose binds it, the way the paragraph above it already does for the computer, and names the in-network path so a deployment that needs no published port knows it has one. The README's loopback bullet named computers only. The supervisor belongs in it for a stronger reason than the computer does. supervisor/src/index.ts argued that the vocabulary is the boundary and the token is not, which is still true and was the whole argument. It now also says what sits in front of both, and that the listener stays on every interface inside its own container, because that is the part someone will otherwise "fix" and break the in-network deployment. The changelog entry is written for the person upgrading: what stops working is reaching the supervisor from another machine, which was the thing worth stopping, and it says what to do instead. --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 2 +- docs/architecture.md | 2 +- supervisor/src/index.ts | 7 +++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 482f9e54..f5e4c841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,22 @@ NAT64 spellings of them — were refused whatever this switch said, before and a not moved. What changed is that it is no longer the only thing left standing in a production deployment that copied the example. +### The supervisor answers on loopback, not on every address the host has + +The supervisor's port was published without an interface in front of it, so it bound every address +the machine had and answered anything that could route to it. This is the service that holds the +Docker socket, so reaching it is root on the host by way of four verbs, and `SUPERVISOR_TOKEN` is a +shared secret rather than a network boundary. The documentation already said not to expose it +outside the deployment network; the compose file did. + +It is now published on `127.0.0.1`, like the computer's own port and the two Bots'. If you reach the +supervisor from another machine, that stops working and it was the thing worth stopping: put the +caller on the host, or run the server inside the compose network, where it reaches the supervisor as +`supervisor:4300` and never uses the published mapping at all. `SUPERVISOR_PORT` still chooses the +host port. + +Nothing changes for a default deployment. `scripts/start.sh` already reached it on `localhost`. + ## 0.0.4 ### A click citing a ref this deployment cannot resolve is refused diff --git a/README.md b/README.md index c57b4af8..60b37f83 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you - **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail. - **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it. - **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events. -- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. +- **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. The supervisor binds there too, because it holds the Docker socket and its token is a shared secret rather than a network boundary. - **Durable threads and memory**: conversations survive restarts through CopilotKit Intelligence, and each deployment stamps the threads it owns. ## Bring your own agent diff --git a/docs/architecture.md b/docs/architecture.md index 6751e95f..9a0759b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,7 @@ With `COMPUTER_SUPERVISOR_URL`, each Bot gets its own computer container, worksp A command on the computer inherits PATH, locale and terminal names, and the proxy variables, not the rest of the process environment. Userinfo is stripped from a proxy URL. `COMPUTER_SHELL_ENV` names anything else a deployment wants passed. -The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it. +The supervisor exposes only ensure, stop, reset, and list operations. It holds the Docker socket, so do not expose it outside the deployment network: Docker Compose binds it to `127.0.0.1:4500`, and a deployment running the server inside the compose network reaches it as `supervisor:4300` and needs no published port at all. Set `COMPUTER_RUNTIME=runsc` to run computers under gVisor on hosts that support it. ## Human control and secrets diff --git a/supervisor/src/index.ts b/supervisor/src/index.ts index 779d19b6..41fc2e08 100644 --- a/supervisor/src/index.ts +++ b/supervisor/src/index.ts @@ -32,6 +32,13 @@ import { namesFor } from "./names"; * processes on the same network from driving it, but even with the token the worst available action * is cycling a computer that already belongs to a Bot. * + * Neither is the network the boundary, but it is the layer in front of both. Compose publishes this + * port on `127.0.0.1` rather than on every address the host has, for the same reason the computer's + * own port is bound there: a secret in an environment variable is one leak away from being known, + * and this process holds the Docker socket. This listener stays on every interface inside its own + * container, which is what the published mapping forwards to and what a server running inside the + * compose network connects to as `supervisor:4300`. + * * Refusing to start without it matches the computer. This process holds the Docker socket, which is * root on the host, so missing authentication is a deployment failure. */