DSH Pocket

DSH Pocket

English | 中文

CI License: GPL-2.0 GitHub stars Awesome DSH Plugin

> Put **DeepSeek Harness in your pocket**: the plugin exposes exactly one port (3081), uses **Gitee or GitHub OAuth sign-in** (pick one during setup) instead of an access PIN, and **you bring your own tunnel** — your phone sees the same screen as your computer, live.

⭐ A Star would make the author's day  ·  Here, take one

## What is this **You want to use DeepSeek Harness on your computer, even when you're not at the computer.** - On your way home, the agent is running a task on your computer — pull out your phone and see where it is, what it produced. - Out and about, you want the agent on your computer to look something up or write a snippet — no remote desktop, no SSH. - The computer is at home or in the office, you're elsewhere, and you want to **drive your DeepSeek Harness from your phone** — send tasks, watch the output, tap approvals. That's what DSH Pocket does: **expose one port, sign in once with Gitee or GitHub, and your phone shows and controls the DeepSeek Harness UI in real time.** What it looks like — the phone shows the exact same UI as your computer, live:

DSH UI on the phone

## ✨ Features | Feature | Description | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 🚪 Single port | Only **3081** is exposed (the proxy). It rewrites inbound Host/Origin to loopback and forwards to your local dsh web — **no dsh config changes**, and no 0.0.0.0 binding (which dsh forbids) | | 🔐 Gitee / GitHub sign-in | **Replaces the access PIN entirely**: pick **one** provider during setup (Gitee in mainland China, GitHub abroad) and bind that account (provider + uid); afterwards any device signs in with the **same account** through an allowlisted origin. Switching means re-selecting and re-binding | | 🔗 Multi callback URLs | Both Gitee and GitHub accept multiple callback URLs (GitHub: up to 10): local `http://127.0.0.1:3081/...`, your tunnel domain `https://your-domain/...`, optionally a LAN IP — **whichever origin you visit is the callback used** | | 🏠 Loopback is free | `127.0.0.1` / `localhost` needs no sign-in (being on the machine is already proof), and the setup page is **loopback-only** | | 🧱 Fail closed | With no credentials or no bound account, **every non-loopback request is refused** — no "installed and exposed" window | | 🌐 Bring your own tunnel | The plugin manages **no tunnel at all**: point a **fixed domain** at `http://127.0.0.1:3081` with frp, Tailscale Funnel, your own nginx reverse proxy, any port-forwarding service (fixed domain required, see below) | | 📱 QR per origin | The settings tab renders a QR code for every allowlisted origin; scan it and tap "Sign in with Gitee" / "Sign in with GitHub" (whichever provider is configured) | | 🧘 Session persistence | Signing in sets an HttpOnly session cookie (30 days) — **no repeated prompts**; sessions are bound to the dsh web process (**restart/update ⇒ sign in again**) | | 🚪 Sign out everywhere | One click rotates the process-level session key, instantly invalidating every signed-in device | | ⚡ Real-time sync | Streaming rides a fully transparent WebSocket — **output on the computer scrolls on the phone**; built-in heartbeat keeps NAT/idle drops from silently killing the link (auto-reconnect) | | 🗜️ Compression | Large JSON responses are gzip/brotli'd (17MB session → ~1MB; brotli q6: fast and small) | | 🧩 No extra service | One npm package (Remote access + the Mobile Web UI component); no server or relay of your own (sign-in uses your own Gitee / GitHub account) | | 📱 Phone UI | The phone shows the same official UI as the computer (one `dsh web`, live-mirrored) and automatically gets the narrow-screen treatment: drawer sidebar, two-level settings navigation, touch composer, Plan review cards | ### 🧩 Two components, switched independently The package ships two components, and installing/updating stays a single command. Each has its own switch on the **Sidebar → Plugins → dsh-pocket** card: | Component | Owns | Switched off | | --- | --- | --- | | `dsh-pocket` (Remote access) | Single-port proxy, Gitee/GitHub OAuth, origin QR codes, update/restart | The proxy stops listening (3081 closes) and there is no "Phone access" tab; the phone can still reach the official UI directly (LAN / tunnel to your local dsh web) | | `dsh-pocket-mobile` (Mobile Web UI) | Active below 1024px: drawer sidebar, panel exit button, two-level settings navigation, touch composer, Plan / approval cards | The phone gets the plain desktop layout (56px icon rail with no way to expand it, settings columns squeezed) | > **The mobile component ships its own client bundle**: the subpackage `dsh-pocket-mobile` (source in `mobile/`, installed together with the package) declares `dsh.client`, and `client/build.mjs` compiles its browser half into `mobile/client/client.js` — so that row has a **Configure** page where the backdrop, composer optimisation and stepped settings navigation can be turned off. > > The switch writes the row's `disabled` override into the profile's `cordis.patch.yml` (the native DSH mechanism): the **host half starts/stops immediately**, while a client bundle enters or leaves the graph on the next page load — refresh once after switching. Settings only keeps Remote access ("Phone access": proxy / OAuth / QR / update / restart); there is **no** mobile-UI tab — the mobile tweaks live on the **Plugins → dsh-pocket** card. ## 🚀 Getting started **Where is it**: after installing and restarting `dsh web`, open **Settings** — the **"Phone access"** entry appears in the left sidebar (same level as "General" and "Models"):

Phone access entry

**Prerequisite**: [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) on the computer. If your terminal says `dsh: command not found`, install it first: ```sh npm install -g @deepseek-ai/dsh # global; verify with: dsh --version # Prefer not to install globally? Prefix every command with npx: npx @deepseek-ai/dsh ``` **Option 1: one-line install (npm registry)** ```sh dsh plugin --profile web add @ds-hu/dsh-pocket -w --registry=https://registry.npmjs.org/ # This fails once with ERR_PNPM_IGNORED_BUILDS: the mobile subpackage is linked by the root # package's postinstall, and pnpm 11 blocks dependency scripts by default. Click # "Allow these scripts and retry" on the plugin panel, then re-run the command (one time only). # Restart dsh web after it installs npx @deepseek-ai/dsh web ``` **Option 2: install from the DSH WebUI Plugins page** 1. Open `dsh web` → **Settings → Plugins**; 2. Click **"Add plugin"** and pick the **"Official npm registry"** source (`https://registry.npmjs.org/`); 3. Enter the package name `@ds-hu/dsh-pocket` and click **"Install"**; 4. If you see `ERR_PNPM_IGNORED_BUILDS` / blocked build scripts, click **"Allow these scripts and retry"** (one time only); 5. When it finishes, **restart dsh web** as prompted. > ℹ️ The package name is **`@ds-hu/dsh-pocket`**: the unscoped `dsh-pocket` on npm is the **original** project (PIN-based, not this one) — don't install the wrong one. > ⚠️ **Already on the GitHub build or an older version?** A `github:` spec pins the commit, so restarting is not enough: uninstall the old plugin from the Plugins page, then install `@ds-hu/dsh-pocket` with either option above. > 🔄 Updates: for an npm install, re-run `dsh plugin --profile web add @ds-hu/dsh-pocket@latest -w` and restart (the Plugins page does not auto-update npm installs yet). The in-UI "one-click update" only covers GitHub installs (re-runs `add` to re-pin the latest commit) and local clone symlinks (`git pull --ff-only`, development setup — see [LOCAL-DEV.md](./LOCAL-DEV.md)). ### Step 1: one-time setup (on this machine, ~2 minutes) **① Pick one provider and create an OAuth app**: | Provider | Where | Scope | | -------------------------- | -------------------------------------------------------- | ------------------------- | | **Gitee** (best in China) | | tick `user_info` | | **GitHub** (best abroad) | | default `read:user` is enough, no review needed | Both accept **multiple** callback URLs (GitHub: up to 10) — add one per access route (**must match exactly**, scheme and port included): | Purpose | Callback URL | | -------------------- | --------------------------------------------------------- | | Local setup | `http://127.0.0.1:3081/pocket-oauth/callback` | | Your tunnel domain | `https://your-fixed-domain/pocket-oauth/callback` | | LAN direct (optional)| `http://your-lan-ip:3081/pocket-oauth/callback` | Copy the resulting **Client ID** and **Client Secret**. > 💡 A GitHub OAuth app with a single callback URL has "wildcard matching" enabled by default (it also matches subdomains/paths). Registering every URL character-for-character is the safer choice; this plugin is protected either way by its single-use `state` plus the callback allowlist. **② Open the setup page in a browser on this machine**: `http://127.0.0.1:3081/pocket-setup` (the settings tab shows this URL with a copy button). **Pick Gitee or GitHub**, fill in the Client ID / Secret and the **access origins (one per line — the callback allowlist)**, then click "Save & bind account". **③ Finish binding**: you are redirected to the provider you picked to authorize → back on the local page it shows "Bound successfully". That account is now bound to this machine. ### Step 2: bring your own tunnel (for remote access) Forward a **fixed domain** to `http://127.0.0.1:3081` with whatever you like (frp, Tailscale Funnel, your own nginx reverse proxy, any port-forwarding service). Key points: - **The domain must be fixed**: both providers match callback URLs exactly, so a changing domain breaks the callback (this is also why the plugin no longer ships a random-domain tunnel) - **Keep the original domain as the Host header** (the default for mainstream tunnels): rewriting Host to `127.0.0.1` makes public traffic look local and skips sign-in - The tunnel URL must be **identical** to one allowlisted origin (`https://` and `http://` count as different) ### Step 3: use it from the phone Open `https://your-fixed-domain` on the phone → tap "**Sign in with Gitee**" (or "**Sign in with GitHub**" if that's the configured provider — the button follows the provider) → authorize with the **same** account → you're in. The UI matches your computer and stays in sync. > **Another device**: just repeat step 3 (same account), no re-setup needed. > **Changing the tunnel domain**: update both the OAuth app callback URL and the settings allowlist, then re-save the setup page (re-binding is required). > **Switching provider (Gitee ⇄ GitHub)**: reopen the setup page on this machine, pick the other provider and bind again — a different provider is a different identity. > **Revoke everything**: use "Sign out everywhere" in the settings tab. ## ⚠️ Security (read this) - **dsh can execute code on your computer.** Authentication is your **Gitee or GitHub account**: only the account **bound during setup (provider *and* uid must both match)** gets in — any other account (even one with its own OAuth app) is rejected, and identical uids across the two providers cannot sneak through - **Loopback is unauthenticated**: reaching it locally already means you're on the machine; that's why the **setup page is loopback-only** (remote access gets 403) - **Fail closed**: with no client credentials, or credentials without a bound account, **every non-loopback request is refused** (an actionable page for browsers, 503 for APIs) - **Never rewrite Host in your tunnel**: if a reverse proxy rewrites Host to `127.0.0.1`, public traffic is treated as local and skips sign-in — the one deployment detail to watch - **The client secret never leaves this machine**: it lives in `$DSH_HOME/dsh-pocket/oauth.json` (mode 0600); the settings tab and RPC **never echo it** (only whether it is configured) - **Gitee / GitHub is only the identity provider**: it never sees your machine's data; the access token is discarded right after fetching your uid — **nothing is persisted** - **There is no PIN any more**: non-browser clients (curl, etc.) can therefore no longer use port 3081 — the inherent trade-off of the OAuth model - **If your provider is unreachable**: remote sign-in is impossible (loopback is unaffected). Gitee needs `gitee.com`; GitHub needs **both** `github.com` and `api.github.com` (Gitee is the safer pick on mainland-China networks) - **Allowlisted origins are your attack surface**: don't share your tunnel domain with untrusted people; any allowlisted origin can start a sign-in (only the bound account passes) - **Local admin actions verify their origin (CSRF protection)**: saving credentials, starting a bind and signing out all check `Sec-Fetch-Site`/`Origin` and require a process-random nonce — other web pages cannot forge these local actions from the victim's browser; the session cookie carries `Secure` on `https` origins (omitted on `http`, where browsers would drop it) ## 🩹 FAQ | Symptom | Cause & fix | | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dsh: command not found` / DSH undefined | Install the CLI: `npm install -g @deepseek-ai/dsh`, or prefix with `npx @deepseek-ai/dsh` | | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 workspace-root restriction: append `-w` (`--workspace-root`) to install/update commands | | Installed/updated but nothing changed | You **must restart `dsh web`**; the running process still holds the old code | | `listen EADDRINUSE ... :3081` | An old process owns the port: macOS/Linux `lsof -ti :3081 \| xargs kill -9`; Windows `netstat -ano \| findstr :3081` then `taskkill /PID /F` | | Changing the port | Plugin mode: set `"proxyPort": 3082` in `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. CLI mode: `dsh-pocket --port 3082` (also update the OAuth app callback URL and the allowlist) | | Sign-in says "authorization failed / redirect_uri mismatch" | The OAuth app callback URL and the origin you are visiting are **not character-identical** (scheme, domain, port, path must all match) | | "This account is not bound to this machine" | You signed in with a different account, or switched provider (Gitee ⇄ GitHub). Re-bind via `/pocket-setup` on the machine, or "Unbind account" first | | Picked GitHub, sign-in keeps failing | GitHub requires outbound access to **both** `github.com` and `api.github.com`; switch to Gitee from an unstable network (re-select it in `/pocket-setup` and bind again) | | GitHub tokens only last 8 hours — will I get logged out? | No. The plugin exchanges the code, fetches the user **once** and discards the token — it never stores or reuses it, so short-lived tokens / refresh tokens are irrelevant | | The phone keeps bouncing back to the sign-in page | The browser dropped the session cookie (Safari drops cookies for `http://` + bare IP; there is a dedicated hint page). Use a Chromium browser, or an `https://` tunnel domain | | Sign in again after every reboot | Sessions are bound to the dsh web process (a per-process random key) — **by design**: a restart invalidates all sessions | | The tunnel domain changed | Update the OAuth app callback URL **and** the settings allowlist, then re-save the setup page (re-binding required) | | After "Restart dsh web" the page says it runs in background | The new process is detached on purpose; stop it with `lsof -ti :3080 \| xargs kill -9` (macOS/Linux) or `netstat -ano \| findstr :3080` + `taskkill /PID /F` (Windows) | | Stuck on 0.x | `^0.x` can't jump to 1.x: for an npm install run `dsh plugin --profile web add @ds-hu/dsh-pocket@latest -w` and restart `dsh web` | ## 💻 DSH Desktop - Live screen mirroring works in DSH Desktop; **updates/restarts are managed by the desktop app** (those two actions are disabled inside the plugin) - ⚠️ Desktop **advanced mode** does not support phone access yet (it disables the web layout, leaving the phone without a layout service → blank screen). Switch back to **compatibility** and restart; in advanced mode the phone shows an explicit notice overlay ## 🗂 Architecture (one package, two client bundles) > Both `dsh.bundle` and `dsh.client` are **package-level** declarations. The Mobile Web UI is delivered as an **in-package subpackage** (`mobile/` = `dsh-pocket-mobile`) which declares `dsh.client` itself and ships its own browser half, so the panel card carries two independently switchable components and the mobile one has a **Configure** page. Delivery mechanism: `mobile/` is published with the root package via its `files` list, and **after installation** the root package's `postinstall` (`scripts/link-mobile-package.mjs`) creates `node_modules/dsh-pocket-mobile -> /mobile` inside the installed package — DSH resolves that row by looking for the package name under the package's own `node_modules`, so the link has to exist there. > > ⚠️ On install (npm and GitHub sources alike) pnpm 11 blocks that `postinstall` first: the command fails once with `ERR_PNPM_IGNORED_BUILDS` — click **Allow these scripts and retry** on the plugin panel, then run the same `add` again (one time only). `dependencies`/`bundledDependencies` cannot replace it: pnpm resolves `file:./mobile` against the *profile* directory and fails the whole install, and any `node_modules/**` committed to the repo is dropped when the artifact is unpacked. | File | Purpose | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `lib/index.js` | **Remote access** entry (row `dsh-pocket`): starts the proxy, registers RPC, owns the OAuth session key (rotation = sign out everywhere) plus bind/unbind/factory reset, desktop env adaptation | | `lib/oauth.mjs` | OAuth core: provider table (Gitee / GitHub endpoints, scope, user-fetch style), config store (`oauth.json`, 0600, includes the provider), state store (single-use + TTL, carries the provider), callback-origin allowlist checks, authorize/token/user calls, session cookie derivation | | `lib/service.mjs` | Service: proxy lifecycle (auto port fallback), status snapshot (OAuth view + a QR per allowlisted origin + LAN IP candidates) | | `lib/proxy.mjs` | Header-rewriting reverse proxy: Host/Origin → loopback, transparent HTTP + WebSocket, polyfill injection, gzip/brotli, and the **auth gate** (loopback free / OAuth session otherwise, fail closed) plus `/pocket-oauth/*` and `/pocket-setup` routes | | `lib/rpc-route.js` | RPC transport (used by `lib/web-rpc.js`): webserver mount / `rpc.handle` fallback / trust fence / 4xx-5xx branches (branch-for-branch with dsh-client-connection's `/api`); a future mobile host half can reuse it via `dsh-pocket/lib/rpc-route.js` | | `lib/settings.mjs` | Settings persistence: proxy port → `$DSH_HOME/dsh-pocket/settings.json` (a legacy `mobileRightbarEnabled` orphan key is no longer read or written; a factory reset clears it) | | `lib/clipboard.mjs` | Clipboard write (inlined into the client bundle at build time): `navigator.clipboard` with an `execCommand` fallback | | `lib/web-rpc.js` | Remote access loopback RPC (channel `/dsh-pocket`): `status` / `oauth.rotateSession` / `oauth.unbind` / `version` / `update` / `restart` / `pocket.reset` | | `client/` | The **Remote access** client artifact (`dsh.client` → `client/client.js`): `index.jsx` only wires the "Phone access" settings tab and the `isLoopback` fallback; `api.js` holds its contract and helpers | | `mobile/` | The **Mobile Web UI** subpackage `dsh-pocket-mobile`: `index.js` is a do-nothing host entry (all UI lives in the browser half), `locale/` supplies the panel row title/description, `package.json` declares the package identity and `dsh.client` | | `mobile/client/styles.js` | The **single source** of the mobile stylesheets (CSS as JS string exports): `shellCss` frame/drawer geometry and the global-panel exit button, `settingsCss`, `composerCss`, `planCss`, `touchCss`. Deliberately not `.css` imports — the native `node --test` runner cannot import CSS | | `mobile/client/responsive-shell.js` | Viewport breakpoint (1024, matching the `ui-layout` sidebar auto-collapse point), style injection and prefs projection, drawer backdrop and idempotent open/close (reads the framework's `[data-sidebar-collapsed]` before deciding whether to call `toggleSidebar`), and auto-close that only recognises *navigation*: a session row being activated, or the main column's identity actually changing — expanding a Workspace or opening a `⋯` menu never closes the drawer | | `mobile/client/settings-adapter.js` | Settings dialog two-level navigation adapter: directory ↔ detail state, injected "back to settings" bar, gated by the stepped-navigation switch | | `mobile/client/index.jsx` | Plugin entry: registers the hamburger into `conversation.header.leading`, registers the global-panel exit button into `shell.overlay` (when a panel takes over the main column the conversation — and with it the hamburger — is not rendered at all, so this is the only way back on a phone), installs the two adapters above, registers the **Configure** page | | `scripts/link-mobile-package.mjs` | The root package's `postinstall`: creates `node_modules/dsh-pocket-mobile -> /mobile` inside the installed package (the subpackage's only delivery mechanism). A link it cannot create is reported as a warning, never a failure — the row is `required: false` and must not break the whole plugin install | | `bin/dsh-pocket.mjs` | CLI: runs the same proxy and OAuth config standalone (`--port` / `--host`) | ## 🛠 Development ```sh npm install npm run build:client # rebuild both client bundles (client/client.js, mobile/client/client.js) npm test # proxy / OAuth / compression / handshake / service / RPC / settings / mobile UI / packaging ``` > Editing `client/**` or `mobile/client/**` (or the `lib/*.mjs` files they import) requires a rebuild: both bundles are committed and `npm test` byte-compares a fresh build against them, so tests can never validate a stale artifact. > In sandboxed environments `node --test` is refused (it spawns one child per test file with piped stdio): run files directly instead, e.g. `node test/xxx.test.js`. Bundling has the same constraint (the esbuild JS API spawns a child) — use the esbuild CLI and then wrap the output. **Want to try local changes without publishing?** Point the plugin at a symlink of this repo and restart dsh web — see [LOCAL-DEV.md](./LOCAL-DEV.md) (including how to switch back to the npm release). ## 🤝 Credits - This project is a rework of [shaobeichen/dsh-pocket](https://github.com/shaobeichen/dsh-pocket) by 程序员少北晨: the access PIN is replaced with **Gitee / GitHub OAuth sign-in** (pick one during setup) and the built-in tunnel was removed (bring your own) - The old narrow-screen mobile adaptation was ported from [mexiaosqwq/dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile) (MIT); that implementation broke with an official Web UI update and was removed entirely (sources and notice file deleted). The current Mobile Web UI is a rewrite built on DSH's existing slot contract: the hamburger registers into `conversation.header.leading`, the drawer geometry targets `AppFrame`'s real column class names, and nothing depends on DOM structure upstream has since changed - Sign-in is built on [Gitee OAuth 2.0](https://gitee.com/api/v5/oauth_doc) and [GitHub OAuth apps](https://docs.github.com/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) ## 📄 License [GPL-2.0](LICENSE) — free software: use, modify and redistribute freely, but **modified versions must stay GPL-licensed** with the copyright notice preserved; commercial use included. > Note: this repo once ported the dsh-web-mobile (MIT, GPL-compatible) mobile adaptation under `mobile/`; that directory was emptied when the official Web UI changed, and its notice file was deleted together with the original code. The current Mobile Web UI is a rewrite built on DSH's existing slot contract; if third-party mobile code is ever brought in again, bring its license and notice along with it. --- **Questions?** Bugs, ideas, requests — please open an issue on [GitHub Issues](https://github.com/cup113/dsh-pocket-oauth/issues) 🙏