From 782a44232f017c1da7d0a859ef597cbef695fe81 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 28 Aug 2026 10:14:05 -0700 Subject: [PATCH 1/2] Refuse a shell the database, show a screen that ended, and unblock a taken-over sign-in Five fixes to issues open against 0.0.5, each generic and none vendor-specific: - The all-in-one image's embedded PostgreSQL moves from trust-auth to scram-sha-256 with a generated password, so a Bot's shell can no longer reach the vault/audit as the owner (#226). - A live screen that ends renders its reason in the branch that is mounted, instead of leaving a frozen frame (#287). - The Bot's browser drops the automation flags that make sites refuse a person's sign-in, at the source rather than by patching navigator.webdriver (#275). - The langgraph Bot carries a continuation turn when a run has no human message, and ends an empty reply on a visible line, so strict providers stop failing silently (#199). - A deterministic acceptance test drives the built-in Bot's authenticated AG-UI contract and asserts no secret is disclosed (#219). --- CHANGELOG.md | 31 +++++ agent-bot/tests/built-in-acceptance.test.ts | 125 ++++++++++++++++++ agent-computer/src/profiles.ts | 14 ++ agent-langgraph/src/history.ts | 23 ++++ agent-langgraph/src/index.ts | 33 ++++- app/src/components/computer/computer-view.tsx | 23 +++- docker/s6/scripts/postgres-init.sh | 53 ++++++-- 7 files changed, 286 insertions(+), 16 deletions(-) create mode 100644 agent-bot/tests/built-in-acceptance.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f9be68..c851fcd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,37 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A Bot's shell can no longer reach the embedded database without a password + +In the all-in-one image the cluster was `trust`-auth on loopback, and the Bot's shell runs in the +same container: it could `psql -h 127.0.0.1 -U openbot` with no password and read the audit trail, +the policy store, and the credential vault as the instance owner. The cluster now uses +`scram-sha-256` with a password generated on first init and kept beside the data, handed to the API +over the container environment. The shell has no way to learn it, so the connection is refused. An +external `DATABASE_URL` deployment is unaffected. + +### A live screen that ends says so, instead of freezing the last frame + +When a Bot's live screen ended — the computer stopped, or the socket failed — the message explaining +why was drawn only by a component the take-the-wheel view does not mount, so the screen sat frozen on +its last frame with nothing said. The reason is now shown where the live screen is. + +### A Bot's browser drops the automation flags a person needs gone to sign in + +The browser announced itself as automated (`navigator.webdriver`, the enable-automation switch), +which sites like Google refuse even when a real person has taken the wheel. Those flags are now off +at the source — the browser flag, not a script that patches `navigator.webdriver` and leaves the +other tells. A headless build still reports `HeadlessChrome` in its user agent, which only running +headed under a virtual display removes; that heavier change is tracked separately. + +### An empty model reply, or a run with no question, no longer ends in silence + +Two failures on strict OpenAI-compatible providers (z.ai GLM, Anthropic): a follow-up run that +carried only tool deltas and no human turn was refused outright, and a reply with no text and no tool +call ended the run with nothing on screen. A run with no human turn now carries a neutral +continuation, and an empty reply ends on a visible line rather than in silence. OpenAI, which +tolerated both, is unchanged. + ### Embedded PostgreSQL initialises on a platform volume, and says so when it cannot `EMBEDDED_POSTGRES=on` could not create its cluster on a platform whose persistent volume is an ext4 diff --git a/agent-bot/tests/built-in-acceptance.test.ts b/agent-bot/tests/built-in-acceptance.test.ts new file mode 100644 index 00000000..d9ac16d0 --- /dev/null +++ b/agent-bot/tests/built-in-acceptance.test.ts @@ -0,0 +1,125 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +/** + * The Bot that ships in the box, driven over its real AG-UI contract. + * + * The other test here covers history parsing; nothing exercised the endpoint. This does: it starts + * the process against a stand-in model, sends an authenticated run and an unauthenticated one, and + * checks the three things that make the built-in Bot usable and safe — a token is required, an + * ordinary prompt comes back as text a person can read, and neither the model key nor the Bot token + * appears in anything the endpoint returns. + * + * The model is a local stand-in so the test is deterministic and needs no network or real key. Its + * only job is to stream one chat-completion chunk of content, which is the shape agent-bot reads. + */ + +const AGENT_TOKEN = "test-managed-agent-token-value"; +const MODEL_KEY = "test-openai-api-key-value"; +const ANSWER = "The standup is at nine. Everything is green."; + +let model: ReturnType; +let bot: ReturnType; +let botPort: number; + +function chunk(delta: Record, finish: string | null) { + return `data: ${JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion.chunk", + choices: [{ index: 0, delta, finish_reason: finish }], + })}\n\n`; +} + +beforeAll(async () => { + // A stand-in for /v1/chat/completions that streams one line of content and stops. + model = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (url.pathname.endsWith("/chat/completions")) { + const body = `${chunk({ role: "assistant", content: ANSWER }, null)}${chunk( + {}, + "stop", + )}data: [DONE]\n\n`; + return new Response(body, { + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + + botPort = 4288; + bot = Bun.spawn(["bun", "src/index.ts"], { + cwd: new URL("..", import.meta.url).pathname, + env: { + ...process.env, + PORT: String(botPort), + MANAGED_AGENT_TOKEN: AGENT_TOKEN, + OPENAI_API_KEY: MODEL_KEY, + OPENAI_BASE_URL: `http://localhost:${model.port}/v1`, + BOT_MODEL: "gpt-5.5", + }, + stdout: "pipe", + stderr: "pipe", + }); + + // Up when the status endpoint answers. + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(`http://localhost:${botPort}/`); + if (res.ok) return; + } catch { + // still starting + } + await Bun.sleep(100); + } + throw new Error("agent-bot did not come up"); +}); + +afterAll(() => { + bot?.kill(); + model?.stop(true); +}); + +function run(headers: Record) { + return fetch(`http://localhost:${botPort}/ag-ui`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ + threadId: "t1", + runId: "r1", + messages: [{ id: "m1", role: "user", content: "When is standup?" }], + tools: [], + }), + }); +} + +describe("the built-in Bot's AG-UI endpoint", () => { + test("refuses a run with no token", async () => { + const res = await run({}); + expect(res.status).toBe(401); + const body = await res.text(); + // The refusal says nothing about the credential it is protecting. + expect(body).not.toContain(AGENT_TOKEN); + expect(body).not.toContain(MODEL_KEY); + }); + + test("refuses a run with the wrong token", async () => { + const res = await run({ "x-openbot-agent-token": "not-the-token" }); + expect(res.status).toBe(401); + }); + + test("answers an authenticated run with readable text, and leaks no secret", async () => { + const res = await run({ "x-openbot-agent-token": AGENT_TOKEN }); + expect(res.status).toBe(200); + const body = await res.text(); + + // A person gets the model's answer back through the AG-UI stream. + expect(body).toContain("RUN_STARTED"); + expect(body).toContain(ANSWER); + + // Neither the Bot token nor the model key is anywhere in the response. + expect(body).not.toContain(AGENT_TOKEN); + expect(body).not.toContain(MODEL_KEY); + }); +}); diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts index 4e6fe26c..c97f041c 100644 --- a/agent-computer/src/profiles.ts +++ b/agent-computer/src/profiles.ts @@ -96,6 +96,15 @@ const LAUNCH_ARGS = [ ...(SANDBOX_ENABLED ? [] : ["--no-sandbox"]), "--disable-dev-shm-usage", "--password-store=basic", + // Drop the automation signals Chromium sets for itself, so a real person who takes the wheel can + // sign in to a site that refuses obvious automation (Google among them). This is the flag, not a + // JS patch of `navigator.webdriver`: the flag turns the property off at the source, where spoofing + // it from a script leaves the other tells a detector cross-checks. It does not change what the Bot + // may do; the governed path is unchanged. The larger tell — a headless build reporting + // `HeadlessChrome` in its user agent — is only removed by running headed under a virtual display, + // which is a heavier image change tracked separately; this reduces the signals it can reduce + // without one. + "--disable-blink-features=AutomationControlled", ]; console.info( @@ -372,6 +381,11 @@ export function createProfiles(root: string, onClosed: BrowserClosed) { const proxy = egressFor(botId, process.env); const context = await chromium.launchPersistentContext(dir, { args: LAUNCH_ARGS, + // Playwright launches with `--enable-automation`, which sets `navigator.webdriver` and the + // "controlled by automated software" banner. Dropped for the same reason as the flag above: + // a person who takes the wheel should be able to sign in. Named explicitly so the sandbox + // default args Playwright still supplies are otherwise left intact. + ignoreDefaultArgs: ["--enable-automation"], // Playwright adds `--no-sandbox` on its own unless told otherwise, so leaving this out // means the flag above decides nothing and a deployment that asked for the sandbox does // not get one. Verified by reading the launched process arguments, not by trusting either. diff --git a/agent-langgraph/src/history.ts b/agent-langgraph/src/history.ts index ce16acf4..2f982528 100644 --- a/agent-langgraph/src/history.ts +++ b/agent-langgraph/src/history.ts @@ -103,9 +103,32 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] { } } + /* + * A run that carries no human turn is answered by OpenAI and refused by the strict providers. + * + * OpenAI tolerates a history that opens on an assistant or tool message. Anthropic and the strict + * OpenAI-compatible providers (z.ai GLM among them) require the first non-system message to be a + * human one, and will not answer a history that is only deltas: an assistant turn and its tool + * results with nothing a person said to respond to. A follow-up run continuing after a tool result + * is exactly that shape, so on those providers it came back empty and the run ended in silence. + * + * A neutral continuation turn gives them one to answer. It is appended only when the history holds + * no human turn at all, so a normal conversation is untouched, and OpenAI — which already answered + * the same history — sees no change beyond one trailing line asking it to continue. + */ + const hasHumanTurn = messages.some( + (message): message is HumanMessage => message instanceof HumanMessage, + ); + if (!hasHumanTurn) { + messages.push(new HumanMessage(CONTINUE_TURN)); + } + return messages; } +/** The continuation a strict provider needs when a run carries only deltas. See toLangChainMessages. */ +const CONTINUE_TURN = "Continue from where the conversation above left off."; + function parseArguments(raw: string): Record { try { const parsed = JSON.parse(raw || "{}"); diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index 58a286b4..a0fcb570 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -332,7 +332,7 @@ function buildGraph(input: RunAgentInput) { return new StateGraph(MessagesAnnotation) .addNode("answer", async (state) => ({ - messages: [await bound.invoke(state.messages)], + messages: [withVisibleReply((await bound.invoke(state.messages)) as AIMessage)], })) .addNode("tools", async (state) => { const last = state.messages.at(-1) as AIMessage; @@ -384,6 +384,37 @@ function buildGraph(input: RunAgentInput) { .compile(); } +/** + * A reply with nothing in it ends the run in silence, so give it a line to end on. + * + * When a model returns no text and no tool call, the conditional edge sees no calls and returns END, + * and the person is left looking at a turn that produced no answer and no reason. Strict providers do + * this on a run they will not answer. Re-asking tends to get the same empty reply, so rather than + * loop, the run ends on a visible message saying what happened. Only a genuinely empty reply is + * touched: a reply with any text, or any tool call, is returned exactly as the model produced it. + */ +function withVisibleReply(reply: AIMessage): AIMessage { + const hasCall = (reply.tool_calls ?? []).length > 0; + if (hasCall || hasVisibleText(reply.content)) return reply; + return new AIMessage({ content: EMPTY_REPLY_FALLBACK }); +} + +function hasVisibleText(content: AIMessage["content"]): boolean { + if (typeof content === "string") return content.trim().length > 0; + if (Array.isArray(content)) { + return content.some((part) => + typeof part === "string" + ? part.trim().length > 0 + : typeof (part as { text?: unknown }).text === "string" && + (part as { text: string }).text.trim().length > 0, + ); + } + return false; +} + +const EMPTY_REPLY_FALLBACK = + "The model returned an empty reply and the run ended without an answer. This can happen with a strict provider; try asking again."; + async function runAgent(input: RunAgentInput): Promise { const encoder = new EventEncoder(); const stream = new ReadableStream({ diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx index 532c85b1..a0990604 100644 --- a/app/src/components/computer/computer-view.tsx +++ b/app/src/components/computer/computer-view.tsx @@ -640,11 +640,24 @@ export function ComputerView({ /> ) : showLiveScreen ? ( - +
+ + {/* + A live screen that ends reports why through `onProblem`, and this is the + branch that is mounted when it does. Without drawing it here the message + landed in `problem`, which only the sibling `NothingToSee` reads, so the + screen ended with the stale last frame frozen on the canvas and nothing said. + */} + {problem ? ( +
+ {problem} +
+ ) : null} +
) : (
/dev/null - s6-setuidgid postgres /usr/lib/postgresql/16/bin/pg_ctl -D "$DATA" -o "-c listen_addresses=127.0.0.1" -w start >/dev/null - s6-setuidgid postgres /usr/lib/postgresql/16/bin/createdb -U openbot openbot - s6-setuidgid postgres /usr/lib/postgresql/16/bin/psql -U openbot -d openbot -c 'CREATE EXTENSION IF NOT EXISTS vector' >/dev/null - s6-setuidgid postgres /usr/lib/postgresql/16/bin/pg_ctl -D "$DATA" -w stop >/dev/null + # The cluster's password, generated once and kept on the same volume as the data. openssl is not in + # this image; /dev/urandom is. 600 and owned by postgres, so the Bot's shell (which runs as pwuser) + # cannot read the file even if it goes looking. + PW="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" + ( umask 077; printf '%s' "$PW" > "$PW_FILE" ) + chown postgres:postgres "$PW_FILE" + + # initdb reads the superuser password from a file (never an argument, which would show in `ps`), and + # sets scram for both local and TCP connections. The temp file is removed the moment initdb returns. + PWTMP="$(mktemp)" + printf '%s' "$PW" > "$PWTMP" + chown postgres:postgres "$PWTMP" + s6-setuidgid postgres "$BIN/initdb" -D "$DATA" -A scram-sha-256 -U openbot --pwfile="$PWTMP" >/dev/null + rm -f "$PWTMP" + + s6-setuidgid postgres "$BIN/pg_ctl" -D "$DATA" -o "-c listen_addresses=127.0.0.1" -w start >/dev/null + # Over TCP with the password now, since the cluster no longer trusts an unauthenticated connection. + PGPASSWORD="$PW" s6-setuidgid postgres "$BIN/createdb" -h 127.0.0.1 -U openbot openbot + PGPASSWORD="$PW" s6-setuidgid postgres "$BIN/psql" -h 127.0.0.1 -U openbot -d openbot -c 'CREATE EXTENSION IF NOT EXISTS vector' >/dev/null + s6-setuidgid postgres "$BIN/pg_ctl" -D "$DATA" -w stop >/dev/null +fi + +# Every boot, not only the first: hand the password-bearing URL to the services that connect over TCP +# (`api` and `migrate`, both `with-contenv`). The password persists with the cluster; the container +# environment is fresh each boot, so this has to run outside the first-init guard above. The file is +# root-written here into s6's own environment directory, which pwuser cannot write and the Bot's shell +# does not read. +if [ -s "$PW_FILE" ]; then + PW="$(cat "$PW_FILE")" + printf 'postgres://openbot:%s@127.0.0.1:5432/openbot' "$PW" \ + > /run/s6/container_environment/DATABASE_URL fi From e44a12dbd612ca6be28184a2ac1b1ee4b3aede9a Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 28 Aug 2026 10:51:23 -0700 Subject: [PATCH 2/2] Fix formatting, and defer the built-in-agent acceptance test The acceptance test spawns agent-bot and could not be run in a bare worktree (workspace dep resolution), so it went to CI unverified and failed there. Pulling it from this PR; #219's test half stays open for a change that can be verified before it ships. The four behavioural fixes remain. --- agent-bot/tests/built-in-acceptance.test.ts | 125 -------------------- agent-langgraph/src/index.ts | 4 +- 2 files changed, 3 insertions(+), 126 deletions(-) delete mode 100644 agent-bot/tests/built-in-acceptance.test.ts diff --git a/agent-bot/tests/built-in-acceptance.test.ts b/agent-bot/tests/built-in-acceptance.test.ts deleted file mode 100644 index d9ac16d0..00000000 --- a/agent-bot/tests/built-in-acceptance.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; - -/** - * The Bot that ships in the box, driven over its real AG-UI contract. - * - * The other test here covers history parsing; nothing exercised the endpoint. This does: it starts - * the process against a stand-in model, sends an authenticated run and an unauthenticated one, and - * checks the three things that make the built-in Bot usable and safe — a token is required, an - * ordinary prompt comes back as text a person can read, and neither the model key nor the Bot token - * appears in anything the endpoint returns. - * - * The model is a local stand-in so the test is deterministic and needs no network or real key. Its - * only job is to stream one chat-completion chunk of content, which is the shape agent-bot reads. - */ - -const AGENT_TOKEN = "test-managed-agent-token-value"; -const MODEL_KEY = "test-openai-api-key-value"; -const ANSWER = "The standup is at nine. Everything is green."; - -let model: ReturnType; -let bot: ReturnType; -let botPort: number; - -function chunk(delta: Record, finish: string | null) { - return `data: ${JSON.stringify({ - id: "chatcmpl-test", - object: "chat.completion.chunk", - choices: [{ index: 0, delta, finish_reason: finish }], - })}\n\n`; -} - -beforeAll(async () => { - // A stand-in for /v1/chat/completions that streams one line of content and stops. - model = Bun.serve({ - port: 0, - async fetch(request) { - const url = new URL(request.url); - if (url.pathname.endsWith("/chat/completions")) { - const body = `${chunk({ role: "assistant", content: ANSWER }, null)}${chunk( - {}, - "stop", - )}data: [DONE]\n\n`; - return new Response(body, { - headers: { "content-type": "text/event-stream" }, - }); - } - return new Response("not found", { status: 404 }); - }, - }); - - botPort = 4288; - bot = Bun.spawn(["bun", "src/index.ts"], { - cwd: new URL("..", import.meta.url).pathname, - env: { - ...process.env, - PORT: String(botPort), - MANAGED_AGENT_TOKEN: AGENT_TOKEN, - OPENAI_API_KEY: MODEL_KEY, - OPENAI_BASE_URL: `http://localhost:${model.port}/v1`, - BOT_MODEL: "gpt-5.5", - }, - stdout: "pipe", - stderr: "pipe", - }); - - // Up when the status endpoint answers. - for (let i = 0; i < 50; i++) { - try { - const res = await fetch(`http://localhost:${botPort}/`); - if (res.ok) return; - } catch { - // still starting - } - await Bun.sleep(100); - } - throw new Error("agent-bot did not come up"); -}); - -afterAll(() => { - bot?.kill(); - model?.stop(true); -}); - -function run(headers: Record) { - return fetch(`http://localhost:${botPort}/ag-ui`, { - method: "POST", - headers: { "content-type": "application/json", ...headers }, - body: JSON.stringify({ - threadId: "t1", - runId: "r1", - messages: [{ id: "m1", role: "user", content: "When is standup?" }], - tools: [], - }), - }); -} - -describe("the built-in Bot's AG-UI endpoint", () => { - test("refuses a run with no token", async () => { - const res = await run({}); - expect(res.status).toBe(401); - const body = await res.text(); - // The refusal says nothing about the credential it is protecting. - expect(body).not.toContain(AGENT_TOKEN); - expect(body).not.toContain(MODEL_KEY); - }); - - test("refuses a run with the wrong token", async () => { - const res = await run({ "x-openbot-agent-token": "not-the-token" }); - expect(res.status).toBe(401); - }); - - test("answers an authenticated run with readable text, and leaks no secret", async () => { - const res = await run({ "x-openbot-agent-token": AGENT_TOKEN }); - expect(res.status).toBe(200); - const body = await res.text(); - - // A person gets the model's answer back through the AG-UI stream. - expect(body).toContain("RUN_STARTED"); - expect(body).toContain(ANSWER); - - // Neither the Bot token nor the model key is anywhere in the response. - expect(body).not.toContain(AGENT_TOKEN); - expect(body).not.toContain(MODEL_KEY); - }); -}); diff --git a/agent-langgraph/src/index.ts b/agent-langgraph/src/index.ts index a0fcb570..9b439a17 100644 --- a/agent-langgraph/src/index.ts +++ b/agent-langgraph/src/index.ts @@ -332,7 +332,9 @@ function buildGraph(input: RunAgentInput) { return new StateGraph(MessagesAnnotation) .addNode("answer", async (state) => ({ - messages: [withVisibleReply((await bound.invoke(state.messages)) as AIMessage)], + messages: [ + withVisibleReply((await bound.invoke(state.messages)) as AIMessage), + ], })) .addNode("tools", async (state) => { const last = state.messages.at(-1) as AIMessage;