Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions supervisor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
33 changes: 33 additions & 0 deletions tests/compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down