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:
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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] || "",
|
||||
};
|
||||
}
|
||||
@@ -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}]`;
|
||||
}
|
||||
Reference in New Issue
Block a user