Skip to content
Closed
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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/<Bot id>` 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

Expand Down
35 changes: 19 additions & 16 deletions agent-computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -51,7 +51,6 @@ import {
sensitiveRefs,
} from "./secret-masking";
import { type BotSession, createSessions } from "./sessions";
import { createShell } from "./shell";
import {
fillSignIn,
fillSignInCode,
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
);

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1209,9 +1207,8 @@ serve<StreamData>({
// 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),
});
Expand All @@ -1228,7 +1225,8 @@ serve<StreamData>({
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.") },
Expand All @@ -1242,8 +1240,9 @@ serve<StreamData>({
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,
),
);
Expand Down Expand Up @@ -1283,6 +1282,9 @@ serve<StreamData>({
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,
Expand Down Expand Up @@ -1310,8 +1312,9 @@ serve<StreamData>({
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,
}),
);
Expand Down
60 changes: 60 additions & 0 deletions agent-computer/src/workspaces.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createWorkspace>;

/** 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<string, { files: Workspace; shell: Shell }>();
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;
},
};
}
50 changes: 50 additions & 0 deletions agent-computer/tests/workspaces.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
6 changes: 6 additions & 0 deletions charts/openbot/templates/computer/statefulset.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
3 changes: 2 additions & 1 deletion charts/openbot/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docker/s6/s6-rc.d/computer/run
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
Loading