Skip to content

Answer the Docker socket on loopback, not on every address the host has - #195

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
beardthelion:fix/supervisor-loopback-only
Aug 23, 2026
Merged

Answer the Docker socket on loopback, not on every address the host has#195
davidmckayv merged 2 commits into
CopilotKit:mainfrom
beardthelion:fix/supervisor-loopback-only

Conversation

@beardthelion

@beardthelion beardthelion commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #194.

What this changes

The supervisor's port was published as "${SUPERVISOR_PORT:-4500}:4300". With no interface in front of it Docker binds every address the host has, so the service holding /var/run/docker.sock answered anything that could route to the machine. SUPERVISOR_TOKEN is a shared secret in an environment variable, not a network boundary.

docs/architecture.md already said "do not expose it outside the deployment network", so the compose file was breaking an instruction this repository had already written down. agent-computer, agent-bot and agent-langgraph are published on 127.0.0.1 already. The supervisor is the one where that argument is strongest and the prefix was missing.

One line of docker-compose.yml changes, plus the places that describe the behaviour: docs/architecture.md, the README's loopback bullet, the module comment in supervisor/src/index.ts, and a CHANGELOG.md entry.

Where it runs

  • New state that outlives a request? None. Nothing in this change stores anything.
  • What happens on the second replica? Nothing differs. A published port is a property of one Docker host, and no server replica reads or writes state introduced here. A deployment running several API replicas inside the compose network never uses the published mapping at all.
  • Anything serialised? None.
  • Anything fanned out to a browser? None.
  • New listener, port, or schedule? No new listener. This narrows where an existing one is published, from every host address to loopback. The supervisor is deliberately not reached through the API ingress: the server reaches it directly, on host loopback in local development and as supervisor:4300 over the compose network otherwise, which is the path a hosted deployment uses and which needs no published port. Scaling is unchanged by this change; one supervisor owns one Docker host's socket, as before.

Boundary and audit

  • Every acting call still goes through the gateway. No acting path is touched. The change is a compose mapping, three prose edits, one comment and one test.
  • New refusals and new failures each write a row. None are introduced.
  • Nothing new is trusted from the client that the server can resolve itself. Nothing new is read from a client.

Changelog

  • CHANGELOG.md under Unreleased, written for the person upgrading: reaching the supervisor from another machine stops working, that was the point, and here is what to do instead.

Proof

Docker 29.1.3, a stand-in service on 4300, four cases:

case from 127.0.0.1 from the host's routable address in-network by service name
published with no prefix (before) 200 200 200
published on 127.0.0.1 (after) 200 connection refused 200
listener bound to loopback inside the container connection refused connection refused n/a

The third row is why this is a compose change and not a serve() change: Docker forwards to the container's interface address rather than its loopback, so binding the process itself would have broken the published mapping and the in-network path together.

tests/compose.test.ts gains a case pinning every service that holds a secret, not only this one. It was red before the fix and is green after, and it fails independently for each of the four services, checked by removing the prefix from COMPUTER_PORT and BOT_PORT in turn.

Quality checks:

  • bun run format:check clean on tracked files.
  • bun run lint clean, 416 files.
  • bun run typecheck clean for app, server and worker. supervisor typechecked separately with bunx tsc --noEmit, since the root script does not reach it and CI checks it in the deployables matrix.
  • bun run test 1251 tests. The failing set is 111 names, byte-identical before and after the change, all pre-existing integration tests that need PostgreSQL.
  • bun run build exit 0.

No lockfile changes. The architecture diagram renders the host port, which has not changed, so it needs no regeneration.

davidmckayv
davidmckayv previously approved these changes Aug 23, 2026

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on the running stack, not from the diff.

The exposure was live on this machine. Before: openbot-supervisor-1 0.0.0.0:4500->4300/tcp, while agent-computer, agent-bot and agent-langgraph were already loopback-only. That process mounts the Docker socket, so reaching it is root-equivalent on the host behind one shared env-var token.

After recreating from this compose file:

openbot-supervisor-1  127.0.0.1:4500->4300/tcp
loopback  127.0.0.1:4500 -> 200
LAN       10.0.0.75:4500 -> 000   (connection refused)

Reachable where it needs to be, gone from the routed network.

Local development is not broken, which was my main worry. I ran scripts/start.sh end to end afterwards: it reaches the supervisor on localhost:4500, per-Bot computers are created normally (one computer per Bot), and a Bot drove a browser to completion. In-compose callers use supervisor:4300 and never touched the published port.

The compose file was also contradicting docs/architecture.md, which already said not to expose this. A template ships its defaults into every clone, so that disagreement was going to be resolved in the wrong direction by whoever read the compose file first.

Worth a follow-up, not this PR: postgres is still published on 0.0.0.0:5432 with openbot/openbot. Same class, same file, and this PR's test deliberately does not cover it. Happy to raise it separately.

Checks: stacked with ten other candidates on current main. Typecheck, lint, format clean; 1371 tests pass, 0 fail.

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.
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The supervisor is published on every address the host has, and it holds the Docker socket

2 participants