Repository navigation
Get Bots past bot checks: headed browsers, Web Bot Auth, a residential proxy option, Bun 1.4.3, and a handoff that keeps its page #772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
dda8c3e
570be2a
c9a837b
e0b191f
4d4c0fa
f675124
d8137c9
6eb77e5
bf70aab
fe9622e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,11 @@ import type { | |
| HandoffSource, | ||
| } from "../../shared/computer-control"; | ||
| import { createActionBarrier } from "./action-barrier"; | ||
| import type { ControlStore, StoredControl } from "./control-store"; | ||
| import { | ||
| type ControlStore, | ||
| MAX_REQUEST_URL, | ||
| type StoredControl, | ||
| } from "./control-store"; | ||
| export type ControlState = ComputerControlState; | ||
| export class ControlError extends Error { | ||
| constructor(message: string) { | ||
|
|
@@ -40,6 +44,26 @@ type Options = { | |
| drainTimeoutMs?: number; | ||
| }; | ||
|
|
||
| /** | ||
| * A page worth sending a blank browser back to: a real http(s) address, short enough to keep. | ||
| * | ||
| * Cloudflare's `__cf_chl_*` parameters are dropped. They are one-time tokens for the challenge that | ||
| * was on screen, which a restart has already lost, so the page kept is the one the Bot was opening. | ||
| */ | ||
| export function pageWorthKeeping(url: unknown): string | undefined { | ||
| if (typeof url !== "string" || url.length > MAX_REQUEST_URL) return undefined; | ||
| try { | ||
| const parsed = new URL(url); | ||
| if (parsed.protocol !== "http:" && parsed.protocol !== "https:") | ||
| return undefined; | ||
| for (const name of [...parsed.searchParams.keys()]) | ||
| if (name.startsWith("__cf_chl_")) parsed.searchParams.delete(name); | ||
| return parsed.toString(); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** Request identity is durable; secret values never enter this state machine. */ | ||
| export function createControl( | ||
| now: () => string = () => new Date().toISOString(), | ||
|
|
@@ -191,6 +215,21 @@ export function createControl( | |
| } | ||
| syncAdmission(); | ||
|
|
||
| /** | ||
| * The page of the most recent request, when that request was interrupted recently. | ||
| * | ||
| * Only the most recent one: a request that finished, was cancelled or expired after an | ||
| * interruption means the person or the Bot moved on, and an older page must not come back. | ||
| */ | ||
| function interruptedPage(at: string): string | undefined { | ||
| const latest = data.requests.at(-1); | ||
| if (latest?.status !== "interrupted" || !latest.url || !latest.finishedAt) | ||
| return undefined; | ||
| return Date.parse(at) - Date.parse(latest.finishedAt) <= HELP_REQUEST_TTL_MS | ||
| ? latest.url | ||
| : undefined; | ||
| } | ||
|
|
||
| function rememberAlias(toolCallId: string, requestId: string) { | ||
| Object.defineProperty(data.aliases, toolCallId, { | ||
| value: requestId, | ||
|
|
@@ -206,6 +245,7 @@ export function createControl( | |
| reason: unknown, | ||
| toolCallId?: string, | ||
| source: HandoffSource = "model", | ||
| pageUrl?: string, | ||
| ): ControlState { | ||
| expire(); | ||
| if ( | ||
|
|
@@ -230,6 +270,13 @@ export function createControl( | |
| } | ||
| barrier.close(); // Before any persistence or awaited browser operation. | ||
| const at = now(); | ||
| /* | ||
| * The page this request is about. A browser that restarted comes back blank, so a request | ||
| * raised on a blank page inherits the page of the request a restart interrupted, if that was | ||
| * recent: the Bot asking again after "the computer process restarted" is still asking about | ||
| * the page it lost, and the person it hands control to needs that page in front of them. | ||
| */ | ||
| const url = pageWorthKeeping(pageUrl) ?? interruptedPage(at); | ||
| const request: HandoffRequest = { | ||
| id: crypto.randomUUID(), | ||
| toolCallId, | ||
|
|
@@ -242,6 +289,7 @@ export function createControl( | |
| createdAt: at, | ||
| updatedAt: at, | ||
| expiresAt: new Date(Date.parse(at) + HELP_REQUEST_TTL_MS).toISOString(), | ||
| ...(url ? { url } : {}), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The address is only needed while the request is live, or when it was interrupted and is still inside the TTL. Could
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in fe9622e. Every ending except a restart now deletes |
||
| }; | ||
| data.currentRequestId = request.id; | ||
| data.requests.push(request); | ||
|
|
@@ -309,6 +357,17 @@ export function createControl( | |
| return get(); | ||
| }, | ||
| interrupt, | ||
| /** | ||
| * Where a blank browser should be sent before a person works the current handoff. | ||
| * | ||
| * The live request's page, while one is waiting or held. Nothing otherwise: once the request | ||
| * is over, whatever the browser shows is the Bot's business again. | ||
| */ | ||
| pageToRestore(): string | undefined { | ||
| expire(); | ||
| const request = current(); | ||
| return active(request) ? request?.url : undefined; | ||
| }, | ||
| snapshotTaken() { | ||
| if ( | ||
| data.holder === "bot" && | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import { mkdir, rm, writeFile } from "node:fs/promises"; | ||
| import { setDefaultAutoSelectFamily } from "node:net"; | ||
| import { join } from "node:path"; | ||
| import { serve } from "bun"; | ||
| import type { Page } from "playwright"; | ||
|
|
@@ -105,6 +106,14 @@ if (!COMPUTER_TOKEN) { | |
| process.exit(1); | ||
| } | ||
|
|
||
| /* | ||
| * No multi-address connect anywhere in this process. Bun turns it on by default, and its timer can | ||
| * throw from `internalConnectMultipleTimeout` after the connection is gone, which exits the process | ||
| * (oven-sh/bun#25633). The egress filter already connects one checked address at a time (see | ||
| * `connectInTurn`); this covers every other connect here, such as reaching an upstream proxy by name. | ||
| */ | ||
| setDefaultAutoSelectFamily(false); | ||
|
|
||
| const RUNTIME = browserRuntimeFromEnv(process.env); | ||
| const BROWSER_MODE = RUNTIME.mode; | ||
| const VIRTUAL_DISPLAY = await startVirtualDisplay( | ||
|
|
@@ -305,6 +314,58 @@ async function currentPage( | |
| return page; | ||
| } | ||
|
|
||
| /** A browser that has not been sent anywhere, which is what a restarted one shows. */ | ||
| function isBlankPage(url: string): boolean { | ||
| return ( | ||
| url === "" || | ||
| url === "about:blank" || | ||
| url.startsWith("chrome://newtab") || | ||
| url.startsWith("chrome://new-tab-page") | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Put the page a handoff is about back in front of the person, when the browser came back blank. | ||
| * | ||
| * A browser restarts underneath a waiting request more often than it should: Chromium crashing, the | ||
| * idle sweep or the cap closing it, the whole computer restarting. Each of those relaunches onto | ||
| * `about:blank`, and before this a person who took control was handed a blank screen with nothing to | ||
| * solve while the Bot waited for them to solve it. The request carries the page it was raised on, so | ||
| * the computer opens it again first. Through the same navigation the Bot uses, so the egress filter | ||
| * and the page-access rule still decide whether it may load. | ||
| * | ||
| * Only a blank browser is touched. A person or a site that moved the page somewhere else meant to. | ||
| * Best effort: a page that will not load is logged and the handoff goes ahead, because a person in | ||
| * front of a blank browser can still type the address, and a refused handoff helps nobody. | ||
| */ | ||
| async function restoreHandoffPage(botId: string): Promise<void> { | ||
| const session = sessions.for(botId); | ||
| const want = session.control.pageToRestore(); | ||
| if (!want) return; | ||
| try { | ||
| const target = await currentPage(botId); | ||
| if (!isBlankPage(target.url())) return; | ||
| await navigateWebPage(target, want, RUNTIME.backend, NAVIGATION_TIMEOUT_MS); | ||
| // A new document, so every ref handed out before it is retired, as after any navigation. | ||
| session.snapshotId += 1; | ||
| console.info( | ||
| JSON.stringify({ | ||
| type: "computer-handoff-page-restored", | ||
| botId, | ||
| origin: new URL(want).origin, | ||
| }), | ||
| ); | ||
| } catch (error) { | ||
| console.info( | ||
| JSON.stringify({ | ||
| type: "computer-handoff-page-not-restored", | ||
| botId, | ||
| reason: error instanceof Error ? error.message : String(error), | ||
| }), | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The page as text, the way a reader sees it. | ||
| * | ||
|
|
@@ -750,9 +811,17 @@ serve<StreamData>({ | |
| typeof body.toolCallId !== "string" | ||
| ) | ||
| return json({ error: "toolCallId must be a string." }, 400); | ||
| return json( | ||
| session.control.requestHelp(body?.reason, body?.toolCallId), | ||
| // The page the Bot is stuck on, recorded with the request. A browser that will not start is | ||
| // no reason to refuse the request: the person is the one who can see why. | ||
| const stuckOn = await currentPage(botId).catch(() => undefined); | ||
| const state = session.control.requestHelp( | ||
| body?.reason, | ||
| body?.toolCallId, | ||
| "model", | ||
| stuckOn?.url(), | ||
| ); | ||
| await restoreHandoffPage(botId); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This (and the one on The restore is best effort and its result doesn't change the response, so could it run without being awaited (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in fe9622e. Help and Take control both start the restore in the background and answer straight away. Restores run one at a time per Bot and give up if the person has sent input or the request has ended by the time the browser is ready. A new real-browser test ( |
||
| return json(state); | ||
| } | ||
|
|
||
| // The Bot asking for one value it must not be told. It has already focused the field. | ||
|
|
@@ -872,8 +941,12 @@ serve<StreamData>({ | |
| } | null; | ||
| if (typeof body?.requestId !== "string" || !body.requestId.trim()) | ||
| return json({ error: "A requestId is required." }, 400); | ||
| if (url.pathname === "/control/take") | ||
| return json(await session.control.take(body.requestId)); | ||
| if (url.pathname === "/control/take") { | ||
| const taken = await session.control.take(body.requestId); | ||
| // The browser may have restarted while the request waited; give the person the page. | ||
| await restoreHandoffPage(botId); | ||
| return json(taken); | ||
| } | ||
| if (url.pathname === "/control/cancel") | ||
| return json(session.control.cancel(body.requestId)); | ||
| return json(session.control.release(body.requestId)); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/computers/stopand/computers/resetboth callinterrupt(), so they leave the requestinterrupted, the same status a process restart uses. That means a help request from a blank browser within 10 minutes of a reset inherits the old page and reopens it. I checked: afterrequestHelp(..., "https://bank.example/reset?token=secret")→interrupt("The computer was reset; …")→requestHelp("other", "b", "model", "about:blank"), the new request has the old URL.Reset is meant to forget everything, so it shouldn't bring that page back, and stop is probably a deliberate "move on" too. Could inheritance be limited to the process-restart interruption? For example, mark that interruption, or clear
urlin the stop and reset routes.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in fe9622e. Only a restart (the process or the browser coming back up) marks a request as keeping its page now, through a
keepsPageflag; the stop and reset routes clear it, including when the browser close reachesrenewRunfirst. Your repro is a test intests/control-handoff-page.test.ts(red on bf70aab, green now), alongside stop or reset after a browser close.