Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

**Before upgrading.** Four things change for an existing deployment:
**Before upgrading.** Five things change for an existing deployment:
- A hosted computer's browser is now headed by default. It uses a little more memory than headless
did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old
behaviour.
Expand All @@ -28,6 +28,21 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
comes back on their next conversation with that Bot (one they turned off and kept stays off).
`OPENBOT_MEMORY_SOURCE_DEFAULTS=false` and `OPENBOT_PROACTIVE_DEFAULTS=false` (Helm: `config.memory.*`)
prevent it.
- Migration `0056_sign_in_code_step` widens the check on `sign_in_requests.status` to allow
`code_required` (below). A server and a computer must be upgraded together: an older computer has
no `/sign-in/code`.

### A sign-in's verification code is asked for after the password

The private sign-in form used to ask for a username, a password and "a verification code, if the
site asks for one" all at once, so a person had to guess whether a code was coming and type one
before the site had sent it. Now the form asks only for the username and password. If the site,
once it has the password, shows a one-time-code field, the request waits in a new `code_required`
state and the form asks for the code the site just sent, on its own. A wrong code keeps asking; a
code nobody enters expires with the request, and the person can still take over the browser or
cancel. The Bot is told only how it ended, never the code, and a saved login still never stores one.
The step is audited as `computer.sign_in_code_requested`, and a failed code as
`computer.sign_in_failed` with `step: "code"`.

### Fewer "Verify you are human" pages

Expand Down
1 change: 1 addition & 0 deletions agent-computer/src/authorisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const ACTING_PATHS = new Set([
"/files/write",
// Typing a person's login into the page. Refused while they hold the wheel, like any other typing.
"/sign-in/fill",
"/sign-in/code",
]);

export function actsOnTheComputer(pathname: string): boolean {
Expand Down
22 changes: 21 additions & 1 deletion agent-computer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ import {
} from "./secret-masking";
import { type BotSession, createSessions } from "./sessions";
import { createShell } from "./shell";
import { fillSignIn, parseSignInFill } from "./sign-in";
import {
fillSignIn,
fillSignInCode,
parseSignInCode,
parseSignInFill,
} from "./sign-in";
import { startVirtualDisplay } from "./virtual-display";
import { applyWebBotAuthPush } from "./web-bot-auth";
import {
Expand Down Expand Up @@ -958,6 +963,21 @@ serve<StreamData>({
return json(result);
}

/**
* The verification code a site asked for after the password, from the same private form.
*
* Handled exactly as the login above: read once, typed by `fillSignInCode` into the page that is
* waiting for it, and dropped.
*/
if (url.pathname === "/sign-in/code" && request.method === "POST") {
const input = parseSignInCode(await request.json().catch(() => null));
if (typeof input === "string") return json({ error: input }, 400);
const target = await currentPage(botId);
const result = await fillSignInCode(target, input);
if (result.submitted) session.snapshotId += 1;
return json(result);
}

if (
["/control/take", "/control/release", "/control/cancel"].includes(
url.pathname,
Expand Down
148 changes: 119 additions & 29 deletions agent-computer/src/sign-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
*
* The private sign-in form posts a username and password to the server, the server posts them here,
* and this types them. The Bot is never involved: it does not pick the fields, it does not see the
* values, and what comes back says only whether a form was submitted and whether a password field is
* still showing.
* values, and what comes back says only whether a form was submitted, whether a password field is
* still showing, and whether the site went on to ask for a verification code.
*
* A verification code is never asked for up front. It comes after the password, and only when the
* site, having accepted the password, shows a one-time-code field: `fillSignIn` reports that and
* stops, the person is asked for the code the site just sent, and `fillSignInCode` types it into the
* page that is still waiting for it.
*
* Three refusals, before anything is typed:
* - the page must be on the exact origin the person was asked to sign in to, so a login cannot be
Expand All @@ -23,12 +28,24 @@ export type SignInFillInput = {
origin: string;
username?: string;
password: string;
code?: string;
};

export type SignInFillResult = {
submitted: boolean;
passwordFieldVisible: boolean;
/** The site accepted the password and is now showing a one-time-code field, on this origin. */
codeRequested: boolean;
url: string;
error?: string;
};

export type SignInCodeInput = { origin: string; code: string };

export type SignInCodeResult = {
submitted: boolean;
/** Whether a code field is still showing after submitting: the plainest sign the code was refused. */
codeFieldVisible: boolean;
passwordFieldVisible: boolean;
url: string;
error?: string;
};
Expand Down Expand Up @@ -58,16 +75,27 @@ export function parseSignInFill(body: unknown): SignInFillInput | string {
return "A sign-in needs a password.";
if (input.username !== undefined && typeof input.username !== "string")
return "The username must be text.";
if (input.code !== undefined && typeof input.code !== "string")
return "The code must be text.";
// A `code` is not read: one is only ever typed after the site asks for it, by `fillSignInCode`.
return {
origin: originOfUrl(input.origin),
password: input.password,
...(input.username ? { username: input.username } : {}),
...(input.code ? { code: input.code } : {}),
};
}

/** Parse the verification-code body, the same way. */
export function parseSignInCode(body: unknown): SignInCodeInput | string {
if (!body || typeof body !== "object") return "A sign-in needs a JSON body.";
const input = body as Record<string, unknown>;
if (typeof input.origin !== "string" || !originOfUrl(input.origin))
return "A sign-in names the website's origin.";
if (typeof input.code !== "string" || !input.code.trim())
return "A verification code is required.";
if (input.code.length > 64)
return "A verification code is at most 64 characters.";
return { origin: originOfUrl(input.origin), code: input.code.trim() };
}

/**
* Find the login fields on the page and mark them for the locators below.
*
Expand Down Expand Up @@ -123,23 +151,39 @@ function markLoginFields(attribute: string) {
return { username: Boolean(username), password: Boolean(password) };
}

/** Find a one-time-code field. Runs in the page. */
/**
* Find a one-time-code field. Runs in the page.
*
* The site's own `autocomplete="one-time-code"` first; otherwise a visible text-like field whose name,
* id or label reads as a verification code. Any earlier mark is cleared, so a field that has gone
* away is never typed into.
*/
function markCodeField(attribute: string) {
for (const node of document.querySelectorAll(`[${attribute}="code"]`))
node.removeAttribute(attribute);
const inputs = [
...document.querySelectorAll<HTMLInputElement>("input"),
].filter((input) => {
const box = input.getBoundingClientRect();
const style = getComputedStyle(input);
return (
box.width > 0 &&
box.height > 0 &&
style.visibility !== "hidden" &&
style.display !== "none" &&
!input.disabled &&
input.type !== "hidden"
!input.readOnly &&
["text", "tel", "number", "password", ""].includes(input.type)
);
});
const code =
inputs.find((input) => /one-time-code/.test(input.autocomplete)) ??
inputs.find((input) =>
/code|otp|totp|2fa|mfa|verif|token/i.test(`${input.name} ${input.id}`),
inputs.find(
(input) =>
input.type !== "password" &&
/code|otp|totp|2fa|mfa|verif|passcode/i.test(
`${input.name} ${input.id} ${input.getAttribute("aria-label") ?? ""}`,
),
);
if (code) code.setAttribute(attribute, "code");
return Boolean(code);
Expand Down Expand Up @@ -188,8 +232,13 @@ async function settle(page: Page) {
.catch(() => undefined);
}

/** A password field still showing, other than a one-time-code field a site drew as one. */
async function passwordVisible(page: Page) {
return (await page.locator("input[type=password]:visible").count()) > 0;
return (
(await page
.locator(`input[type=password]:visible:not([${LOGIN_ATTRIBUTE}="code"])`)
.count()) > 0
);
}

async function typeSecret(field: Locator, value: string) {
Expand All @@ -204,6 +253,7 @@ export async function fillSignIn(
const answer = (error: string, submitted = false): SignInFillResult => ({
submitted,
passwordFieldVisible: false,
codeRequested: false,
url: page.url(),
error,
});
Expand Down Expand Up @@ -246,30 +296,70 @@ export async function fillSignIn(
await typeSecret(passwordField, input.password);
await submitFrom(page, passwordField);
await settle(page);
if (input.code) {
if (originOfUrl(page.url()) !== input.origin)
return answer(
`The sign-in moved to ${originOfUrl(page.url()) || "another page"} before the code was asked for. Take over the browser to finish.`,
true,
);
let hasCode = await page.evaluate(markCodeField, LOGIN_ATTRIBUTE);
if (!hasCode) {
await page.waitForTimeout(2_000);
hasCode = await page.evaluate(markCodeField, LOGIN_ATTRIBUTE);
}
if (hasCode) {
const codeField = page.locator(`[${LOGIN_ATTRIBUTE}="code"]`);
await typeSecret(codeField, input.code);
await submitFrom(page, codeField);
await settle(page);
}
// A password field still showing is a refused login; the server says so. Only past that is a
// code field the site asking for a second step. The code field is looked for first, so one a
// site draws as a password input is not mistaken for the login's own, and is then given a
// moment to appear.
let codeRequested = await page.evaluate(markCodeField, LOGIN_ATTRIBUTE);
const refused = await passwordVisible(page);
if (!codeRequested && !refused) {
await page.waitForTimeout(2_000);
codeRequested = await page.evaluate(markCodeField, LOGIN_ATTRIBUTE);
}
if (codeRequested && originOfUrl(page.url()) !== input.origin)
return answer(
`The sign-in moved to ${originOfUrl(page.url()) || "another page"} to ask for a verification code. Take over the browser to finish signing in there.`,
true,
);
return {
submitted: true,
passwordFieldVisible: await passwordVisible(page),
passwordFieldVisible: codeRequested ? false : refused,
codeRequested,
url: page.url(),
};
} catch {
return answer("The login could not be typed into the page.");
}
}

/**
* Type the verification code a site asked for into the page that is waiting for it.
*
* Only after `fillSignIn` reported `codeRequested`, and with the same refusals: the page must still
* be on the origin the person was asked to sign in to, and must still be showing a code field.
*/
export async function fillSignInCode(
page: Page,
input: SignInCodeInput,
): Promise<SignInCodeResult> {
const answer = (error: string, submitted = false): SignInCodeResult => ({
submitted,
codeFieldVisible: false,
passwordFieldVisible: false,
url: page.url(),
error,
});
if (originOfUrl(page.url()) !== input.origin)
return answer(
`The Bot's browser is not on ${input.origin} any more. Take over the browser to finish signing in.`,
);
try {
if (!(await page.evaluate(markCodeField, LOGIN_ATTRIBUTE)))
return answer("The site is not asking for a verification code any more.");
const codeField = page.locator(`[${LOGIN_ATTRIBUTE}="code"]`);
await typeSecret(codeField, input.code);
await submitFrom(page, codeField);
await settle(page);
const codeFieldVisible =
originOfUrl(page.url()) === input.origin &&
(await page.evaluate(markCodeField, LOGIN_ATTRIBUTE));
return {
submitted: true,
codeFieldVisible,
passwordFieldVisible: await passwordVisible(page),
url: page.url(),
};
} catch {
return answer("The verification code could not be typed into the page.");
}
}
2 changes: 2 additions & 0 deletions agent-computer/tests/authorisation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ describe("what the wheel stops while a person is driving", () => {
"/scroll",
"/exec",
"/files/write",
"/sign-in/fill",
"/sign-in/code",
]) {
expect(actsOnTheComputer(path)).toBeTrue();
}
Expand Down
Loading
Loading