Skip to content
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ ONEPASSWORD_CLIENT_ID=
ONEPASSWORD_CLIENT_SECRET=
# Generate once with openssl rand -base64 32; keep stable and backed up.
ONEPASSWORD_TOKEN_ENCRYPTION_KEY=
# Leave unset until hosted extension support is fixed. Setting this disables reusable Contexts.
# Leave unset until hosted extension support is fixed. Personal-browser mode also requires ONEPASSWORD_ENABLED=true.
BROWSERBASE_1PASSWORD_EXTENSION_ID=
# Optional Linq messaging. Keep tokens and your personal number in .env.local.
LINQ_API_KEY=
Expand Down
31 changes: 21 additions & 10 deletions agent/lib/browser-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CaptchaTransition } from "../../lib/browser/captcha";
import { personalBrowserEnabled, withPersonalBrowserLock } from "../../server/onepassword/browser-policy";
import {
createAgentContext,
Expand Down Expand Up @@ -27,6 +28,7 @@ type BrowserOperation<T> = (browser: BrowserSession) => Promise<T>;
interface BrowserRuntime {
browser: BrowserSession;
state: ConversationState;
captchaTransitions: CaptchaTransition[];
idleTimer?: ReturnType<typeof setTimeout>;
closing?: Promise<void>;
}
Expand Down Expand Up @@ -65,11 +67,14 @@ function getRuntime(sessionId: string): BrowserRuntime {
title: durable.browser.title,
},
};
const captchaTransitions: CaptchaTransition[] = [];
const runtime = {
state,
captchaTransitions,
browser: createRuntimeBrowser(
sessionId,
state,
captchaTransitions,
durable.browser.contextId,
durable.browser.proxyLocation,
),
Expand Down Expand Up @@ -134,16 +139,6 @@ async function runBrowserOperationUnlocked<T>(sessionId: string, operation: Brow
};
}

function commitCaptchaQueue(runtime: BrowserRuntime): void {
const transitions = runtime.browser.drainCaptchaTransitions();
if (!transitions.length) return;
taskState.update((current) => {
let next = current;
for (const transition of transitions) next = applyCaptchaTransition(next, transition);
return updateTaskBrowser(next, runtime.state.browser);
});
}

export async function saveBrowserRuntime(sessionId: string): Promise<void> {
const runtime = runtimes.get(sessionId);
if (runtime)
Expand Down Expand Up @@ -238,6 +233,7 @@ export async function createBrowserContext(
runtime.browser = createRuntimeBrowser(
sessionId,
runtime.state,
runtime.captchaTransitions,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we give each replacement browser a fresh CAPTCHA transition queue here and in selectBrowserContext?

if a CAPTCHA starts and errors between tool calls, createBrowserContext closes the old browser but passes its pending events into the replacement. the next snapshot succeeds on the new browser, then draining the old queue marks the task waiting_for_user for a challenge in the closed browser.

i reproduced this locally; the regression test passes at the merge base and fails on this PR. replacing the queue when replacing the browser also keeps late callbacks from the retired browser out of the new queue.

context.id,
runtime.state.browser.proxyLocation,
);
Expand Down Expand Up @@ -270,6 +266,7 @@ export async function selectBrowserContext(
runtime.browser = createRuntimeBrowser(
sessionId,
runtime.state,
runtime.captchaTransitions,
contextId,
runtime.state.browser.proxyLocation,
);
Expand Down Expand Up @@ -318,16 +315,30 @@ export async function saveBrowserContext(
};
}

function commitCaptchaQueue(runtime: BrowserRuntime): void {
if (!runtime.captchaTransitions.length) return;
const transitions = runtime.captchaTransitions.splice(0);
taskState.update((current) => {
let next = current;
for (const transition of transitions) next = applyCaptchaTransition(next, transition);
return updateTaskBrowser(next, runtime.state.browser);
});
}

function createRuntimeBrowser(
sessionId: string,
state: ConversationState,
captchaTransitions: CaptchaTransition[],
contextId?: string,
proxyLocation?: BrowserProxyLocation,
): BrowserSession {
return new BrowserSession(state, {
contextId,
proxyLocation,
captcha: taskState.get().captcha,
onCaptchaTransition: (transition) => {
captchaTransitions.push(transition);
},
ensureContext: personalBrowserEnabled() ? undefined : async (currentContextId) => {
const context = await ensureAgentContext(sessionId, currentContextId);
state.browser.contextId = context.id;
Expand Down
13 changes: 2 additions & 11 deletions agent/lib/task-state.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { defineState } from "eve/context";

import { captchaTimeoutMs, type CaptchaTransition } from "../../lib/browser/captcha";
import type { BrowserProxyLocation, BrowserState } from "../../server/browser/types.js";

export type TaskStatus =
"idle" | "running" | "solving_captcha" | "waiting_for_user" | "complete" | "failed" | "canceled";
export type CaptchaStatus = "idle" | "solving" | "solved" | "errored" | "timed_out";
export interface CaptchaJournalEntry {
id: string;
type: "started" | "finished" | "errored" | "timed_out";
type: CaptchaTransition["type"];
at: string;
}
export interface DurableCaptchaRecord {
Expand All @@ -20,12 +21,6 @@ export interface DurableCaptchaRecord {
page?: { origin: string; path: string };
journal: CaptchaJournalEntry[];
}
export interface CaptchaTransition {
id: string;
type: CaptchaJournalEntry["type"];
at: string;
page?: { origin: string; path: string };
}
export const CAPTCHA_JOURNAL_LIMIT = 32;
export type BrowserLifecycle =
"not_started" | "starting" | "active" | "disconnected" | "expired" | "closed" | "error";
Expand Down Expand Up @@ -219,10 +214,6 @@ export function reconcileCaptchaAfterRestart(
page: captcha.page,
});
}
export function captchaTimeoutMs(): number {
const value = Number(process.env.BELL_CAPTCHA_TIMEOUT_MS ?? 30_000);
return Number.isFinite(value) && value > 0 ? value : 30_000;
}
function captchaBlocksGeneric(record: DurableTaskRecord): boolean {
return (
record.status === "waiting_for_user" ||
Expand Down
4 changes: 2 additions & 2 deletions docs/onepassword.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ Before changing the flag to `true`, the platform and SDK must support loading bo
| `ONEPASSWORD_TOKEN_ENCRYPTION_KEY` | Stable 32-byte base64 key; generate with `openssl rand -base64 32`. Back it up with the database and do not rotate by overwriting it. |
| `BROWSERBASE_API_KEY` | Browserbase access. |
| `BROWSERBASE_PROJECT_ID` | Optional when the project is inferred from the API key. |
| `BROWSERBASE_1PASSWORD_EXTENSION_ID` | Browserbase upload ID for the supported Agentic Autofill extension. This enables personal-browser mode. |
| `BROWSERBASE_1PASSWORD_EXTENSION_ID` | Browserbase upload ID for the supported Agentic Autofill extension. Personal-browser mode also requires `ONEPASSWORD_ENABLED=true`. |

3. Register the OAuth client through a 1Password Business account. The connecting test user needs a separate US Individual or Families account and the supported desktop app/extension. Follow the current [Browserbase setup guide](https://docs.browserbase.com/integrations/1password/agentic-autofill) for partner access and extension packaging. A regular service-account token is not a replacement for this client or extension.
4. Keep the public callback and `/eve/v1/connections/.../callback/...` paths reachable through Vercel protection without adding query parameters to the registered redirect. The callbacks use server-issued, short-lived capabilities; do not make unrelated application routes public.
5. Keep Linq signature verification, receiving-line configuration, and the sender allowlist enabled. Groups are deliberately rejected. The web operator login does not establish an individual iMessage identity and cannot select someone else's personal account.
6. Start new conversations for the rollout. Remove `BROWSERBASE_CONTEXT_ID` when personal-browser mode is enabled. Bell disables reusable Context creation, selection, saving, and cookie export in this mode. Existing contexts are not deleted, and their cookies are not migrated into personal sessions. Disable the old `ONEPASSWORD_OAUTH_STATE_SECRET`; the new record encryption key replaces its use.

Setting the extension upload ID changes browser launch configuration. Stagehand 4 also needs its own extension. Bell requires Stagehand initialization and the 1Password runtime API to succeed in the same session; failures stop the operation and do not fall back to reading passwords through the SDK. The local synthetic test verifies coexistence in Chrome. Verify the actual hosted extension combination before enabling this on a public deployment; an upload ID alone is not proof of compatibility.
Personal-browser mode requires `ONEPASSWORD_ENABLED=true` and the extension upload ID together. That pair changes browser launch configuration. Stagehand 4 also needs its own extension. Bell requires Stagehand initialization and the 1Password runtime API to succeed in the same session; failures stop the operation and do not fall back to reading passwords through the SDK. The local synthetic test verifies coexistence in Chrome. Verify the actual hosted extension combination before enabling this on a public deployment; an upload ID alone is not proof of compatibility.

## User flow

Expand Down
11 changes: 11 additions & 0 deletions lib/browser/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ export const CAPTCHA_EVENT_NAMES = [
"browserbase-solving-errored",
] as const;
export type CaptchaSignal = "started" | "finished" | "errored";
export interface CaptchaTransition {
id: string;
type: CaptchaSignal | "timed_out";
at: string;
page?: { origin: string; path: string };
}

export function captchaTimeoutMs(): number {
const value = Number(process.env.BELL_CAPTCHA_TIMEOUT_MS ?? 30_000);
return Number.isFinite(value) && value > 0 ? value : 30_000;
}

export function parseBrowserbaseCaptchaEvent(value: unknown): CaptchaSignal | undefined {
if (!value || typeof value !== "object") return;
Expand Down
Loading
Loading