dsh-uni-browser 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Xiang Bai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # dsh-uni-browser
2
+
3
+ Persistent, named browser profiles for [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness), powered by a local [uni-browser](https://github.com/baixianger/uni-browser) daemon.
4
+
5
+ ## What it does
6
+
7
+ - Registers named Camoufox or Chromium profiles in DSH Settings → Uni Browser.
8
+ - Opens and stops profiles without losing their local cookies, localStorage, or IndexedDB.
9
+ - Lets DSH agents navigate, snapshot, click, type, and press through uni-browser's audited action API.
10
+ - Keeps browser passwords, cookies, and daemon tokens out of the DSH UI and tool parameters.
11
+
12
+ ## Prerequisite
13
+
14
+ Start a local uni-browser daemon first. The plugin connects to its standard Unix socket (`$UNI_BROWSER_SOCKET`, then `$XDG_RUNTIME_DIR/uni-browser/uni.sock`, then `~/.uni-browser/uni.sock`). It does not start a browser automatically.
15
+
16
+ ## Login profiles
17
+
18
+ Create a profile with **headless disabled**, open it, and sign in yourself in the visible browser. Later opens reuse the same managed profile directory. **Stop** retains it; **Forget** permanently removes it after confirmation.
19
+
20
+ ## Security boundary
21
+
22
+ This first release is local-only. It uses uni-browser's NDJSON action plane rather than direct CDP/Juggler passthrough, so browser actions remain in uni-browser's audit trail.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-uni-browser
3
+ name: dsh-uni-browser
package/lib/client.js ADDED
@@ -0,0 +1,20 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-uni-browser",
3
+ factory: (require) => {
4
+ const module = { exports: {} }; const exports = module.exports; const React = require("react"); const h = React.createElement; const inject = ["slots", "connection"];
5
+ function apply(ctx) {
6
+ const call = async (endpoint, args = {}) => { const response = await ctx.connection.rpc.call("/dsh-uni-browser", endpoint, { args }); if (!response?.ok) throw new Error(response?.error?.message ?? "Browser request failed"); return response.value; };
7
+ function BrowserSettings() {
8
+ const [profiles, setProfiles] = React.useState([]); const [health, setHealth] = React.useState(null); const [name, setName] = React.useState(""); const [engine, setEngine] = React.useState("camoufox"); const [error, setError] = React.useState(""); const [working, setWorking] = React.useState(false);
9
+ const refresh = React.useCallback(async () => { const [profileResult, healthResult] = await Promise.all([call("profiles"), call("health").catch(() => null)]); setProfiles(profileResult.profiles ?? []); setHealth(healthResult); setError(""); }, []);
10
+ React.useEffect(() => { void refresh().catch((cause) => setError(String(cause.message ?? cause))); }, [refresh]);
11
+ const create = async (event) => { event.preventDefault(); if (!name.trim() || working) return; setWorking(true); try { await call("create", { name: name.trim(), engine, headless: false }); setName(""); await refresh(); } catch (cause) { setError(String(cause.message ?? cause)); } finally { setWorking(false); } };
12
+ const operate = async (endpoint, id, confirm = undefined) => { setWorking(true); try { await call(endpoint, { id, ...(confirm === undefined ? {} : { confirm }) }); await refresh(); } catch (cause) { setError(String(cause.message ?? cause)); } finally { setWorking(false); } };
13
+ const field = { boxSizing: "border-box", minHeight: 38, border: "1px solid var(--dsw-alias-border-l2, #ddd)", borderRadius: 9, padding: "0 11px", background: "var(--dsw-alias-bg-layer-1, #fff)", color: "inherit" }; const button = { ...field, cursor: "pointer" };
14
+ return h("div", { style: { width: "min(720px, 100%)", padding: "8px 4px 48px" } }, h("div", { style: { marginBottom: 26 } }, h("h2", { style: { margin: "0 0 6px", fontSize: 22 } }, "Uni Browser"), h("p", { style: { margin: 0, color: "var(--dsw-alias-text-secondary, #777)", lineHeight: 1.5 } }, "Named browser profiles keep their local login state. DSH never displays passwords, cookies, or daemon tokens.")), error && h("p", { role: "alert", style: { padding: 10, borderRadius: 9, background: "rgba(210,48,48,.08)", color: "var(--dsw-alias-state-error-primary, #b42318)" } }, error), h("section", { style: { marginBottom: 28 } }, h("h3", { style: { margin: "0 0 6px", fontSize: 15 } }, "Local daemon"), h("p", { style: { margin: 0, color: health?.online ? "#228b5b" : "var(--dsw-alias-text-secondary, #777)", fontSize: 13 } }, health?.online ? `Online · ${health.socketPath}` : "Offline · start uni-browser daemon before opening a profile.")), h("section", { style: { marginBottom: 28 } }, h("h3", { style: { margin: "0 0 8px", fontSize: 15 } }, "New persistent profile"), h("form", { onSubmit: create, style: { display: "flex", gap: 8, flexWrap: "wrap" } }, h("input", { value: name, onChange: (event) => setName(event.target.value), placeholder: "e.g. Research Camoufox", style: { ...field, flex: "1 1 240px" } }), h("select", { value: engine, onChange: (event) => setEngine(event.target.value), style: button }, h("option", { value: "camoufox" }, "Camoufox"), h("option", { value: "chromium" }, "Chromium")), h("button", { type: "submit", disabled: working || !name.trim(), style: button }, "Create"))), h("section", null, h("h3", { style: { margin: "0 0 10px", fontSize: 15 } }, "Registered profiles"), h("div", { style: { display: "grid", gap: 8 } }, profiles.map((profile) => h("div", { key: profile.id, style: { padding: "12px", borderRadius: 10, background: "var(--dsw-alias-bg-layer-2, #f6f7f8)" } }, h("div", { style: { display: "flex", justifyContent: "space-between", gap: 12 } }, h("div", null, h("strong", null, profile.name), h("div", { style: { color: "var(--dsw-alias-text-secondary, #777)", fontSize: 12, marginTop: 3 } }, `${profile.engine} · ${profile.active ? "Running" : "Stopped"}`)), h("div", { style: { display: "flex", gap: 6 } }, h("button", { type: "button", disabled: working, onClick: () => operate(profile.active ? "close" : "open", profile.id), style: button }, profile.active ? "Stop" : "Open"), h("button", { type: "button", disabled: working, onClick: () => { if (window.confirm(`Forget ${profile.name}? Its local login state will be deleted.`)) void operate("forget", profile.id, true); }, style: { ...button, color: "var(--dsw-alias-state-error-primary, #b42318)" } }, "Forget"))))), profiles.length === 0 && h("p", { style: { color: "var(--dsw-alias-text-secondary, #777)", fontSize: 13 } }, "No profiles registered yet."))));
15
+ }
16
+ ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "uni-browser", order: 36, label: () => "Uni Browser" }, BrowserSettings));
17
+ }
18
+ exports.apply = apply; exports.inject = inject; return module.exports;
19
+ }
20
+ });
package/lib/index.js ADDED
@@ -0,0 +1,101 @@
1
+ import { defineTool } from "@deepseek-ai/dsh-tools";
2
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import net from "node:net";
6
+
7
+ export const name = "dsh-uni-browser";
8
+ export const inject = ["connection", "tools"];
9
+
10
+ const safeId = (value) => String(value).trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
11
+
12
+ export class UniBrowserClient {
13
+ constructor(config = {}) { this.socketPath = config.socketPath ?? process.env.UNI_BROWSER_SOCKET ?? join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), ".uni-browser"), "uni.sock"); }
14
+ async call(action, session = "default", params = {}) {
15
+ const id = crypto.randomUUID(); const request = `${JSON.stringify({ id, action, session, params })}\n`;
16
+ return new Promise((resolve, reject) => {
17
+ let buffer = ""; let settled = false;
18
+ const socket = net.createConnection(this.socketPath);
19
+ const fail = (error) => { if (!settled) { settled = true; reject(new Error(`uni-browser daemon unavailable at ${this.socketPath}: ${error.message}`)); } socket.destroy(); };
20
+ socket.setTimeout(10_000, () => fail(new Error("request timed out")));
21
+ socket.once("error", fail);
22
+ socket.on("connect", () => socket.write(request));
23
+ socket.on("data", (chunk) => {
24
+ buffer += chunk.toString("utf8"); let newline;
25
+ while ((newline = buffer.indexOf("\n")) >= 0) {
26
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); if (!line.trim()) continue;
27
+ let response; try { response = JSON.parse(line); } catch { continue; }
28
+ if (response.id !== id || typeof response.success !== "boolean") continue;
29
+ settled = true; socket.end();
30
+ if (response.success) resolve(response.data ?? {}); else reject(new Error(response.error ?? `uni-browser rejected ${action}`));
31
+ return;
32
+ }
33
+ });
34
+ });
35
+ }
36
+ }
37
+
38
+ export class ProfileStore {
39
+ constructor(config = {}) {
40
+ this.root = config.root ?? join(homedir(), ".dsh", "dsh-uni-browser");
41
+ this.path = config.path ?? join(this.root, "profiles.json");
42
+ this.profilesRoot = config.profilesRoot ?? join(this.root, "profiles");
43
+ this.state = { version: 1, profiles: [] }; this.ready = this.#load();
44
+ }
45
+ async #load() { try { const parsed = JSON.parse(await readFile(this.path, "utf8")); if (parsed?.version === 1 && Array.isArray(parsed.profiles)) this.state = parsed; } catch (error) { if (error?.code !== "ENOENT") throw error; } }
46
+ async #save() { await mkdir(this.root, { recursive: true }); const temp = `${this.path}.${crypto.randomUUID()}.tmp`; await writeFile(temp, `${JSON.stringify(this.state, null, 2)}\n`, { mode: 0o600 }); await rename(temp, this.path); await chmod(this.path, 0o600); }
47
+ async list() { await this.ready; return this.state.profiles.map((profile) => ({ ...profile })); }
48
+ async create({ name, engine = "camoufox", headless = false }) {
49
+ await this.ready; const id = safeId(name); if (!id) throw new Error("profile name must contain letters or numbers");
50
+ if (!["camoufox", "chromium"].includes(engine)) throw new Error("engine must be camoufox or chromium");
51
+ if (this.state.profiles.some((profile) => profile.id === id)) throw new Error(`profile ${id} already exists`);
52
+ const profile = { id, name: String(name).trim(), engine, headless: Boolean(headless), session: `dsh-${id}`, createdAt: Date.now() };
53
+ this.state.profiles.push(profile); await this.#save(); return { ...profile };
54
+ }
55
+ async get(id) { await this.ready; const profile = this.state.profiles.find((item) => item.id === safeId(id)); if (!profile) throw new Error(`unknown browser profile: ${id}`); return profile; }
56
+ profileDir(profile) { return join(this.profilesRoot, profile.id); }
57
+ async forget(id) { await this.ready; const profile = await this.get(id); await rm(this.profileDir(profile), { recursive: true, force: true }); this.state.profiles = this.state.profiles.filter((item) => item.id !== profile.id); await this.#save(); return profile; }
58
+ }
59
+
60
+ export class DshUniBrowser {
61
+ constructor(config = {}) { this.client = config.client ?? new UniBrowserClient(config); this.store = config.store ?? new ProfileStore(config); }
62
+ async health() { const data = await this.client.call("daemon.ping"); return { online: Boolean(data.pong), socketPath: this.client.socketPath }; }
63
+ async profiles() {
64
+ const profiles = await this.store.list(); let active = new Set(); let online = false;
65
+ try { const data = await this.client.call("session.list"); active = new Set((data.sessions ?? []).map(String)); online = true; } catch {}
66
+ return profiles.map((profile) => ({ ...profile, active: active.has(profile.session), daemonOnline: online }));
67
+ }
68
+ async create(input) { return this.store.create(input); }
69
+ async open(id) { const profile = await this.store.get(id); await mkdir(this.store.profileDir(profile), { recursive: true }); const data = await this.client.call("session.create", profile.session, { engine: profile.engine, headless: profile.headless, user_data_dir: this.store.profileDir(profile), audit: true }); return { profile, data }; }
70
+ async close(id) { const profile = await this.store.get(id); const data = await this.client.call("session.close", profile.session, { purge: false }); return { profile, data }; }
71
+ async forget(id, confirm) { if (confirm !== true) throw new Error("forgetting a profile permanently deletes its login state; pass confirm: true"); const profile = await this.store.get(id); try { await this.client.call("session.close", profile.session, { purge: false }); } catch {} return this.store.forget(profile.id); }
72
+ async action(id, action, params = {}) { const profile = await this.store.get(id); return this.client.call(action, profile.session, params); }
73
+ }
74
+
75
+ const profileOutput = { schema: { type: "object", additionalProperties: false, properties: { id: { type: "string", required: true }, name: { type: "string", required: true }, engine: { type: "string", required: true }, session: { type: "string", required: true } } }, render: (_args, value) => [{ type: "text", text: `${value.name} (${value.engine})` }] };
76
+
77
+ export function apply(ctx, config) {
78
+ const browser = new DshUniBrowser(config); ctx.provide("dshUniBrowser", browser);
79
+ ctx.connection.rpc.handle("/dsh-uni-browser", async (endpoint, payload) => {
80
+ const args = payload?.args ?? {};
81
+ if (endpoint === "health") return { ok: true, value: await browser.health() };
82
+ if (endpoint === "profiles") return { ok: true, value: { profiles: await browser.profiles() } };
83
+ if (endpoint === "create") return { ok: true, value: await browser.create(args) };
84
+ if (endpoint === "open") return { ok: true, value: await browser.open(args.id) };
85
+ if (endpoint === "close") return { ok: true, value: await browser.close(args.id) };
86
+ if (endpoint === "forget") return { ok: true, value: await browser.forget(args.id, args.confirm) };
87
+ throw new Error(`unknown dsh-uni-browser endpoint: ${endpoint}`);
88
+ }, { authority: "trusted-host" });
89
+ ctx.tools.register(defineTool({ name: "uni_browser_profiles", description: "List registered persistent uni-browser profiles. Use a profile id explicitly before browser actions.", parameters: {}, output: { schema: { type: "array", items: { type: "object" } }, render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }] }, async execute() { return browser.profiles(); } }));
90
+ ctx.tools.register(defineTool({ name: "uni_browser_profile_create", description: "Register a named persistent browser profile. The user logs in manually after opening it; no passwords or cookies are supplied to this tool.", parameters: { name: { type: "string", required: true }, engine: { type: "string", required: false, description: "camoufox or chromium; defaults to camoufox." }, headless: { type: "boolean", required: false, description: "Use false when a human must log in visibly." } }, output: profileOutput, async execute(args) { return browser.create(args); } }));
91
+ ctx.tools.register(defineTool({ name: "uni_browser_open", description: "Start a registered browser profile and restore its local login state. Use a headed profile for human login or approval flows.", parameters: { profile: { type: "string", required: true } }, output: { schema: { type: "object" }, render: (_args, value) => [{ type: "text", text: `Opened ${value.profile.name}` }] }, async execute(args) { return browser.open(args.profile); } }));
92
+ ctx.tools.register(defineTool({ name: "uni_browser_close", description: "Stop a browser profile without deleting its cookies or other persistent login state.", parameters: { profile: { type: "string", required: true } }, output: { schema: { type: "object" }, render: (_args, value) => [{ type: "text", text: `Stopped ${value.profile.name}; profile data remains.` }] }, async execute(args) { return browser.close(args.profile); } }));
93
+ ctx.tools.register(defineTool({ name: "uni_browser_forget", description: "Permanently delete a browser profile and all of its local login state. Use only after the user explicitly asks to forget it.", parameters: { profile: { type: "string", required: true }, confirm: { type: "boolean", required: true } }, output: profileOutput, async execute(args) { return browser.forget(args.profile, args.confirm); } }));
94
+ for (const [name, action, parameters, description] of [
95
+ ["uni_browser_navigate", "page.navigate", { profile: { type: "string", required: true }, url: { type: "string", required: true } }, "Navigate an open browser profile to a URL."],
96
+ ["uni_browser_snapshot", "page.snapshot", { profile: { type: "string", required: true } }, "Read the accessibility snapshot of an open browser profile."],
97
+ ["uni_browser_click", "page.click", { profile: { type: "string", required: true }, selector: { type: "string", required: true }, humanized: { type: "boolean", required: false } }, "Click a CSS selector in an open browser profile."],
98
+ ["uni_browser_type", "page.type", { profile: { type: "string", required: true }, selector: { type: "string", required: true }, text: { type: "string", required: true }, humanized: { type: "boolean", required: false } }, "Type text into a CSS selector in an open browser profile."],
99
+ ["uni_browser_press", "page.press", { profile: { type: "string", required: true }, key: { type: "string", required: true } }, "Press a key in an open browser profile."]
100
+ ]) ctx.tools.register(defineTool({ name, description, parameters, output: { schema: { type: "object" }, render: (_args, value) => [{ type: "text", text: JSON.stringify(value) }] }, async execute(args) { const { profile, ...params } = args; return browser.action(profile, action, params); } }));
101
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "dsh-uni-browser",
3
+ "version": "0.1.0-rc.0",
4
+ "description": "Persistent, auditable uni-browser profiles for DeepSeek Harness.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "exports": { ".": "./lib/index.js", "./client": "./lib/client.js", "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" },
8
+ "dsh": { "client": { "inject": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-settings"], "platform": "web" }, "bundle": { "patch": "./cordis.patch.yml" } },
9
+ "files": ["lib/", "docs/", "README.md", "LICENSE", "cordis.patch.yml"],
10
+ "scripts": { "check": "node --check lib/index.js && node --check lib/client.js && npm pack --dry-run", "test": "node --test test/**/*.test.mjs" },
11
+ "keywords": ["deepseek-harness", "dsh", "browser", "camoufox", "chromium", "uni-browser"],
12
+ "author": "Xiang Bai",
13
+ "license": "MIT",
14
+ "repository": { "type": "git", "url": "git+https://github.com/baixianger/dsh-uni-browser.git" },
15
+ "homepage": "https://github.com/baixianger/dsh-uni-browser#readme",
16
+ "engines": { "node": ">=22" },
17
+ "publishConfig": { "access": "public", "tag": "next" },
18
+ "peerDependencies": { "@deepseek-ai/dsh-tools": ">=0.1.0-rc.5", "react": "^18.2.0" },
19
+ "devDependencies": { "@deepseek-ai/dsh-tools": "^0.1.0-rc.7" }
20
+ }