From bbd593ace93bddd2d92ce361e65b027f4ca9dd90 Mon Sep 17 00:00:00 2001 From: Dusty Date: Mon, 26 Oct 2026 16:41:40 -0700 Subject: [PATCH 1/4] Ship the routines worker in the server image The server image already carries the API and the built app. Add worker/src and its node_modules (the isolated install's link to @openbot/shared), so the same published image can run the worker with `bun ../worker/src/index.ts`. Nothing uses it yet; this is step 1 of running OpenBot Desktop's server, app and worker as containers instead of building them on the customer's machine. --- server/Dockerfile | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/server/Dockerfile b/server/Dockerfile index 0dda2d0968..f088a1c3f4 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,9 +1,14 @@ -# The API and the app it serves, in one image. +# The API, the app it serves, and the routines worker, in one image. # # ONE IMAGE ON PURPOSE. There is no CORS anywhere in the server, so the app must reach `/api` on its # own origin. Shipping them together means a deployment needs one service and no path rules, and the # two halves cannot end up disagreeing about which host they are on. # +# The worker rides along for a smaller version of the same reason: it is the server's own routine +# sweep (`server/src/routines/sweep.ts`) in a loop, importing the server's source rather than a copy +# of it, so a worker built from another commit would be sweeping with somebody else's rules. One +# image runs either process; the command decides which. +# # Multi-stage so the build's dependencies do not ship. Non-root because nothing here needs to be. FROM oven/bun:1.4.3 AS deps @@ -53,11 +58,20 @@ COPY --from=deps /app/package.json package.json COPY --from=deps /app/bun.lock bun.lock COPY --from=deps /app/server/node_modules server/node_modules COPY --from=deps /app/toolkit/node_modules toolkit/node_modules +# The worker's own `node_modules` holds one thing, the link to `@openbot/shared`, and without it the +# first import in `worker/src/env.ts` fails. The install is isolated: each workspace resolves through +# the folder beside its own manifest, so the server's copy of that link is no use to the worker. +COPY --from=deps /app/worker/node_modules worker/node_modules COPY server server COPY shared shared COPY toolkit toolkit COPY examples examples +# Its source and manifest, not its tests. Run it with `bun ../worker/src/index.ts` from the working +# directory below; it reaches the server's source by relative import, so it needs `server/` beside +# it, which is already here. +COPY worker/package.json worker/package.json +COPY worker/src worker/src # The built app, served by the process below. `APP_DIST_DIR` is what turns that on, so an image # without this layer is still a working API. From 26c6dbff01362988480f431a5929441d76d58d5d Mon Sep 17 00:00:00 2001 From: Dusty Date: Mon, 26 Oct 2026 16:47:29 -0700 Subject: [PATCH 2/4] Add server and worker services to the compose file OpenBot's own server (the API and the built app it serves) and the routines worker, from the published server image, behind an `openbot` profile so scripts/start.sh and a plain `docker compose up -d` keep running them on the host as before. The server reads the same .env a host server does, with every address overridden to a service on the compose network, PORT and SERVER_PORT both pinned to 3001 (the server refuses to start when they disagree), NODE_ENV cleared so a local OpenBot may still reach private addresses, and the app and server host ports both mapped to it on loopback. The worker gets its three settings and sits on the data network only. Bots keep their host.docker.internal tool URL by default; whoever starts the server service points them at server:3001. --- docker-compose.yml | 147 +++++++++++++++++++++++++++++++++++++-- tests/compose.test.ts | 156 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8677f7708a..c8b61e8ec5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,9 @@ services: # says it plainly: isolation is the container's job, a shell can reach whatever the container # can reach. # - # `migrate` is the only service that reaches this by name, so the two of them are all that - # belongs here. Everything else that needs the database is outside this file: the API server - # runs on the host in local development and connects through the published port below. + # `migrate` reaches this by name, and so do `server` and `worker` when an installed OpenBot runs + # them; those are all that belong here. In local development the API server and the worker run + # on the host instead and connect through the published port below. environment: POSTGRES_DB: openbot POSTGRES_USER: openbot @@ -74,6 +74,138 @@ services: networks: - data + # OpenBot itself, the API and the app it serves, as an installed OpenBot runs it. + # + # BEHIND A PROFILE, BECAUSE A DEVELOPER'S SERVER IS ON THE HOST. `scripts/start.sh` runs the server + # and the worker as host processes for hot reload, names the services it wants from this file, and + # owns port 3001 while it does. Without the profile, a plain `docker compose up -d` would start a + # second server beside that one, fighting it for the port and the routines. Naming `server` on the + # command line still starts it, profile or not, which is how an installed OpenBot asks for it. + # + # The image `migrate` runs, because it is one image: the API, the built app at `APP_DIST_DIR`, and + # the worker below. A machine with that image needs Docker and nothing else, which is the point. + server: + profiles: ["openbot"] + build: + context: . + dockerfile: server/Dockerfile + # See migrate above for what pull_policy decides, and why an installed OpenBot overrides the + # `build` section away rather than trusting it. + image: ${SERVER_IMAGE:-openbot-server:latest} + pull_policy: ${IMAGE_PULL_POLICY:-build} + # The settings a host server reads with `--env-file=../.env`, so the two run the same deployment. + # Optional, because `docker compose config` and a first start both come before anything wrote it. + # + # Everything in `environment:` below wins over this file. That is what lets the addresses there + # name services on this network: the file says `127.0.0.1`, which inside this container is the + # container. + env_file: + - path: ./.env + required: false + environment: + # Both names, because `.env` carries `SERVER_PORT` for the host port, and the server refuses to + # start when the two disagree (`serverPort` in server/src/config.ts). Inside this container it + # is always 3001; the host port is the mapping's business, below. + PORT: "3001" + SERVER_PORT: "3001" + # Not production, as a host server is not. The image says production, and in production the + # server refuses `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS`, which a local OpenBot needs: its + # supervisor, computers and Bots are all at private addresses, on purpose. Empty rather than + # `development`, because unset is what a host server has, and libraries that look at this + # treat the two alike. + NODE_ENV: "" + # Every address below names a service on this network rather than the loopback port the host + # server reaches it on. + DATABASE_URL: postgres://openbot:openbot@postgres:5432/openbot + AGENT_COMPUTER_URL: http://agent-computer:4100 + # Per-Bot computers too, which is not automatic: the supervisor publishes each on a loopback + # port unless `COMPUTER_NETWORK` names this project's `default` network, and a loopback port on + # the host is not one this container can open. Whoever starts this service sets that as well. + COMPUTER_SUPERVISOR_URL: http://supervisor:4300 + # The managed Bot by name, when the deployment has one. `:+` keeps an empty setting empty, which + # is how a deployment without that Bot says so: the package drops its row while this is blank. + MANAGED_AGENT_AG_UI_URL: ${MANAGED_AGENT_AG_UI_URL:+http://agent-langgraph:4201/ag-ui} + # These three have a container address only the deployment knows, so they take the shape + # `OPENAI_CONTAINER_BASE_URL` already has on the Bots below: the container's own address when + # one is given, and otherwise the deployment's, which for anything not on this machine is + # already right. The picked harness's path, a model endpoint on the host, and Intelligence. + PICKED_HARNESS_URL: ${PICKED_HARNESS_CONTAINER_URL:-${PICKED_HARNESS_URL:-}} + OPENAI_BASE_URL: ${OPENAI_CONTAINER_BASE_URL:-${OPENAI_BASE_URL:-}} + INTELLIGENCE_API_URL: ${INTELLIGENCE_CONTAINER_API_URL:-${INTELLIGENCE_API_URL:-}} + INTELLIGENCE_GATEWAY_WS_URL: ${INTELLIGENCE_CONTAINER_GATEWAY_WS_URL:-${INTELLIGENCE_GATEWAY_WS_URL:-}} + # The deployment's secrets, from the environment Compose runs in and then from `.env`, which + # Compose reads for these `${...}` and the host server reads for itself. OpenBot Desktop keeps + # them out of that file and hands them to Compose in its environment, so they reach this + # container only by being named here. The defaults are the ones every other service in this + # file and `scripts/start.sh` agree on. + AGENT_TOOL_TOKEN: ${AGENT_TOOL_TOKEN:-} + COMPUTER_TOKEN: ${COMPUTER_TOKEN:-openbot-dev-computer-token} + KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-} + MANAGED_AGENT_TOKEN: ${MANAGED_AGENT_TOKEN:-} + SUPERVISOR_TOKEN: ${SUPERVISOR_TOKEN:-openbot-dev-supervisor-token} + WORKER_SHARED_SECRET: ${WORKER_SHARED_SECRET:-openbot-dev-worker-secret} + INTELLIGENCE_API_KEY: ${INTELLIGENCE_API_KEY:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} + # The desktop's host-access broker proves itself with this. Minted per start and given to the + # server alone, so it is named here and nowhere else in this file. + OPENBOT_DESKTOP_HOST_TOKEN: ${OPENBOT_DESKTOP_HOST_TOKEN:-} + ports: + # Two host ports, one process. The app's is the address a window is given: the server serves + # the built app itself, so that one port reaches both halves on one origin. The server's is + # where everything else on the host already calls it, OpenBot Desktop's host-access broker + # among them. Loopback, for the reason every other port here is. + - "127.0.0.1:${APP_PORT:-3010}:3001" + - "[::1]:${APP_PORT:-3010}:3001" + - "127.0.0.1:${SERVER_PORT:-3001}:3001" + - "[::1]:${SERVER_PORT:-3001}:3001" + # For whatever stays on the host and is addressed as such: a model endpoint on this machine, and + # anything else the Bots below already reach the same way. + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + migrate: + condition: service_completed_successfully + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "bun -e \"const r = await fetch('http://127.0.0.1:3001/health'); process.exit(r.ok ? 0 : 1)\""] + interval: 10s + timeout: 5s + retries: 5 + # Both: `data` for the database, `default` for the Bots, the computers and the supervisor. This is + # the one service the note on `networks:` at the end of this file says may sit on both. + networks: + - data + - default + + # The routines worker, from the same image: the server's own sweep in a loop. See + # `worker/src/index.ts` for why a local deployment has this rather than a CronJob. + worker: + profiles: ["openbot"] + build: + context: . + dockerfile: server/Dockerfile + image: ${SERVER_IMAGE:-openbot-server:latest} + pull_policy: ${IMAGE_PULL_POLICY:-build} + command: ["bun", "../worker/src/index.ts"] + # Its three settings and nothing else. It is handed no `.env`: it does not read the deployment's + # configuration, and the less this process holds, the less a mistake in it can spend. + environment: + DATABASE_URL: postgres://openbot:openbot@postgres:5432/openbot + SERVER_INTERNAL_URL: http://server:3001 + WORKER_SHARED_SECRET: ${WORKER_SHARED_SECRET:-openbot-dev-worker-secret} + depends_on: + migrate: + condition: service_completed_successfully + server: + condition: service_started + restart: unless-stopped + # `data` only. It reaches the database there, and the server, which is on it too. Nothing a Bot + # can reach can reach this. + networks: + - data + # The Bot's computer is long-lived so browser sessions remain signed in across turns. agent-computer: build: @@ -385,6 +517,10 @@ services: BOT_REASONING_EFFORT: ${BOT_REASONING_EFFORT:-} # Where this Bot runs a tool: back through the deployment that granted it, never at the vendor. # `host.docker.internal` because the API server runs on the host, not in this network. + # + # Left as the default even though `server` above is now in this file, because the default is + # what `scripts/start.sh` relies on and that server is on the host. A deployment that starts + # `server` sets this to `http://server:3001/api/agent-tools/call`, for every Bot at once. OPENBOT_TOOL_URL: ${OPENBOT_TOOL_URL:-http://host.docker.internal:3001/api/agent-tools/call} AGENT_TOOL_TOKEN: ${AGENT_TOOL_TOKEN:-} extra_hosts: @@ -396,10 +532,9 @@ services: retries: 5 networks: - # The database and the one service that reaches it by name. Nothing a Bot can reach is on it. + # The database and the services that reach it by name. Nothing a Bot can reach is on it. # - # A deployment that runs the API server inside Compose rather than on the host puts that service - # on both this and `default`, which is the one place the two are meant to meet. + # `server` is on both this and `default`, which is the one place the two are meant to meet. data: # Everything else, which is where `default` already put it. Named here only so that adding a # service without a `networks:` key keeps landing beside the Bots rather than beside the database. diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 8871209c26..6674ac68ae 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -9,6 +9,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { parse } from "yaml"; function composeFile() { return readFileSync( @@ -17,6 +18,35 @@ function composeFile() { ); } +/** The file as Compose reads it before interpolation: every `${...}` is still text. */ +function composeServices() { + return ( + parse(composeFile()) as { + services: Record< + string, + { + profiles?: string[]; + image?: string; + build?: { context: string; dockerfile: string }; + command?: string[]; + env_file?: Array<{ path: string; required: boolean }>; + environment?: Record; + depends_on?: Record; + restart?: string; + networks?: string[]; + } + >; + } + ).services; +} + +function serverDockerfile() { + return readFileSync( + join(import.meta.dir, "..", "server", "Dockerfile"), + "utf8", + ); +} + function rootDockerfile() { return readFileSync(join(import.meta.dir, "..", "Dockerfile"), "utf8"); } @@ -180,6 +210,8 @@ function runComposeConfig(env: Record) { "/dev/null", "--profile", "harness", + "--profile", + "openbot", "config", "--format", "json", @@ -239,6 +271,9 @@ test("publishes every service on a settable port with the documented default", ( ["SUPERVISOR_PORT", "4500", "4300"], ["BOT_PORT", "4200", "4200"], ["LANGGRAPH_PORT", "4201", "4201"], + // One process behind both: the server serves the built app on its own port. + ["APP_PORT", "3010", "3001"], + ["SERVER_PORT", "3001", "3001"], ] as const; for (const [name, host, container] of published) { @@ -269,6 +304,8 @@ test("publishes every service that holds a secret on loopback only", () => { "BOT_PORT", "LANGGRAPH_PORT", "PICKED_HARNESS_PORT", + "APP_PORT", + "SERVER_PORT", ]) { const published = [ ...compose.matchAll( @@ -563,3 +600,122 @@ test("gives the selected harness the same governed callback as the framework Bot ); } }); + +/** + * OpenBot's own server and worker, for an installed OpenBot, and out of a developer's way. + * + * `scripts/start.sh` runs both on the host, so a plain `docker compose up -d` that also started them + * would put a second server on port 3001 and a second worker on the routines. The profile is what + * keeps that from happening; naming the service is still enough to start it. + */ +test("keeps OpenBot's own server and worker behind a profile", () => { + const services = composeServices(); + + for (const name of ["server", "worker"]) { + expect(services[name].profiles).toEqual(["openbot"]); + } +}); + +test("runs the server and the worker from the image migrations already use", () => { + const services = composeServices(); + + for (const name of ["server", "worker"]) { + expect(services[name].image).toBe(services.migrate.image); + expect(services[name].build).toEqual(services.migrate.build); + expect(services[name].depends_on?.migrate).toEqual({ + condition: "service_completed_successfully", + }); + // Restarted by Docker, because nothing on the host is watching them any more. + expect(services[name].restart).toBe("unless-stopped"); + } + expect(services.worker.command).toEqual(["bun", "../worker/src/index.ts"]); +}); + +test("ships the worker in the server image", () => { + const dockerfile = serverDockerfile(); + + expect(dockerfile).toContain("COPY worker/src worker/src"); + // The isolated install's link to `@openbot/shared`. Without it the worker's first import fails. + expect(dockerfile).toContain( + "COPY --from=deps /app/worker/node_modules worker/node_modules", + ); +}); + +/** + * Every address the server is given names a service on its network. + * + * `.env` says `127.0.0.1` and `localhost`, which is right for a server on the host and means the + * container itself inside one. The settings in `environment:` win over that file, so these are what + * the server actually uses. + */ +test("gives the server container addresses, not the host's loopback", () => { + const server = composeServices().server; + const environment = server.environment ?? {}; + + expect(server.env_file).toEqual([{ path: "./.env", required: false }]); + expect(environment).toMatchObject({ + PORT: "3001", + SERVER_PORT: "3001", + DATABASE_URL: "postgres://openbot:openbot@postgres:5432/openbot", + AGENT_COMPUTER_URL: "http://agent-computer:4100", + COMPUTER_SUPERVISOR_URL: "http://supervisor:4300", + }); + for (const [name, value] of Object.entries(environment)) { + expect(`${name}=${value}`).not.toMatch(/localhost|127\.0\.0\.1|\[::1\]/); + } + // Production refuses `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS`, which a local OpenBot needs. + expect(environment.NODE_ENV).toBe(""); +}); + +test("puts the server beside the database and the Bots, and the worker beside the database only", () => { + const services = composeServices(); + + expect(services.server.networks).toEqual(["data", "default"]); + expect(services.worker.networks).toEqual(["data"]); + expect(Object.keys(services.worker.environment ?? {}).sort()).toEqual([ + "DATABASE_URL", + "SERVER_INTERNAL_URL", + "WORKER_SHARED_SECRET", + ]); + expect(services.worker.environment?.SERVER_INTERNAL_URL).toBe( + "http://server:3001", + ); +}); + +test("hands the desktop's host-access token to the server and nothing else", () => { + const holders = Object.entries(composeServices()) + .filter(([, service]) => + Object.keys(service.environment ?? {}).includes( + "OPENBOT_DESKTOP_HOST_TOKEN", + ), + ) + .map(([name]) => name); + + expect(holders).toEqual(["server"]); +}); + +test("addresses the server's Bots and endpoints from inside the network", () => { + const withBots = runComposeConfig({ + MANAGED_AGENT_AG_UI_URL: "http://127.0.0.1:4201/ag-ui", + PICKED_HARNESS_URL: "http://127.0.0.1:4202/run", + PICKED_HARNESS_CONTAINER_URL: "http://agent-harness:4202/run", + OPENAI_BASE_URL: "http://127.0.0.1:3001/api/model-provider/v1", + OPENAI_CONTAINER_BASE_URL: + "http://host.docker.internal:3001/api/model-provider/v1", + }); + expect(withBots.services.server.environment).toMatchObject({ + MANAGED_AGENT_AG_UI_URL: "http://agent-langgraph:4201/ag-ui", + PICKED_HARNESS_URL: "http://agent-harness:4202/run", + OPENAI_BASE_URL: "http://host.docker.internal:3001/api/model-provider/v1", + }); + + // No managed Bot stays no managed Bot, and a remote harness keeps its own address. + const without = runComposeConfig({ + MANAGED_AGENT_AG_UI_URL: "", + PICKED_HARNESS_URL: "https://agents.example/run", + }); + expect(without.services.server.environment).toMatchObject({ + MANAGED_AGENT_AG_UI_URL: "", + PICKED_HARNESS_URL: "https://agents.example/run", + }); +}); From a33a164ccf68d8c466a37cbbac3dcec1bd8c5046 Mon Sep 17 00:00:00 2001 From: Dusty Date: Mon, 26 Oct 2026 16:54:27 -0700 Subject: [PATCH 3/4] Give the browser its own Intelligence gateway address The runtime sends the browser the gateway's /client socket in /info, and the browser opens it itself. A server in a container reaches local Intelligence at host.docker.internal, which the browser on the host cannot resolve, while the browser's localhost is the container from inside it. INTELLIGENCE_BROWSER_GATEWAY_WS_URL, when set, is the address /info (and a trajectory grant) gives the browser; the server's own runner and channel sockets stay on INTELLIGENCE_GATEWAY_WS_URL. The compose server service sets it to the deployment's own gateway URL and reads its container address from INTELLIGENCE_CONTAINER_GATEWAY_WS_URL. --- docker-compose.yml | 4 ++ server/src/config.ts | 23 ++++++- server/src/copilot.ts | 1 + server/tests/config.test.ts | 32 +++++++++ tests/compose.test.ts | 5 ++ toolkit/src/copilotkit/agents.ts | 40 ++++++++++- .../intelligence-browser-gateway.test.ts | 68 +++++++++++++++++++ 7 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 toolkit/tests/intelligence-browser-gateway.test.ts diff --git a/docker-compose.yml b/docker-compose.yml index c8b61e8ec5..617cdca6bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -133,6 +133,10 @@ services: OPENAI_BASE_URL: ${OPENAI_CONTAINER_BASE_URL:-${OPENAI_BASE_URL:-}} INTELLIGENCE_API_URL: ${INTELLIGENCE_CONTAINER_API_URL:-${INTELLIGENCE_API_URL:-}} INTELLIGENCE_GATEWAY_WS_URL: ${INTELLIGENCE_CONTAINER_GATEWAY_WS_URL:-${INTELLIGENCE_GATEWAY_WS_URL:-}} + # The browser opens the gateway too, at the address the server gives it in `/info`, and the + # browser is on the host. So it gets the deployment's own address, the one a host server would + # have used, whatever this container uses itself. + INTELLIGENCE_BROWSER_GATEWAY_WS_URL: ${INTELLIGENCE_GATEWAY_WS_URL:-} # The deployment's secrets, from the environment Compose runs in and then from `.env`, which # Compose reads for these `${...}` and the host server reads for itself. OpenBot Desktop keeps # them out of that file and hands them to Compose in its environment, so they reach this diff --git a/server/src/config.ts b/server/src/config.ts index 6188b79c1e..55c18e4e23 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -38,6 +38,11 @@ export type IntelligenceSettings = { gatewayWsUrl: string; apiKey: string; licenseToken?: string; + /** + * The gateway as the browser reaches it, when that is not where this server reaches it. See + * `INTELLIGENCE_BROWSER_GATEWAY_WS_URL` in `runtimeCapabilities`. + */ + browserGatewayWsUrl?: string; }; export type DockerComputerConfig = { @@ -990,10 +995,26 @@ function runtimeCapabilities(environment: Environment): RuntimeCapabilities { ); } + /* + * Optional, and only ever a second address for the same gateway. The browser opens the gateway + * itself, at the address `/info` gives it, so a server that reaches Intelligence somewhere the + * browser cannot follow names the browser's address here. The server in OpenBot Desktop's + * container does: it reaches local Intelligence at `host.docker.internal`, and the window on the + * host reaches the same gateway at `localhost`. Unset, the browser is given `gatewayWsUrl`, as + * before. + */ + const browserGatewayWsUrl = url( + environment, + "INTELLIGENCE_BROWSER_GATEWAY_WS_URL", + ); + return { mode: "intelligence", durableHistory: true, - intelligence: settings as IntelligenceSettings, + intelligence: { + ...(settings as IntelligenceSettings), + ...(browserGatewayWsUrl ? { browserGatewayWsUrl } : {}), + }, }; } diff --git a/server/src/copilot.ts b/server/src/copilot.ts index ac0b822079..2580aa4dc8 100644 --- a/server/src/copilot.ts +++ b/server/src/copilot.ts @@ -1756,6 +1756,7 @@ export function mountCopilotRuntime( new IntelligenceKnowingANewThread({ apiUrl: intelligence.apiUrl, wsUrl: intelligence.gatewayWsUrl, + browserWsUrl: intelligence.browserGatewayWsUrl, apiKey: intelligence.apiKey, getLearningContainerId: async ({ agentId, diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 9b271fb1e9..e3d6436a67 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -134,6 +134,38 @@ describe("deployment configuration", () => { ); }); + // The browser opens the gateway itself, at the address `/info` gives it. A server in a container + // reaches local Intelligence somewhere the browser on the host cannot, so it may name both. + test("keeps the browser's gateway address apart from the server's, when one is given", () => { + const config = loadConfig({ + ...baseEnvironment, + INTELLIGENCE_GATEWAY_WS_URL: "ws://host.docker.internal:14401", + INTELLIGENCE_BROWSER_GATEWAY_WS_URL: "ws://localhost:14401", + }); + if (config.runtime.mode !== "intelligence") { + throw new Error("expected the Intelligence runtime"); + } + expect(config.runtime.intelligence.gatewayWsUrl).toBe( + "ws://host.docker.internal:14401", + ); + expect(config.runtime.intelligence.browserGatewayWsUrl).toBe( + "ws://localhost:14401", + ); + + const without = loadConfig(baseEnvironment); + if (without.runtime.mode !== "intelligence") { + throw new Error("expected the Intelligence runtime"); + } + expect(without.runtime.intelligence.browserGatewayWsUrl).toBeUndefined(); + + expect(() => + loadConfig({ + ...baseEnvironment, + INTELLIGENCE_BROWSER_GATEWAY_WS_URL: "not a url", + }), + ).toThrow("INTELLIGENCE_BROWSER_GATEWAY_WS_URL must be a valid URL"); + }); + test("still forwards a licence token when a deployment sets one", () => { const config = loadConfig({ ...baseEnvironment, diff --git a/tests/compose.test.ts b/tests/compose.test.ts index 6674ac68ae..c3b0e22f43 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -702,11 +702,16 @@ test("addresses the server's Bots and endpoints from inside the network", () => OPENAI_BASE_URL: "http://127.0.0.1:3001/api/model-provider/v1", OPENAI_CONTAINER_BASE_URL: "http://host.docker.internal:3001/api/model-provider/v1", + INTELLIGENCE_GATEWAY_WS_URL: "ws://localhost:14401", + INTELLIGENCE_CONTAINER_GATEWAY_WS_URL: "ws://host.docker.internal:14401", }); expect(withBots.services.server.environment).toMatchObject({ MANAGED_AGENT_AG_UI_URL: "http://agent-langgraph:4201/ag-ui", PICKED_HARNESS_URL: "http://agent-harness:4202/run", OPENAI_BASE_URL: "http://host.docker.internal:3001/api/model-provider/v1", + // The server's own way to the gateway, and the browser's, which is on the host. + INTELLIGENCE_GATEWAY_WS_URL: "ws://host.docker.internal:14401", + INTELLIGENCE_BROWSER_GATEWAY_WS_URL: "ws://localhost:14401", }); // No managed Bot stays no managed Bot, and a remote harness keeps its own address. diff --git a/toolkit/src/copilotkit/agents.ts b/toolkit/src/copilotkit/agents.ts index 1486e5d7d7..46e6224941 100644 --- a/toolkit/src/copilotkit/agents.ts +++ b/toolkit/src/copilotkit/agents.ts @@ -337,11 +337,35 @@ export class UnavailableAgent extends AbstractAgent { * the instance that owns those fields. The authentication observer separately binds methods to * that original instance, so it preserves this override and its private-field receiver. * - * `getThreadMessages` is the only override. `handleGetThreadMessages` in the runtime calls it and + * `getThreadMessages` is one override. `handleGetThreadMessages` in the runtime calls it and * returns `Response.json` of whatever comes back, so an empty history here is the `{ messages: [] }` * the browser expects and a 200 instead of a 500. + * + * `ɵgetClientWsUrl` is the other, and only when `browserWsUrl` is given. That address is the one + * the runtime sends the browser in `/info`, and the browser opens it itself, so it has to be the + * gateway as the browser can reach it. A server in a container reaches local Intelligence at + * `host.docker.internal`, which a browser on the host cannot resolve, while the browser's + * `localhost` is, inside the container, the container. Everything the server opens itself, the + * runner and channel sockets, stays on `wsUrl`. */ export class IntelligenceKnowingANewThread extends CopilotKitIntelligence { + readonly #browserClientWsUrl: string | undefined; + + constructor( + config: ConstructorParameters[0] & { + browserWsUrl?: string; + }, + ) { + const { browserWsUrl, ...platform } = config; + super(platform); + const browser = browserWsUrl?.trim(); + this.#browserClientWsUrl = browser ? clientSocket(browser) : undefined; + } + + override ɵgetClientWsUrl(): string { + return this.#browserClientWsUrl ?? super.ɵgetClientWsUrl(); + } + override getThreadMessages( params: Parameters[0], ) { @@ -350,3 +374,17 @@ export class IntelligenceKnowingANewThread extends CopilotKitIntelligence { }); } } + +/** + * The gateway's client socket, from any of the three addresses the platform names it by. + * + * The rule the runtime applies to `wsUrl` (`deriveClientWsUrl`, which it does not export), so a + * browser address written the way the server's is lands on the same path. + */ +function clientSocket(wsUrl: string): string { + const base = wsUrl.replace(/\/$/, ""); + for (const socket of ["/client", "/runner", "/channels"]) { + if (base.endsWith(socket)) return `${base.slice(0, -socket.length)}/client`; + } + return `${base}/client`; +} diff --git a/toolkit/tests/intelligence-browser-gateway.test.ts b/toolkit/tests/intelligence-browser-gateway.test.ts new file mode 100644 index 0000000000..3cbe73838b --- /dev/null +++ b/toolkit/tests/intelligence-browser-gateway.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { IntelligenceKnowingANewThread } from "../src/copilotkit/agents"; + +/** + * Two addresses for one gateway: the server's, and the browser's. + * + * The runtime sends the browser a websocket address in `/info` and the browser opens it itself. A + * server in a container reaches local Intelligence at `host.docker.internal`, a name the browser on + * the host cannot resolve; the browser reaches it at `localhost`, which inside the container is the + * container. One setting could not be right for both. + */ +describe("the gateway address a browser is given", () => { + const base = { + apiUrl: "http://host.docker.internal:18082", + wsUrl: "ws://host.docker.internal:14401", + apiKey: "synthetic-runtime-key", + }; + + test("is the server's own when no browser address is set", () => { + const client = new IntelligenceKnowingANewThread(base); + + expect(client.ɵgetClientWsUrl()).toBe( + "ws://host.docker.internal:14401/client", + ); + }); + + test("is the browser's when one is set, and the server keeps its own", () => { + const client = new IntelligenceKnowingANewThread({ + ...base, + browserWsUrl: "ws://localhost:14401", + }); + + expect(client.ɵgetClientWsUrl()).toBe("ws://localhost:14401/client"); + // Everything the server opens itself stays on the server's address. + expect(client.ɵgetRunnerWsUrl()).toBe( + "ws://host.docker.internal:14401/runner", + ); + expect(client.ɵgetChannelsWsUrl()).toBe( + "ws://host.docker.internal:14401/channels", + ); + }); + + test("names the client socket however the browser address was written", () => { + for (const browserWsUrl of [ + "ws://localhost:14401/", + "ws://localhost:14401/client", + "ws://localhost:14401/runner", + "ws://localhost:14401/channels", + ]) { + const client = new IntelligenceKnowingANewThread({ + ...base, + browserWsUrl, + }); + expect(client.ɵgetClientWsUrl()).toBe("ws://localhost:14401/client"); + } + }); + + test("ignores a blank browser address", () => { + const client = new IntelligenceKnowingANewThread({ + ...base, + browserWsUrl: " ", + }); + + expect(client.ɵgetClientWsUrl()).toBe( + "ws://host.docker.internal:14401/client", + ); + }); +}); From 4a6f528753edf488181406f0c0c0938ad58e5be8 Mon Sep 17 00:00:00 2001 From: Dusty Date: Mon, 26 Oct 2026 16:59:10 -0700 Subject: [PATCH 4/4] Mount the model sign-in folder into the server service A Google or xAI sign-in leaves .openbot/model-oauth.json on the host, and the server reads and renews it itself (temp file plus rename, and a .lock folder beside it), so the folder is bind-mounted rather than the file. Not created by Compose, which would make it root-owned on Linux. The user is settable so the Linux desktop can run the server as the host user who owns that 0600 file. --- docker-compose.yml | 18 ++++++++++++++++++ tests/compose.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 617cdca6bd..306dde456d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -155,6 +155,24 @@ services: # The desktop's host-access broker proves itself with this. Minted per start and given to the # server alone, so it is named here and nowhere else in this file. OPENBOT_DESKTOP_HOST_TOKEN: ${OPENBOT_DESKTOP_HOST_TOKEN:-} + # A Google or xAI sign-in, which the server reads and renews itself. The file is on the host, + # at `.openbot/model-oauth.json` beside this one, and reaches the container through the mount + # below, so a host path here becomes the mount's. Empty stays empty: no sign-in, no file. + OPENBOT_MODEL_OAUTH_FILE: ${OPENBOT_MODEL_OAUTH_FILE:+/openbot/model-oauth.json} + # Who the server runs as. The image's own user by default. On Linux the desktop names the host's, + # because the sign-in file below is the host user's and readable by nobody else, and a bind mount + # there keeps the host's numbers; Docker Desktop's file sharing on macOS and Windows does not. + user: "${OPENBOT_SERVER_USER:-1001:1001}" + volumes: + # The directory, not the file: the server renews the token by writing a sibling and renaming it + # over the original, which a single-file mount cannot take. Not created here, because Docker + # would create it as root on Linux and the desktop could then not write its sign-in into it; + # whoever starts this service makes it first. + - type: bind + source: ./.openbot + target: /openbot + bind: + create_host_path: false ports: # Two host ports, one process. The app's is the address a window is given: the server serves # the built app itself, so that one port reaches both halves on one origin. The server's is diff --git a/tests/compose.test.ts b/tests/compose.test.ts index c3b0e22f43..478c103cca 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -34,6 +34,16 @@ function composeServices() { depends_on?: Record; restart?: string; networks?: string[]; + user?: string; + volumes?: Array< + | string + | { + type: string; + source: string; + target: string; + bind?: { create_host_path?: boolean }; + } + >; } >; } @@ -724,3 +734,29 @@ test("addresses the server's Bots and endpoints from inside the network", () => PICKED_HARNESS_URL: "https://agents.example/run", }); }); + +/** + * A Google or xAI sign-in is a file the server renews itself, so it reaches the container as the + * folder it sits in: the renewal writes a sibling and renames it over the original. + */ +test("mounts the model sign-in folder into the server, and only there", () => { + const services = composeServices(); + const server = services.server; + + expect(server.volumes).toContainEqual({ + type: "bind", + source: "./.openbot", + target: "/openbot", + bind: { create_host_path: false }, + }); + expect(server.environment?.OPENBOT_MODEL_OAUTH_FILE).toBe( + // biome-ignore lint/suspicious/noTemplateCurlyInString: the unexpanded placeholder is what is asserted. + "${OPENBOT_MODEL_OAUTH_FILE:+/openbot/model-oauth.json}", + ); + // biome-ignore lint/suspicious/noTemplateCurlyInString: the unexpanded placeholder is what is asserted. + expect(server.user).toBe("${OPENBOT_SERVER_USER:-1001:1001}"); + for (const [name, service] of Object.entries(services)) { + if (name === "server") continue; + expect(JSON.stringify(service.volumes ?? [])).not.toContain(".openbot"); + } +});