omdsh-dev/dsh-browser
DeepSeek Harness plugin plus Chrome/Firefox extension that operates your real tabs through text-only, index-addressed browser tools.
Overview
dsh-browser connects DeepSeek Harness (dsh) to the Chrome or Firefox tabs you already have open. It ships as one pnpm workspace with two halves: a dsh plugin, @yuxianglin/dsh-bridge-browser, that registers 15 browser_* tools with the agent, and an MV3 extension that carries out those tools in your real browser profile. A side panel in the extension doubles as a chat client for dsh. Logins, cookies and open tabs are the user’s own, because no new browser is launched.
The design is deliberately text-only. A page reaches the model as a structured text snapshot: title, URL, a main-content excerpt, a numbered list of interactive elements and a list of form fields. The model then acts by number (“click 7”). No screenshots are taken for page control, and the extension does not use CDP or the debugger permission. Every action is a DOM operation performed by a content script.
This is a tool layer, not an agent. The planning loop, history, model choice and tool calling all belong to dsh. The repository adds the bridge, the extension, an approval system for state-changing actions, and a benchmark suite that compares the extension backend with a Playwright-based backend that uses the same tool contract.
Architecture
flowchart LR
M["dsh agent loop"] --> T["browser_* tools (plugin)"]
T --> S["BridgeServer /ext/bridge"]
S -->|"tool.call over WebSocket"| B["Extension service worker"]
B --> A["Approval policy + side panel"]
B --> R["Tab affinity: controlled tab"]
B -->|"chrome.tabs.sendMessage"| C["Content script per frame"]
C --> SN["Text snapshot + stable ids"]
C --> AC["DOM actions: click/type/press"]
P["Side panel (React)"] -->|"rpc frames"| S
S --> G["dsh Gateway: sessions, models"]
| Component | Path | Role |
|---|---|---|
| Plugin entry | packages/browser/bridge-browser/src/index.ts |
Cordis plugin: config, token, upgrade route, discovery route, tool registration, system-prompt hint |
| Tool set | packages/browser/bridge-browser/src/tools.ts |
15 browser_* definitions; each forwards to the bridge and returns one text block |
| Bridge server | packages/browser/bridge-browser/src/server.ts |
WebSocket auth (hello), single active connection, tool.call/tool.cancel, gateway RPC passthrough |
| Wire protocol | packages/browser/bridge-browser/src/protocol.ts |
Frame types, error codes, BridgeCaps (text-only, snapshot budgets) |
| Session helpers | session-deferral.ts, session-workspace.ts, browser-context.ts |
Lazy session creation, workspace grouping, injecting a followed tab’s snapshot into the agent |
| Service worker | extensions/dsh-browser/src/background/index.ts |
Bridge client, settings, approvals, tab affinity, tool routing |
| Tool dispatch | extensions/dsh-browser/src/background/tools.ts |
Frame discovery, approval checks, content-script messaging and re-injection, tab-level tools |
| Authorization | background/authorization.ts, src/security/ |
Read vs action prompts, origin trust, untrusted-content wrapping |
| Content script | extensions/dsh-browser/src/content/ |
Snapshot, stable element ids, privacy masking, DOM actions, settle detection |
| Side panel | extensions/dsh-browser/src/panel/ |
React chat UI, approval dialogs, sessions, image attachments |
| Benchmark | benchmark/ |
Paired extension vs Playwright runs on a local task site |
How a request flows
Take a model call to browser_click with index: 7:
- Tool call. dsh runs the tool registered in
registerBrowserTools, which callsbridge.requestTool(name, args, signal, timeout, sessionId)(tools.ts). - Dispatch.
requestToolfails fast withbridge-closedif no extension is connected, then sends atool.callframe with anexpiresAtdeadline. On timeout or abort it sendstool.cancel, so a late approval click cannot run an expired action (server.ts). - Route. In the service worker,
routeToolCallbuilds an abort controller and resolves the session’s controlled tab throughresolveToolTabbefore callingdispatchToolCall(background/index.ts). - Validate and authorize.
dispatchToolCalllists the tab’s frames, checks that index 7 still refers to a live element in the target document, and asksapprovalPromptForCallwhether a prompt is needed. If the user approves, it re-lists frames and refuses to proceed when the page changed while the dialog was open (background/tools.ts). - Execute. The worker sends a
DSH_ACTIONmessage to the right frame withchrome.tabs.sendMessage(background/tools.ts). If the content script is missing, for example in a tab that was open before the extension loaded, it injects it withchrome.scripting.executeScriptand retries once. - Act.
clickActionscrolls the element into view and callsel.click(). Links get special handling: the content script dispatches a cancelable click so SPA routers can intercept it, then setslocation.hrefitself for ordinary same-frame links (actions.ts). - Return. After a MutationObserver-based settle wait, the result text includes a delta snapshot of what changed. It travels back as
tool.resultand becomes a single text content block for the model.
Key components
Text snapshot and stable ids
buildSnapshot collects visible interactive elements, sorts open-dialog elements first and in-viewport elements next, and caps the list at maxItems (60 by default) (snapshot.ts). Ids come from ElementIds, a WeakMap registry that gives each element a number once and keeps it for as long as the element exists. It also writes a data-dsh-el attribute on the element (ids.ts). Because ids survive re-snapshots, renderSnapshot can emit a delta that lists only changed and removed elements, and it warns the model when more than half the set was renumbered (snapshot.ts).
Privacy and prompt-injection boundary
isSensitiveField masks password inputs, cc-* autocomplete fields and any field whose id, name or aria-label matches patterns such as password, card or secret (privacy.ts). The match is coarse, so it also masks harmless fields whose names contain those words. Page text is wrapped in an UNTRUSTED_PAGE_CONTENT block with a fresh nonce, so page content cannot forge the closing tag (untrusted.ts). The code itself calls this defense in depth. The real enforcement is the approval step.
Approvals
approvalPromptForCall classifies reads (browser_snapshot, browser_get_text) and state-changing actions, and computes the origins involved. Cross-origin navigations and back/forward can never be added to the trust list (authorization.ts). authorizeToolCall skips the prompt when unrestricted access is on or the origin is trusted for the session or permanently. Otherwise it shows a side-panel dialog that times out after 60 seconds (background/index.ts). Page reads default to sharePageContent: 'auto', so out of the box only actions prompt (background/index.ts).
Bridge authentication
The first frame must be hello. A loopback socket whose Origin starts with chrome-extension:// may skip the token. Everything else, including Firefox’s per-install moz-extension:// origins, must present the 256-bit bearer token (server.ts). Privileged gateway methods such as settings.* and credentials.* stay loopback-only even with a valid token. Only one extension connection is active at a time, and a new one replaces the old.
Tab affinity and session context
TabAffinityController binds tools to one user-controlled tab per session. When the user switches tabs, dispatch pauses for a keep/follow decision rather than silently acting on the new tab. When the user follows a tab, BrowserContextInjector pushes a fresh snapshot into the agent’s inbox, replacing any older one, so the next turn can use its indices directly (browser-context.ts).
Extending it
- New tools. Add a definition in
tools.tsand a matching action in the content script’srunActionswitch. The tool name doubles as the wire action name. - Budgets.
snapshotMaxChars,maxInteractiveItemsandtoolTimeoutMsare plugin config. They are negotiated to the extension inhello.ok, so there is no shared config file (index.ts). - Model guidance. The plugin adds a one-paragraph system-prompt section telling the model to snapshot on demand and reuse injected snapshots (index.ts).
- Other clients. The protocol is exported as
@yuxianglin/dsh-bridge-browser/protocol, so a different extension or client could speak it.
Running it
- Requirements. Node.js
^22.19or>=24, pnpm, Chrome 116+ or Firefox 140+, and dsh0.2.0-rc.2or a compatible newer version. The plugin refuses to mount if the gateway lackswireStream(index.ts). - Install.
scripts/install.shorinstall.ps1builds the plugin, registers it in the dshwebprofile and builds the unpacked Chrome extension, which you then load fromchrome://extensions.pnpm startrunsdsh web. Firefox needs a source build and a temporary add-on. - Discovery. With no bridge URL set, the extension probes
127.0.0.1on a fixed list of dsh web and desktop ports for/ext/bridge-config(background/index.ts).
Strengths and caveats
- Strength: the user’s real session. There is no headless copy and no profile import. Whatever the user is logged into, the model can reach, subject to approval.
- Strength: careful approval semantics. Approvals are origin-scoped, re-validated after the dialog closes, cancelled when the bridge times out, and denied if nobody answers within 60 seconds. With the panel closed, the user gets an OS notification instead of a silent pass.
- Strength: cheap, stable addressing. Indices persist across snapshots and delta output keeps follow-up turns small. The repo’s own paired benchmark shows fewer tool calls than its Playwright baseline (3.4 vs 4.7), though it is a six-task suite on a local site.
- Caveat: synthetic events. Clicks, key presses and typing are DOM calls (
el.click(),dispatchEvent, native value setters), soisTrustedis false. Sites that check for trusted input, or that rely on real key events for typing, may not respond.browser_pressdispatches onlykeydown/keyup. - Caveat: dsh only. The tools are dsh tool definitions and the panel speaks the dsh gateway. Using it with another agent means reimplementing the plugin side.
- Caveat: broad loopback trust. The token-free path accepts any
chrome-extension://origin, not only this extension’s id. Any other installed Chrome extension can therefore connect to a local bridge without the token. - Caveat: one connection. A second browser window or profile replaces the first connection, and non-loopback deployment is explicitly discouraged.
Sources: code at 23895d7, deepwiki-open wiki (12 pages), OpenDeepWiki wiki (15 pages), verified Q&A.
How it answers the Browser & computer control questions
Each answer was drafted by a code-reading agent at commit 23895d7. Its citations were checked mechanically. Compare with the other browser & computer control →
How is the page represented to the model?
answeredThe page is represented as structured text only - no screenshots or accessibility tree dump. The BridgeCaps type enforces textOnly=true (protocol.ts:64-66). renderSnapshot() (snapshot.ts:363-420) produces one text block: URL, title, ready-state, main content excerpt, a numbered interactive-element inventory, and form fields. buildSnapshot() (snapshot.ts:180-306) orchestrates: collectInteractive() (extract.ts:146-155) queries the DOM against a fixed CSS selector (a[href], button, input:not([type=hidden]), ARIA roles), filtered by isVisible() (extract.ts:40-46). Elements sort dialog-first then viewport-first (snapshot.ts:195-196). Each element gets a stable numeric id via ElementIds (ids.ts:19-71), backed by WeakMap plus data-dsh-el attribute. Main text uses a readability-lite heuristic preferring main/article/largest-section-with-paragraphs (extract.ts:165-185). Three budgets cap output: maxChars (default 32,000), maxItems (default 60), maxForms (default 30) - set in protocol.ts:37-40 and passed as SnapshotBudget. Truncation notes explain cuts (snapshot.ts:355-361). Delta mode returns only changed/removed elements (snapshot.ts:268-286). Sensitive fields (password, credit-card) are masked via isSensitiveField() (privacy.ts:30-41) with real values replaced by bullets (privacy.ts:49-51).
How are actions executed and how are elements targeted?
answeredActions execute in the user real browser via the content script (not CDP or Playwright). The bridge sends a tool.call frame over WebSocket (server.ts:226-233); background routeToolCall() (background/index.ts:1058-1199) dispatches via chrome.tabs.sendMessage() (background/tools.ts:157-164). runAction() (actions.ts:168-196) routes to handler functions. Elements are targeted by stable inventory index via elementOrThrow() (actions.ts:126-132) calling ids.elementByIndex() - no CSS selectors or coordinates. clickAction() (actions.ts:226-291) calls scrollIntoView then el.click() with MutationObserver page-settle. Controlled same-frame links dispatch a MouseEvent then set location.href; native activation is preserved when the link has noreferrer/ping attributes (actions.ts:244-254). typeAction() (actions.ts:365-381) uses a native-value setter via Object.getOwnPropertyDescriptor (actions.ts:146-158) for React/Vue inputs, dispatching input/change events. ContentEditable hosts use execCommand(insertText) (actions.ts:338-363) so Lexical/ProseMirror process input. pressAction() sends keydown/keyup events (actions.ts:383-394). scrollAction() uses window.scrollTo/scrollBy (actions.ts:396-417). Tab-level operations (navigate, back, forward, reload, open/close tabs) use chrome.tabs APIs (background/tools.ts:217-241, 864-974) - the only path for protected DOM pages. All actions settle with a MutationObserver-based DOM-quiet detector (actions.ts:53-120) with configurable policies. Actions returning page state include a delta snapshot (actions.ts:216-224).
How is the agent loop / planning implemented?
answeredThe agent loop is owned by DeepSeek Harness itself - this repository is a plugin providing browser tools that DSH calls. The bridge registers tools via registerBrowserTools() (tools.ts:83-101) using defineTool() from @deepseek-ai/dsh-tools. Each tool declares typed parameters and timeoutMs. When DSH calls a tool, the bridge dispatches a tool.call frame over the WebSocket (server.ts:226-233) to the connected extension. The extension background receives it in routeToolCall() (background/index.ts:1058-1199), resolves the controlled tab via TabAffinityController (tab-affinity.ts), dispatches to the content script, and sends tool.result back. In-flight calls have a per-call timeout (default 90s, index.ts:56) tracked by setTimeout in BridgeServer.pendingTools (server.ts:111-115). The tool.cancel frame (protocol.ts:101-102) withdraws timed-out or cancelled calls. DSH maintains history between steps; the bridge adds a system-prompt section telling the model to call browser_snapshot on demand rather than hoarding page text (index.ts:262-272). Tab-affinity management ensures manual tab switches create a handoff decision that blocks dispatch until resolved (tab-affinity.ts:6-12). Session-to-tab bindings persist via PageSessionContextTracker (session-continuity.ts:40-88). The TransientEventCache (transient-events.ts:9-53) preserves panel state across reconnects. Sessions are deferred via withSessionDeferral() (session-deferral.ts:54-172) so they materialize only on first prompt.
How are failures, retries and self-healing handled?
answeredFailure handling spans three layers. Bridge layer: BridgeServer.requestTool() sets a per-call timer (server.ts:220-223) and wraps cancellation via AbortSignal. On timeout it sends tool.cancel and rejects with BridgeToolError(timeout) (server.ts:221-222). Socket replacement settles all pending tools as bridge-closed (server.ts:514-518). The BridgeClient (background/bridge.ts:111-230) implements exponential-backoff reconnection: base 500ms, x2 per attempt, capped at 10s, jittered 0.5-1x (bridge.ts:225-229). Extension layer: dispatchToolCall() (background/tools.ts:671-797) handles content-script failures. When sendAction catches Receiving end does not exist (tools.ts:166-167), it auto-injects the content script via injectContentScript() (tools.ts:134-148) and retries the dispatch (tools.ts:756-796). TabManagementContext.commitAction/rollbackActionCommit (background/index.ts:1074-1078) track whether a state-changing operation can be withdrawn. validateElementTarget() (background/tools.ts:809-818) rejects stale element references by comparing frame document keys. Server layer: session-purge.ts (session-purge.ts:76-154) handles durable storage errors with typed codes. Session deferral provides 30-minute TTL for abandoned sessions (session-deferral.ts:62-69). The MutationObserver settle detection (actions.ts:53-120) has hard caps so continuously-animated pages cannot stall tools.
Which models are supported and how are they called?
answeredThis repository is a plugin for DeepSeek Harness, which is model-agnostic - it supports any provider through its plugin architecture. The bridge itself never calls models; it only defines browser tools that DSH agent loop invokes. Model selection flows through DSH Host API: session.models calls session/modelCatalog on the gateway (remote-host-api.ts:230-248), returning available model groups, current selection, and failures. adaptModelCatalog() (remote-host-api.ts:947-962) normalises this into a provider/model record. The session-deferral wrapper (session-deferral.ts:128-149) handles model queries for provisional sessions. Model selection (provider, model, optional reasoningEffort) can be set before the first prompt (session-deferral.ts:133-149). There are no vision or screenshot capabilities - BridgeCaps sets textOnly=true (protocol.ts:64-66). The README benchmarks (README.md:46-48) use deepseek-v4-flash. Tools are defined with structured JSON-schema parameters via defineTool() from @deepseek-ai/dsh-tools (tools.ts:117+), and DSH handles tool-calling through its normal agent loop - the bridge just routes calls to the extension and returns text results.
How are browser sessions, profiles, auth and anti-bot handled?
answeredSessions are grounded in the user real browser - the extension runs as a first-party Chrome/Firefox MV3 extension, so all login state, cookies, and local profiles are inherited naturally. There is no headless browser, no Playwright, no separate profile management. The bridge authenticates the extension via a bearer token presented in the hello frame within HELLO_TIMEOUT_MS (server.ts:276-279). Loopback connections can skip the token if Origin comes from chrome-extension:// (server.ts:304-309); non-loopback and Firefox connections always require the token. The token is generated once and persisted as a 0600 file (token.ts:72-78). Session-to-tab binding is managed by PageSessionContextTracker (session-continuity.ts:40-88), mapping (tabId, windowId, urlKey) to sessionIds, persisted in chrome.storage.session. TabAffinityController (tab-affinity.ts:58+) tracks controlled-tab state and blocks tool dispatch during user tab switches with a handoff decision. Bridge sessions are grouped into a dedicated workspace via withSessionWorkspace() (session-workspace.ts:26-82). Session creation is deferred until first prompt by withSessionDeferral() (session-deferral.ts:54-172); abandoned provisional sessions expire after 30 minutes. Approved action origins are cached per session (background/index.ts:189). The ApprovalCoordinator (approval-coordinator.ts:25-101) manages per-action user approval with a 60s timeout. There are no proxies, CAPTCHA handlers, or stealth measures - the model uses whatever browser state the user already has.