Skip to content

Commit 3592f85

Browse files
authored
Land the UX work: chart sources, search, calm computer start, waiting rows, app cards, owner apps, per-Bot files (#847)
* Make every chart say where its figures came from A chart now carries a basis (read, given, own knowledge), the sources it was read from and an as-of date, and draws them under the plot. Figures the Bot recalled without looking up are marked as unchecked. The model has to give a basis; older conversations without one still render, with no footer. * Tell Bots to look figures up before charting them * Let an admin turn on and give an app to a Bot in one press Adds POST /api/plugins/for/:agentId/apps/:app/approve, admin only. It files the request the app's state needs, as the admin, and approves it straight away through the same code the inbox's Approve runs: the approve body moved into a shared carryOut, and the filing checks into a shared requestable, so the one press can't grant more or check less than the two presses would. The trail reads as the two presses: app_access.requested, then app_access.approved marked direct. No refs, so only read-only actions are granted, as the inbox does. A press that can't be carried out withdraws the request it filed; one of the admin's own that was already waiting is put back as it was. * Say on the app card's marker whether the app was blocked when the Bot asked A refusal is blocked by definition, but show_app_access also answers for an app that is ready, and its card can't tell later whether the app became ready since or was ready all along. The tool now writes blocked: true or false from the state it found, so a card drawn already ready can offer to carry on only where something actually changed. Older answers without the field offer nothing. * Give an admin the fix itself on an app card, not a request to themselves An admin looking at an app that is off, or that a Bot wasn't given, now gets one primary button: "Turn on and give it to <Bot>" or "Give it to <Bot>". It calls the new one-press approve route, so the app is turned on and its read-only actions granted with the same checks and trail as the inbox. Send and write actions are still chosen on the Bot's page. Where one press can't do it (no broker key, a set-up left unfinished, a team account nobody connected) the admin gets a link to the app's admin page in a new tab instead. The card reads the app again when its tab has the focus back, so it follows whatever was done over there. * Carry the task on by itself once a Bot's app card says the app is ready When the person finishes connecting in the other tab, or an admin turns the app on and gives it to the Bot, the card used to say "Ask again and the Bot can use it". Now the card that sees its app go from blocked to ready sends one message on the person's behalf, "Gmail is ready now. Please carry on.", through the same ask any transcript card uses. Why a message and not handoff-resume: that resumes a browser tool call that is still open. An app card is drawn from a call that already answered, and its turn has ended, so there is nothing open to answer. The next turn is what carries the task on. A visible message says why the Bot started again, and the Bot gets the whole conversation with it. Guards: - Only while the card is the latest thing: the person has said nothing since it, has nothing queued, and the Bot isn't working. The transcript passes this down with the tool call id. - Only once per card, across re-renders, remounts and tabs, by a mark in localStorage keyed by the tool call id, set before sending. - Only automatically where the card saw the change. A card drawn already ready (an admin approved while the conversation was closed) offers a Continue button. For show_app_access it does so only if the marker says the app was blocked when the Bot asked. * Record who may give an app to a Bot, and whether its owner or an admin gave it * Tell a Bot its computer is still starting, rather than failing, when a cold start outlasts the wait * Answer a screenshot of a computer that is not ready with its state, without waking it * Tell whether a Bot holds a search tool, from its refs or its tool names holdsWebSearch reads the grant refs a built-in Bot is built with, and researchRoleOfToolName reads the mcp__server__tool names a Bot running its own loop is offered. Either counts a search tool from any research provider, without needing its reader alongside. RESEARCH_APP_NAMES lists the catalogue's search apps by title, the name show_app_access resolves. There is no app called "search", so a Bot with none has to name one, and listing every provider keeps the choice with the administrator. A test holds the list to the catalogue's titles. * Send a search to the search tool, and never to the browser Asked to "search for" something in a demo, a Bot drove its browser to a search engine. The computer guidance told it to navigate whenever asked to look at, check or read anything, and never to claim it cannot browse; the one line saying a page is not a search read as a hedge beside them. computerGuidance now takes what the run holds and says one thing: - With a search tool, finding information goes to it, and the computer is for signing in, clicking through, filling in forms, or opening a page the person asked to see. - Without one, the browser never goes to Google, Bing or DuckDuckGo to look something up. The Bot answers from what it knows, says it has not checked it, and calls show_app_access once with one of Parallel Search, Exa Search or Firecrawl so the person can ask for it. "Never claim you cannot browse" is gone; opening a page the person names is still something the Bot does. Every path gets the right wording. Built-in Bots are worded in builtInAgentConfiguration from the tools they are built with, so a narrowed run follows what it was offered. agent-bot and agent-langgraph work it out from the tool names each run offers them, which are the only view they have of their grants. * Say in computer_navigate's description that it is not a web search The tool description repeated the old prompt's "look at, visit, open or check a website", which reads "check the latest on X" as a page to open. It now says to open a particular site and never a search engine, so the tool list agrees with the guidance beside it. * Format the not-ready screenshot route tests * Type the still-starting headless test's fetch double * Report a live screen being retried as reconnecting, not as a problem * Show a computer starting calmly, and a problem only when there is one * Say what a conversation is waiting on, on reads and on every busy event The busy signal said only that something was running, on the socket and nowhere else. Read what a conversation waits on from the rows that already say so: an unfinished hop it asked, folded with its relay, and an open routine run. The channel read and the roster carry the list, and every busy event carries the whole list at the moment it is sent. * Tell the asking conversation when a hop is queued and when the queue moves The delivery's busy signal fires inside the run, while the row still says the hop is in flight, so it cannot be what says the hop is over. The runner now settles after finish or release, with the relay already queued, and the desk announces a hop the moment it is offered. * Show a conversation as working while a routine fires into it Signalled around the firing and cleared after the run row has its outcome, since the open run row is what the conversation reads. * Show a computer call turned away by a cold start as not done yet, and tell the Bot to try again * Have a Bot say what it is about to do before its first computer action * Tell a Bot's owner when an app is theirs to give it, and let an admin decide which apps are * Let a Bot's owner give it their own apps, and take back only what they gave * Keep a Team Bot on each teammate's own account for apps its owner gave it * Restore the canvas and image prototypes the calm-start tests stub * Keep what a conversation waits on in the roster and in the open channel's read Busy events now carry the whole work list. It replaces the row's list in the roster and the channel read the conversation screen uses, and both are refetched with the roster after a reconnect or a resync. The sidebar stays lit while a hop or a routine is pending between turns. * Show who a conversation is waiting on, with a running clock, where the answer will land "Asking Ana… 0:12" for a Bot its Bot asked, "Ana is running a routine…" for a routine, at the end of the transcript, from the server's list. Hidden while this tab's own turn runs. ProgressRow is the reusable line for later waits. * Let an admin choose on an app's page whether Bot owners can give it * Ask a Bot's owner on the app card whether to let it use their app, in one press * Give a new Bot your connected apps, and switch them on its page * Keep a chart's sources line readable when the model's arguments are odd Separate the unchecked note from the sources list, name a repeated source once, and skip sources or dates that are not the shape the schema asks for instead of breaking the card. * Test the sources line with an unchecked note and malformed arguments * Count a routine firing as the conversation moving on past an app card The card that carries a task on once its app is ready only did so while it was the latest thing, but a routine firing after it did not count, so a later connect sent "please carry on" onto the routine's work. Any user turn now counts. The rule is pulled out as latestFrom and tested. * Write the withdrawal into the trail when an admin's one press is undone A one press that could not be carried out withdrew the request it had just filed without a trail row, leaving an app_access.requested nothing answered. It now writes app_access.cancelled, marked direct, as a withdrawal does. * Word the no-search guidance as a condition, not a claim that the Bot holds no search tool Search tools the catalogue roles do not recognise (a Composio toolkit, a server added by URL, Exa's advanced search granted alone) would otherwise be contradicted by the prompt. * Name computerGuidance, not the removed COMPUTER_GUIDANCE, in a copilot test comment * Tell an admin's one press to try again, not to approve a request The one press withdraws the request it filed when it cannot be carried out, yet its refusals ended "then approve this request again". They now end "then try again" there; the inbox's wording is unchanged. The browser's fallback sentence follows the copy rules too. * Refuse an owner a grant on an action their app does not advertise * Record nothing when an owner's grant lands on one that already stands * Say where an owner gives a write action, in the give route's note * Format the owner grant refusal * Tell a Bot its computer is still starting when the supervisor's own health wait runs out The supervisor answers a container that is up but not yet healthy with a 503 after 60s, which is well inside the server's 120s ensure timeout, so the docker and desktop paths never reached the gentle still-starting answer. The supervisor now marks that 503 as starting and the provider maps it to ComputerStillStartingError. Also awaits a rejects assertion that was never checked. * Report a docker computer whose browser is still booting as starting, not ready Docker says running as soon as the process exists, so status called a booting container ready, the screenshot route went on to /ensure, and the poll was held for up to a minute on the health wait. The supervisor now passes the healthcheck verdict through its list, and running with a starting healthcheck reads as starting. An older supervisor sends no verdict and reads as before. * Answer a cold sandbox start within 45s, under a load balancer's idle timeout locate waited up to 120s inside the person's request. Behind an ALB's default 60s idle timeout the browser got a bare gateway error, and the action could still land once the pod came up after the model had been told it failed. locate now gives up at 45s with the still-starting answer while the pod keeps coming up; update's background restart keeps the full resume budget. * Keep the computer panel honest when a not-ready answer goes stale A screenshot failure after an 'absent' answer kept the old state, so the side panel went on calling a failing computer off, and an off computer is never timed out, so the failure was never shown. A failure now clears the polled state and the status read resumes. A not-ready answer also drops the last frame, which showed a sleeping computer as live and kept its stream reconnecting. * Format the stale-state computer panel test * Read a conversation's open hops through an index instead of scanning every hop The work list is read on every roster page and every busy event. It filtered work_items on payload->>'threadId' with no index, and joined each relay to its hop by building 'relay:' || key for every row, so both sides scanned a day of finished hops. A partial index on unfinished rows by thread, and a join that cuts the hop key out of the relay key, turn it into two index lookups. * Keep what a conversation waits on to the person whose thread it is Every member of a shared channel has a thread of their own in it. The work list was broadcast to all members on every busy event, read from whichever thread the signal came from, so another member learned which Bot somebody asked and lost their own list to it; the roster also listed every member's routine runs. Hops are now read from the reader's thread and routines from the reader's own; the list goes only to the thread's owner, and the other members hear the busy flag alone, as before. Each list is stamped with the database clock just before it is read, so a browser can tell an older list that arrived late from a newer one. * Ignore a work list read before the one a conversation already holds Busy events are read on whichever replica sent them and can arrive out of order. The roster and the open conversation now keep the list with the latest stamp, so a late event no longer puts back a hop that has already settled. * Keep the assertion-shape guard green across the work-list tests The branch moved four allow-listed offences down their files, and its own tests added four new ones: an absent busy flag and work list asserted through a ?. chain, and an expected undefined key in a toEqual. The moved entries are re-keyed; the new ones assert the missing key directly instead. * Check the first-action line in both computer guidance variants * Follow the moved headless-components lines in the assertion-shape allow-list * Follow the moved computer-supervisor and computer-sandbox lines in the assertion-shape allow-list * Hold the admin's approve call to an absent body, so the assertion can fail * Hold the owner's give call to an absent body, and follow the moved siblingReads line in the allow-list * Add a changelog line for chart sources * Add a changelog line for search routing * Add a changelog line for the app card carrying on * Add a changelog line for the handoff progress row * Add a changelog line for the calm computer start * Add a changelog line for owner app grants * Mark a Shared-account audience an owner's grant set, so it can be withdrawn with it * Let an owner's grant set a Shared app's audience, and read it only while owners decide * Set the audience when an owner gives a Shared app, and withdraw it on take-back or a rule change * Test an owner's Shared-app grant: usable at once, scoped to the Bot, withdrawn with the grant or the rule * Say on a Shared app's page that owners who give it let their Bots use the team account * Document owners giving Shared apps, and add the changelog line * Say where a chart's figures came from in Slack and Teams too * Give each Bot its own files on a computer many Bots share * End a hop at once when the other Bot's address is one this deployment will not dial * Recognise a refused address by its message, since the AG-UI client keeps only that * Show the asked Bot's avatar on the waiting row, and drop the clock * Read container health in its own file, so its test does not need dockerode
1 parent 2b65797 commit 3592f85

141 files changed

Lines changed: 39124 additions & 572 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11-
**Before upgrading.** Eight things change for an existing deployment:
11+
**Before upgrading.** Nine things change for an existing deployment:
1212
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
1313
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
1414
behaviour.
@@ -39,6 +39,83 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
3939
research run replaces them.
4040
- Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to
4141
empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them.
42+
- On Helm's `shared` computer and the docker-compose `agent-computer`, each Bot's files now live in
43+
`computers/<Bot id>` on the workspace volume (below). Files saved before the upgrade stay at the
44+
root of the volume, where no Bot sees them; move them into a Bot's folder to give them back.
45+
46+
### Each Bot on a shared computer has its own files
47+
48+
A computer that serves many Bots kept one folder of files for all of them, so a note one Bot saved
49+
was a file every other Bot could list and read. Each Bot now gets its own folder, and its commands
50+
start there. Logins were already kept apart. This is on for Helm's `shared` computer and the
51+
docker-compose `agent-computer`, and `COMPUTER_SEPARATE_WORKSPACES=on` turns it on for the
52+
all-in-one image. A command can still read another Bot's folder, so a deployment that needs people
53+
kept apart should give each Bot a computer of its own (`computers.mode: sandbox`).
54+
55+
### Every chart says where its figures came from
56+
57+
A chart a Bot draws now ends with a line naming what its figures were read from and when they are
58+
from, such as "Sources: World Bank World Development Indicators · As of 2025". Figures a Bot charted
59+
from its own knowledge, without looking them up, are marked "From the Bot's own knowledge. Not
60+
checked against a source." Bots are told to look up figures that change over time before charting
61+
them. In Slack and Teams the same line follows the chart in the reply. Charts already in a
62+
conversation are drawn as before, with no such line.
63+
64+
### A Bot searches with its search tool, never its browser
65+
66+
Asked to search for something, a Bot could drive its browser to Google or another search engine
67+
instead of using the search tool it held. With a search tool it now searches with that, and keeps
68+
the browser for signing in, clicking through a site, filling in a form or opening a page the person
69+
named. Without one it answers from what it knows, says it has not checked, and shows a card for a
70+
search app (Parallel Search, Exa Search or Firecrawl) that the person can ask an administrator for.
71+
72+
### A computer that is starting says so, instead of showing a problem
73+
74+
Opening the Computer panel while a Bot's computer was asleep or starting showed "Can't show the
75+
screen right now", and an action during a cold start was marked "didn't work". The panel now says
76+
the computer is starting, or is off until the Bot next uses it, and shows a problem only when it
77+
can't be reached or has gone two and a half minutes without a picture; a live screen being
78+
reconnected shows a small "Reconnecting…" badge. An action turned away by a cold start reads "not
79+
done yet" and the Bot is told to try again. Looking at the panel no longer wakes a sleeping computer,
80+
a Bot says in one sentence what it is about to do before its first computer action, and a hosted
81+
computer's cold start answers within 45 seconds, under a load balancer's usual 60-second idle
82+
timeout. A Docker computer still booting its browser reads as starting once the supervisor is
83+
upgraded too; an older supervisor works as before.
84+
85+
### A conversation shows who it is waiting on
86+
87+
When a Bot asks another Bot, the end of the conversation now shows that Bot's avatar and "Asking
88+
Risk Analyst…" until the answer comes back; a routine firing into it reads "Market Scout is running a
89+
routine…". A Bot whose address this deployment will not dial is reported at once, not after five
90+
minutes of retries. The sidebar keeps the conversation lit while it waits, and both survive a
91+
reload. In a shared conversation only the person whose Bot asked sees whom it asked; the others see
92+
only that it is busy. Migration `0063_work_items_open_thread` adds one partial index to `work_items`.
93+
94+
### A Bot's owner can give it their own apps
95+
96+
The owner of a Bot can now let it use an app they connect for themselves, without an administrator:
97+
from the app card ("Let Research Bot use your Gmail?"), under Your apps on the Bot's page, or on the
98+
New Bot sheet, which offers their connected apps. Calls under such a grant always use the asking
99+
person's own account, even on a published Team Bot, and an owner can take back only what they gave.
100+
By default this covers apps each person connects for themselves and Routines; an administrator
101+
decides per app on its page, and can switch it off for people with the "Give apps to their own Bots"
102+
capability. Migration `0063_owner_app_grants` adds two nullable columns, `mcp_servers.owner_grants`
103+
and `plugin_grants.granted_as`.
104+
105+
Where an administrator lets owners give a Shared app, an owner's grant works straight away: it lets
106+
the people who can use the Bot (just the owner, or a Team Bot's audience) use the team account, with
107+
no separate approval. Writes still ask first. Taking the app back, or the administrator switching the
108+
app back to admins only, withdraws that. Migration `0064_owner_shared_audience` adds the nullable
109+
`shared_use_approvals.approved_as`.
110+
111+
### An app card carries the task on once the app is ready
112+
113+
When a Bot stopped because it needed an app, and the person then connected it in another tab or an
114+
administrator turned it on for the Bot, the card said "Ask again". It now sends "Gmail is ready now.
115+
Please carry on." for the person, once, and the Bot picks the task back up; a card opened after the
116+
change offers Continue instead. An administrator looking at the card gets the fix itself, "Turn on
117+
and give it to Sales" or "Give it to Sales", which grants the app's read-only actions with the same
118+
checks and trail as approving a request, or a link to the app's page where one press can't do it.
42119

43120
### Screens share one spacing, row and empty-state design
44121

‎agent-bot/src/history.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88
import type { RunAgentInput } from "@ag-ui/core";
99
import type OpenAI from "openai";
10-
import { COMPUTER_GUIDANCE, NO_ANSWER_CAME } from "../../shared/bot-prompt";
10+
import { computerGuidance, NO_ANSWER_CAME } from "../../shared/bot-prompt";
11+
import { researchRoleOfToolName } from "../../shared/research";
1112
import { userContent } from "../../shared/user-content";
1213

1314
export { NO_ANSWER_CAME };
@@ -17,7 +18,19 @@ export function toProviderMessages(
1718
input: RunAgentInput,
1819
): OpenAI.Chat.ChatCompletionMessageParam[] {
1920
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
20-
{ role: "system", content: COMPUTER_GUIDANCE },
21+
/*
22+
* Worded for the tools this run offers, which is all this Bot can see of its grants: the names
23+
* say whether one of them is a search tool, and that decides whether the browser is told to
24+
* leave searching to it or never to search at all.
25+
*/
26+
{
27+
role: "system",
28+
content: computerGuidance({
29+
search: (input.tools ?? []).some(
30+
(tool) => researchRoleOfToolName(tool.name) === "search",
31+
),
32+
}),
33+
},
2134
// AG-UI application context is separate from history. The A2UI catalog and tool instructions
2235
// arrive here; omitting them leaves the model guessing component names and action schemas.
2336
...(input.context ?? []).map(({ description, value }) => ({

‎agent-bot/tests/history.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,44 @@
11
import { describe, expect, test } from "bun:test";
22
import type { RunAgentInput } from "@ag-ui/core";
3+
import { computerGuidance } from "../../shared/bot-prompt";
34
import { NO_ANSWER_CAME, toProviderMessages } from "../src/history";
45

6+
/**
7+
* Whether the browser is told to leave searching to a search tool is decided by the tools this run
8+
* offers, since the names are all this Bot sees of its grants. "search for X" made a Bot holding
9+
* a search tool drive its browser to a search engine instead.
10+
*/
11+
describe("the computer guidance this Bot is given", () => {
12+
const offered = (...names: string[]) =>
13+
({
14+
messages: [{ id: "u1", role: "user", content: "Search for X." }],
15+
tools: names.map((name) => ({ name, description: "", parameters: {} })),
16+
}) as unknown as RunAgentInput;
17+
18+
test("leaves searching to a search tool the run offers", () => {
19+
const [system] = toProviderMessages(
20+
offered("show_app_access", "mcp__exa__web_search_exa"),
21+
);
22+
expect(system).toEqual({
23+
role: "system",
24+
content: computerGuidance({ search: true }),
25+
});
26+
});
27+
28+
test("never searches in the browser when no search tool is offered", () => {
29+
for (const run of [
30+
offered("show_app_access", "mcp__google-drive__search_files"),
31+
// A reader alone does not find sources.
32+
offered("mcp__exa__web_fetch_exa"),
33+
{ messages: [] } as unknown as RunAgentInput,
34+
])
35+
expect(toProviderMessages(run)[0]).toEqual({
36+
role: "system",
37+
content: computerGuidance({ search: false }),
38+
});
39+
});
40+
});
41+
542
/**
643
* The Bot that ships in the box, and the conversation a declined handover used to end.
744
*

‎agent-computer/src/index.ts‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ import {
2727
TAKE_CONTROL_FIRST,
2828
} from "./control";
2929
import { describeHumanGesture } from "./demonstration";
30-
import { enterText, FieldValueError } from "./enter-text";
3130
import { handleEgressPolicyRequest, startEgressFilter } from "./egress";
31+
import { enterText, FieldValueError } from "./enter-text";
3232
import { identity } from "./identity";
3333
import {
3434
assertPageAccess,
@@ -51,7 +51,6 @@ import {
5151
sensitiveRefs,
5252
} from "./secret-masking";
5353
import { type BotSession, createSessions } from "./sessions";
54-
import { createShell } from "./shell";
5554
import {
5655
fillSignIn,
5756
fillSignInCode,
@@ -61,12 +60,12 @@ import {
6160
import { startVirtualDisplay } from "./virtual-display";
6261
import { applyWebBotAuthPush } from "./web-bot-auth";
6362
import {
64-
createWorkspace,
6563
WorkspaceFileError,
6664
WorkspaceFileNotFoundError,
6765
WorkspaceFileTooLargeError,
6866
WorkspacePathError,
6967
} from "./workspace";
68+
import { createWorkspaces } from "./workspaces";
7069

7170
/**
7271
* The Bot's computer: one long-lived browser, reachable over HTTP.
@@ -197,9 +196,9 @@ function botIdOf(request: Request, fallback?: string | null): string {
197196
* The Bot's durable files.
198197
*
199198
* Rooted at WORKSPACE_DIR, which the image creates and docker-compose mounts as a volume, so what a
200-
* Bot saves outlives the container. Built once at boot: the root is fixed, and resolving it per request
201-
* would only add a syscall to every call. Everything about why confinement is harder than it looks
202-
* lives in workspace.ts.
199+
* Bot saves outlives the container. Each folder is built the first time its Bot uses it and kept, so
200+
* a request adds no syscall after that. Which folder a Bot gets lives in workspaces.ts; everything
201+
* about why confinement is harder than it looks lives in workspace.ts.
203202
*/
204203
/**
205204
* The two directories this process cannot do its job without, checked before it says it is ready.
@@ -245,8 +244,10 @@ await assertWritable(
245244
process.env.PROFILES_DIR?.trim() || "/profiles",
246245
);
247246

248-
const workspace = createWorkspace(
247+
// One folder per Bot where one process serves many; see workspaces.ts.
248+
const workspaces = createWorkspaces(
249249
process.env.WORKSPACE_DIR?.trim() || "/workspace",
250+
process.env.COMPUTER_SEPARATE_WORKSPACES?.trim().toLowerCase() === "on",
250251
);
251252

252253
/**
@@ -286,9 +287,6 @@ const profiles = createProfiles(
286287
await sessions.get(botId)?.viewer.releaseAll(COMPUTER_STOPPED);
287288
},
288289
);
289-
// Rooted in the same workspace the file tools use, so a command and a written file see one
290-
// directory rather than two.
291-
const shell = createShell(process.env.WORKSPACE_DIR?.trim() || "/workspace");
292290

293291
/**
294292
* 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<StreamData>({
12091207
// MAY touch a path: the gateway in front of this process does that.
12101208
if (url.pathname === "/files/download" && request.method === "GET") {
12111209
try {
1212-
const file = await workspace.download(
1213-
url.searchParams.get("path") ?? "",
1214-
);
1210+
const { files } = await workspaces.for(botId);
1211+
const file = await files.download(url.searchParams.get("path") ?? "");
12151212
return new Response(file.body, {
12161213
headers: downloadHeaders(file.name, file.bytes),
12171214
});
@@ -1228,7 +1225,8 @@ serve<StreamData>({
12281225
path?: unknown;
12291226
} | null;
12301227
try {
1231-
return json(await workspace.read(String(body?.path ?? "")));
1228+
const { files } = await workspaces.for(botId);
1229+
return json(await files.read(String(body?.path ?? "")));
12321230
} catch (error) {
12331231
return json(
12341232
{ error: describe(error, "The file could not be read.") },
@@ -1242,8 +1240,9 @@ serve<StreamData>({
12421240
path?: unknown;
12431241
} | null;
12441242
try {
1243+
const { files } = await workspaces.for(botId);
12451244
return json(
1246-
await workspace.list(
1245+
await files.list(
12471246
typeof body?.path === "string" ? body.path : undefined,
12481247
),
12491248
);
@@ -1283,6 +1282,9 @@ serve<StreamData>({
12831282
return json({ error: timeout.error }, 400);
12841283
}
12851284
try {
1285+
// Rooted in the same folder the file tools use, so a command and a written file see one
1286+
// directory rather than two.
1287+
const { shell } = await workspaces.for(botId);
12861288
return json(
12871289
await shell.run({
12881290
command: body.command,
@@ -1310,8 +1312,9 @@ serve<StreamData>({
13101312
return json({ error: "The contents to write are required." }, 400);
13111313
}
13121314
try {
1315+
const { files } = await workspaces.for(botId);
13131316
return json(
1314-
await workspace.write(String(body?.path ?? ""), body.contents, {
1317+
await files.write(String(body?.path ?? ""), body.contents, {
13151318
append: body.append === true,
13161319
}),
13171320
);

‎agent-computer/src/workspaces.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Whose files a request reaches.
3+
*
4+
* WHY. One computer process can serve many Bots: Helm's default `computers.mode: shared` and the
5+
* docker-compose `agent-computer` service with no supervisor both do. Browser profiles were already
6+
* kept apart by the id each request names (`bot-id.ts`), but the workspace was one folder, so a note
7+
* one Bot saved for one person was a file every other Bot could list and read. With
8+
* `COMPUTER_SEPARATE_WORKSPACES=on` each id gets its own folder under the workspace root, and the
9+
* shell starts there with HOME pointing at it.
10+
*
11+
* WHY NOT ALWAYS. A computer the supervisor or the sandbox starts serves one Bot, and the desktop's
12+
* computer serves one person. Their files already sit at the root of the volume, and moving the
13+
* root under them would hide every file they had. So only the deployments that share one process
14+
* turn this on.
15+
*
16+
* WHAT IT IS NOT. A command is still a process in a shared container and can read any path the
17+
* container can. Separate folders keep each Bot's file tools to its own files and keep commands from
18+
* landing in another Bot's folder by default. Isolation between people who run commands needs a
19+
* computer each, which `computers.mode: sandbox` gives.
20+
*/
21+
import { mkdir } from "node:fs/promises";
22+
import { isPlainBotId, UnusableBotIdError } from "./bot-id";
23+
import { createShell, type Shell } from "./shell";
24+
import { createWorkspace } from "./workspace";
25+
26+
export type Workspace = ReturnType<typeof createWorkspace>;
27+
28+
/** The folder an id's files live in. The same allow-list as profiles, so an id is one segment. */
29+
export function workspaceDirectoryFor(
30+
root: string,
31+
botId: string,
32+
separate: boolean,
33+
): string {
34+
if (!separate) return root;
35+
if (!isPlainBotId(botId)) throw new UnusableBotIdError(botId);
36+
return `${root}/computers/${botId}`;
37+
}
38+
39+
export function createWorkspaces(
40+
root: string,
41+
separate: boolean,
42+
sourceEnv: NodeJS.ProcessEnv = process.env,
43+
) {
44+
const made = new Map<string, { files: Workspace; shell: Shell }>();
45+
return {
46+
/** The files and shell for this id, creating its folder the first time it is used. */
47+
async for(botId: string): Promise<{ files: Workspace; shell: Shell }> {
48+
const directory = workspaceDirectoryFor(root, botId, separate);
49+
const known = made.get(directory);
50+
if (known) return known;
51+
await mkdir(directory, { recursive: true });
52+
const created = {
53+
files: createWorkspace(directory),
54+
shell: createShell(directory, sourceEnv),
55+
};
56+
made.set(directory, created);
57+
return created;
58+
},
59+
};
60+
}

0 commit comments

Comments
 (0)