From a36bb18fbc6fa017fcbfae70e38edd0be6254440 Mon Sep 17 00:00:00 2001 From: amelb3 Date: Sun, 25 Oct 2026 13:14:31 -0700 Subject: [PATCH 1/6] refactor: remove standalone Browsie app and unused dependencies --- .env.example | 2 +- .github/workflows/check.yml | 2 +- README.md | 38 +- agent/channels/eve.ts | 4 +- agent/hooks/task-state.ts | 20 +- agent/lib/browser-runtime.ts | 104 +- agent/lib/task-state.ts | 84 +- agent/tools/context_list.ts | 6 +- agent/tools/context_save.ts | 2 +- agent/tools/human_handoff.ts | 2 +- agent/tools/screenshot.ts | 2 +- agent/tools/vault_login.ts | 2 +- app/handoff/[token]/live/route.ts | 2 +- app/handoff/[token]/route.ts | 6 +- docs/baselayer.md | 2 +- docs/browser-runtime.md | 78 + docs/link.md | 6 +- docs/linq.md | 5 +- docs/provider-tests.md | 6 +- docs/repository.md | 73 +- lib/browser/captcha.ts | 4 - .../LICENSE => licenses/browsie-MIT.txt | 0 package.json | 15 +- .../skills/build-browser-agent/SKILL.md | 109 - packages/browsie/.env.example | 44 - packages/browsie/.gitignore | 10 - packages/browsie/CONTRIBUTING.md | 47 - packages/browsie/README.md | 185 -- packages/browsie/agent/agent.ts | 67 - packages/browsie/agent/channels/eve.ts | 1 - packages/browsie/agent/channels/linq.ts | 34 - .../browsie/agent/hooks/browser-lifecycle.ts | 1 - packages/browsie/agent/hooks/task-state.ts | 1 - packages/browsie/agent/instructions.md | 98 - .../skills/amazon-product-research/SKILL.md | 12 - .../agent/skills/browser-basics/SKILL.md | 14 - .../browsie/agent/skills/fill-form/SKILL.md | 15 - packages/browsie/agent/tools/agent.ts | 3 - packages/browsie/agent/tools/bash.ts | 3 - .../browsie/agent/tools/context_create.ts | 1 - packages/browsie/agent/tools/context_list.ts | 1 - packages/browsie/agent/tools/context_save.ts | 1 - .../browsie/agent/tools/context_select.ts | 1 - packages/browsie/agent/tools/human_handoff.ts | 1 - .../browsie/agent/tools/proxy_location.ts | 23 - packages/browsie/agent/tools/read_file.ts | 3 - packages/browsie/agent/tools/run.ts | 1 - packages/browsie/agent/tools/screenshot.ts | 1 - packages/browsie/agent/tools/snapshot.ts | 1 - .../browsie/agent/tools/vault_list_logins.ts | 1 - packages/browsie/agent/tools/vault_login.ts | 1 - packages/browsie/agent/tools/web_fetch.ts | 3 - packages/browsie/agent/tools/web_search.ts | 3 - packages/browsie/agent/tools/write_file.ts | 3 - .../app/api/browser-live-view/route.ts | 50 - .../app/api/context-capability/route.ts | 19 - .../browsie/app/api/context-sessions/route.ts | 77 - packages/browsie/app/api/contexts/route.ts | 69 - packages/browsie/app/api/tasks/route.ts | 112 - .../browsie/app/api/vault/native/route.ts | 57 - .../app/api/vault/onepassword/route.ts | 17 - packages/browsie/app/fixture/captcha/route.ts | 12 - packages/browsie/app/fixture/form/route.ts | 14 - .../browsie/app/handoff/[token]/live/route.ts | 2 - packages/browsie/app/handoff/[token]/route.ts | 2 - packages/browsie/app/layout.tsx | 16 - packages/browsie/app/page.tsx | 11 - packages/browsie/app/s/[sessionId]/page.tsx | 13 - packages/browsie/app/s/page.tsx | 11 - packages/browsie/docs/agent-layers.md | 53 - packages/browsie/docs/contexts-and-vaults.md | 25 - packages/browsie/docs/stagehand-harness.md | 85 - packages/browsie/docs/stagehand-v4-doc-map.md | 88 - packages/browsie/docs/vault-and-otp.md | 13 - packages/browsie/next-env.d.ts | 7 - packages/browsie/next.config.ts | 18 - packages/browsie/proxy.ts | 7 - packages/browsie/server/agent-live-view.ts | 31 - packages/browsie/server/context-capability.ts | 46 - packages/browsie/server/context-studio.ts | 440 ---- packages/browsie/src/App.tsx | 1457 ------------ packages/browsie/src/BrowserLiveView.tsx | 51 - packages/browsie/src/ContextStudio.tsx | 435 ---- packages/browsie/src/VaultStudio.tsx | 313 --- packages/browsie/src/chat-scroll.ts | 10 - packages/browsie/src/client-context.ts | 22 - packages/browsie/src/context-studio.ts | 70 - packages/browsie/src/styles.css | 2023 ----------------- packages/browsie/src/task-history.ts | 32 - packages/browsie/src/types.ts | 58 - .../tests/browser-live-view-ui.test.tsx | 42 - packages/browsie/tests/chat-scroll.test.ts | 35 - .../browsie/tests/context-studio-ui.test.tsx | 163 -- packages/browsie/tests/context-studio.test.ts | 231 -- packages/browsie/tests/linq-channel.test.ts | 111 - packages/browsie/tests/prompt.test.ts | 27 - packages/browsie/tests/server-only.ts | 1 - .../browsie/tests/studio-security.test.ts | 129 -- packages/browsie/tests/task-history.test.ts | 10 - packages/browsie/tests/task-list.test.ts | 43 - packages/browsie/tsconfig.json | 42 - packages/browsie/vercel.json | 3 - pnpm-lock.yaml | 424 +--- server/browser/agent-contexts.ts | 2 +- server/browser/browser-session.ts | 6 +- server/browser/browserbase-console.ts | 158 -- server/browser/context-metadata.ts | 3 - server/browser/vault/native.ts | 92 +- server/browser/vault/onepassword.ts | 2 +- server/browser/vault/types.ts | 34 - .../studio-auth.ts => operator-auth.ts} | 25 +- tests/browser/agent-contexts.test.ts | 51 + .../browser}/browser-close.test.ts | 10 +- .../browser}/browser-restart-real.test.ts | 77 +- .../browser}/browser-security.test.ts | 4 +- .../browser}/cancellation.test.ts | 2 +- .../browser}/captcha-live.test.ts | 29 +- .../browser}/captcha-state.test.ts | 8 +- .../browser}/context-cancellation.test.ts | 10 +- .../browser}/context-save-real.test.ts | 8 +- .../browser}/eve-origin.test.ts | 12 +- tests/browser/fixtures/native-vault.json | 27 + .../tests => tests/browser}/handoff.test.ts | 14 +- .../browser}/hosted-page.test.ts | 4 +- .../browser}/human-handoff-tool.test.ts | 12 +- tests/browser/linq-environment.test.ts | 19 + .../tests => tests/browser}/live-view.test.ts | 2 +- .../browser}/onepassword-cli.test.ts | 2 +- .../browser}/onepassword-login-real.test.ts | 6 +- tests/browser/operator-auth.test.ts | 40 + .../browser}/proxy-location-real.test.ts | 4 +- .../browser}/recaptcha-real.test.ts | 4 +- .../tests => tests/browser}/recovery.test.ts | 2 +- .../tests => tests/browser}/redact.test.ts | 2 +- .../tests => tests/browser}/run-code.test.ts | 2 +- .../browser}/task-state.test.ts | 93 +- .../tests => tests/browser}/vault.test.ts | 50 +- tests/projection.test.ts | 2 +- tsconfig.json | 3 +- vitest.config.ts | 3 +- 140 files changed, 533 insertions(+), 8472 deletions(-) create mode 100644 docs/browser-runtime.md rename packages/browsie/LICENSE => licenses/browsie-MIT.txt (100%) delete mode 100644 packages/browsie/.agents/skills/build-browser-agent/SKILL.md delete mode 100644 packages/browsie/.env.example delete mode 100644 packages/browsie/.gitignore delete mode 100644 packages/browsie/CONTRIBUTING.md delete mode 100644 packages/browsie/README.md delete mode 100644 packages/browsie/agent/agent.ts delete mode 100644 packages/browsie/agent/channels/eve.ts delete mode 100644 packages/browsie/agent/channels/linq.ts delete mode 100644 packages/browsie/agent/hooks/browser-lifecycle.ts delete mode 100644 packages/browsie/agent/hooks/task-state.ts delete mode 100644 packages/browsie/agent/instructions.md delete mode 100644 packages/browsie/agent/skills/amazon-product-research/SKILL.md delete mode 100644 packages/browsie/agent/skills/browser-basics/SKILL.md delete mode 100644 packages/browsie/agent/skills/fill-form/SKILL.md delete mode 100644 packages/browsie/agent/tools/agent.ts delete mode 100644 packages/browsie/agent/tools/bash.ts delete mode 100644 packages/browsie/agent/tools/context_create.ts delete mode 100644 packages/browsie/agent/tools/context_list.ts delete mode 100644 packages/browsie/agent/tools/context_save.ts delete mode 100644 packages/browsie/agent/tools/context_select.ts delete mode 100644 packages/browsie/agent/tools/human_handoff.ts delete mode 100644 packages/browsie/agent/tools/proxy_location.ts delete mode 100644 packages/browsie/agent/tools/read_file.ts delete mode 100644 packages/browsie/agent/tools/run.ts delete mode 100644 packages/browsie/agent/tools/screenshot.ts delete mode 100644 packages/browsie/agent/tools/snapshot.ts delete mode 100644 packages/browsie/agent/tools/vault_list_logins.ts delete mode 100644 packages/browsie/agent/tools/vault_login.ts delete mode 100644 packages/browsie/agent/tools/web_fetch.ts delete mode 100644 packages/browsie/agent/tools/web_search.ts delete mode 100644 packages/browsie/agent/tools/write_file.ts delete mode 100644 packages/browsie/app/api/browser-live-view/route.ts delete mode 100644 packages/browsie/app/api/context-capability/route.ts delete mode 100644 packages/browsie/app/api/context-sessions/route.ts delete mode 100644 packages/browsie/app/api/contexts/route.ts delete mode 100644 packages/browsie/app/api/tasks/route.ts delete mode 100644 packages/browsie/app/api/vault/native/route.ts delete mode 100644 packages/browsie/app/api/vault/onepassword/route.ts delete mode 100644 packages/browsie/app/fixture/captcha/route.ts delete mode 100644 packages/browsie/app/fixture/form/route.ts delete mode 100644 packages/browsie/app/handoff/[token]/live/route.ts delete mode 100644 packages/browsie/app/handoff/[token]/route.ts delete mode 100644 packages/browsie/app/layout.tsx delete mode 100644 packages/browsie/app/page.tsx delete mode 100644 packages/browsie/app/s/[sessionId]/page.tsx delete mode 100644 packages/browsie/app/s/page.tsx delete mode 100644 packages/browsie/docs/agent-layers.md delete mode 100644 packages/browsie/docs/contexts-and-vaults.md delete mode 100644 packages/browsie/docs/stagehand-harness.md delete mode 100644 packages/browsie/docs/stagehand-v4-doc-map.md delete mode 100644 packages/browsie/docs/vault-and-otp.md delete mode 100644 packages/browsie/next-env.d.ts delete mode 100644 packages/browsie/next.config.ts delete mode 100644 packages/browsie/proxy.ts delete mode 100644 packages/browsie/server/agent-live-view.ts delete mode 100644 packages/browsie/server/context-capability.ts delete mode 100644 packages/browsie/server/context-studio.ts delete mode 100644 packages/browsie/src/App.tsx delete mode 100644 packages/browsie/src/BrowserLiveView.tsx delete mode 100644 packages/browsie/src/ContextStudio.tsx delete mode 100644 packages/browsie/src/VaultStudio.tsx delete mode 100644 packages/browsie/src/chat-scroll.ts delete mode 100644 packages/browsie/src/client-context.ts delete mode 100644 packages/browsie/src/context-studio.ts delete mode 100644 packages/browsie/src/styles.css delete mode 100644 packages/browsie/src/task-history.ts delete mode 100644 packages/browsie/src/types.ts delete mode 100644 packages/browsie/tests/browser-live-view-ui.test.tsx delete mode 100644 packages/browsie/tests/chat-scroll.test.ts delete mode 100644 packages/browsie/tests/context-studio-ui.test.tsx delete mode 100644 packages/browsie/tests/context-studio.test.ts delete mode 100644 packages/browsie/tests/linq-channel.test.ts delete mode 100644 packages/browsie/tests/prompt.test.ts delete mode 100644 packages/browsie/tests/server-only.ts delete mode 100644 packages/browsie/tests/studio-security.test.ts delete mode 100644 packages/browsie/tests/task-history.test.ts delete mode 100644 packages/browsie/tests/task-list.test.ts delete mode 100644 packages/browsie/tsconfig.json delete mode 100644 packages/browsie/vercel.json delete mode 100644 server/browser/browserbase-console.ts delete mode 100644 server/browser/context-metadata.ts rename server/{browser/studio-auth.ts => operator-auth.ts} (60%) create mode 100644 tests/browser/agent-contexts.test.ts rename {packages/browsie/tests => tests/browser}/browser-close.test.ts (91%) rename {packages/browsie/tests => tests/browser}/browser-restart-real.test.ts (51%) rename {packages/browsie/tests => tests/browser}/browser-security.test.ts (98%) rename {packages/browsie/tests => tests/browser}/cancellation.test.ts (98%) rename {packages/browsie/tests => tests/browser}/captcha-live.test.ts (54%) rename {packages/browsie/tests => tests/browser}/captcha-state.test.ts (96%) rename {packages/browsie/tests => tests/browser}/context-cancellation.test.ts (89%) rename {packages/browsie/tests => tests/browser}/context-save-real.test.ts (90%) rename {packages/browsie/tests => tests/browser}/eve-origin.test.ts (83%) create mode 100644 tests/browser/fixtures/native-vault.json rename {packages/browsie/tests => tests/browser}/handoff.test.ts (91%) rename {packages/browsie/tests => tests/browser}/hosted-page.test.ts (97%) rename {packages/browsie/tests => tests/browser}/human-handoff-tool.test.ts (86%) create mode 100644 tests/browser/linq-environment.test.ts rename {packages/browsie/tests => tests/browser}/live-view.test.ts (91%) rename {packages/browsie/tests => tests/browser}/onepassword-cli.test.ts (94%) rename {packages/browsie/tests => tests/browser}/onepassword-login-real.test.ts (92%) create mode 100644 tests/browser/operator-auth.test.ts rename {packages/browsie/tests => tests/browser}/proxy-location-real.test.ts (89%) rename {packages/browsie/tests => tests/browser}/recaptcha-real.test.ts (91%) rename {packages/browsie/tests => tests/browser}/recovery.test.ts (98%) rename {packages/browsie/tests => tests/browser}/redact.test.ts (95%) rename {packages/browsie/tests => tests/browser}/run-code.test.ts (92%) rename {packages/browsie/tests => tests/browser}/task-state.test.ts (67%) rename {packages/browsie/tests => tests/browser}/vault.test.ts (51%) diff --git a/.env.example b/.env.example index d11cd3e..aa8ace1 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ BELL_DEMO_URL=https://bell-stack-demo-site.vercel.app/protected STAGEHAND_BROWSER=browserbase # Disable network logging before any merchant credentials are presented. BROWSIE_LOG_SESSION=false -# Optional shared-runtime features: +# Optional browser runtime features (legacy BROWSIE_* names remain supported): # Local handoff only. For phone use, set this to Bell's deployed HTTPS origin. BROWSIE_PUBLIC_URL=http://127.0.0.1:4320 BROWSIE_HANDOFF_SECRET= diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 76307e0..a3fcda3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -36,4 +36,4 @@ jobs: with: node-version: 24 - run: pnpm install --frozen-lockfile - - run: pnpm check:workspace + - run: pnpm check diff --git a/README.md b/README.md index 7eeb3a6..4fe4401 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,10 @@ in one persistent Browserbase session. Ask for a screenshot to update the captur messages at the same `/s/...` address reuse the conversation and browser. Stop cancels active work. The panel is a screenshot viewer; use `human_handoff` when you need to interact with the browser. -Browser tools, hooks, and runtime code live directly in Bell. Both web apps use the root -`package.json` and lockfile; there is no separate browser package or library build. -The optional Browsie interface remains in `packages/browsie`; run it with `pnpm dev:studio`. -Its existing environment and data paths are unchanged. Restart the running app after runtime edits. -See [repository layout and source provenance](docs/repository.md). +Browser tools, hooks, and runtime code live directly in Bell. One Next.js app and one Eve agent +use the root `package.json` and lockfile. Restart Bell after runtime edits. See +[repository layout and source provenance](docs/repository.md) and +[browser configuration and credential handling](docs/browser-runtime.md). For phone use, point Linq directly at Bell's HTTPS deployment and use the same origin for handoff links. Hosted browser launches keep `browserSettings.verified: true` and @@ -66,7 +65,7 @@ Best Buy browsing does not require Baselayer and does not demonstrate merchant v the request when prompted. Bell must run on the same computer as the desktop app. For hosted access, set `OP_SERVICE_ACCOUNT_TOKEN` instead; it takes precedence over `OP_ACCOUNT`. Save a Login item with the destination website, username, and password before asking Bell to log in. - Add a one-time password field if the site uses TOTP. The shared vault tools enforce the saved + Add a one-time password field if the site uses TOTP. The vault tools enforce the saved website host and HTTPS before filling credentials. Keep `BROWSIE_LOG_SESSION=false` before starting a hosted login session. Credentials are resolved on the server; a URL alone does not provide a login. @@ -76,22 +75,23 @@ Best Buy browsing does not require Baselayer and does not demonstrate merchant v - Handoff: set a random `BROWSIE_HANDOFF_SECRET` of at least 32 characters and `BROWSIE_PUBLIC_URL` to the app's reachable origin. Configure this before asking for a handoff. -The shared runtime retains its `BROWSIE_*` configuration names. Bell does not copy the Context or -Vault administration screens. Their agent tools remain available. Link checkout is available -through the private Linq flow after configuration and approval. Visa is not integrated. +The browser runtime retains its `BROWSIE_*` configuration names and `.browsie` data paths for +compatibility. Context and vault tools run in Bell; there is no separate Browsie app to start. +Link checkout is available through the private Linq flow after configuration and approval. +Visa is not integrated. ## Check the code ```sh -pnpm check:workspace +pnpm check ``` -This checks Browsie and Bell, including both standalone production builds. Browsie's live tests -remain opt-in and skip without their required configuration. The default suite makes no live -provider calls. Set `TEST_LINK_DATABASE_URL` to a disposable PostgreSQL database to include -Link persistence tests; CI runs these automatically. A successful build verifies compilation only. -Keep this demo local until you configure application authentication and durable storage. Eve's -default HTTP channel rejects unauthenticated production traffic. The shared vault/Context data -uses local files and is scoped to a single demo operator. See -[`packages/browsie/README.md`](packages/browsie/README.md) for runtime -settings and [the vault guide](packages/browsie/docs/vault-and-otp.md) for the credential boundary. +This runs tests, TypeScript, lint, and the Bell production build. Browser runtime tests live in +`tests/browser`; live provider tests remain opt-in and skip without their required configuration. +The default suite makes no live provider calls. Set `TEST_LINK_DATABASE_URL` to a disposable +PostgreSQL database to include Link persistence tests; CI runs these automatically. A successful build verifies compilation only. +Keep this demo local until you configure application authentication and durable storage. Bell's +HTTP channel rejects unauthenticated production traffic. Vault and Context metadata use local +files scoped to a single demo operator. See the [browser runtime guide](docs/browser-runtime.md) +for storage, authentication, and credential boundaries and [provider checks](docs/provider-tests.md) +for live regression commands. diff --git a/agent/channels/eve.ts b/agent/channels/eve.ts index 8f98769..781c724 100644 --- a/agent/channels/eve.ts +++ b/agent/channels/eve.ts @@ -2,9 +2,9 @@ import { eveChannel } from "eve/channels/eve"; import { localDev, vercelOidc } from "eve/channels/auth"; import { browserChannelEvents } from "../lib/browser-runtime"; -import { studioAuth } from "../../server/browser/studio-auth"; +import { operatorAuth } from "../../server/operator-auth"; export default eveChannel({ - auth: [studioAuth, vercelOidc(), localDev()], + auth: [operatorAuth, vercelOidc(), localDev()], events: browserChannelEvents, }); diff --git a/agent/hooks/task-state.ts b/agent/hooks/task-state.ts index 0dbca44..101564a 100644 --- a/agent/hooks/task-state.ts +++ b/agent/hooks/task-state.ts @@ -1,22 +1,12 @@ -import { defineHook } from "eve/hooks"; +import { defineHook, type HookEvent } from "eve/hooks"; import { closeBrowserRuntime } from "../lib/browser-runtime"; import { applyTaskEvent, taskState } from "../lib/task-state"; -const update = (event: unknown, ctx: { session: { id: string } }) => - taskState.update((current) => - applyTaskEvent( - current, - event as { - type: string; - data?: Record; - meta?: { id?: string; at?: string }; - }, - ctx.session.id, - ), - ); +const update = (event: HookEvent, ctx: { session: { id: string } }) => + taskState.update((current) => applyTaskEvent(current, event, ctx.session.id)); -const cancel = async (event: unknown, ctx: { session: { id: string } }) => { +const cancel = async (event: HookEvent<"turn.cancelled">, ctx: { session: { id: string } }) => { update(event, ctx); await closeBrowserRuntime(ctx.session.id); }; @@ -26,8 +16,6 @@ export default defineHook({ "turn.started": update, "message.received": update, "step.started": update, - "message.completed": update, - "action.result": update, "input.requested": update, "input.resolved": update, "turn.completed": update, diff --git a/agent/lib/browser-runtime.ts b/agent/lib/browser-runtime.ts index f493d1f..5c15f15 100644 --- a/agent/lib/browser-runtime.ts +++ b/agent/lib/browser-runtime.ts @@ -7,7 +7,6 @@ import { selectAgentContext, } from "../../server/browser/agent-contexts.js"; import { BrowsieBrowserSession, CaptchaHandoffError } from "../../server/browser/browser-session.js"; -import { addTrace } from "../../server/browser/trace.js"; import type { BrowserProxyLocation, ConversationState, TraceEvent } from "../../server/browser/types.js"; import { runWithCancellation } from "./cancellation"; import { @@ -15,7 +14,6 @@ import { closeTaskBrowser, reconcileCaptchaAfterRestart, recordTaskBrowserContext, - recordTaskBrowserProxyLocation, setTaskBrowserContext, taskState, updateTaskBrowser, @@ -56,7 +54,7 @@ function getRuntime(sessionId: string): BrowserRuntime { traces: [], browser: { provider: durable.browser.sessionId ? "browserbase" : "not-started", - status: durable.browser.lifecycle === "active" ? "idle" : "idle", + status: "idle", contextId: durable.browser.contextId, contextStatus: durable.browser.contextStatus, proxyLocation: durable.browser.proxyLocation, @@ -98,7 +96,7 @@ export async function runBrowserOperation( ), new Promise((resolve) => setTimeout(resolve, 50)), ]); - commitCaptchaQueue(runtime, traceStart); + commitCaptchaQueue(runtime); } let result: T; try { @@ -111,7 +109,7 @@ export async function runBrowserOperation( action: "Complete the challenge in the existing Live View, then resume this task.", } as T; } - commitCaptchaQueue(runtime, traceStart); + commitCaptchaQueue(runtime); scheduleIdleClose(sessionId, runtime); return { result, @@ -128,30 +126,20 @@ export async function runBrowserOperation( }; } -function commitCaptchaQueue(runtime: BrowserRuntime, traceStart: number): void { +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, - runtime.state.traces.slice(traceStart).map((trace) => trace.id), - ); + return updateTaskBrowser(next, runtime.state.browser); }); } export async function saveBrowserRuntime(sessionId: string): Promise { const runtime = runtimes.get(sessionId); if (runtime) - taskState.update((current) => - updateTaskBrowser( - current, - runtime.state.browser, - runtime.state.traces.map((trace) => trace.id), - ), - ); + taskState.update((current) => updateTaskBrowser(current, runtime.state.browser)); } export async function closeBrowserRuntime(sessionId: string): Promise { @@ -165,13 +153,7 @@ export async function closeBrowserRuntime(sessionId: string): Promise { runtime.closing ??= runtime.browser.close().then(() => { runtimes.delete(sessionId); taskState.update((current) => - closeTaskBrowser( - updateTaskBrowser( - current, - runtime.state.browser, - runtime.state.traces.map((trace) => trace.id), - ), - ), + closeTaskBrowser(updateTaskBrowser(current, runtime.state.browser)), ); }).finally(() => { runtime.closing = undefined; }); return runtime.closing; @@ -191,10 +173,6 @@ export function hasBrowserRuntime(sessionId: string): boolean { return runtimes.has(sessionId); } -export async function listBrowserContexts() { - return listAgentContexts(); -} - export async function createBrowserContext( sessionId: string, name: string, @@ -303,7 +281,7 @@ export async function saveBrowserContext( if (!contextId) { const created = await createBrowserContext( sessionId, - name?.trim() || `Browsie ${new Date().toISOString().slice(0, 10)}`, + name?.trim() || `Bell ${new Date().toISOString().slice(0, 10)}`, signal, ); contextId = created.id; @@ -328,51 +306,6 @@ export async function saveBrowserContext( }; } -export function configureBrowserProxyLocation( - sessionId: string, - requested: BrowserProxyLocation, -): BrowserToolOutput<{ - status: "configured"; - location: BrowserProxyLocation; - appliesTo: "next_browser_session"; -}> { - if (!process.env.BROWSERBASE_API_KEY) throw new Error("Browserbase is not configured."); - const runtime = getRuntime(sessionId); - if (runtime.browser.hasStarted() || runtime.state.browser.sessionId) { - throw new Error( - "Proxy location must be set before the first browser action. Start a new task to use another location.", - ); - } - const location = normalizeProxyLocation(requested); - const traceStart = runtime.state.traces.length; - runtime.state.browser.proxyLocation = location; - taskState.update((current) => recordTaskBrowserProxyLocation(current, location)); - runtime.browser = createRuntimeBrowser( - sessionId, - runtime.state, - runtime.state.browser.contextId, - location, - ); - addTrace( - runtime.state, - "browser", - "proxy.location.configure", - `Set the next Browserbase session proxy location to ${formatProxyLocation(location)}.`, - { location, appliesTo: "next_browser_session" }, - ); - return { - result: { - status: "configured", - location, - appliesTo: "next_browser_session", - }, - workbench: { - browser: { ...runtime.state.browser }, - traces: runtime.state.traces.slice(traceStart), - }, - }; -} - function createRuntimeBrowser( sessionId: string, state: ConversationState, @@ -392,29 +325,10 @@ function createRuntimeBrowser( }); } -function normalizeProxyLocation(location: BrowserProxyLocation): BrowserProxyLocation { - const country = location.country.trim().toUpperCase(); - const state = location.state?.trim().toUpperCase(); - const city = location.city?.trim(); - if (!/^[A-Z]{2}$/.test(country)) - throw new Error("Proxy country must be a two-letter country code."); - if (state && !/^[A-Z0-9_-]{1,32}$/.test(state)) throw new Error("Proxy state is invalid."); - if (city && city.length > 80) throw new Error("Proxy city is too long."); - return { - country, - ...(state ? { state } : {}), - ...(city ? { city } : {}), - }; -} - -function formatProxyLocation(location: BrowserProxyLocation): string { - return [location.city, location.state, location.country].filter(Boolean).join(", "); -} - function defaultContextName(record: { title: string }): string { const title = record.title.trim(); if (title && title !== "New task") return title.slice(0, 80); - return `Browsie ${new Date().toISOString().slice(0, 10)}`; + return `Bell ${new Date().toISOString().slice(0, 10)}`; } function scheduleIdleClose(sessionId: string, runtime: BrowserRuntime): void { diff --git a/agent/lib/task-state.ts b/agent/lib/task-state.ts index b8bb607..8ba7703 100644 --- a/agent/lib/task-state.ts +++ b/agent/lib/task-state.ts @@ -29,18 +29,11 @@ export interface CaptchaTransition { export const CAPTCHA_JOURNAL_LIMIT = 32; export type BrowserLifecycle = "not_started" | "starting" | "active" | "disconnected" | "expired" | "closed" | "error"; -export interface DurableTaskMessage { - id: string; - role: "user" | "assistant"; - text: string; - at: string; -} export interface DurableTaskRecord { version: 1; taskId: string; sessionId: string; title: string; - messages: DurableTaskMessage[]; status: TaskStatus; activeStep?: string; waitReason?: "otp" | "captcha" | "approval" | "other"; @@ -54,29 +47,18 @@ export interface DurableTaskRecord { url?: string; title?: string; }; - loadedSkillIds: string[]; - artifacts: Array<{ - id: string; - kind: string; - label: string; - createdAt: string; - }>; - traceEventIds: string[]; processedEventIds: string[]; createdAt: string; updatedAt: string; } +// Keep the persisted key so existing Bell sessions retain their browser and Context. export const taskState = defineState("browsie.task.v1", () => ({ version: 1, taskId: "", sessionId: "", title: "New task", - messages: [], status: "idle", browser: { lifecycle: "not_started" }, - loadedSkillIds: [], - artifacts: [], - traceEventIds: [], processedEventIds: [], createdAt: "", updatedAt: "", @@ -108,24 +90,9 @@ export function applyTaskEvent( processedEventIds: id ? [...next.processedEventIds, id].slice(-2_000) : next.processedEventIds, updatedAt: at, }; - if (event.type === "message.received") { + if (event.type === "message.received" && next.title === "New task") { const text = safeText(event.data?.message ?? event.data?.text); - if (text) - next = appendMessage(next, { - id: id ?? `user-${at}`, - role: "user", - text, - at, - }); - } else if (event.type === "message.completed") { - const text = safeText(event.data?.message); - if (text) - next = appendMessage(next, { - id: id ?? `assistant-${at}`, - role: "assistant", - text, - at, - }); + if (text) next = { ...next, title: text.slice(0, 64) }; } else if (event.type === "turn.started") next = captchaBlocksGeneric(next) ? next @@ -153,26 +120,7 @@ export function applyTaskEvent( activeStep: "resuming", waitReason: undefined, }; - else if (event.type === "action.result") { - const result = event.data?.result; - if (result && typeof result === "object") { - const action = result as Record; - if (action.kind === "load-skill-result" && typeof action.name === "string" && !action.isError) - next = { - ...next, - loadedSkillIds: [...new Set([...next.loadedSkillIds, action.name])], - }; - } - } else if (event.type === "turn.completed") - next = captchaBlocksGeneric(next) - ? next - : { - ...next, - status: "idle", - activeStep: undefined, - waitReason: undefined, - }; - else if (event.type === "session.waiting") + else if (event.type === "turn.completed" || event.type === "session.waiting") next = captchaBlocksGeneric(next) ? next : { @@ -295,7 +243,6 @@ function captchaHandoff( export function updateTaskBrowser( record: DurableTaskRecord, browser: BrowserState, - traceIds: string[], ): DurableTaskRecord { const lifecycle: BrowserLifecycle = browser.status === "starting" @@ -326,7 +273,6 @@ export function updateTaskBrowser( url: safePageUrl(browser.url), title: safeText(browser.title), }, - traceEventIds: [...new Set([...record.traceEventIds, ...traceIds])].slice(-2_000), updatedAt: new Date().toISOString(), }; } @@ -370,20 +316,6 @@ export function recordTaskBrowserContext( }; } -export function recordTaskBrowserProxyLocation( - record: DurableTaskRecord, - proxyLocation: BrowserProxyLocation, -): DurableTaskRecord { - return { - ...record, - browser: { - ...record.browser, - proxyLocation, - }, - updatedAt: new Date().toISOString(), - }; -} - export function closeTaskBrowser(record: DurableTaskRecord): DurableTaskRecord { return { ...record, @@ -395,14 +327,6 @@ export function closeTaskBrowser(record: DurableTaskRecord): DurableTaskRecord { updatedAt: new Date().toISOString(), }; } -function appendMessage(record: DurableTaskRecord, message: DurableTaskMessage): DurableTaskRecord { - if (record.messages.some((item) => item.id === message.id)) return record; - const title = - record.messages.some((item) => item.role === "user") || message.role !== "user" - ? record.title - : message.text.slice(0, 64); - return { ...record, title, messages: [...record.messages, message] }; -} function classifyWait(data?: Record): DurableTaskRecord["waitReason"] { const value = JSON.stringify(data ?? {}).toLowerCase(); if (value.includes("captcha")) return "captcha"; diff --git a/agent/tools/context_list.ts b/agent/tools/context_list.ts index b1f5122..04a8c7f 100644 --- a/agent/tools/context_list.ts +++ b/agent/tools/context_list.ts @@ -1,14 +1,14 @@ import { defineTool, toolOutput } from "eve/tools"; import { z } from "zod"; -import { listBrowserContexts } from "../lib/browser-runtime"; +import { listAgentContexts } from "../../server/browser/agent-contexts"; export default defineTool({ description: - "List reusable Browserbase Contexts that Browsie can select for a later browser task. Context IDs are safe metadata, not credentials.", + "List reusable Browserbase Contexts that Bell can select for a later browser task. Context IDs are safe metadata, not credentials.", inputSchema: z.object({}), async execute() { - const contexts = await listBrowserContexts(); + const contexts = await listAgentContexts(); return { contexts: contexts.map(({ id, name, selected, writerActive }) => ({ id, diff --git a/agent/tools/context_save.ts b/agent/tools/context_save.ts index 09c2feb..6c3f29d 100644 --- a/agent/tools/context_save.ts +++ b/agent/tools/context_save.ts @@ -5,7 +5,7 @@ import { runCancelableBrowserWork, saveBrowserContext } from "../lib/browser-run export default defineTool({ description: - "Finish and save the current browser identity as a reusable Browserbase Context. This closes the browser and waits for Browserbase synchronization. If the task began as a fresh browser, provide a name and Browsie will create a Context and preserve the current cookies before it closes. Never claim that a Context is saved until this tool returns status saved.", + "Finish and save the current browser identity as a reusable Browserbase Context. This closes the browser and waits for Browserbase synchronization. If the task began as a fresh browser, provide a name and Bell will create a Context and preserve the current cookies before it closes. Never claim that a Context is saved until this tool returns status saved.", inputSchema: z.object({ name: z.string().trim().min(1).max(80).optional(), }), diff --git a/agent/tools/human_handoff.ts b/agent/tools/human_handoff.ts index 6c8b5da..5580778 100644 --- a/agent/tools/human_handoff.ts +++ b/agent/tools/human_handoff.ts @@ -9,7 +9,7 @@ const HANDOFF_TTL_MS = 10 * 60 * 1_000; export default defineTool({ description: - "Create a short-lived Browsie Live View link when the user must act in the current browser, such as completing an OTP, login, CAPTCHA, passkey, or other human-only step. The browser must already be running. After this tool returns, print the complete raw handoff URL on its own line, never hide it in Markdown link syntax, ask the user to reply done, and end the turn. Do not call ask_question.", + "Create a short-lived Browserbase Live View link when the user must act in the current browser, such as completing an OTP, login, CAPTCHA, passkey, or other human-only step. The browser must already be running. After this tool returns, print the complete raw handoff URL on its own line, never hide it in Markdown link syntax, ask the user to reply done, and end the turn.", inputSchema: z.object({ reason: z.enum(["otp", "login", "captcha", "passkey", "other"]), }), diff --git a/agent/tools/screenshot.ts b/agent/tools/screenshot.ts index cf2a1da..f0dfbb6 100644 --- a/agent/tools/screenshot.ts +++ b/agent/tools/screenshot.ts @@ -5,7 +5,7 @@ import { runBrowserOperation, runCancelableBrowserWork } from "../lib/browser-ru export default defineTool({ description: - "Capture the visible page when layout, images, or visual state are important. The Browsie workbench shows the image.", + "Capture the visible page when layout, images, or visual state are important. Bell's web chat shows the image in its browser preview.", inputSchema: z.object({}), async execute(_input, ctx) { return runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => diff --git a/agent/tools/vault_login.ts b/agent/tools/vault_login.ts index 08166b9..29df73d 100644 --- a/agent/tools/vault_login.ts +++ b/agent/tools/vault_login.ts @@ -10,7 +10,7 @@ import { taskState } from "../lib/task-state"; const selector = z.string().min(1).max(500); export default defineTool({ description: - "Fill and submit a mapped Login without exposing credentials to the model. Navigate and snapshot first. Pass the opaque item handle plus exact selectors. A successTarget is required to prove login. If an OTP field exists but the item has no TOTP secret, this returns waiting_for_user; immediately call ask_question so the Eve task parks durably.", + "Fill and submit a mapped Login without exposing credentials to the model. Navigate and snapshot first. Pass the opaque item handle plus exact selectors. A successTarget is required to prove login. If this returns waiting_for_user, call human_handoff with reason otp (or captcha for a CAPTCHA challenge), share the returned handoff URL, and end the turn until the user replies done.", inputSchema: z.object({ provider: z.enum(["native", "onepassword"]), itemId: z.string().min(1), diff --git a/app/handoff/[token]/live/route.ts b/app/handoff/[token]/live/route.ts index 2a37db5..f5749f9 100644 --- a/app/handoff/[token]/live/route.ts +++ b/app/handoff/[token]/live/route.ts @@ -28,7 +28,7 @@ export async function GET(_request: Request, context: { params: Promise<{ token: { error: status === 410 - ? "This handoff link expired. Ask Browsie for a new link." + ? "This handoff link expired. Ask Bell for a new link." : "The live browser is not available.", }, { diff --git a/app/handoff/[token]/route.ts b/app/handoff/[token]/route.ts index c92fcec..badb16e 100644 --- a/app/handoff/[token]/route.ts +++ b/app/handoff/[token]/route.ts @@ -22,7 +22,7 @@ function handoffPage(livePath: string): string { - Browsie human handoff + Bell human handoff
Browsie fixture

Tell us about you

This form tests a safe, multi-step browser task.

-
-
-`; - -export function GET() { - return new Response(formFixtureHtml, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); -} diff --git a/packages/browsie/app/handoff/[token]/live/route.ts b/packages/browsie/app/handoff/[token]/live/route.ts deleted file mode 100644 index d68fab5..0000000 --- a/packages/browsie/app/handoff/[token]/live/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GET } from "../../../../../../app/handoff/[token]/live/route"; -export const runtime = "nodejs"; diff --git a/packages/browsie/app/handoff/[token]/route.ts b/packages/browsie/app/handoff/[token]/route.ts deleted file mode 100644 index 1115733..0000000 --- a/packages/browsie/app/handoff/[token]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { GET } from "../../../../../app/handoff/[token]/route"; -export const runtime = "nodejs"; diff --git a/packages/browsie/app/layout.tsx b/packages/browsie/app/layout.tsx deleted file mode 100644 index 51f6a52..0000000 --- a/packages/browsie/app/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { Metadata } from "next"; - -import "../src/styles.css"; - -export const metadata: Metadata = { - title: "Browsie", - description: "A personal browser assistant built with Eve, Stagehand v4, and Browserbase.", -}; - -export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { - return ( - - {children} - - ); -} diff --git a/packages/browsie/app/page.tsx b/packages/browsie/app/page.tsx deleted file mode 100644 index a46ad6d..0000000 --- a/packages/browsie/app/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import App from "../src/App"; - -export default function HomePage() { - return ( - - ); -} diff --git a/packages/browsie/app/s/[sessionId]/page.tsx b/packages/browsie/app/s/[sessionId]/page.tsx deleted file mode 100644 index 3fc373c..0000000 --- a/packages/browsie/app/s/[sessionId]/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import App from "../../../src/App"; - -export default async function SessionPage({ params }: { params: Promise<{ sessionId: string }> }) { - const { sessionId } = await params; - return ( - - ); -} diff --git a/packages/browsie/app/s/page.tsx b/packages/browsie/app/s/page.tsx deleted file mode 100644 index 1a65ea9..0000000 --- a/packages/browsie/app/s/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import App from "../../src/App"; - -export default function NewSessionPage() { - return ( - - ); -} diff --git a/packages/browsie/docs/agent-layers.md b/packages/browsie/docs/agent-layers.md deleted file mode 100644 index b345eb1..0000000 --- a/packages/browsie/docs/agent-layers.md +++ /dev/null @@ -1,53 +0,0 @@ -# How the agent layers work - -Browsie uses five separate layers. Each layer has one job. - -| Layer | Its job | What belongs here | -| ---------------- | ----------------------------- | --------------------------------------------------- | -| System prompt | Set stable behavior | Instruction order, page trust boundary, submit rule | -| Tool description | Define an exact interface | Inputs, outputs, state rules, ID lifetime | -| Skill | Give a task or site procedure | Form steps, Amazon research checks | -| Context | Give the browser an identity | Cookies, local storage, saved sign-in state | -| Vault | Supply a secret at use time | Passwords, one-time codes, API credentials | - -Do not copy one layer into all other layers. Repeated instructions create conflicts and make failures -hard to find. - -## System prompt - -Keep the system prompt stable and specific. Explain who the browser operator is, what environment -it controls, how its tools work, how it recovers, and how it verifies completion. Put site steps in -skills instead of adding them to the system prompt. - -## Tool descriptions - -Tool descriptions are contracts, not tutorials. Browsie exposes three browser tools: - -- `snapshot` reads the current accessibility tree. Its bracketed IDs expire after the page changes. -- `run` makes a short batch of exact changes in the current browser. -- `screenshot` records visual state when the accessibility tree is not sufficient. - -All three tools use the same browser session. A tool must not start a new browser. - -## Skills - -A skill is a small Markdown procedure. Eve loads Browsie skills from `agent/skills/**/SKILL.md`. -The skill content sets its scope: - -- Browser skills contain browser-wide procedure. -- Task skills contain common jobs, such as form filling. -- Site skills contain website-specific procedure. - -Load only the skill that matches the request. A good skill states when to use it, what to check, and -where to stop. It must not contain secrets. - -## Contexts - -A Browserbase Context keeps browser state between sessions. It can keep cookies and site state, but -it is not a password manager. Use a fresh Context when a task must not share identity or state. - -## Vault - -A vault stays outside the model prompt, skill files, and traces. A later vault adapter can inject a -credential into the active browser only when a task needs it. Browsie includes this boundary in its -interface, but it does not include a 1Password adapter in Phase 1. diff --git a/packages/browsie/docs/contexts-and-vaults.md b/packages/browsie/docs/contexts-and-vaults.md deleted file mode 100644 index 246a8d0..0000000 --- a/packages/browsie/docs/contexts-and-vaults.md +++ /dev/null @@ -1,25 +0,0 @@ -# Contexts and vaults - -Browser identity has two parts. A Context keeps browser state. A vault supplies secrets. - -## Browserbase Context - -Set `BROWSERBASE_CONTEXT_ID` and use `STAGEHAND_BROWSER=browserbase`. Browsie passes the Context to -the Browserbase session with persistence enabled. This can keep cookies and site state for later -sessions. - -Use a separate Context for each identity or work boundary. Do not use one shared personal Context -for all users of a deployed Browsie server. - -## Vault boundary - -The Phase 1 interface shows a vault area, but it does not read credentials. A future 1Password -adapter should follow these rules: - -1. The user chooses a vault item by an opaque ID. -2. The model sees a label, not the secret value. -3. A trusted adapter writes the value into the exact browser field. -4. The adapter does not return the value to the model. -5. The run trace records that a credential was used, but not its value. - -Skills can say when a credential is necessary. Skills must not contain the credential. diff --git a/packages/browsie/docs/stagehand-harness.md b/packages/browsie/docs/stagehand-harness.md deleted file mode 100644 index 89cfd20..0000000 --- a/packages/browsie/docs/stagehand-harness.md +++ /dev/null @@ -1,85 +0,0 @@ -# Stagehand and Eve harness design - -Browsie follows the native Eve pattern from the -[Stagehand integration overview](https://docs.stagehand.dev/v4/integrations/overview) and the -[Stagehand Eve integration](https://docs.stagehand.dev/v4/integrations/eve). - -```text -Next.js web chat - │ - ▼ -Eve durable model loop - │ - ├── snapshot ──┐ - ├── run ───────┼── one in-process browser runtime ── one persistent browser - └── screenshot ┘ │ - ├── Stagehand v4 + local Chrome - └── Stagehand v4 + Browserbase + optional Context -``` - -## Why Eve - -Eve supplies durable sessions, message history, streaming events, skills, hooks, and native tools. -`withEve` mounts the agent and Next.js application on one origin. Browsie does not need a local -HTTP browser service or an MCP process because its three tools and browser runtime run inside Eve. - -The direct design gives one useful ownership rule: the Eve session ID owns the browser runtime. -The first browser tool starts it. Later tools and later user turns reuse it. A lifecycle hook saves -the trace after each turn and closes the browser when the Eve session completes or fails. - -In hosted mode, Browsie starts the browser with Stagehand's `browserbase.launch` factory. This -factory adds the Stagehand browser extension. Browsie then creates the Stagehand client with -`Stagehand.create`. It saves one Browserbase session ID for each Eve session and uses -`browserbase.connect` to recover that browser after a process restart. - -## Why three browser tools - -1. `snapshot` reads a compact accessibility tree and gives short-lived target IDs. -2. `run` performs a small batch of exact state changes. -3. `screenshot` supplies visual evidence to the workbench. - -The tool result contains two projections. The model sees only the compact browser result. The Eve -event keeps full workbench data, such as traces and the screenshot. This keeps large UI data out of -the model context while the chat can still explain how the task ran. - -## Exact action mode - -Browsie supports these typed actions: - -- `goto` -- `click` -- `fill` -- `type` -- `press` -- `select` -- `wait` - -It does not run model-written JavaScript in the Eve process. Snapshot IDs expire after page state -changes, and the next action must use a new snapshot. - -Navigation accepts only HTTP and HTTPS URLs without embedded credentials. A destination cannot -change the browser provider. Local fixtures require the operator to set `STAGEHAND_BROWSER=local`; -a localhost URL never switches a hosted session to the application server's local browser. - -## Hosted session settings - -The Browserbase adapter requests `proxies: true`, `keepAlive: true`, and -`browserSettings.verified: true` for every new session. An optional Browserbase Context uses -`persist: true`. A connection error causes a new verified, proxied session attempt. It never causes -a weaker hosted session. - -## How other harnesses map to the contract - -| Harness | Stagehand connection | Browsie lesson | -| ------------- | ------------------------------------- | ----------------------------------------------------- | -| Eve | Native tools in one durable app | Browsie's current design. | -| Claude Code | Agent SDK with one MCP process | Expose the same three tools through MCP. | -| Codex | Codex SDK thread with one MCP process | Keep the browser client alive for the thread. | -| CrewAI | Python with MCP | Add a screenshot-file projection for text-only paths. | -| Deep Agents | Local MCP or managed tools | Bind one browser to one managed thread. | -| Mastra | One MCP client around the model loop | Do not create a client for each tool call. | -| fx | MCP tools | Check discovery and screenshot frame limits. | -| Pi | Native extension tools | Keep lazy browser start. | -| Vercel AI SDK | Model loop with one MCP process | Use this when Eve durability is not needed. | - -The complete source list is in the [Stagehand v4 documentation map](stagehand-v4-doc-map.md). diff --git a/packages/browsie/docs/stagehand-v4-doc-map.md b/packages/browsie/docs/stagehand-v4-doc-map.md deleted file mode 100644 index 32d8e40..0000000 --- a/packages/browsie/docs/stagehand-v4-doc-map.md +++ /dev/null @@ -1,88 +0,0 @@ -# Stagehand v4 documentation map - -This map covers the current Stagehand v4 documentation index. Browsie uses it as the source list for -code, templates, and future videos. Source index: [Stagehand llms.txt](https://docs.stagehand.dev/llms.txt). - -## First steps - -| Document | Use in Browsie | -| ---------------------------------------------------------------------- | --------------------------------------------------- | -| [Introduction](https://docs.stagehand.dev/v4/first-steps/introduction) | Product mental model and Stagehand primitive choice | -| [Quickstart](https://docs.stagehand.dev/v4/first-steps/quickstart) | Setup and first browser session | -| [Installation](https://docs.stagehand.dev/v4/first-steps/installation) | Package and runtime requirements | -| [AI rules](https://docs.stagehand.dev/v4/first-steps/ai-rules) | Correct Stagehand v4 code rules | - -## Browser primitives - -| Document | Use in Browsie | -| ------------------------------------------------------- | ------------------------------------------- | -| [Act](https://docs.stagehand.dev/v4/basics/act) | Optional AI action path after exact tools | -| [Extract](https://docs.stagehand.dev/v4/basics/extract) | Future structured research results | -| [Observe](https://docs.stagehand.dev/v4/basics/observe) | Future action planning and selector caching | -| [WebMCP](https://docs.stagehand.dev/v4/basics/webmcp) | Future use of tools supplied by a page | - -## Configuration - -| Document | Use in Browsie | -| -------------------------------------------------------------------------- | -------------------------------------------- | -| [Browser](https://docs.stagehand.dev/v4/configuration/browser) | Local and Browserbase factories | -| [Models](https://docs.stagehand.dev/v4/configuration/models) | Separate harness and Stagehand model choices | -| [Logging](https://docs.stagehand.dev/v4/configuration/logging) | Future Stagehand log forwarding | -| [Observability](https://docs.stagehand.dev/v4/configuration/observability) | Session and trace view design | - -## Harness integrations - -The integration pages define Browsie's persistent browser and three-tool design. See -[Stagehand harness design](stagehand-harness.md) for the detailed comparison. - -| Document | Connection | -| --------------------------------------------------------------------------- | --------------------------------------------------- | -| [Integration overview](https://docs.stagehand.dev/v4/integrations/overview) | Shared `run`, `snapshot`, and `screenshot` contract | -| [Claude Code](https://docs.stagehand.dev/v4/integrations/claude-code) | MCP over standard input and output | -| [Codex](https://docs.stagehand.dev/v4/integrations/codex) | MCP over standard input and output | -| [Eve](https://docs.stagehand.dev/v4/integrations/eve) | Native tools and a durable browser | -| [Deep Agents](https://docs.stagehand.dev/v4/integrations/deep-agents) | Local MCP or managed native tools | -| [CrewAI](https://docs.stagehand.dev/v4/integrations/crewai) | Python harness and MCP | -| [Mastra](https://docs.stagehand.dev/v4/integrations/mastra) | One MCP client around the model loop | -| [fx](https://docs.stagehand.dev/v4/integrations/fx) | MCP and tool discovery limits | -| [Pi](https://docs.stagehand.dev/v4/integrations/pi) | Native extension with lazy browser start | -| [Vercel AI SDK](https://docs.stagehand.dev/v4/integrations/vercel-ai-sdk) | Web agent loop and MCP | - -## Best practices - -| Document | Use in Browsie | -| ------------------------------------------------------------------------------------- | --------------------------------------- | -| [Prompting](https://docs.stagehand.dev/v4/best-practices/prompting-best-practices) | Clear, specific browser requests | -| [Observe use cases](https://docs.stagehand.dev/v4/best-practices/usecase-observe) | Future planning patterns | -| [Caching actions](https://docs.stagehand.dev/v4/best-practices/caching) | Future repeated-task speed path | -| [Multiple tabs](https://docs.stagehand.dev/v4/best-practices/using-multiple-tabs) | Future tab-aware worker state | -| [User data](https://docs.stagehand.dev/v4/best-practices/user-data) | Browserbase Context behavior | -| [Speed optimization](https://docs.stagehand.dev/v4/best-practices/speed-optimization) | Short batches and fewer model calls | -| [Cost optimization](https://docs.stagehand.dev/v4/best-practices/cost-optimization) | Exact actions before AI actions | -| [Deployments](https://docs.stagehand.dev/v4/best-practices/deployments) | Long-lived worker deployment needs | -| [MCP integrations](https://docs.stagehand.dev/v4/best-practices/mcp-integrations) | Future tools outside the browser worker | - -## Migration - -| Document | Use in Browsie | -| ----------------------------------------------------------------------- | -------------------------------------- | -| [Stagehand v3 to v4](https://docs.stagehand.dev/v4/migrations/v3) | Keep examples on v4 factories and APIs | -| [Playwright to v4](https://docs.stagehand.dev/v4/migrations/playwright) | Explain exact APIs to Playwright users | - -## API reference - -| Document | Use in Browsie | -| ------------------------------------------------------------------ | --------------------------------------------------- | -| [Stagehand](https://docs.stagehand.dev/v4/reference/stagehand) | Client creation and close lifecycle | -| [Context](https://docs.stagehand.dev/v4/reference/context) | Pages, cookies, headers, and clipboard | -| [Clipboard](https://docs.stagehand.dev/v4/reference/clipboard) | Future copy and paste tasks | -| [Page](https://docs.stagehand.dev/v4/reference/page) | Navigation, snapshot, screenshot, and exact actions | -| [WebMCP reference](https://docs.stagehand.dev/v4/reference/webmcp) | Page tool inspection and invocation | -| [Response](https://docs.stagehand.dev/v4/reference/response) | Future navigation and error evidence | -| [Locator](https://docs.stagehand.dev/v4/reference/locator) | Exact field and element interaction | - -## Other primary sources - -- [Stagehand GitHub repository](https://github.com/browserbase/stagehand) -- [Stagehand releases](https://github.com/browserbase/stagehand/releases) -- [Stagehand OpenAPI specification](https://app.stainless.com/api/spec/documented/stagehand/openapi.documented.yml) diff --git a/packages/browsie/docs/vault-and-otp.md b/packages/browsie/docs/vault-and-otp.md deleted file mode 100644 index 4b150b8..0000000 --- a/packages/browsie/docs/vault-and-otp.md +++ /dev/null @@ -1,13 +0,0 @@ -# Vault and OTP security boundary - -Browsie ships a reference Native Vault and a 1Password SDK provider. The Native Vault is for local open-source demos, not a production password manager. Each record gets a random data key; AES-256-GCM encrypts the payload; the server-only master key wraps the data key. The atomic JSON adapter writes mode `0600`, but deployed applications must replace it with durable database or object storage and a KMS-backed master key. - -Set `BROWSIE_VAULT_MASTER_KEY` to 32 random bytes in base64 (or 64 hex characters). Never put it in browser code, Eve state, chat, traces, or logs. Saved passwords and TOTP secrets are never returned by the API or UI. - -For automated 1Password access, create a least-privilege service account, grant only the required vault, and set `OP_SERVICE_ACCOUNT_TOKEN` on the server. Browsie uses pinned `@1password/sdk` 0.5.0. For optional local use, enable SDK integration in the 1Password desktop app and set `OP_ACCOUNT`; the normal 1Password approval or biometric prompt applies. Service-account mode takes precedence and is the deployable path. - -The model can list only labels, host mappings, field availability, and opaque item/field references. `vault_login` resolves values server-side immediately before filling. Stagehand v4.0.2 does not expose the older `act(..., { variables })` API; Browsie therefore uses a browser-session-bound `experimentalBatch` adapter with Stagehand logging disabled and redacted results. A success selector is mandatory because filling and clicking alone do not prove authentication. - -Vault login requires HTTPS on an allowed hostname. The runtime rechecks the destination before each credential fill and submit, including an OTP step after navigation. Hosted login also requires `BROWSIE_LOG_SESSION=false` before the browser session starts. Changing this setting requires a new session; it cannot disable recording retroactively. - -TOTP codes are generated server-side using RFC 6238 and are never stored. Challenges without a mapped TOTP secret must park the Eve task in `waiting_for_user`; the interactive Browserbase Live View remains the primary safe handoff. diff --git a/packages/browsie/next-env.d.ts b/packages/browsie/next-env.d.ts deleted file mode 100644 index ce4e94a..0000000 --- a/packages/browsie/next-env.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/// -/// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/browsie/next.config.ts b/packages/browsie/next.config.ts deleted file mode 100644 index f081f78..0000000 --- a/packages/browsie/next.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { withEve } from "eve/next"; -import type { NextConfig } from "next"; - -const appRoot = path.dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = path.resolve(appRoot, "../.."); - -const nextConfig: NextConfig = { - agentRules: false, - allowedDevOrigins: ["127.0.0.1", "localhost"], - outputFileTracingRoot: workspaceRoot, - serverExternalPackages: ["@browserbasehq/stagehand"], - turbopack: { root: workspaceRoot }, -}; - -export default withEve(nextConfig); diff --git a/packages/browsie/proxy.ts b/packages/browsie/proxy.ts deleted file mode 100644 index 7e0d643..0000000 --- a/packages/browsie/proxy.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { studioAccess } from "../../server/browser/studio-auth"; - -export function proxy(request: Request) { - return studioAccess(request); -} - -export const config = { matcher: ["/", "/s/:path*", "/api/:path*"] }; diff --git a/packages/browsie/server/agent-live-view.ts b/packages/browsie/server/agent-live-view.ts deleted file mode 100644 index 1b70bf4..0000000 --- a/packages/browsie/server/agent-live-view.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Client, type MessageStreamEvent } from "eve/client"; -import { z } from "zod"; - -const browserResult = z.object({ - kind: z.literal("tool-result"), isError: z.literal(false).optional(), - output: z.object({ workbench: z.object({ browser: z.object({ - provider: z.string(), status: z.string(), sessionId: z.string().optional(), - }) }) }), -}); - -export function activeBrowserSession(events: readonly MessageStreamEvent[]): string | undefined { - for (let index = events.length - 1; index >= 0; index--) { - const event = events[index]; - if (["turn.cancelled", "session.failed", "session.completed"].includes(event.type)) return; - if (event.type !== "action.result") continue; - const result = browserResult.safeParse(event.data.result); - if (!result.success) continue; - const browser = result.data.output.workbench.browser; - return browser.provider === "browserbase" && browser.status === "active" ? browser.sessionId : undefined; - } -} - -export async function taskOwnsBrowser(request: Request, eveSessionId: string, browserSessionId: string): Promise { - const client = new Client({ - host: new URL(request.url).origin, - headers: { authorization: request.headers.get("authorization") ?? "" }, - redirect: "manual", - }); - const snapshot = await client.sessions.attach(eveSessionId).snapshot({ signal: request.signal }); - return activeBrowserSession(snapshot.events) === browserSessionId; -} diff --git a/packages/browsie/server/context-capability.ts b/packages/browsie/server/context-capability.ts deleted file mode 100644 index c73c61d..0000000 --- a/packages/browsie/server/context-capability.ts +++ /dev/null @@ -1,46 +0,0 @@ -import "server-only"; - -import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -import { assertStudioOrigin, studioAccess, studioCredentials } from "../../../server/browser/studio-auth"; - -export const CONTEXT_CAPABILITY_HEADER = "x-browsie-context-capability"; -const TTL_MS = 6 * 60 * 60 * 1_000; - -export function issueContextCapability(request: Request): { - token: string; -} { - requireOperator(request); - const payload = `${Date.now() + TTL_MS}.${randomBytes(32).toString("base64url")}`; - const token = `${payload}.${signature(payload)}`; - return { token }; -} - -export function requireContextCapability(request: Request): void { - requireOperator(request); - const supplied = request.headers.get(CONTEXT_CAPABILITY_HEADER); - if (!supplied) throw new ContextCapabilityError(); - const parts = /^(\d{13})\.([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]{43})$/.exec(supplied); - const expires = Number(parts?.[1]); - if (!parts || expires <= Date.now() || expires > Date.now() + TTL_MS || - !safeEqual(parts[3], signature(`${parts[1]}.${parts[2]}`))) throw new ContextCapabilityError(); -} - -export class ContextCapabilityError extends Error {} - -function requireOperator(request: Request): void { - if (studioAccess(request)) throw new ContextCapabilityError(); - assertStudioOrigin(request); -} - -function signature(payload: string): string { - const credentials = studioCredentials(); - if (!credentials) throw new ContextCapabilityError(); - return createHmac("sha256", credentials.password) - .update(JSON.stringify([CONTEXT_CAPABILITY_HEADER, credentials.username, payload])).digest("base64url"); -} - -function safeEqual(left: string, right: string): boolean { - const a = Buffer.from(left); - const b = Buffer.from(right); - return a.length === b.length && timingSafeEqual(a, b); -} diff --git a/packages/browsie/server/context-studio.ts b/packages/browsie/server/context-studio.ts deleted file mode 100644 index a074a92..0000000 --- a/packages/browsie/server/context-studio.ts +++ /dev/null @@ -1,440 +0,0 @@ -import "server-only"; - -import Browserbase from "@browserbasehq/sdk"; -import { - Stagehand, - browserbase as stagehandBrowserbase, - type StagehandBrowser, -} from "@browserbasehq/stagehand"; - -import { - CAPTCHA_TIMEOUT_MS, - challengeAfterTimeout, - challengeFromConsole, - CONTEXT_SYNC_WAIT_MS, - type ChallengeState, - type ContextRecord, - type ContextSessionPurpose, - type ContextSessionView, -} from "../src/context-studio"; -import { toEmbeddedBrowserbaseLiveViewUrl } from "../../../lib/browser/live-view"; -import { BrowserbaseConsoleAdapter } from "../../../server/browser/browserbase-console"; -import { getContextMetadataStore, type ContextMetadataStore } from "../../../server/browser/context-metadata"; - -type RemoteSessionStatus = "PENDING" | "RUNNING" | "ERROR" | "TIMED_OUT" | "COMPLETED"; - -export interface RemoteContext { - id: string; - name?: string; - createdAt?: string; - updatedAt?: string; -} - -export interface ContextStudioGateway { - createContext(name: string): Promise<{ id: string }>; - retrieveContext(id: string): Promise; - deleteContext(id: string): Promise; - createSession(contextId: string, purpose: ContextSessionPurpose): Promise<{ id: string }>; - retrieveSession(id: string): Promise<{ status: RemoteSessionStatus }>; - releaseSession(id: string): Promise; - navigateSession(id: string, url: string): Promise; - liveView(id: string): Promise; - attachConsole(id: string, listener: (message: string) => void): Promise<() => Promise>; -} - -interface ActiveSession extends ContextSessionView { - detachConsole?: () => Promise; - captchaTimer?: ReturnType; - finishPromise?: Promise; -} - -export class ContextStudioService { - private readonly activeBySession = new Map(); - private readonly writerByContext = new Map(); - - constructor( - private readonly gateway: ContextStudioGateway, - private readonly metadata: ContextMetadataStore, - private readonly sleep: (milliseconds: number) => Promise = (milliseconds) => - new Promise((resolve) => setTimeout(resolve, milliseconds)), - ) {} - - async listContexts(): Promise { - const metadata = await this.metadata.read(); - const configuredId = process.env.BROWSERBASE_CONTEXT_ID; - if (configuredId && !metadata.contexts[configuredId]) { - await this.metadata.add(configuredId, "Configured Context"); - return this.listContexts(); - } - const records = await Promise.all( - Object.entries(metadata.contexts).map(async ([id, local]) => { - if (local.status === "draft") return undefined; - try { - const remote = await this.gateway.retrieveContext(id); - return { - id, - name: local.name || remote.name || "Saved Context", - createdAt: remote.createdAt || local.createdAt, - updatedAt: remote.updatedAt || local.updatedAt, - selected: metadata.selectedContextId === id, - writerActive: this.writerByContext.has(id), - } satisfies ContextRecord; - } catch { - return undefined; - } - }), - ); - return records.filter((record) => record !== undefined); - } - - async createContext(name: string): Promise { - const cleanName = validateName(name); - const remote = await this.gateway.createContext(cleanName); - await this.metadata.add(remote.id, cleanName); - await this.metadata.select(remote.id); - return { - id: remote.id, - name: cleanName, - selected: true, - writerActive: false, - }; - } - - async selectContext(id: string): Promise { - validateId(id, "Context"); - await this.gateway.retrieveContext(id); - await this.metadata.select(id); - } - - async deleteContext(id: string): Promise { - validateId(id, "Context"); - if (this.writerByContext.has(id)) - throw new ContextConflictError("Finish the active session first."); - await this.gateway.deleteContext(id); - await this.metadata.remove(id); - } - - async startSession( - contextId: string, - purpose: ContextSessionPurpose, - startUrl: string, - ): Promise { - validateId(contextId, "Context"); - if (purpose !== "login" && purpose !== "test") throw new Error("Invalid session purpose."); - const destination = validateStartUrl(startUrl); - if (this.writerByContext.has(contextId)) { - throw new ContextConflictError("This Context already has an active writer."); - } - await this.gateway.retrieveContext(contextId); - this.writerByContext.set(contextId, "starting"); - let remoteId: string | undefined; - try { - const remote = await this.gateway.createSession(contextId, purpose); - remoteId = remote.id; - const session: ActiveSession = { - sessionId: remote.id, - contextId, - purpose, - status: "starting", - challenge: { status: "idle" }, - }; - this.activeBySession.set(remote.id, session); - this.writerByContext.set(contextId, remote.id); - session.detachConsole = await this.gateway.attachConsole(remote.id, (message) => { - this.receiveConsoleMessage(remote.id, message); - }); - await this.gateway.navigateSession(remote.id, destination); - session.status = "live"; - session.message = - purpose === "login" - ? "Use Live View to sign in, including OTP or other challenges." - : "Use Live View to confirm that your saved login is present."; - return publicSession(session); - } catch (error) { - if (remoteId) await this.gateway.releaseSession(remoteId).catch(() => undefined); - this.writerByContext.delete(contextId); - throw error; - } - } - - getSession(sessionId: string): ContextSessionView | undefined { - validateId(sessionId, "Session"); - const session = this.activeBySession.get(sessionId); - return session ? publicSession(session) : undefined; - } - - async getLiveView(sessionId: string): Promise { - const session = this.activeBySession.get(sessionId); - if (!session || (session.status !== "live" && session.status !== "disconnected")) - return undefined; - return this.gateway.liveView(sessionId); - } - - markDisconnected(sessionId: string): ContextSessionView | undefined { - const session = this.activeBySession.get(sessionId); - if (!session) return undefined; - if (session.status === "live") { - session.status = "disconnected"; - session.message = "Live View disconnected. Reconnect to the same running session."; - } - return publicSession(session); - } - - markReconnected(sessionId: string): ContextSessionView | undefined { - const session = this.activeBySession.get(sessionId); - if (!session) return undefined; - if (session.status === "disconnected") { - session.status = "live"; - session.message = "Live View reconnected."; - } - return publicSession(session); - } - - finishSession(sessionId: string): ContextSessionView { - const session = this.activeBySession.get(sessionId); - if (!session) throw new Error("Unknown session."); - if (!session.finishPromise || session.status === "error") { - session.status = "closing"; - session.message = "Closing the browser before saving the Context."; - session.finishPromise = this.finishInBackground(session); - } - return publicSession(session); - } - - private receiveConsoleMessage(sessionId: string, message: string): void { - const session = this.activeBySession.get(sessionId); - if (!session || session.status !== "live") return; - const previous = session.challenge; - session.challenge = challengeFromConsole(previous, message); - if (session.challenge.status === "solving_captcha" && previous.status !== "solving_captcha") { - if (session.captchaTimer) clearTimeout(session.captchaTimer); - session.captchaTimer = setTimeout(() => { - session.challenge = challengeAfterTimeout(session.challenge); - session.message = - "CAPTCHA solving exceeded 30 seconds. The session is still live for manual completion."; - }, CAPTCHA_TIMEOUT_MS); - session.message = "Browserbase is solving a CAPTCHA. This can take up to 30 seconds."; - } else if (session.challenge.status === "captcha_solved") { - if (session.captchaTimer) clearTimeout(session.captchaTimer); - session.captchaTimer = undefined; - session.message = "CAPTCHA solving finished. Continue in the same Live View."; - } else if (session.challenge.status === "captcha_error") { - if (session.captchaTimer) clearTimeout(session.captchaTimer); - session.captchaTimer = undefined; - session.message = "CAPTCHA solving errored. Complete the challenge in Live View."; - } - } - - private async finishInBackground(session: ActiveSession): Promise { - try { - if (session.captchaTimer) clearTimeout(session.captchaTimer); - await session.detachConsole?.().catch(() => undefined); - await this.gateway.releaseSession(session.sessionId); - const closed = await this.waitUntilClosed(session.sessionId); - if (!closed) { - session.status = "error"; - session.message = - "Browserbase did not confirm that the session closed. The Context is not marked saved."; - return; - } - session.status = "syncing"; - session.message = "Session closed. Waiting a few seconds for Context synchronization."; - await this.sleep(CONTEXT_SYNC_WAIT_MS); - session.status = "saved"; - session.message = "Login session closed and Context synchronization wait completed."; - } catch { - session.status = "error"; - session.message = "The session could not be closed safely. The Context is not marked saved."; - } finally { - if (session.status === "saved") { - this.writerByContext.delete(session.contextId); - } else { - session.finishPromise = undefined; - } - } - } - - private async waitUntilClosed(sessionId: string): Promise { - for (let attempt = 0; attempt < 12; attempt += 1) { - const remote = await this.gateway.retrieveSession(sessionId); - if ( - remote.status === "COMPLETED" || - remote.status === "TIMED_OUT" || - remote.status === "ERROR" - ) - return true; - await this.sleep(1_000); - } - return false; - } -} - -export class ContextConflictError extends Error {} - -export function browserbaseSessionOptions(contextId: string, purpose: ContextSessionPurpose) { - return { - ...(process.env.BROWSERBASE_PROJECT_ID - ? { projectId: process.env.BROWSERBASE_PROJECT_ID } - : {}), - keepAlive: true, - proxies: true, - browserSettings: { - context: { id: contextId, persist: true }, - verified: true, - solveCaptchas: true, - }, - userMetadata: { product: "browsie", flow: `context-${purpose}` }, - } as const; -} - -class BrowserbaseGateway implements ContextStudioGateway { - private readonly sdk: Browserbase; - private readonly apiKey: string; - private readonly launched = new Map(); - private readonly connections = new Map< - string, - { - browser: StagehandBrowser; - stagehand: Stagehand; - console: BrowserbaseConsoleAdapter; - } - >(); - - constructor(apiKey: string) { - this.apiKey = apiKey; - this.sdk = new Browserbase({ apiKey }); - } - async createContext(name: string) { - return this.sdk.contexts.create({ - name, - ...(process.env.BROWSERBASE_PROJECT_ID - ? { projectId: process.env.BROWSERBASE_PROJECT_ID } - : {}), - }); - } - retrieveContext(id: string) { - return this.sdk.contexts.retrieve(id); - } - deleteContext(id: string) { - return deleteBrowserbaseContext(this.apiKey, id); - } - async createSession(contextId: string, purpose: ContextSessionPurpose) { - const browser = await stagehandBrowserbase.launch({ - apiKey: this.apiKey, - ...browserbaseSessionOptions(contextId, purpose), - }); - if (!browser.sessionId) throw new Error("Stagehand did not return a Browserbase session ID."); - this.launched.set(browser.sessionId, browser); - return { id: browser.sessionId }; - } - async retrieveSession(id: string) { - const session = await this.sdk.sessions.retrieve(id); - return { status: session.status }; - } - async releaseSession(id: string) { - const launched = this.launched.get(id); - this.launched.delete(id); - const connection = this.connections.get(id); - this.connections.delete(id); - connection?.console.close(); - if (launched && launched !== connection?.browser) await launched.close().catch(() => undefined); - await connection?.stagehand.close().catch(() => undefined); - await this.sdk.sessions.update(id, { status: "REQUEST_RELEASE" }).catch(() => undefined); - } - async liveView(id: string) { - const debug = await this.sdk.sessions.debug(id); - return toEmbeddedBrowserbaseLiveViewUrl(debug.debuggerFullscreenUrl); - } - async navigateSession(id: string, url: string) { - const connection = this.connections.get(id); - if (!connection) throw new Error("The Browserbase session is unavailable for navigation."); - const page = - (await connection.stagehand.browser.context.activePage()) ?? - (await connection.stagehand.browser.context.pages())[0] ?? - (await connection.stagehand.browser.context.newPage()); - await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); - } - async attachConsole(id: string, listener: (message: string) => void) { - const browser = this.launched.get(id); - if (!browser) throw new Error("The Stagehand browser session is unavailable."); - const remote = await this.sdk.sessions.retrieve(id); - if (!remote.connectUrl) throw new Error("The Browserbase CDP connection is unavailable."); - const console = await BrowserbaseConsoleAdapter.connect(remote.connectUrl, listener); - let stagehand: Stagehand; - try { - stagehand = await Stagehand.create({ browser }); - } catch (error) { - console.close(); - throw error; - } - this.launched.delete(id); - this.connections.set(id, { browser, stagehand, console }); - return async () => console.close(); - } -} - -declare global { - var __browsieContextStudio: ContextStudioService | undefined; -} - -export function getContextStudio(): ContextStudioService { - const apiKey = process.env.BROWSERBASE_API_KEY; - if (!apiKey) throw new Error("Browserbase is not configured."); - globalThis.__browsieContextStudio ??= new ContextStudioService( - new BrowserbaseGateway(apiKey), - getContextMetadataStore(), - ); - return globalThis.__browsieContextStudio; -} - -function validateName(value: string): string { - const clean = value.trim(); - if (clean.length < 1 || clean.length > 80) - throw new Error("Context name must be 1–80 characters."); - return clean; -} - -function validateId(value: string, label: string): void { - if (!/^[a-zA-Z0-9_-]{8,128}$/.test(value)) throw new Error(`Invalid ${label} ID.`); -} - -function publicSession(session: ActiveSession): ContextSessionView { - return { - sessionId: session.sessionId, - contextId: session.contextId, - purpose: session.purpose, - status: session.status, - challenge: { ...session.challenge } as ChallengeState, - message: session.message, - }; -} - -export async function deleteBrowserbaseContext( - apiKey: string, - contextId: string, - fetcher: typeof fetch = fetch, -): Promise { - const response = await fetcher( - `https://api.browserbase.com/v1/contexts/${encodeURIComponent(contextId)}`, - { - method: "DELETE", - headers: { "x-bb-api-key": apiKey }, - }, - ); - if (!response.ok) throw new Error("Browserbase rejected Context deletion."); -} - -export function validateStartUrl(value: string): string { - if (value.length > 2_048) throw new Error("Website URL is too long."); - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error("Enter a valid HTTPS website URL."); - } - if (url.protocol !== "https:" || url.username || url.password || !url.hostname) { - throw new Error("Enter a valid HTTPS website URL without embedded credentials."); - } - url.hash = ""; - return url.toString(); -} diff --git a/packages/browsie/src/App.tsx b/packages/browsie/src/App.tsx deleted file mode 100644 index 71e5848..0000000 --- a/packages/browsie/src/App.tsx +++ /dev/null @@ -1,1457 +0,0 @@ -"use client"; - -import { useEveAgent } from "eve/react"; -import { Client } from "eve/client"; -import { - Activity, - ArrowDown, - ArrowLeft, - ArrowUp, - BookOpen, - Bot, - Braces, - ChevronDown, - ChevronUp, - CircleDot, - Compass, - Database, - Expand, - Globe2, - KeyRound, - LockKeyhole, - Menu, - MessageSquare, - Monitor, - Plus, - Settings2, - ShieldCheck, - Sparkles, - Square, - Wrench, - X, -} from "lucide-react"; -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; - -import { isNearScrollBottom } from "./chat-scroll"; -import { buildClientContext } from "./client-context"; -import ContextStudio from "./ContextStudio"; -import BrowserLiveView from "./BrowserLiveView"; -import { parseTaskHistory, TASK_HISTORY_KEY, type TaskHistoryItem } from "./task-history"; -import type { AgentSettings, Bootstrap, BrowserState, TraceEvent } from "./types"; -import VaultStudio from "./VaultStudio"; - -type Message = { id: string; role: "user" | "assistant"; text: string }; -type OptimisticMessage = Message & { baseUserCount: number }; -type PendingInput = { - requestId: string; - prompt: string; - allowFreeform?: boolean; - options?: Array<{ id: string; label: string }>; -}; -type WorkspaceView = "chat" | "skills" | "context" | "vault" | "settings"; -type CaptchaProjection = { - status: "idle" | "solving" | "solved" | "errored" | "timed_out"; - attemptCount: number; -}; -type BrowserCardView = "browser" | "activity"; -type StopStatus = "idle" | "stopping" | "stopped"; - -const starterPrompts = [ - { icon: Braces, label: "Fill a demo form", hint: "Stop before Submit" }, - { - icon: Globe2, - label: "Open and explain a page", - hint: "Use the browser tool loop", - }, - { - icon: BookOpen, - label: "Show how skills work", - hint: "See the loaded skill", - }, -]; - -const viewNames: Record = { - chat: "Personal browser", - skills: "Skills", - context: "Contexts", - vault: "Vault", - settings: "Settings", -}; - -const initialBrowser: BrowserState = { - provider: "not-started", - status: "idle", -}; -const AGENT_SETTINGS_KEY = "browsie.agent-settings.v1"; -const defaultAgentSettings: AgentSettings = { - model: "gpt-5.6-sol", - reasoningEffort: "medium", -}; -const builtInSkills: Bootstrap["skills"] = [ - { - id: "browser-basics", - name: "browser-basics", - description: - "Use snapshots, exact actions, screenshots, and recovery in one persistent browser.", - path: "agent/skills/browser-basics/SKILL.md", - scope: "browser", - }, - { - id: "fill-form", - name: "fill-form", - description: "Inspect and fill forms. Verify values before a requested submit.", - path: "agent/skills/fill-form/SKILL.md", - scope: "task", - }, - { - id: "amazon-product-research", - name: "amazon-product-research", - description: "Compare products with live facts and useful alternate sources.", - path: "agent/skills/amazon-product-research/SKILL.md", - scope: "site", - }, -]; - -export default function App({ - sessionId, - modelConfigured, - contextConfigured, - browserbaseConfigured, -}: { - sessionId?: string; - modelConfigured: boolean; - contextConfigured: boolean; - browserbaseConfigured: boolean; -}) { - const [input, setInput] = useState(""); - const [sidebar, setSidebar] = useState(true); - const [view, setView] = useState("chat"); - const [history, setHistory] = useState([]); - const [optimisticMessages, setOptimisticMessages] = useState([]); - const [stopStatus, setStopStatus] = useState("idle"); - const [stopError, setStopError] = useState(); - const [retiring, setRetiring] = useState(false); - const [retirementError, setRetirementError] = useState(); - const stopRequestRef = useRef | undefined>(undefined); - const cancellationObservedRef = useRef(false); - const [agentSettings, setAgentSettings] = useState(defaultAgentSettings); - const agent = useEveAgent({ - initialSession: sessionId ? { sessionId, streamIndex: 0 } : undefined, - resume: Boolean(sessionId), - onEvent(event) { - if (event.type === "turn.started") { - if (!stopRequestRef.current) { - cancellationObservedRef.current = false; - setStopStatus("idle"); - setStopError(undefined); - } - } else if (event.type === "turn.cancelled") { - cancellationObservedRef.current = true; - } - }, - onFinish() { - const hadStopRequest = Boolean(stopRequestRef.current); - stopRequestRef.current = undefined; - if (cancellationObservedRef.current) { - cancellationObservedRef.current = false; - setStopStatus("stopped"); - } else if (hadStopRequest) { - setStopStatus("idle"); - } - }, - onSessionChange(session) { - if (session) - void fetch("/api/tasks", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionId: session.sessionId }), - }); - if (!sessionId && session) { - History.prototype.replaceState.call( - window.history, - window.history.state, - "", - `/s/${encodeURIComponent(session.sessionId)}`, - ); - } - }, - prepareSend(payload) { - return { - ...payload, - clientContext: buildClientContext( - contextConfigured - ? "Saved Browserbase Context" - : browserbaseConfigured - ? "Automatic draft Browserbase Context" - : "Fresh session", - `${window.location.origin}/fixture/form`, - undefined, - agentSettings, - ), - }; - }, - }); - const busy = agent.status === "submitted" || agent.status === "streaming"; - const resuming = agent.status === "resuming" || retiring; - const messages = useMemo(() => projectMessages(agent.data.messages), [agent.data.messages]); - const visibleMessages = useMemo(() => { - const confirmedUsers = messages.filter((message) => message.role === "user"); - const pendingMessages = optimisticMessages.filter( - (optimistic) => - !confirmedUsers - .slice(optimistic.baseUserCount) - .some((message) => message.text === optimistic.text), - ); - return [...messages, ...pendingMessages]; - }, [messages, optimisticMessages]); - const workbench = useMemo(() => projectWorkbench(agent.events), [agent.events]); - const pendingInput = useMemo(() => findPendingInput(agent.data.messages), [agent.data.messages]); - const traces = workbench.traces; - const browser = workbench.browser; - const captcha = workbench.captcha; - const activeSkill = workbench.activeSkill; - const activeContext = - browser.contextStatus === "draft" - ? "Draft Context · Capturing" - : browser.contextId || contextConfigured - ? "Saved Browserbase Context" - : browserbaseConfigured - ? "Draft Context starts with browser" - : "Fresh session"; - const activeSessionId = agent.session?.sessionId ?? sessionId; - const firstRequest = messages.find((message) => message.role === "user")?.text; - const bootstrap = useMemo( - () => ({ - product: "Browsie", - stagehandVersion: "v4", - harness: "Eve", - model: agentSettings.model, - reasoningEffort: agentSettings.reasoningEffort, - toolContract: ["run", "snapshot", "screenshot"], - modelConfigured, - agentMode: "agent", - browserMode: browserbaseConfigured ? "browserbase" : "local", - contexts: [ - { - name: activeContext, - configured: contextConfigured || browser.contextStatus === "saved", - }, - ], - skills: builtInSkills, - }), - [ - activeContext, - agentSettings, - browser.contextStatus, - browserbaseConfigured, - contextConfigured, - modelConfigured, - ], - ); - - useEffect(() => { - const legacy = parseTaskHistory(window.localStorage.getItem(TASK_HISTORY_KEY)); - void Promise.all( - legacy.map((item) => - fetch("/api/tasks", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionId: item.sessionId }), - }), - ), - ) - .then(() => { - setAgentSettings(parseAgentSettings(window.localStorage.getItem(AGENT_SETTINGS_KEY))); - window.localStorage.removeItem(TASK_HISTORY_KEY); - return fetch("/api/tasks", { cache: "no-store" }); - }) - .then((response) => response.json()) - .then((body: { tasks?: TaskHistoryItem[] }) => setHistory(body.tasks ?? [])) - .catch(() => undefined); - }, []); - - useEffect(() => { - if (!activeSessionId || !firstRequest) return; - void fetch("/api/tasks", { cache: "no-store" }) - .then((response) => response.json()) - .then((body: { tasks?: TaskHistoryItem[] }) => setHistory(body.tasks ?? [])) - .catch(() => undefined); - }, [activeSessionId, firstRequest, messages.length]); - - useEffect(() => { - const updateLayout = () => setSidebar(window.innerWidth > 720); - updateLayout(); - window.addEventListener("resize", updateLayout); - return () => window.removeEventListener("resize", updateLayout); - }, []); - - const latestTool = useMemo(() => { - const reversed = [...traces].reverse(); - if (workbench.stopped) return reversed.find((trace) => trace.name === "browser.stop"); - return reversed.find((trace) => trace.kind === "tool" || trace.kind === "browser"); - }, [traces, workbench.stopped]); - - async function submit(text = input) { - const clean = text.trim(); - if (!clean || resuming) return; - setInput(""); - setView("chat"); - cancellationObservedRef.current = false; - setStopStatus("idle"); - setStopError(undefined); - - const optimisticId = `optimistic-${crypto.randomUUID()}`; - if (!pendingInput?.allowFreeform) { - setOptimisticMessages((current) => [ - ...current, - { - id: optimisticId, - role: "user", - text: clean, - baseUserCount: messages.filter((message) => message.role === "user").length, - }, - ]); - } - - try { - if (pendingInput?.allowFreeform) { - await agent.respond([{ requestId: pendingInput.requestId, text: clean }]); - return; - } - await agent.send(clean, busy ? { turnPolicy: "steer" } : undefined); - } catch (error) { - console.error(error); - } finally { - setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticId)); - } - } - - async function stop() { - if (!busy || stopRequestRef.current) return; - setStopStatus("stopping"); - setStopError(undefined); - const request = agent.cancel(); - stopRequestRef.current = request; - try { - const result = await request; - if (result.status === "no_active_turn") { - stopRequestRef.current = undefined; - setStopStatus("idle"); - } - } catch (error) { - if (stopRequestRef.current !== request) return; - stopRequestRef.current = undefined; - setStopStatus("idle"); - setStopError( - error instanceof Error - ? `Browsie could not stop: ${error.message}` - : "Browsie could not stop. Try again.", - ); - } - } - - async function newChat() { - if (busy || resuming) return; - setRetiring(true); - setRetirementError(undefined); - try { - if (agent.session) - await new Client({ host: "" }).sessions.attach(agent.session.sessionId).reset(); - } catch { - setRetirementError("Could not close this task. Please retry."); - return; - } finally { - setRetiring(false); - } - agent.reset(); - cancellationObservedRef.current = false; - stopRequestRef.current = undefined; - setInput(""); - setOptimisticMessages([]); - setStopStatus("idle"); - setStopError(undefined); - setView("chat"); - History.prototype.replaceState.call(window.history, window.history.state, "", "/s"); - if (window.innerWidth <= 720) setSidebar(false); - } - - function openWorkspace(nextView: WorkspaceView) { - setView(nextView); - if (window.innerWidth <= 720) setSidebar(false); - } - - function saveAgentSettings(settings: AgentSettings) { - setAgentSettings(settings); - window.localStorage.setItem(AGENT_SETTINGS_KEY, JSON.stringify(settings)); - } - - return ( -
- - -
-
-
- - {view !== "chat" && ( - - )} - {viewNames[view]} -
- {view === "chat" && ( - - - {browser.status === "active" - ? "Browser live" - : bootstrap.modelConfigured - ? "Ready" - : "Setup needed"} - - )} -
- - {view === "chat" ? ( - { - if (resuming) return; - await agent.respond([{ requestId, optionId }]); - }} - resuming={resuming} - setInput={setInput} - stop={stop} - stopError={stopError} - stopStatus={stopStatus} - stopped={workbench.stopped} - submit={submit} - traces={traces} - /> - ) : ( - - )} -
-
- ); -} - -function NavButton({ - active, - badge, - icon, - label, - onClick, -}: { - active: boolean; - badge?: number; - icon: React.ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - ); -} - -function ChatView({ - eveSessionId, - bootstrap, - browser, - captcha, - busy, - error, - input, - latestTool, - messages, - pendingInput, - respond, - resuming, - setInput, - stop, - stopError, - stopStatus, - stopped, - submit, - traces, -}: { - bootstrap: Bootstrap; - browser: BrowserState; - eveSessionId?: string; - captcha?: CaptchaProjection; - busy: boolean; - pendingInput?: PendingInput; - respond: (requestId: string, optionId: string) => Promise; - error?: string; - input: string; - latestTool?: TraceEvent; - messages: Message[]; - resuming: boolean; - setInput: (value: string) => void; - stop: () => Promise; - stopError?: string; - stopStatus: StopStatus; - stopped: boolean; - submit: (text?: string) => Promise; - traces: TraceEvent[]; -}) { - const messagesRef = useRef(null); - const nearBottomRef = useRef(true); - const previousMessageVersionRef = useRef(""); - const [hasNewMessage, setHasNewMessage] = useState(false); - const [browserExpanded, setBrowserExpanded] = useState(true); - const hasBrowser = browser.provider !== "not-started"; - const latestMessage = messages.at(-1); - const messageVersion = `${messages.length}:${latestMessage?.id ?? ""}:${latestMessage?.text.length ?? 0}:${busy}`; - - useEffect(() => { - const container = messagesRef.current; - if (!container) return; - const changed = previousMessageVersionRef.current !== messageVersion; - previousMessageVersionRef.current = messageVersion; - if (!changed) return; - - if (nearBottomRef.current) { - const frame = requestAnimationFrame(() => { - container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); - setHasNewMessage(false); - }); - return () => cancelAnimationFrame(frame); - } - setHasNewMessage(true); - }, [messageVersion]); - - function scrollToLatest() { - const container = messagesRef.current; - if (!container) return; - nearBottomRef.current = true; - setHasNewMessage(false); - container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); - } - - return ( -
-
-
{ - const nearBottom = isNearScrollBottom(event.currentTarget); - nearBottomRef.current = nearBottom; - if (nearBottom) setHasNewMessage(false); - }} - > - {messages.length === 0 ? ( -
-

BROWSIE · BROWSER AGENT

-

- Your personal -
- browser agent. -

-

- Ask Browsie to browse, research, and complete tasks on the web. Follow the live - browser and inspect each action. -

- {!bootstrap.modelConfigured && ( -
- -
- Connect the agent model - - Add an OpenAI key to .env. Then restart Browsie. - -
-
- )} -
- {starterPrompts.map(({ icon: Icon, label, hint }) => ( - - ))} -
-
- ) : ( -
- {messages.map((message) => ( -
-
- {message.role === "assistant" && ( -
- Browsie -
- )} -

- -

-
-
- ))} - {pendingInput ? ( -
- {pendingInput.prompt} - {pendingInput.options?.map((option) => ( - - ))} - {pendingInput.allowFreeform ? ( - Enter the requested value below to resume this same task. - ) : null} -
- ) : null} - {busy ? ( -
-
-
- Browsie -
-
- - - -
- - {stopStatus === "stopping" ? "Stopping Browsie" : "Browsie is working"} - -
-
- ) : null} - {!busy && stopped ? ( -
-
-
- Browsie -
-

- Stopped. The browser session is closed. Send a message to continue in a - replacement browser with the same Context. -

-
-
- ) : null} - {stopError ? ( -
-
-
- Browsie -
-

{stopError}

-
-
- ) : null} - {!busy && error ? ( -
-
-
- Browsie -
-

{error}

-
-
- ) : null} -
- )} -
- - {hasNewMessage ? ( - - ) : null} - -