diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf0b573..851be0dc95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Eight things change for an existing deployment: +**Before upgrading.** Nine things change for an existing deployment: - A hosted computer's browser is now headed by default. It uses a little more memory than headless did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old behaviour. @@ -39,6 +39,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. research run replaces them. - Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. +- On Helm's `shared` computer and the docker-compose `agent-computer`, each Bot's files now live in + `computers/` on the workspace volume (below). Files saved before the upgrade stay at the + root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. + +### Each Bot on a shared computer has its own files + +A computer that serves many Bots kept one folder of files for all of them, so a note one Bot saved +was a file every other Bot could list and read. Each Bot now gets its own folder, and its commands +start there. Logins were already kept apart. This is on for Helm's `shared` computer and the +docker-compose `agent-computer`, and `COMPUTER_SEPARATE_WORKSPACES=on` turns it on for the +all-in-one image. A command can still read another Bot's folder, so a deployment that needs people +kept apart should give each Bot a computer of its own (`computers.mode: sandbox`). ### Screens share one spacing, row and empty-state design diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index 0848640259..b6640d13e8 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -27,8 +27,8 @@ import { TAKE_CONTROL_FIRST, } from "./control"; import { describeHumanGesture } from "./demonstration"; -import { enterText, FieldValueError } from "./enter-text"; import { handleEgressPolicyRequest, startEgressFilter } from "./egress"; +import { enterText, FieldValueError } from "./enter-text"; import { identity } from "./identity"; import { assertPageAccess, @@ -51,7 +51,6 @@ import { sensitiveRefs, } from "./secret-masking"; import { type BotSession, createSessions } from "./sessions"; -import { createShell } from "./shell"; import { fillSignIn, fillSignInCode, @@ -61,12 +60,12 @@ import { import { startVirtualDisplay } from "./virtual-display"; import { applyWebBotAuthPush } from "./web-bot-auth"; import { - createWorkspace, WorkspaceFileError, WorkspaceFileNotFoundError, WorkspaceFileTooLargeError, WorkspacePathError, } from "./workspace"; +import { createWorkspaces } from "./workspaces"; /** * The Bot's computer: one long-lived browser, reachable over HTTP. @@ -197,9 +196,9 @@ function botIdOf(request: Request, fallback?: string | null): string { * The Bot's durable files. * * Rooted at WORKSPACE_DIR, which the image creates and docker-compose mounts as a volume, so what a - * Bot saves outlives the container. Built once at boot: the root is fixed, and resolving it per request - * would only add a syscall to every call. Everything about why confinement is harder than it looks - * lives in workspace.ts. + * Bot saves outlives the container. Each folder is built the first time its Bot uses it and kept, so + * a request adds no syscall after that. Which folder a Bot gets lives in workspaces.ts; everything + * about why confinement is harder than it looks lives in workspace.ts. */ /** * The two directories this process cannot do its job without, checked before it says it is ready. @@ -245,8 +244,10 @@ await assertWritable( process.env.PROFILES_DIR?.trim() || "/profiles", ); -const workspace = createWorkspace( +// One folder per Bot where one process serves many; see workspaces.ts. +const workspaces = createWorkspaces( process.env.WORKSPACE_DIR?.trim() || "/workspace", + process.env.COMPUTER_SEPARATE_WORKSPACES?.trim().toLowerCase() === "on", ); /** @@ -286,9 +287,6 @@ const profiles = createProfiles( await sessions.get(botId)?.viewer.releaseAll(COMPUTER_STOPPED); }, ); -// Rooted in the same workspace the file tools use, so a command and a written file see one -// directory rather than two. -const shell = createShell(process.env.WORKSPACE_DIR?.trim() || "/workspace"); /** * The id normally arrives as a header on every request. This is the fallback for a caller that has no @@ -1209,9 +1207,8 @@ serve({ // MAY touch a path: the gateway in front of this process does that. if (url.pathname === "/files/download" && request.method === "GET") { try { - const file = await workspace.download( - url.searchParams.get("path") ?? "", - ); + const { files } = await workspaces.for(botId); + const file = await files.download(url.searchParams.get("path") ?? ""); return new Response(file.body, { headers: downloadHeaders(file.name, file.bytes), }); @@ -1228,7 +1225,8 @@ serve({ path?: unknown; } | null; try { - return json(await workspace.read(String(body?.path ?? ""))); + const { files } = await workspaces.for(botId); + return json(await files.read(String(body?.path ?? ""))); } catch (error) { return json( { error: describe(error, "The file could not be read.") }, @@ -1242,8 +1240,9 @@ serve({ path?: unknown; } | null; try { + const { files } = await workspaces.for(botId); return json( - await workspace.list( + await files.list( typeof body?.path === "string" ? body.path : undefined, ), ); @@ -1283,6 +1282,9 @@ serve({ return json({ error: timeout.error }, 400); } try { + // Rooted in the same folder the file tools use, so a command and a written file see one + // directory rather than two. + const { shell } = await workspaces.for(botId); return json( await shell.run({ command: body.command, @@ -1310,8 +1312,9 @@ serve({ return json({ error: "The contents to write are required." }, 400); } try { + const { files } = await workspaces.for(botId); return json( - await workspace.write(String(body?.path ?? ""), body.contents, { + await files.write(String(body?.path ?? ""), body.contents, { append: body.append === true, }), ); diff --git a/agent-computer/src/workspaces.ts b/agent-computer/src/workspaces.ts new file mode 100644 index 0000000000..5fb756346b --- /dev/null +++ b/agent-computer/src/workspaces.ts @@ -0,0 +1,60 @@ +/** + * Whose files a request reaches. + * + * WHY. One computer process can serve many Bots: Helm's default `computers.mode: shared` and the + * docker-compose `agent-computer` service with no supervisor both do. Browser profiles were already + * kept apart by the id each request names (`bot-id.ts`), but the workspace was one folder, so a note + * one Bot saved for one person was a file every other Bot could list and read. With + * `COMPUTER_SEPARATE_WORKSPACES=on` each id gets its own folder under the workspace root, and the + * shell starts there with HOME pointing at it. + * + * WHY NOT ALWAYS. A computer the supervisor or the sandbox starts serves one Bot, and the desktop's + * computer serves one person. Their files already sit at the root of the volume, and moving the + * root under them would hide every file they had. So only the deployments that share one process + * turn this on. + * + * WHAT IT IS NOT. A command is still a process in a shared container and can read any path the + * container can. Separate folders keep each Bot's file tools to its own files and keep commands from + * landing in another Bot's folder by default. Isolation between people who run commands needs a + * computer each, which `computers.mode: sandbox` gives. + */ +import { mkdir } from "node:fs/promises"; +import { isPlainBotId, UnusableBotIdError } from "./bot-id"; +import { createShell, type Shell } from "./shell"; +import { createWorkspace } from "./workspace"; + +export type Workspace = ReturnType; + +/** The folder an id's files live in. The same allow-list as profiles, so an id is one segment. */ +export function workspaceDirectoryFor( + root: string, + botId: string, + separate: boolean, +): string { + if (!separate) return root; + if (!isPlainBotId(botId)) throw new UnusableBotIdError(botId); + return `${root}/computers/${botId}`; +} + +export function createWorkspaces( + root: string, + separate: boolean, + sourceEnv: NodeJS.ProcessEnv = process.env, +) { + const made = new Map(); + return { + /** The files and shell for this id, creating its folder the first time it is used. */ + async for(botId: string): Promise<{ files: Workspace; shell: Shell }> { + const directory = workspaceDirectoryFor(root, botId, separate); + const known = made.get(directory); + if (known) return known; + await mkdir(directory, { recursive: true }); + const created = { + files: createWorkspace(directory), + shell: createShell(directory, sourceEnv), + }; + made.set(directory, created); + return created; + }, + }; +} diff --git a/agent-computer/tests/workspaces.test.ts b/agent-computer/tests/workspaces.test.ts new file mode 100644 index 0000000000..354f55982b --- /dev/null +++ b/agent-computer/tests/workspaces.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { UnusableBotIdError } from "../src/bot-id"; +import { createWorkspaces, workspaceDirectoryFor } from "../src/workspaces"; + +let root: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "workspaces-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("a computer serving many Bots gives each its own files", async () => { + const workspaces = createWorkspaces(root, true); + const sales = await workspaces.for("sales"); + const support = await workspaces.for("support"); + await sales.files.write("notes.md", "sales only"); + + expect((await sales.files.read("notes.md")).text).toBe("sales only"); + await expect(support.files.read("notes.md")).rejects.toThrow(); + expect((await support.files.list()).entries).toEqual([]); +}); + +test("a command starts in its Bot's folder, with HOME there too", async () => { + const workspaces = createWorkspaces(root, true, { PATH: process.env.PATH }); + const { shell } = await workspaces.for("sales"); + const result = await shell.run({ command: 'pwd; echo "$HOME"' }); + // pwd reports the real path, which differs where the temporary directory is a symlink (macOS). + const folder = await realpath(join(root, "computers", "sales")); + const [cwd, home] = result.stdout.trim().split("\n"); + expect(cwd).toBe(folder); + expect(await realpath(home ?? "")).toBe(folder); +}); + +test("a computer serving one Bot keeps its files where they were", async () => { + await writeFile(join(root, "notes.md"), "from before"); + const workspaces = createWorkspaces(root, false); + const { files } = await workspaces.for("sales"); + expect((await files.read("notes.md")).text).toBe("from before"); +}); + +test("an id that is not a plain name never becomes a folder", () => { + expect(() => workspaceDirectoryFor(root, "../etc", true)).toThrow( + UnusableBotIdError, + ); + expect(workspaceDirectoryFor(root, "../etc", false)).toBe(root); +}); diff --git a/charts/openbot/templates/computer/statefulset.yaml b/charts/openbot/templates/computer/statefulset.yaml index c5c96cb3cd..fde0a21d84 100644 --- a/charts/openbot/templates/computer/statefulset.yaml +++ b/charts/openbot/templates/computer/statefulset.yaml @@ -149,6 +149,12 @@ spec: secretKeyRef: name: {{ default (include "openbot.secretName" .) .Values.computers.existingTokenSecret }} key: computer-token + {{- /* + Every Bot reaches this one pod, so each gets its own folder of files and its shell + starts there. See agent-computer/src/workspaces.ts. + */}} + - name: COMPUTER_SEPARATE_WORKSPACES + value: "on" {{- with .Values.computers.maxBrowsers }} - name: COMPUTER_MAX_BROWSERS value: {{ . | quote }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index 5a1dfcb733..042ba6d865 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -327,7 +327,8 @@ config: # How a Bot gets a computer. # # shared One browser for every Bot, run by this chart. Needs nothing installed in the cluster, -# and is what a first install should use. +# and is what a first install should use. Each Bot keeps its own logins and files, +# but a command one Bot runs can read another's files. # sandbox A computer each, as a `Sandbox` from kubernetes-sigs/agent-sandbox, suspended when # idle and resumed with its logins intact. Needs that controller in the cluster. # external Neither. `url` points at a computer somebody else runs. diff --git a/docker-compose.yml b/docker-compose.yml index a7c8614439..8677f7708a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -116,6 +116,9 @@ services: # minutes; closing a browser costs only a relaunch, because the profile is on the volume below. COMPUTER_MAX_BROWSERS: ${COMPUTER_MAX_BROWSERS:-} COMPUTER_BROWSER_IDLE_MS: ${COMPUTER_BROWSER_IDLE_MS:-} + # Every Bot without a computer of its own reaches this one, so each gets its own folder of + # files and its shell starts there. The supervisor's per-Bot computers do not need it. + COMPUTER_SEPARATE_WORKSPACES: "on" volumes: - agent-workspace:/workspace # Chromium's user-data directory is volume-backed so logins survive container restarts. diff --git a/docker/s6/s6-rc.d/computer/run b/docker/s6/s6-rc.d/computer/run index 0e71e8cb9b..972d55e788 100755 --- a/docker/s6/s6-rc.d/computer/run +++ b/docker/s6/s6-rc.d/computer/run @@ -26,6 +26,7 @@ for name in \ BUN_INSTALL PLAYWRIGHT_BROWSERS_PATH SPIFFE_ENDPOINT_SOCKET \ COMPUTER_TOKEN COMPUTER_BOT_ID COMPUTER_BROWSER_MODE COMPUTER_MAX_BROWSERS \ COMPUTER_BROWSER_IDLE_MS COMPUTER_SANDBOX COMPUTER_SHELL_ENV COMPUTER_STOPPED \ + COMPUTER_SEPARATE_WORKSPACES \ ACTION_TIMEOUT_MS NAVIGATION_TIMEOUT_MS AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS do eval "isset=\${$name+x}" diff --git a/docs/configuration.md b/docs/configuration.md index 2f0af4edf3..03dc400abc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -628,6 +628,7 @@ is still the record. | `AGENT_COMPUTER_URL` | Shared computer URL. If absent, computer routes are not mounted. | | `COMPUTER_TOKEN` | Secret every computer request must present. The computer refuses to start without it. | | `COMPUTER_MAX_BROWSERS` | How many Bots may hold a running browser at once. `8` by default; the least recently used is closed past it. | +| `COMPUTER_SEPARATE_WORKSPACES` | `on` gives each Bot its own folder of files on a computer many Bots share, with its shell starting there. On for Helm's `shared` computer and the docker-compose `agent-computer`; off elsewhere. Files saved before it was on stay at the root of the volume. Commands can still read other folders: use `computers.mode: sandbox` to keep people apart. | | `COMPUTER_BROWSER_IDLE_MS` | How long an untouched browser is kept. 30 minutes by default; `0` keeps them resident. | | `COMPUTER_BROWSER_BACKEND` | `managed` by default (full bundled Chromium); `local-chrome` opts into installed Chrome with dedicated profiles and a loopback API. | | `COMPUTER_BROWSER_MODE` | Managed defaults to `headed` on Linux, which is every hosted computer (a container, a pod, the desktop app's engine): full Chromium on the computer's own Xvfb display. A computer run directly on macOS or Windows defaults to `headless`, where headed would open windows on the desktop. `headless` is full Chromium's new headless mode. Local Chrome requires `headed`. See [Getting past bot checks](#getting-past-bot-checks). |