Open computers from the chat, let bots ask for a take-over, show task progress

- Serve each computer to its owner under /computers/<id>/ (HTTP and
  websockets proxied to the AIO sandbox) and show it in a shared
  ComputerView with browser, terminal and code modes, opened from the
  computers modal or a take-over button in the thread.
- ask_user_to_take_over lets a bot hand a computer to the user for a
  login or captcha; the message gets a take-over button and needs-you,
  and the viewer hands it back with a chat message.
- While a task runs the thread shows "<bot> is working..." with the
  task's latest step; steps read as plain words, not tool names.
- A coordinator with no workers no longer claims to delegate, and bots
  describe started tasks in the first person.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 05:35:44 +01:00
co-authored by Claude Opus 5.5
parent b1023ba8ff
commit 56daf0c462
44 changed files with 1289 additions and 75 deletions
+29 -3
View File
@@ -5,11 +5,11 @@ import describeJobStep from "./describe-job-step";
const candidate = { model: { slug: "deepseek/deepseek-chat:free" } } as HarnessCandidate;
describe("describeJobStep", () => {
test("names each tool once, never its arguments", () => {
test("says what each tool does once, never its arguments", () => {
expect(
describeJobStep({
index: 2,
text: "Let me look.",
text: null,
candidate,
tool_calls: [
{ id: "a", name: "sandbox_execute_bash", arguments: '{"command":"cat secret"}' },
@@ -19,7 +19,7 @@ describe("describeJobStep", () => {
}),
).toEqual({
kind: "tool",
text: "Using sandbox_execute_bash, update_memory",
text: "Running a command, Updating memory",
metadata: {
step: 2,
model: "deepseek/deepseek-chat:free",
@@ -28,6 +28,32 @@ describe("describeJobStep", () => {
});
});
test("prefers what the model said it's doing", () => {
expect(
describeJobStep({
index: 0,
text: "Opening the Gmail tab.\nThen reading it.",
candidate,
tool_calls: [{ id: "a", name: "browser_navigate", arguments: "{}" }],
})?.text,
).toBe("Opening the Gmail tab.");
});
test("labels tool families and falls back to the name", () => {
expect(
describeJobStep({
index: 0,
text: "",
candidate,
tool_calls: [
{ id: "a", name: "browser_navigate", arguments: "{}" },
{ id: "b", name: "browser_click", arguments: "{}" },
{ id: "c", name: "some_mcp_tool", arguments: "{}" },
],
})?.text,
).toBe("Using the browser, Using some_mcp_tool");
});
test("keeps the first line of a text-only turn, cut to one line", () => {
const step = describeJobStep({
index: 0,
+27 -11
View File
@@ -1,15 +1,21 @@
import type { BotJobUpdateKind } from "@/src/dict/bot-queue-dict";
import {
JobStepToolLabels,
JobStepToolPrefixLabels,
type BotJobUpdateKind,
} from "@/src/dict/bot-queue-dict";
import type { TextLoopStep } from "@/src/harness/text/run-text-loop";
/**
* Longest step line kept: the panel shows one line per step.
* Longest step line kept: the chat shows one line per step.
*/
const MaxStepChars = 200;
/**
* One model turn as a `bots_queue_updates` row: the tools it called, or
* the first line of what it wrote. Null for an empty turn. Never the tool
* arguments, which can hold anything the bot typed.
* One model turn as a `bots_queue_updates` row, read by the user while the
* bot works: the first line the model wrote (models often say what
* they're about to do), else what its tools do in plain words. Null for an
* empty turn. Never the tool arguments, which can hold anything the bot
* typed.
*/
export default function describeJobStep({
text,
@@ -22,24 +28,34 @@ export default function describeJobStep({
metadata: Record<string, unknown>;
} | null {
const metadata = { step: index, model: candidate.model.slug };
const line = firstLine({ text });
if (tool_calls.length) {
const tools = [...new Set(tool_calls.map((call) => call.name))];
const labels = [...new Set(tools.map((name) => toolLabel({ name })))];
return {
kind: "tool",
text: `Using ${tools.join(", ")}`,
text: line || labels.join(", "),
metadata: { ...metadata, tools },
};
}
if (!line) return null;
return { kind: "note", text: line, metadata };
}
function firstLine({ text }: { text: string | null }) {
const line = text?.trim().split("\n").find((l) => l.trim())?.trim();
if (!line) return null;
return {
kind: "note",
text: line.length > MaxStepChars ? `${line.slice(0, MaxStepChars - 1)}…` : line,
metadata,
};
return line.length > MaxStepChars ? `${line.slice(0, MaxStepChars - 1)}…` : line;
}
function toolLabel({ name }: { name: string }) {
const prefix = Object.keys(JobStepToolPrefixLabels).find((p) => name.startsWith(p));
return JobStepToolLabels[name] || (prefix && JobStepToolPrefixLabels[prefix]) || `Using ${name}`;
}
+25 -9
View File
@@ -1,6 +1,6 @@
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import { ChatReplyNote, CoordinatorReplyNote } from "@/src/data/harness-prompts";
import { ChatReplyNote, CoordinatorReplyNote, CoordinatorSoloNote } from "@/src/data/harness-prompts";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
@@ -9,6 +9,7 @@ import grabBotChatTools from "@/src/harness/tools/bot/grab-bot-chat-tools";
import botMessagesToHarness from "./bot-messages-to-harness";
import composeComputersNote from "./compose-computers-note";
import loadAttachmentParts from "./load-attachment-parts";
import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
@@ -40,12 +41,32 @@ export default async function runBotReply({
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const take_overs: string[] = [];
const tools = await grabBotChatTools({
bot,
user_id: bot.user_id,
computers,
message_id: job.message_id || undefined,
onTakeOver: ({ ref }) => take_overs.push(ref),
});
/**
* delegate_task is only there while the coordinator has workers.
*/
const crew_note =
bot.kind != "coordinator"
? ""
: tools.some((tool) => tool.name == "delegate_task")
? CoordinatorReplyNote
: CoordinatorSoloNote;
const result = await runTextLoop({
system: [
prompt,
ChatReplyNote,
composeComputersNote({ computers, is_coordinator: bot.kind == "coordinator" }),
bot.kind == "coordinator" ? CoordinatorReplyNote : "",
crew_note,
`Now: ${new Date().toUTCString()}.`,
]
.filter(Boolean)
@@ -54,17 +75,12 @@ export default async function runBotReply({
messages: history,
parts: await loadAttachmentParts({ messages: history }),
}),
tools: await grabBotChatTools({
bot,
user_id: bot.user_id,
computers,
message_id: job.message_id || undefined,
}),
tools,
max_steps: AppData.BotReplyMaxToolSteps,
bot_id: bot.id,
signal,
onStep: report,
});
return { result: result.text };
return withTakeOverRefs({ text: result.text, refs: take_overs });
}
+28 -3
View File
@@ -4,9 +4,11 @@ import grabBotPrompt from "@/src/functions/backend/db/bots/grab-bot-prompt";
import runTextLoop from "@/src/harness/text/run-text-loop";
import openComputerTools from "@/src/harness/tools/computer/open-computer-tools";
import grabBotTools from "@/src/harness/tools/bot/grab-bot-tools";
import askToTakeOverTool from "@/src/harness/tools/computer/ask-to-take-over-tool";
import searchComputersTool from "@/src/harness/tools/computer/search-computers-tool";
import computerRef from "@/src/utils/computer-ref";
import composeJobBrief from "./compose-job-brief";
import withTakeOverRefs from "./with-take-over-refs";
import type { BotJobContext, BotJobOutcome } from "./types";
type Params = BotJobContext & {
@@ -17,6 +19,8 @@ type Params = BotJobContext & {
* Works through a task: the bot follows its brief with its full prompt,
* its own tools, search_computers across all its computers and, when the
* job has a computer, that computer's tools (over MCP), until it answers. That answer is the report for the user.
* When only the user can go on (a login, a captcha), the bot asks them to
* take over (`ask_user_to_take_over`) and the report needs them.
* Every model turn is reported as a step (`report`).
*/
export default async function runBotTask({
@@ -33,13 +37,22 @@ export default async function runBotTask({
try {
const prompt = await grabBotPrompt({ bot_id: bot.id, user_id: bot.user_id });
const computers = await grabBotComputers({ bot_id: bot.id, user_id: bot.user_id });
const take_overs: string[] = [];
/**
* A take-over defaults to the computer the task runs on.
*/
const take_over_computers = [
...computers.filter((c) => c.id == job.computer_id),
...computers.filter((c) => c.id != job.computer_id),
];
const result = await runTextLoop({
system: [
prompt,
TaskRunNote,
computer
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything.`
? `You're working on the computer "${computer.name}" (a Linux sandbox with a shell, files, code runners and a browser). Use its tools; don't ask the user to run anything. When only the user can go on (a login, a captcha, a code sent to them), ask them to take over with ask_user_to_take_over.`
: `You have no computer for this task.`,
computers.length > 1
? `search_computers runs a read-only command on every computer you have. When you name one in the report, write its ref, e.g. ${computerRef({ id: computers[0]!.id || 0 })}: the user can click it to switch you to that computer.`
@@ -64,7 +77,16 @@ export default async function runBotTask({
],
tools: [
...grabBotTools({ user_id: bot.user_id, bot_id: bot.id }),
...(computers.length ? [searchComputersTool({ bot_id: bot.id, computers })] : []),
...(computers.length
? [
searchComputersTool({ bot_id: bot.id, computers }),
askToTakeOverTool({
computers: take_over_computers,
in_task: true,
onAsk: ({ ref }) => take_overs.push(ref),
}),
]
: []),
...(computer?.tools || []),
],
bot_id: bot.id,
@@ -73,7 +95,10 @@ export default async function runBotTask({
report({ ...step, text: step.text && computer ? computer.redact(step.text) : step.text }),
});
return { result: computer ? computer.redact(result.text) : result.text };
return withTakeOverRefs({
text: computer ? computer.redact(result.text) : result.text,
refs: take_overs,
});
} finally {
await computer?.close();
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import takeOverRef from "@/src/utils/take-over-ref";
import withTakeOverRefs from "./with-take-over-refs";
const ref = takeOverRef({ id: 1, mode: "browser" });
describe("withTakeOverRefs", () => {
test("adds a take-over the bot didn't write, once", () => {
expect(withTakeOverRefs({ text: "Please log in to Gmail.", refs: [ref, ref] })).toEqual({
result: `Please log in to Gmail.\n\n${ref}`,
needs_you: true,
});
});
test("keeps one the bot wrote itself", () => {
const text = `Log in here: ${ref}`;
expect(withTakeOverRefs({ text, refs: [ref] })).toEqual({ result: text, needs_you: true });
});
test("leaves a message without a take-over alone", () => {
expect(withTakeOverRefs({ text: "Done.", refs: [] })).toEqual({ result: "Done.", needs_you: false });
});
});
+21
View File
@@ -0,0 +1,21 @@
type Params = {
text: string;
/**
* The take-over refs the bot asked for in this run (`askToTakeOverTool`).
*/
refs: string[];
};
/**
* A job's message with every take-over the bot asked for in it: refs the
* bot didn't write itself are added at the end, so the user always gets
* the button. A message asking for a take-over needs the user.
*/
export default function withTakeOverRefs({ text, refs }: Params) {
const missing = [...new Set(refs)].filter((ref) => !text.includes(ref));
return {
result: [text.trim(), ...missing].filter(Boolean).join("\n\n"),
needs_you: refs.length > 0,
};
}
@@ -0,0 +1,29 @@
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import computerViewUrl from "@/src/utils/computer-view-url";
type Props = {
computer_id: number;
name: string;
mode: ComputerViewMode;
/**
* The mode on show; the others stay loaded but hidden.
*/
active: boolean;
};
/**
* One of a computer's apps in a frame. A raw iframe: twui has no
* equivalent.
*/
export default function ComputerViewFrame({ computer_id, name, mode, active }: Props) {
const title = ComputerViewModes.find((m) => m.value == mode)?.title || mode;
return (
<iframe
src={computerViewUrl({ computer_id, mode })}
title={`${name}: ${title}`}
allow="clipboard-read; clipboard-write; fullscreen"
className={`absolute inset-0 w-full h-full border-0 bg-sunken ${active ? "" : "hidden"}`}
/>
);
}
@@ -0,0 +1,31 @@
import Button from "@moduletrace/twui/layout/Button";
type Props = {
title: string;
active: boolean;
onPick: () => void;
};
/**
* One mode in a computer view's toolbar (Browser, Terminal, Code).
*/
export default function ComputerViewModeButton({ title, active, onPick }: Props) {
return (
<Button
type="button"
title={title}
role="tab"
aria-selected={active}
variant="ghost"
color="text"
onClick={onPick}
className={`px-3! py-1.5! text-[13px] leading-[18px] rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink ${
active
? "bg-sunken dark:bg-sunken text-ink dark:text-ink font-bold"
: "text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken"
}`}
>
{title}
</Button>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import Link from "@moduletrace/twui/layout/Link";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import { ExternalLink } from "lucide-react";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import computerViewUrl from "@/src/utils/computer-view-url";
import ComputerViewFrame from "./computer-view-frame";
import ComputerViewModeButton from "./computer-view-mode-button";
type Props = {
computer_id: number;
/**
* The computer's name, for the frame titles.
*/
name: string;
/**
* The mode it opens in.
*/
default_mode?: ComputerViewMode;
className?: string;
};
/**
* A computer the user can work on: its browser, terminal or code editor
* (`ComputerViewModes`), served through the app (`/computers/<id>/`).
* A mode stays open once visited, so switching back keeps its state.
*/
export default function ComputerView({ computer_id, name, default_mode, className }: Props) {
const [mode, setMode] = useState<ComputerViewMode>(
default_mode || ComputerViewModes[0].value,
);
const [visited, setVisited] = useState<ComputerViewMode[]>([mode]);
const pick = ({ value }: { value: ComputerViewMode }) => {
setMode(value);
setVisited((prev) => (prev.includes(value) ? prev : [...prev, value]));
};
return (
<Stack className={`w-full min-h-0 gap-0! items-stretch ${className || ""}`}>
<Row className="w-full gap-1! flex-nowrap items-center px-3 py-2 border-0 border-b border-solid border-hairline">
<Row role="tablist" aria-label={`${name} modes`} className="gap-1! flex-nowrap">
{ComputerViewModes.map((item) => (
<ComputerViewModeButton
key={item.value}
title={item.title}
active={item.value == mode}
onPick={() => pick({ value: item.value })}
/>
))}
</Row>
<Link
href={computerViewUrl({ computer_id, mode })}
target="_blank"
rel="noopener noreferrer"
title="Open in a new tab"
aria-label="Open in a new tab"
className="ml-auto p-2 text-ink-secondary hover:text-ink rounded-default hover:bg-sunken"
>
<ExternalLink size={16} aria-hidden />
</Link>
</Row>
<Stack className="relative grow min-h-0 gap-0! items-stretch bg-sunken">
{visited.map((value) => (
<ComputerViewFrame
key={value}
computer_id={computer_id}
name={name}
mode={value}
active={value == mode}
/>
))}
</Stack>
</Stack>
);
}
+9 -2
View File
@@ -24,8 +24,9 @@ export const ChatReplyNote = `# Replying in chat
You are chatting with the user. Answer their latest messages directly and briefly, in markdown.
- Quick questions and small changes: answer now.
- Work that takes more than a moment, or anything on a computer (running commands, editing files, browsing): call start_task with a clear brief, then tell the user in a sentence that you're on it. The result is posted here when it's done.
- Work that takes more than a moment, or anything on a computer (running commands, editing files, browsing): call start_task with a clear brief, then tell the user in one sentence, in the first person, what you're doing, e.g. "I'm going through your inbox now. I'll report back when I'm done." A task you start is your own work: never say you handed it to anyone else. The result is posted here when it's done.
- When a message mixes both, answer the quick parts in this reply and start a task for the rest.
- When only the user can do something on a computer (log in, solve a captcha, enter a code), ask them to take over with ask_user_to_take_over and say exactly what to do. They'll tell you when they hand it back; then carry on.
- When the user tells you something worth keeping, save it with update_memory. When they correct how you work, update your instructions with update_instructions.
- Never claim you did something you didn't do.`;
@@ -34,9 +35,15 @@ You are chatting with the user. Answer their latest messages directly and briefl
*/
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user and follow up on anything left.`;
/**
* Added to the coordinator's chat prompt instead while it has no workers,
* so it doesn't talk about a crew that doesn't exist.
*/
export const CoordinatorSoloNote = `You have no worker bots yet: you do all the work yourself, with start_task for anything long or on a computer. Never say you assigned, delegated or handed work to a worker or anyone else. If a kind of job keeps coming up, you can suggest a new bot for it.`;
/**
* Added to a bot's prompt when it works through a queued task.
*/
export const TaskRunNote = `# Working on a task
You are working through a task on your own; the user isn't watching. Use your tools until the task is done, then answer with the report for the user: what you did, what you found, and anything left for them. Keep the report short and in markdown. If you're blocked on something only the user can give you, stop and ask for it in the report.`;
You are working through a task on your own; the user isn't watching. Use your tools until the task is done, then answer with the report for the user: what you did, what you found, and anything left for them. Keep the report short and in markdown. If you're blocked on something only the user can give you, stop and ask for it in the report. If they need to do it on the computer themselves (log in, a captcha, a code), call ask_user_to_take_over first.`;
+26
View File
@@ -42,3 +42,29 @@ export const BotReplyJobTitle = "Thinking";
* The bot's status line while its job waits for a rate-limited model.
*/
export const BotWaitingStatusLine = "Waiting for a free model";
/**
* How a job step that called a tool reads in the chat's working row, by
* tool name. Tools not listed read "Using <name>".
*/
export const JobStepToolLabels: Record<string, string> = {
sandbox_execute_bash: "Running a command",
sandbox_execute_code: "Running code",
sandbox_read_file: "Reading a file",
sandbox_write_file: "Writing a file",
sandbox_file_operations: "Working with files",
search_computers: "Searching the computers",
ask_user_to_take_over: "Asking you to take over",
start_task: "Starting a task",
delegate_task: "Handing work to the crew",
assign_computer: "Changing computer access",
update_memory: "Updating memory",
update_instructions: "Updating instructions",
};
/**
* Labels for whole families of tools, by name prefix.
*/
export const JobStepToolPrefixLabels: Record<string, string> = {
browser_: "Using the browser",
};
+31
View File
@@ -87,3 +87,34 @@ export const ComputerMcpExcludedTools = [
"browser_gui_screenshot",
"browser_gui_execute_action",
] as const;
/**
* How the user can open a computer, each one of the AIO sandbox's own
* apps at `path` (relative to the computer's address). More of them
* (VNC, opencode, codex) can join later.
*/
export const ComputerViewModes = [
{
title: "Browser",
value: "browser",
path: "browser-ui",
},
{
title: "Terminal",
value: "terminal",
path: "terminal",
},
{
title: "Code",
value: "code",
path: "code-server/",
},
] as const;
export type ComputerViewMode = (typeof ComputerViewModes)[number]["value"];
/**
* Where the app serves a computer to its owner's browser:
* `/computers/<id>/<path>`, proxied to the sandbox (`handleComputerProxy`).
*/
export const ComputerProxyPrefix = "/computers";
@@ -0,0 +1,69 @@
import type { ServerWebSocket } from "bun";
import type { WebSocketSession } from "@/src/websocket/types";
type Socket = ServerWebSocket<WebSocketSession>;
/**
* Bridges a user's websocket on `/computers/<id>/...` to the same path on
* the computer (a terminal shell, the browser's CDP, the code editor).
* Messages sent before the computer's side opens are held, and either
* side closing closes the other.
*/
export const computerProxySocket = {
open(ws: Socket) {
const proxy = ws.data.proxy;
if (!proxy) return;
const upstream = new WebSocket(proxy.url, {
headers: proxy.headers,
protocols: proxy.protocols,
} as any);
upstream.binaryType = "arraybuffer";
proxy.upstream = upstream;
proxy.pending = [];
upstream.onopen = () => {
for (const message of proxy.pending || []) upstream.send(message as string | Uint8Array<ArrayBuffer>);
proxy.pending = undefined;
};
upstream.onmessage = (event) => {
ws.send(typeof event.data == "string" ? event.data : new Uint8Array(event.data));
};
upstream.onclose = (event) => {
ws.close(closeCode({ code: event.code }), event.reason);
};
upstream.onerror = () => {
ws.close(1011, "The computer isn't answering");
};
},
message(ws: Socket, message: string | Buffer) {
const proxy = ws.data.proxy;
if (!proxy?.upstream) return;
if (proxy.pending) proxy.pending.push(message);
else if (proxy.upstream.readyState == WebSocket.OPEN) {
proxy.upstream.send(message as string | Uint8Array<ArrayBuffer>);
}
},
close(ws: Socket) {
const upstream = ws.data.proxy?.upstream;
if (upstream && upstream.readyState < WebSocket.CLOSING) upstream.close();
},
};
/**
* Codes a server may not send itself (no status, abnormal) become a
* normal close.
*/
function closeCode({ code }: { code: number }) {
return code == 1005 || code == 1006 || code == 1015 ? 1000 : code;
}
@@ -0,0 +1,46 @@
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer";
import userAuth from "@/src/functions/backend/auth/user-auth";
import parseComputerProxyPath from "@/src/utils/parse-computer-proxy-path";
type Params = {
req: Request;
};
/**
* Where a `/computers/<id>/...` request goes: the computer's own address
* for that path, only for the computer's owner. An error response when
* the user isn't logged in, the computer isn't theirs, or it has no
* container.
*/
export default async function computerProxyTarget({ req }: Params) {
const url = new URL(req.url);
const parsed = parseComputerProxyPath({ pathname: url.pathname });
if (!parsed) return { error: new Response("Not Found", { status: 404 }) };
const session = await userAuth({ req });
if (!session.user?.id) return { error: new Response("Unauthorized", { status: 401 }) };
const computer = await grabUserComputer({
computer_id: parsed.computer_id,
user_id: session.user.id,
});
if (!computer?.id) return { error: new Response("Not Found", { status: 404 }) };
const address = await grabComputerMcpUrl({ computer_id: computer.id });
if (!address) {
return { error: new Response("This computer has no container.", { status: 502 }) };
}
return {
session,
url,
prefix: parsed.prefix,
base_url: address.base_url,
target: `${address.base_url}/${parsed.rest}${url.search}`,
};
}
@@ -0,0 +1,70 @@
import type { Server } from "bun";
import computerProxyRequestHeaders from "@/src/utils/computer-proxy-request-headers";
import computerProxyResponseHeaders from "@/src/utils/computer-proxy-response-headers";
import type { WebSocketSession } from "@/src/websocket/types";
import computerProxyTarget from "./computer-proxy-target";
type Params = {
req: Request;
server: Server<WebSocketSession>;
};
/**
* Serves a computer's own apps (browser, terminal, code) to its owner
* under `/computers/<id>/`, so the chat can show them in a frame without
* the sandbox being reachable from the user's browser. Plain requests are
* fetched and streamed back; websockets are bridged
* (`computerProxySocket`).
*/
export default async function handleComputerProxy({ req, server }: Params) {
const resolved = await computerProxyTarget({ req });
if (resolved.error) return resolved.error;
const { session, url, prefix, base_url, target } = resolved;
const headers = computerProxyRequestHeaders({ headers: req.headers, url, prefix });
if (req.headers.get("upgrade")?.toLowerCase() == "websocket") {
const protocols = (req.headers.get("sec-websocket-protocol") || "")
.split(",")
.map((p) => p.trim())
.filter(Boolean);
for (const name of [...headers.keys()]) {
if (name.startsWith("sec-websocket-")) headers.delete(name);
}
const upgraded = server.upgrade(req, {
headers: protocols[0] ? { "Sec-WebSocket-Protocol": protocols[0] } : undefined,
data: {
user: session.user!,
user_types: session.user_types ?? [],
proxy: {
url: target.replace(/^http/, "ws"),
headers: Object.fromEntries(headers.entries()),
protocols,
},
} satisfies WebSocketSession,
});
return upgraded ? undefined : new Response("Upgrade failed", { status: 400 });
}
const has_body = req.method != "GET" && req.method != "HEAD";
const res = await fetch(target, {
method: req.method,
headers,
body: has_body ? req.body : undefined,
redirect: "manual",
signal: req.signal,
}).catch(() => null);
if (!res) return new Response("This computer isn't answering.", { status: 502 });
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: computerProxyResponseHeaders({ headers: res.headers, base_url, prefix }),
});
}
+14 -2
View File
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import type { HarnessTool } from "../../types";
import askToTakeOverTool from "../computer/ask-to-take-over-tool";
import searchComputersTool from "../computer/search-computers-tool";
import assignComputerTool from "./assign-computer-tool";
import delegateTaskTool from "./delegate-task-tool";
@@ -19,12 +20,17 @@ type Params = {
* The user message being answered.
*/
message_id?: number | string;
/**
* Called when the bot asks the user to take over a computer.
*/
onTakeOver?: (params: { ref: string }) => void;
};
/**
* The tools a bot replies in chat with: its own (memory, instructions),
* start_task for longer work, search_computers to find what's on its
* computers, and for the coordinator delegate_task and assign_computer
* computers, ask_user_to_take_over when only the user can do something on
* one, and for the coordinator delegate_task and assign_computer
* (which computers each crew member can use). Anything
* else on a computer goes through a task, never mid-reply.
*/
@@ -33,6 +39,7 @@ export default async function grabBotChatTools({
user_id,
computers,
message_id,
onTakeOver,
}: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0;
const tools = [
@@ -43,7 +50,12 @@ export default async function grabBotChatTools({
computers,
message_id,
}),
...(computers.length ? [searchComputersTool({ bot_id, computers })] : []),
...(computers.length
? [
searchComputersTool({ bot_id, computers }),
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
]
: []),
];
if (bot.kind != "coordinator") return tools;
+1 -1
View File
@@ -60,7 +60,7 @@ export default function startTaskTool({
computer_id: computer_id || undefined,
});
return `Task #${job_id} queued. Tell the user you're on it; the result will be posted here when it's done.`;
return `Task #${job_id} queued; you'll work on it yourself in the background. Tell the user in one sentence, in the first person, what you're doing (e.g. "I'm checking your inbox now. I'll report back when I'm done."); the result will be posted here when it's done.`;
},
};
}
@@ -0,0 +1,65 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import takeOverRef from "@/src/utils/take-over-ref";
import type { HarnessTool } from "../../types";
type Params = {
/**
* The bot's computers, the one to default to first.
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* In a task the bot stops and reports; in chat it says so in its reply.
*/
in_task?: boolean;
/**
* Called with the take-over ref, which the bot's message must carry
* (`withTakeOverRefs`).
*/
onAsk: (params: { ref: string }) => void;
};
type Args = {
computer_id?: number;
mode: ComputerViewMode;
};
const Modes = ComputerViewModes.map((m) => m.value) as [ComputerViewMode, ...ComputerViewMode[]];
/**
* Lets a bot ask the user to take over one of its computers, for what
* only they can do there (a login, a captcha, a code). Their message
* gets a button that opens the computer in that mode, and the bot shows
* needs-you until they hand it back in chat.
*/
export default function askToTakeOverTool({ computers, in_task, onAsk }: Params): HarnessTool<Args> {
const listed = computers
.map((computer) => `${computer.id} (${computer.name || computer.kind})`)
.join(", ");
return {
name: "ask_user_to_take_over",
description: `Ask the user to take over one of your computers when only they can do something on it: log in, solve a captcha, enter a code, approve a prompt, or show you something. They get a button that opens it in the mode you pick: browser (the web browser), terminal (a shell) or code (the code editor). Computers: ${listed}. Defaults to ${computers[0]?.id}.`,
schema: z.object({
computer_id: z.number().int().optional(),
mode: z.enum(Modes),
}),
run: async ({ args }) => {
const computer =
computers.find((c) => c.id == args.computer_id) || (args.computer_id ? null : computers[0]);
if (!computer?.id) {
throw new Error(`You have no computer with id ${args.computer_id}. Computers: ${listed}.`);
}
const ref = takeOverRef({ id: computer.id, mode: args.mode });
onAsk({ ref });
return in_task
? `Asked; the take-over button ${ref} is added to your report. Stop working now and finish with the report: what you've done so far and exactly what the user needs to do on the computer. When they hand it back they'll tell you in chat, and you carry on from there.`
: `Asked; the take-over button ${ref} is added to your reply. Tell the user in a sentence or two exactly what to do on the computer. They'll tell you here when they hand it back; carry on then.`;
},
};
}
@@ -2,10 +2,12 @@ import React from "react";
import type { BotComputers } from "../(hooks)/use-bot-computers";
/**
* The open bot's computers and the switch, shared by the header's
* computer select and the computer buttons in its messages.
* The open bot's computers, the switch and the viewer, shared by the
* header's computers modal and the computer buttons in its messages.
*/
export const BotComputersContext = React.createContext<BotComputers>({
computers: [],
switchComputer: async () => {},
openComputer: () => {},
closeComputer: () => {},
});
@@ -2,8 +2,19 @@ import { useState } from "react";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotUpdatedData } from "@/src/types";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { ComputerViewMode } from "@/src/dict/computer-dict";
import setBotComputer from "../(functions)/set-bot-computer";
/**
* The computer open in the viewer, in which mode, and whether the bot
* asked the user to take it over (the viewer then offers to hand it back).
*/
export type ComputerViewing = {
computer_id: number;
mode: ComputerViewMode;
take_over?: boolean;
};
export type BotComputers = {
/**
* The bot's computers, the one it's on first.
@@ -15,6 +26,9 @@ export type BotComputers = {
switching_id?: number;
error?: string;
switchComputer: (params: { computer_id: number }) => Promise<void>;
viewing?: ComputerViewing;
openComputer: (params: ComputerViewing) => void;
closeComputer: () => void;
};
type Params = {
@@ -25,12 +39,14 @@ type Params = {
/**
* Keeps the open bot's computers and switches the one it's on, in place:
* no reload, the list comes back reordered. A switch made in another tab
* arrives as `server:bot-updated` with the bot's `computer_id`.
* arrives as `server:bot-updated` with the bot's `computer_id`. Also holds
* the computer open in the viewer (`ComputerViewerModal`).
*/
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
const [computers, setComputers] = useState(initial);
const [switching_id, setSwitchingId] = useState<number>();
const [error, setError] = useState<string>();
const [viewing, setViewing] = useState<ComputerViewing>();
useServerEvent({
event: "server:bot-updated",
@@ -66,5 +82,13 @@ export default function useBotComputers({ bot_id, computers: initial }: Params):
setSwitchingId(undefined);
};
return { computers, switching_id, error, switchComputer };
return {
computers,
switching_id,
error,
switchComputer,
viewing,
openComputer: (params) => setViewing(params),
closeComputer: () => setViewing(undefined),
};
}
@@ -0,0 +1,54 @@
import { useState } from "react";
import useServerEvent from "@/src/hooks/use-server-event";
import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/types";
type Params = {
bot_id: number;
/**
* The bot's active task when the page loaded.
*/
task: BotTaskWithSteps | null;
};
type Progress = {
task_id: number | null;
text: string | null;
};
function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
return {
task_id: task?.id || null,
text: task?.updates[task.updates.length - 1]?.text || null,
};
}
/**
* The latest step of the open bot's active task (a `bots_queue_updates`
* row's text), kept current from `server:bot-job-update`. Starts over when
* `server:bot-updated` brings a different task, or none.
*/
export default function useTaskProgress({ bot_id, task }: Params) {
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
useServerEvent({
event: "server:bot-job-update",
onData: (update: BotJobUpdateData) => {
if (update.bot_id != bot_id || !update.task_id || !update.text) return;
setProgress({ task_id: update.task_id, text: update.text });
},
});
useServerEvent({
event: "server:bot-updated",
onData: (data: BotUpdatedData) => {
if (data.id != bot_id) return;
setProgress((prev) =>
(data.task?.id || null) == prev.task_id ? prev : fromTask({ task: data.task }),
);
},
});
return progress.text;
}
@@ -1,7 +1,8 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Check } from "lucide-react";
import { AppWindow, Check } from "lucide-react";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
type Props = {
@@ -11,14 +12,21 @@ type Props = {
*/
current: boolean;
onPick: () => void;
/**
* Opens the computer in the viewer.
*/
onOpen: () => void;
};
/**
* One computer in the header's computers modal, checked when it's the
* one the bot is on.
* one the bot is on, with a button that opens it.
*/
export default function BotComputerOption({ computer, current, onPick }: Props) {
export default function BotComputerOption({ computer, current, onPick, onOpen }: Props) {
const name = computer.name || `Computer ${computer.id}`;
return (
<Row className="w-full gap-1! flex-nowrap items-center pr-2 hover:bg-sunken transition-colors duration-[var(--m-fast)]">
<Row
role="radio"
aria-checked={current}
@@ -30,14 +38,14 @@ export default function BotComputerOption({ computer, current, onPick }: Props)
onPick();
}
}}
className={`w-full min-h-9 gap-2.5! flex-nowrap items-center px-4 py-1.5 text-ink transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink ${
current ? "cursor-default" : "cursor-pointer hover:bg-sunken"
className={`grow min-w-0 min-h-9 gap-2.5! flex-nowrap items-center px-4 py-1.5 text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink ${
current ? "cursor-default" : "cursor-pointer"
}`}
>
<Check size={16} aria-hidden className={`shrink-0 ${current ? "" : "invisible"}`} />
<Stack className="min-w-0 gap-0!">
<Span className="text-sm leading-5 text-ink truncate w-full">
{computer.name || `Computer ${computer.id}`}
{name}
</Span>
{computer.description ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
@@ -46,5 +54,17 @@ export default function BotComputerOption({ computer, current, onPick }: Props)
) : null}
</Stack>
</Row>
<Button
type="button"
variant="ghost"
color="text"
title={`Open ${name}`}
aria-label={`Open ${name}`}
onClick={onOpen}
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink rounded-default focus-visible:ring-2 focus-visible:ring-ink"
>
<AppWindow size={16} aria-hidden />
</Button>
</Row>
);
}
@@ -16,10 +16,11 @@ type Props = {
/**
* The header's computer icon, beside the model icon: opens what the bot's
* computers are, the one it's on checked, and with more than one,
* switches it.
* switches it. Each can be opened in the viewer.
*/
export default function BotComputersModal({ bot }: Props) {
const { computers, switching_id, error, switchComputer } = useContext(BotComputersContext);
const { computers, switching_id, error, switchComputer, openComputer } =
useContext(BotComputersContext);
const [open, setOpen] = useState(false);
const current = computers[0];
@@ -48,7 +49,7 @@ export default function BotComputersModal({ bot }: Props) {
{can_switch ? `${name}'s computers` : `${name}'s computer`}
</H2>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one.`}
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one. Open one to use it yourself.`}
</Span>
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{can_switch
@@ -69,6 +70,10 @@ export default function BotComputersModal({ bot }: Props) {
onPick={async () => {
await switchComputer({ computer_id: computer.id || 0 });
}}
onOpen={() => {
setOpen(false);
openComputer({ computer_id: computer.id || 0, mode: "browser" });
}}
/>
))}
</Stack>
@@ -1,5 +1,6 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotStone from "@/src/components/general/bot-stone";
import type { BotHue } from "@/src/dict/bot-dict";
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
@@ -7,28 +8,49 @@ import type { BotWithLastMessage } from "@/src/types";
type Props = {
bot: BotWithLastMessage;
/**
* The latest step of the bot's active task.
*/
progress?: string | null;
};
/**
* Under the last message while the bot is working on a reply (or waiting
* for a model to come back), so a slow reply doesn't look like none.
* Under the last message while the bot is working on a reply or a task
* (or waiting for a model to come back), so slow work doesn't look like
* none. A task shows its latest step, else its title.
*/
export default function BotThinkingRow({ bot }: Props) {
export default function BotThinkingRow({ bot, progress }: Props) {
const name = bot.name || "The bot";
const thinking = bot.face == "working" && bot.status_line == BotReplyJobTitle;
const working = bot.face == "working" && !thinking;
const waiting = bot.face == "asleep" && bot.status_line == BotWaitingStatusLine;
if (!thinking && !waiting) return null;
if (!thinking && !working && !waiting) return null;
const label = thinking
? `${name} is thinking…`
: working
? `${name} is working…`
: `${BotWaitingStatusLine}…`;
const detail = working ? progress || bot.status_line : null;
return (
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
<BotStone
hue={(bot.hue || null) as BotHue | null}
face={thinking ? "working" : "asleep"}
face={waiting ? "asleep" : "working"}
size={28}
/>
<Span className="text-sm leading-5 text-ink-secondary animate-pulse motion-reduce:animate-none">
{thinking ? `${bot.name || "The bot"} is thinking…` : `${BotWaitingStatusLine}…`}
</Span>
<Stack className="min-w-0 gap-0! items-start">
<Span className="text-sm leading-5 text-ink-secondary animate-pulse motion-reduce:animate-none">
{label}
</Span>
{detail ? (
<Span className="w-full text-xs leading-4 text-ink-secondary truncate">
{detail}
</Span>
) : null}
</Stack>
</Row>
);
}
@@ -0,0 +1,98 @@
import { useContext, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import Button from "@moduletrace/twui/layout/Button";
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import ComputerView from "@/src/components/general/computer-view";
import type { BotWithLastMessage } from "@/src/types";
import { BotComputersContext } from "../(data)/bot-computers-context";
import sendBotMessage from "../(functions)/send-bot-message";
type Props = {
bot: BotWithLastMessage;
};
/**
* The computer the user opened (from the computers modal, or a take-over
* the bot asked for), large, in its browser, terminal or code editor.
* After a take-over the user hands it back from here, which tells the
* bot in chat so it carries on.
*/
export default function ComputerViewerModal({ bot }: Props) {
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
const [handing_back, setHandingBack] = useState(false);
const [error, setError] = useState<string>();
const computer = computers.find((c) => c.id == viewing?.computer_id);
if (!viewing || !computer?.id) return null;
const name = computer.name || `Computer ${computer.id}`;
const bot_name = bot.name || "the bot";
const handBack = async () => {
setHandingBack(true);
setError(undefined);
const res = await sendBotMessage({
bot_id: bot.id || 0,
text: `I'm done on ${name}. You can carry on.`,
media_ids: [],
}).catch(() => null);
setHandingBack(false);
if (res?.success) closeComputer();
else setError(res?.msg || "Couldn't tell the bot");
};
return (
<Modal
open
setOpen={(open) => {
if (!open) closeComputer();
}}
className="max-w-[1280px] h-[90vh] p-0 bg-paper flex flex-col overflow-hidden"
>
<Row className="w-full gap-3! flex-nowrap items-center pl-5 pr-14 py-3 border-0 border-b border-solid border-hairline">
<Stack className="min-w-0 gap-0! items-start">
<H2 className="text-base! leading-6! tracking-normal! font-bold text-ink truncate w-full">
{name}
</H2>
{viewing.take_over ? (
<Span className="text-[13px] leading-[18px] text-ink-secondary">
{`${bot_name} asked you to take over. Hand it back when you're done.`}
</Span>
) : null}
</Stack>
{viewing.take_over ? (
<Stack className="ml-auto shrink-0 gap-1! items-end">
<Button
type="button"
size="small"
loading={handing_back}
onClick={handBack}
title={`Tell ${bot_name} you're done`}
>
{`Hand back to ${bot_name}`}
</Button>
{error ? (
<Span role="alert" className="text-[13px] leading-[18px] text-ink-secondary">
{error}
</Span>
) : null}
</Stack>
) : null}
</Row>
<ComputerView
key={`${computer.id}-${viewing.mode}`}
computer_id={computer.id}
name={name}
default_mode={viewing.mode}
className="grow"
/>
</Modal>
);
}
@@ -1,11 +1,14 @@
import type { ComponentProps } from "react";
import Link from "@moduletrace/twui/layout/Link";
import { ComputerRefHrefPrefix } from "@/src/utils/link-computer-refs";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
import ComputerRefChip from "./computer-ref-chip";
import TakeOverChip from "./take-over-chip";
/**
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
* becomes its switch button; anything else opens in a new tab.
* becomes its switch button, a take-over request a button that opens the
* computer; anything else opens in a new tab.
*/
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (href?.startsWith(ComputerRefHrefPrefix)) {
@@ -14,6 +17,21 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
if (computer_id) return <ComputerRefChip computer_id={computer_id} />;
}
if (href?.startsWith(TakeOverRefHrefPrefix)) {
const [id, mode] = href.slice(TakeOverRefHrefPrefix.length).split("-");
const computer_id = Number(id);
const known = ComputerViewModes.some((m) => m.value == mode);
if (computer_id) {
return (
<TakeOverChip
computer_id={computer_id}
mode={(known ? mode : ComputerViewModes[0].value) as ComputerViewMode}
/>
);
}
}
return (
<Link
href={href}
@@ -7,14 +7,19 @@ import MessageGroupBlock from "./message-group-block";
type Props = {
bot: BotWithLastMessage;
messages: BotMessageWithAttachments[];
/**
* The latest step of the bot's active task, shown while it works.
*/
progress?: string | null;
};
/**
* Bottom-anchored: the stack sits 24px above the composer rule and grows
* upward. Empty space belongs at the top. New messages announce politely.
* While the bot works on a reply, a thinking row sits under the last one.
* While the bot works on a reply or a task, a status row sits under the
* last one.
*/
export default function MessageThread({ bot, messages }: Props) {
export default function MessageThread({ bot, messages, progress }: Props) {
const groups = groupMessages({ messages });
return (
@@ -28,7 +33,7 @@ export default function MessageThread({ bot, messages }: Props) {
{groups.map((group) => (
<MessageGroupBlock key={group.key} bot={bot} group={group} />
))}
<BotThinkingRow bot={bot} />
<BotThinkingRow bot={bot} progress={progress} />
</Stack>
</Stack>
);
@@ -0,0 +1,43 @@
import { useContext } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import { Hand } from "lucide-react";
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
import { BotComputersContext } from "../(data)/bot-computers-context";
type Props = {
computer_id: number;
mode: ComputerViewMode;
};
/**
* The bot asked the user to take over a computer (`takeOverRef`): a
* button that opens it in the viewer, in the mode the bot asked for. A
* computer the bot can't use (any more) is shown by name only.
*/
export default function TakeOverChip({ computer_id, mode }: Props) {
const { computers, openComputer } = useContext(BotComputersContext);
const computer = computers.find((c) => c.id == computer_id);
const name = computer?.name || `computer ${computer_id}`;
const mode_title = ComputerViewModes.find((m) => m.value == mode)?.title || mode;
if (!computer) {
return <Span className="font-bold">{name}</Span>;
}
return (
<Button
type="button"
variant="outlined"
color="gray"
size="smaller"
title={`Open ${name} (${mode_title}) and take over`}
onClick={() => openComputer({ computer_id, mode, take_over: true })}
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[13px] font-bold text-ink whitespace-nowrap"
>
<Hand size={14} aria-hidden className="shrink-0" />
{`Take over ${name} · ${mode_title}`}
</Button>
);
}
@@ -7,15 +7,18 @@ import useLiveBots from "@/src/hooks/use-live-bots";
import useServerEvent from "@/src/hooks/use-server-event";
import { BotComputersContext } from "../(data)/bot-computers-context";
import useBotComputers from "../(hooks)/use-bot-computers";
import useTaskProgress from "../(hooks)/use-task-progress";
import ConversationHeader from "../(partials)/conversation-header";
import MessageThread from "../(partials)/message-thread";
import Composer from "../(partials)/composer";
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
/**
* The conversation column: header, bottom-anchored thread, composer, the
* thread and composer held to a readable width.
* The bot's computers are shared with the header's modal and the computer
* buttons in the thread, so a switch from either shows in both.
* buttons in the thread, so a switch from either shows in both; either
* can open one in the viewer.
*/
export default function Conversation() {
const { pageProps } = useContext(AppContext);
@@ -25,20 +28,32 @@ export default function Conversation() {
pageProps?.bot_messages || [],
);
const bots = useLiveBots({ bots: pageProps?.bots || [], open_bot_id: bot?.id || undefined });
const bots = useLiveBots({
bots: pageProps?.bots || [],
open_bot_id: bot?.id || undefined,
});
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
const bot_computers = useBotComputers({
bot_id: bot?.id || 0,
computers: pageProps?.bot_computers || [],
});
const progress = useTaskProgress({
bot_id: bot?.id || 0,
task: pageProps?.bot_task || null,
});
console.log(`Refreshing ...`);
useServerEvent({
event: "server:bot-message",
onData: (message: BotMessageWithAttachments) => {
if (message.bot_id != bot?.id) return;
setMessages((prev) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
prev.some((m) => m.id == message.id)
? prev
: [...prev, message],
);
if (message.author == "bot") {
@@ -59,16 +74,23 @@ export default function Conversation() {
bot={live_bot}
harnesses={pageProps?.bot_harness_overview?.harnesses}
/>
<MessageThread bot={live_bot} messages={messages} />
<MessageThread
bot={live_bot}
messages={messages}
progress={progress}
/>
<Composer
bot={bot}
onSent={({ message }) =>
setMessages((prev) =>
prev.some((m) => m.id == message.id) ? prev : [...prev, message],
prev.some((m) => m.id == message.id)
? prev
: [...prev, message],
)
}
/>
</Stack>
<ComputerViewerModal bot={live_bot} />
</BotComputersContext.Provider>
);
}
@@ -1,5 +1,6 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
@@ -40,13 +41,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
* The header's models modal: a worker's own settings, or for the
* coordinator the defaults it runs on (without the providers).
*/
const [bot_messages, bot_harness_overview, bot_computers] = await Promise.all([
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
grabBotMessages({ bot_id: bot.id }),
(bot.kind == "coordinator"
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
grabBotComputers({ bot_id: bot.id, user_id: user.id }),
grabActiveBotTask({ bot_id: bot.id }),
]);
return {
@@ -56,6 +58,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_messages,
bot_harness_overview,
bot_computers,
bot_task,
},
};
};
+6
View File
@@ -8,6 +8,8 @@ import { logError, grabRequestIP } from "./functions/backend/logger";
import botsQueue from "./bots-queue";
import liveRelay from "./live-relay";
import enableForeignKeys from "./functions/backend/db/enable-foreign-keys";
import handleComputerProxy from "./functions/server/computers/handle-computer-proxy";
import { ComputerProxyPrefix } from "./dict/computer-dict";
declare global {}
@@ -32,6 +34,10 @@ const server = Bun.serve({
}
}
if (url.pathname.startsWith(`${ComputerProxyPrefix}/`)) {
return await handleComputerProxy({ req, server });
}
if (url.pathname === WEBSOCKET_ENDPOINT) {
const session = await userAuth({ req });
+4
View File
@@ -277,6 +277,10 @@ export type PagePropsType = {
* The open bot's computers, the one it's on first.
*/
bot_computers?: BUN_SQLITE_COBBLE_COMPUTERS[] | null;
/**
* The open bot's active task and what its job has done so far.
*/
bot_task?: BotTaskWithSteps | null;
environment?: string;
has_password?: boolean;
backups?: DbBackup[] | null;
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import computerProxyRequestHeaders from "./computer-proxy-request-headers";
import computerProxyResponseHeaders from "./computer-proxy-response-headers";
describe("computerProxyRequestHeaders", () => {
test("drops the app's session cookies and forwards where it's served", () => {
const headers = computerProxyRequestHeaders({
headers: new Headers({
host: "localhost:7227",
cookie: `${AppData.AuthKeyCookieName}=secret; ${AppData.AuthCSRFCookieName}=csrf; _xsrf=keep`,
"user-agent": "test",
}),
url: new URL("http://localhost:7227/computers/3/terminal"),
prefix: "/computers/3",
});
expect(headers.get("cookie")).toBe("_xsrf=keep");
expect(headers.get("host")).toBeNull();
expect(headers.get("user-agent")).toBe("test");
expect(headers.get("x-forwarded-host")).toBe("localhost:7227");
expect(headers.get("x-forwarded-proto")).toBe("http");
expect(headers.get("x-forwarded-prefix")).toBe("/computers/3");
});
test("sends no cookie header when only the app's were set", () => {
const headers = computerProxyRequestHeaders({
headers: new Headers({ cookie: `${AppData.AuthKeyCookieName}=secret` }),
url: new URL("https://cobble.local/computers/3/"),
prefix: "/computers/3",
});
expect(headers.get("cookie")).toBeNull();
expect(headers.get("x-forwarded-proto")).toBe("https");
});
});
describe("computerProxyResponseHeaders", () => {
const base_url = "http://cobble-computer:8080";
const prefix = "/computers/3";
test("lets the chat frame the computer", () => {
const headers = computerProxyResponseHeaders({
headers: new Headers({
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; frame-ancestors 'none'",
}),
base_url,
prefix,
});
expect(headers.get("x-frame-options")).toBeNull();
expect(headers.get("content-security-policy")).toBe("default-src 'self'");
});
test("points redirects back under the prefix", () => {
const location = (value: string) =>
computerProxyResponseHeaders({ headers: new Headers({ location: value }), base_url, prefix }).get(
"location",
);
expect(location("http://cobble-computer:8080/jupyter/lab")).toBe("/computers/3/jupyter/lab");
expect(location("/code-server?folder=/home/gem")).toBe("/computers/3/code-server?folder=/home/gem");
expect(location("/computers/3/terminal")).toBe("/computers/3/terminal");
expect(location("./?folder=/home/gem")).toBe("./?folder=/home/gem");
expect(location("https://example.com/")).toBe("https://example.com/");
});
});
@@ -0,0 +1,49 @@
import { AppData } from "@/src/data/app-data";
type Params = {
headers: Headers;
/**
* The request's own address, as the user's browser sees it.
*/
url: URL;
/**
* Where the computer is served, e.g. `/computers/3`.
*/
prefix: string;
};
/**
* Headers hop-by-hop or tied to the app's own connection, never sent on.
*/
const Dropped = ["host", "connection", "keep-alive", "upgrade", "content-length", "transfer-encoding"];
/**
* The headers a request is sent to a computer with: the user's own,
* without the app's session cookies, plus the forwarded host, protocol
* and prefix, which the sandbox builds its links (and its browser's CDP
* address) from.
*/
export default function computerProxyRequestHeaders({ headers, url, prefix }: Params) {
const out = new Headers(headers);
for (const name of Dropped) out.delete(name);
const cookies = (headers.get("cookie") || "")
.split(";")
.map((c) => c.trim())
.filter(Boolean)
.filter((c) => {
const name = c.split("=")[0];
return name != AppData.AuthKeyCookieName && name != AppData.AuthCSRFCookieName;
});
if (cookies.length) out.set("cookie", cookies.join("; "));
else out.delete("cookie");
out.set("x-forwarded-host", headers.get("x-forwarded-host") || url.host);
out.set("x-forwarded-proto", headers.get("x-forwarded-proto") || url.protocol.replace(":", ""));
out.set("x-forwarded-prefix", prefix);
return out;
}
@@ -0,0 +1,53 @@
type Params = {
headers: Headers;
/**
* The computer's own address, e.g. `http://cobble-computer:8080`.
*/
base_url: string;
/**
* Where the computer is served, e.g. `/computers/3`.
*/
prefix: string;
};
/**
* Headers that would stop the chat from showing the computer in a frame,
* or that no longer fit the body (fetch has already decoded it).
*/
const Dropped = ["x-frame-options", "content-encoding", "content-length", "transfer-encoding", "connection"];
/**
* The headers a computer's response goes back with: framing allowed, and
* redirects pointed back under the computer's prefix.
*/
export default function computerProxyResponseHeaders({ headers, base_url, prefix }: Params) {
const out = new Headers(headers);
for (const name of Dropped) out.delete(name);
const csp = out.get("content-security-policy");
if (csp?.includes("frame-ancestors")) {
out.set(
"content-security-policy",
csp
.split(";")
.filter((d) => !d.trim().startsWith("frame-ancestors"))
.join(";"),
);
}
const location = out.get("location");
if (location) {
const base = base_url.replace(/\/+$/, "");
if (location.startsWith(base)) {
out.set("location", `${prefix}${location.slice(base.length) || "/"}`);
} else if (location.startsWith("/") && !location.startsWith("//") && !location.startsWith(`${prefix}/`)) {
out.set("location", `${prefix}${location}`);
}
}
return out;
}
+20
View File
@@ -0,0 +1,20 @@
import {
ComputerProxyPrefix,
ComputerViewModes,
type ComputerViewMode,
} from "@/src/dict/computer-dict";
type Params = {
computer_id: number | string;
mode: ComputerViewMode;
};
/**
* The app address of one of a computer's modes, e.g.
* `/computers/3/terminal`.
*/
export default function computerViewUrl({ computer_id, mode }: Params) {
const path = ComputerViewModes.find((m) => m.value == mode)?.path || "";
return `${ComputerProxyPrefix}/${computer_id}/${path}`;
}
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import computerRef from "./computer-ref";
import linkComputerRefs from "./link-computer-refs";
import takeOverRef from "./take-over-ref";
describe("linkComputerRefs", () => {
test("links every computer a message names", () => {
@@ -16,4 +17,12 @@ describe("linkComputerRefs", () => {
expect(linkComputerRefs({ text })).toBe(text);
});
test("links take-over requests", () => {
expect(
linkComputerRefs({
text: `Please log in to Gmail. ${takeOverRef({ id: 3, mode: "browser" })}`,
}),
).toBe("Please log in to Gmail. [take over](#take-over-3-browser)");
});
});
+19 -7
View File
@@ -6,13 +6,25 @@
export const ComputerRefHrefPrefix = "#computer-";
/**
* Turns every computer ref in a message into a markdown link to
* `#computer-<id>`, so it survives markdown rendering as a link the chat
* can swap for a switch button.
* The link target a take-over ref (`takeOverRef`) becomes, e.g.
* `#take-over-3-browser`. The chat renders these as buttons that open the
* computer in that mode.
*/
export const TakeOverRefHrefPrefix = "#take-over-";
/**
* Turns every computer ref (`[computer:3]`) and take-over ref
* (`[take-over:3:browser]`) in a message into a markdown link, so it
* survives markdown rendering as a link the chat can swap for a button.
*/
export default function linkComputerRefs({ text }: { text: string }) {
return text.replace(
/\[computer:(\d+)\](?!\()/g,
(_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`,
);
return text
.replace(
/\[computer:(\d+)\](?!\()/g,
(_, id: string) => `[computer ${id}](${ComputerRefHrefPrefix}${id})`,
)
.replace(
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
);
}
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import parseComputerProxyPath from "./parse-computer-proxy-path";
describe("parseComputerProxyPath", () => {
test("splits the computer id from the path on it", () => {
expect(parseComputerProxyPath({ pathname: "/computers/3/v1/shell/ws" })).toEqual({
computer_id: 3,
prefix: "/computers/3",
rest: "v1/shell/ws",
});
});
test("takes the computer's root with or without a slash", () => {
expect(parseComputerProxyPath({ pathname: "/computers/3" })?.rest).toBe("");
expect(parseComputerProxyPath({ pathname: "/computers/3/" })?.rest).toBe("");
});
test("ignores anything else", () => {
expect(parseComputerProxyPath({ pathname: "/computers" })).toBeNull();
expect(parseComputerProxyPath({ pathname: "/computers/x/terminal" })).toBeNull();
expect(parseComputerProxyPath({ pathname: "/computers/0/terminal" })).toBeNull();
expect(parseComputerProxyPath({ pathname: "/computersx/3" })).toBeNull();
});
});
+22
View File
@@ -0,0 +1,22 @@
import { ComputerProxyPrefix } from "@/src/dict/computer-dict";
type Params = {
pathname: string;
};
/**
* Splits `/computers/<id>/<rest>` into the computer id and the path on
* the computer (no leading slash). Null for anything else.
*/
export default function parseComputerProxyPath({ pathname }: Params) {
const match = pathname.match(new RegExp(`^${ComputerProxyPrefix}/(\\d+)(?:/(.*))?$`));
const computer_id = Number(match?.[1]);
if (!match || !computer_id) return null;
return {
computer_id,
prefix: `${ComputerProxyPrefix}/${computer_id}`,
rest: match[2] || "",
};
}
+15
View File
@@ -0,0 +1,15 @@
import type { ComputerViewMode } from "@/src/dict/computer-dict";
type Params = {
id: number | string;
mode: ComputerViewMode;
};
/**
* How a bot asks the user to take over one of its computers in a message:
* `[take-over:3:browser]`. The chat shows it as a button that opens the
* computer in that mode.
*/
export default function takeOverRef({ id, mode }: Params) {
return `[take-over:${id}:${mode}]`;
}
+7
View File
@@ -10,6 +10,7 @@ import type { WebSocketDataType } from "@/src/types";
import { wsSendData } from "./(functions)/ws-send-data";
import checkSessionAccess from "./(functions)/check-session-access";
import logEvent from "@/src/functions/backend/logger";
import { computerProxySocket } from "@/src/functions/server/computers/computer-proxy-socket";
/**
* The twui `useWebSocket` hook sends a raw keepalive string to keep the
@@ -27,6 +28,8 @@ function parseMessage(message: string | Buffer): WebSocketDataType | undefined {
export const websocket = {
open(ws: ServerWebSocket<WebSocketSession>) {
if (ws.data.proxy) return computerProxySocket.open(ws);
const { user } = ws.data;
joinRoom(ws, "public");
@@ -54,6 +57,8 @@ export const websocket = {
ws: ServerWebSocket<WebSocketSession>,
message: string | Buffer,
) {
if (ws.data.proxy) return computerProxySocket.message(ws, message);
const data = parseMessage(message);
if (!data) return;
@@ -73,6 +78,8 @@ export const websocket = {
},
close(ws: ServerWebSocket<WebSocketSession>) {
if (ws.data.proxy) return computerProxySocket.close(ws);
leaveAllRooms(ws);
},
+17 -1
View File
@@ -4,4 +4,20 @@ import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
export type WebSocketSession = {
user: User;
user_types: BUN_SQLITE_COBBLE_USER_TYPES[];
};
/**
* Set on a socket bridged to a computer, never on the app's own `/ws`.
*/
proxy?: ComputerProxySession;
};
/**
* A websocket bridged to a computer (`computerProxySocket`): the address
* on the computer and the headers to open it with. `upstream` and
* `pending` are filled once it opens.
*/
export type ComputerProxySession = {
url: string;
headers: Record<string, string>;
protocols: string[];
upstream?: WebSocket;
pending?: (string | Buffer)[];
};