dsh-plugin-browserskill
Tencent/BrowserSkillBrowserSkill 的 DeepSeek Harness 浏览器自动化桥接插件,通过原生浏览器工具控制可见的 Chrome 和 Edge Agent Window,支持可访问性与 VOM 页面观察、截图、隔离的多会话控制和 Web UI 实时观察浮层。
给 dsh agent 一个真正的浏览器:每个会话拥有自己的 Chrome 或 Edge 进程、独立 profile 与独立 CDP 端口,六个工具驱动它,右侧边栏实时镜像同一批页面,你既能看着它干活,也能直接在画面上点击、输入接管。除此之外无需再装东西——它驱动机器上已有的 Chrome 或 Edge,不下载浏览器,不需要配套应用或扩展,标签页与登录状态在工具调用之间一直保留。
English | 中文
A real local browser for every conversation. The agent opens pages and works in them while the sidebar mirrors the same browser, so you can watch the work happen and take over whenever you want. It runs in both the Web and the Desktop interface.
Chrome or Edge runs as its own process, with its own profile and its own CDP port, and every conversation gets its own: tabs, sign-ins, and ports are never shared between conversations. The agent drives it with nine tools, and the sidebar shows the same pages — either side can act on them.
It is not an embedded web view. The page runs in an ordinary browser process, so sites see a normal browser, DevTools or another Playwright can attach to it while the mirror is live, and closing the harness does not leave a browser pretending to be part of the app.
browser_snapshot prints the page's accessibility tree with a ref per node, which is how the agent reads an unfamiliar page and how it names the element to click; find narrows a large page, and boxes gives positions without a screenshot.element.click() still takes these — plus chords such as Ctrl+A, native dropdowns and checkboxes, dialogs answered by the call that opens them, and an explicit refusal when something else would receive the press.browser_evaluate is the general-purpose one; the others cover what code cannot express, such as reading a page without knowing its selectors first, or asking what the page said while it failed. See Tools.chrome://inspect, another Playwright, or the bundled scripts/cdp.mjs can attach to the same browser the sidebar is mirroring.Check the host dsh version first:
dsh -V
Then install with the #<tag> ref that version pairs with — a build targets one dsh generation, and a github: install without a ref takes the default branch HEAD, which drifts:
| Your dsh | Plugin version | Install command |
|---|---|---|
| ≥ 0.1.7-rc.1 | v0.2.3 (latest) | dsh plugin add --profile web github:CJYLZS/dsh-browser#v0.2.3 |
| 0.1.5-rc.2 | v0.1.x | dsh plugin add --profile web github:CJYLZS/dsh-browser#v0.1.0 |
For a dsh on the current generation, the latest release is:
dsh plugin add --profile web github:CJYLZS/dsh-browser#v0.2.3
The built lib/ is committed with each tag, so a tag install needs no build step. The profile's package.json records the ref you chose; to change versions, re-add with the new ref, and to remove the plugin use dsh plugin remove --profile web dsh-browser. Restart the harness after installing. A Chrome or Edge installation is required; playwright-core is a dependency and downloads no browser of its own.
The Desktop app owns its own profile, and the ordinary CLI refuses to manage --profile desktop on purpose, so install through the Desktop CLI that ships with the app:
"<Desktop install dir>/resources/runtime/cli/bin/dsh.cmd" plugin --profile desktop add github:CJYLZS/dsh-browser#v0.2.3
Use <Desktop install dir>/resources/runtime/cli/bin/dsh on macOS and Linux. Restart the app afterwards, the same way the Web profile restarts after dsh plugin add; the same remove verb uninstalls it.
A build targets one dsh generation, so install the plugin version that pairs with your dsh. The fork is in the client half — dsh 0.1.7-rc.1 replaced the settings model with no compatibility layer — which is why one build cannot serve both generations.
That fork is in the client half's imports, so one build cannot target both generations and the plugin pairs by generation:
peerDependencies: >=0.1.7-rc.1 with no upper bound, so one build runs on the whole 0.2.0 line it is verified against (why the cap went away). The settings page's fields are marked volatile in the schema, and an edit reaches the running browsers through loader/volatile-update.peerDependencies: ^0.1.5-rc.2. The settings page registers a namespace scope and renders its own controls.An older dsh fails loudly: dsh activates a plugin only when its dsh peers satisfy the declared ranges, and names the plugin and the unsatisfied ranges when they do not. A later generation passes that check, so whether this build still runs on one is a question for verification rather than for the version number.
Three steps, and the agent needs no instruction beyond what it is asked to do:
curl http://127.0.0.1:9333/json/version answers while the browser runs; chrome://inspect, another Playwright over chromium.connectOverCDP, and scripts/cdp.mjs all attach to it.The sidebar and the browser stay matched: one tab per page the browser has open, a new page adds a tab, closing a tab closes the page, and closing the last one stops the browser until something asks for another one. Which page the tools act on is the agent's choice (browser_tabs), independent of which tab you are looking at. Understand the design covers the rest.
The plugin keys everything by the harness session id, and that one decision explains most of its behavior:
exec.agent.id, which is the same SessionId the API layer resumes sessions with). Neither path can reach a browser belonging to another conversation, and a tool called with no session — a scheduled job, or a subagent without one — fails instead of landing in somebody's browser.debugPortMin–debugPortMax) because one number could never be the address: every session gets its own browser and its own listener. Each browser takes the lowest port in the window it can actually bind and holds it until it exits, because a probe's answer is stale the moment it is given. The top of the window is what stops a search from wandering into whatever else the machine runs: a browser that finds nothing free inside it fails with the window in the message. GET /dsh-browser/status reports the port each session ended up on; nothing should assume it is one of the configured values.Page.startScreencast is attached to one CDP session, and every path that starts a browser — the pane's restart, recovery from a crash, a launch setting change — replaces that session. A newly started browser therefore re-attaches the cast as soon as it is ready if anyone is watching, or a pane left open across a restart would keep its last frame forever while its status line kept updating.closed with the reason, publishes it to the pane, and leaves the next request to start a fresh browser. Nothing has to be fixed by hand, including the browser process dying between two tool calls.Accessibility.getFullAXTree is printed as one line per node, with ignored wrappers dropped and a ref for every node that has a DOM node behind it. A ref names a node on one page: navigating invalidates all of them, and a stale ref fails with a message pointing at browser_snapshot rather than clicking whatever now occupies that position.role with name, text, or selector — is the question that answer came from, asked again when the action runs, so a re-render or a navigation does not stop it. A locator matching more than one element is refused rather than guessed at, and the refusal lists every candidate with its nearest named ancestors, which is the only thing that tells two identically named controls apart. Matching reads the accessibility tree, so a locator matches exactly what a snapshot would print; a selector is walked by the page itself, so it reaches an open shadow root and a same-origin frame — the parts a query rooted at the document cannot see into, which is why target and snapshotIgnore resolve their selectors the same way.browser_tabs's open/select, independent of which pane the user is looking at.isTrustedApiRequest and browser authentication) before answering. An unauthenticated request to any of them is refused with 401.cordis.yml entry stays the base layer beneath it; clearing a field removes the override. Every field changes how a browser is launched or encoded, so a write restarts the running browsers and the panes reconnect on their own.The settings section (Settings → Browser) edits the fields below and reports what is actually running, which is not the same thing as what is configured: the banner lists every session's browser with the port, window mode, and page count it really has. A deployment without a settings provider registers nothing, and the composition entry is the whole configuration.
| Field | Default | Meaning |
|---|---|---|
channel | chrome | Which installed browser to drive: chrome or msedge. |
executablePath | empty | Explicit browser binary, for installs the channel lookup misses; overrides channel. |
headless | true | Run without a window. Headless has nothing to occlude or minimize, so it is the recommended value. |
stealth | true | Remove the two markers a Playwright-started browser carries: navigator.webdriver and, headless, a user agent spelled HeadlessChrome/…. |
userDataDir | empty | Parent directory of the per-session profiles; empty means a temporary profile per browser, removed when it exits. |
debugPortMin / debugPortMax | 9333 / 9400 | Window the per-session CDP ports are allocated from; each browser takes the lowest free one. Changing it moves where the next browser listens and leaves running browsers on the ports they already hold. |
viewportWidth / viewportHeight | 1440 / 900 | Page size in headless mode, which has no window to take a size from. |
quality | 70 | JPEG quality of mirrored frames. |
maxWidth / maxHeight | 1600 / 1200 | Longest edge of a mirrored frame, in device pixels. |
everyNthFrame | 1 | Mirror every Nth composited frame. |
snapshotNodes | 300 | Most accessibility-tree nodes one browser_snapshot prints before it truncates and says so. |
maxInstances | 4 | How many session browsers may run at once; reaching the limit fails the request rather than evicting a browser someone is watching. |
startupUrl | about:blank | First address a browser opens. |
extraArgs | [] | Additional browser arguments, appended after the plugin's own. |
stealth is on by default because it was measured, not assumed: with the markers in place Google served its "unusual traffic" page three times out of three, and with them removed three times out of three searches returned results — including for a real Edge window started the same way, which is what showed the markers rather than the browser to be the deciding factor. The setting exists so the comparison can be reproduced. When a deployment needs more than markers can hide, executablePath accepts any Chromium binary the user supplies, including hardened builds; no third-party binary is bundled or downloaded by this plugin.
| Tool | What it does |
|---|---|
browser_navigate | Open an address; returns the final URL, the title, and the tab list. |
browser_snapshot | Read the page as roles, names, and refs (- button "Sign in" [ref=e2]); returns the tab list too. find narrows it to what matches a text or a /regex/, with the path to each match; boxes adds each element's position in viewport pixels; target and depth take one subtree to a level. |
browser_click | Click the element a snapshot ref names — or one found by role + name, text, or selector when the call runs — with real mouse events at its position. button picks the right or middle button, double sends the two press-release pairs a page reads as one double click; a press the page says something else would receive is refused, and force sends it anyway and reports what received it. A native <select> and a checkbox cannot be driven by a press — the popup belongs to the browser process and its options have no box to click, and a toggled checkbox writes no attribute — so select: ["Two"] picks an option by its value or its visible label and `checked: true |
browser_type | Type into the element a ref names — or one found by role + name, text, or selector when the call runs — replacing its content by default, and press a key afterwards: a chord such as Control+A or Shift+Tab works. The characters to insert are value; text names an element, as it does on browser_click. With no element, value and key go to whatever the page has focused and nothing is replaced, which is how Escape closes a menu. It also reports focused, the element the focus ended on, which is the only way to see where a bare Tab sent it. |
browser_wait | Wait until the page reaches a state, then say whether it did: text, role + name, selector, url, or time (a fixed wait, the last resort) — exactly one, with timeoutMs up to 30 s. A condition that has not held is not an error: the result carries matched: false, how long it waited, where the page is now, and what the page changed meanwhile. It waits on the page already open; it does not start a browser. |
browser_console | Read what the page and the browser have said about themselves since the address loaded: console output, uncaught exceptions, and browser log entries such as a failed request or a blocked resource. Optional levels, filter, and limit. This is where a failure that leaves no mark in the DOM shows up, and it is the whole history, not just the last call — it is cleared when the page navigates. |
browser_screenshot | Capture the page to a JPEG file and return its path: the viewport by default, fullPage: true for the whole document, or one element named the way the acting tools name one. inline: true also attaches the image to the result (needs a model that declares image input; the call is refused, with the file still available, when it does not). |
browser_evaluate | Evaluate a JavaScript expression in the page: reading values, scrolling, history.back(), and anything else a tool argument list would express worse. A returned promise is awaited, a returned function is called, and top-level await and return both work. A string comes back as itself; anything else as JSON; a result too large to print is written to a file whose path comes back. |
browser_tabs | See the browser's pages and change them: list reports every page — its id, its address, and which one the tools act on right now, from the same list the sidebar's tabs are built from; open asks for a new page and selects it; select makes a page that is already open the one the other tools act on; close closes one page, and closing the last page stops the browser. It never switches on its own: which page a pane shows and which page the tools act on are two things, and clicking a sidebar tab does not move the latter. |
An action result carries a compact list of what changed. changed says the page moved (dom), which does not distinguish a click that opened what it named from one that only repainted a spinner. So the action also reports the first few changes the page made, each in facts the page itself can stand behind — its tag, the role it declares, and a short piece of what it says: dom: +1 status "Saved"; ~ p "24.1k" → "24.2k"; ~ button "Save" disabled: (none) → "true". The list is capped at five and the changes that did not fit are counted rather than dropped, and every entry is structured (kind/tag/role/preview/attribute/from/to) so a PTC program can branch on it. This is the page's own description of itself, for knowing where to look: the accessible roles and names are still browser_snapshot's to report.
A dialog is answered by the call that opens it. A confirm, prompt, or alert blocks the page until it is answered, so there is no moment at which a later call could answer one: pass dialog: "accept" — with dialogText for a prompt — on the call that triggers it, or the default dismissal is reported in the result. Every dialog is answered before the call returns and none is silent: the result names what the page asked and how it was answered, and changed says dialog, so "the page did not change" can never be the report for a click a site stopped to confirm.
The set is deliberately small because browser_evaluate exists: a tool earns its place only by doing something code cannot, which here means trusted input, reading a page without knowing its selectors first, and the two questions that cannot be asked after the fact — whether a state has been reached (browser_wait), and what the page said about itself while it failed (browser_console, whose listener has to be subscribed before the page's own scripts run). All three are deliberate exceptions to "new capability becomes a parameter, not a tool name" (the third is browser_tabs: a page is not a property of the current page, and page script cannot see another target); the screenshot's shapes are the rule followed instead: fullPage, an element target, and inline are parameters on the tool that already existed. Choosing an option and setting a checkbox are the same shape — select and checked are parameters on browser_click — because a press can do neither. Deliberately absent are back/forward/reload (the pane has reload; history is one expression), hover, drag, upload, and download — each would be added when a real case needs it, not in anticipation.
Screenshots are written to a file, and the path comes back so it is durable in the session log. inline: true also attaches the image to the result, which costs no second call; the attachment is a reference the harness's attachment service mints (the plugin declares that service structurally and does not depend on its package), and the call is refused before the capture when the calling model does not declare image input — an image block is part of the tool result, so attaching one to a model that cannot read it would fail that request and every request after it.
channel lookup, temporary directories, and process teardown are the parts most likely to differ.