dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
为 DSH 会话提供 OpenAI 兼容的 HTTP 接口与手机面板:读会话、浏览文件、发送或排队消息,并在手机上处理审批与提问。
An OpenAI-compatible HTTP face for the DeepSeek Harness, so a phone running the Chatbox app can drive a DSH session.
The package is named dsh-openai-bridge; its repository is
AprilCrystal/dsh-remote.
中文使用指南 → GUIDE.zh.md — 面向使用者(怎么登录、各个按钮干什么、权限验证码、引用文件与上传的区别、安全边界、常见问题)。本文件面向开发者。
部署与配置记录 → SETUP.zh.md — 一台 Windows 机器上实际跑通的配置过程:为什么以前要给整个 HTTP 服务换绑、现在为什么反而不能(0.0.0.0 会让 DSH 起不来,见 §0)、令牌生成后为什么必须重启、防火墙「按程序 vs 按端口」与 Public/Private 的坑。含开发这边的逐条核对结果。
手机面板功能详解 → PANEL.zh.md — 思考/复制/停止、插入消息与双端同步、分支对话、问答卡片:怎么用、边界在哪、哪些还没在真机上验证过。
dsh plugin --profile <your-profile> add github:AprilCrystal/dsh-remote
A fresh machine needs no profile edits. The bundle mounts on the existing
webserver and inherits its loopback bind, which is exactly what it wants. Do
not add a webserver row to the profile patch to "open it up": since
@deepseek-ai/dsh-host-webserver 0.2.1-rc.2 a wildcard host there is rejected
while resolving config, and because that plugin is required, DSH then fails to
start at all — see SETUP.zh.md §0 for the symptom and the fix.
Nothing needs to be built, and nothing needs to be allowlisted. A github:
install fetches sources rather than built artifacts, which normally forces the
author to ship a prepare script and the user to grant a pnpm allowBuilds
permission — i.e. permission to execute the package's code at install time. This
package sidesteps all of it: every entry point under lib/ is plain ESM
JavaScript and is committed as-is, so there is no build step to run.
Then give it a token. The bundle layer reads it from the environment, so either
export it or set it in the profile's cordis.patch.yml:
DSH_OPENAI_BRIDGE_TOKEN=<a-long-random-secret>
With no token the bridge does not mount — with one exception. GET /setup
answers from this machine only (403 to every other peer) and is where you read the
address a phone should use and generate the token. Everything else — /v1,
/bridge, and the approval/request listener — stays unmounted, so an
unconfigured plugin still cannot touch the approval flow.
The token may be set here, or left to $DSH_HOME/openai-bridge.token, which the
setup page writes for you. A configured token always wins.
This plugin imports nothing from the harness. Every capability is reached
through ctx.get(...) or through a service the harness already put in the context.
That rule was broken in exactly two places, both calling
await import('@deepseek-ai/dsh-llm') to reach createUserMessage. It worked
here and it took the whole send path down on a second machine:
[错误] Cannot find package '@deepseek-ai/dsh-llm' imported from index.js
A bare harness specifier resolves only if DSH happens to have placed that package
in a node_modules directory above the plugin's own — which depends on how DSH was
installed, and is an accident rather than a guarantee. On this machine the chain
happens to hit ~/.dsh/profiles/node_modules; on the other it did not.
What replaced it:
| Was | Is now |
|---|---|
import('@deepseek-ai/dsh-llm') + createUserMessage + agent.followup | agent.followup(createLocalUserMessage(text)) — the same call, with the message built locally |
inbox.replace(id, newMessage) with a freshly built message | sessionController.updateQueue({ action: { kind: 'edit', content } }) — takes content, keeps the identity and the queue position |
inbox.remove(id) | sessionController.updateQueue({ action: { kind: 'remove' } }) |
| — | sessionController.updateQueue({ action: { kind: 'steer' } }) — a new 转插话 button, for free |
It was changed to sessionController.prompt(...) first, on the reasoning that the
controller is the same admission path the desktop composer uses. That shipped and
broke every turn:
[错误] Cannot read properties of undefined (reading 'throwIfAborted')
prompt takes a required second argument — prompt(request, signal) — and its
wrapper begins with signal.throwIfAborted() with no optional chaining
(packages/api/session-controller/src/index.ts:330). One argument means an
undefined signal, and the throw happens before any admission work.
So submission went back to the agent's own API, with a locally built message. The
reasoning is not "the controller is wrong": it is that what the controller adds
over followup here is small — ensureAgent already resumes the session and
this bridge carries no attachments — while followup is the path that has been
working in the field, and the turn path cannot be exercised from a test process. A
test now asserts that prompt( is not called at all, and says why.
updateQueue is still used, and deliberately: it takes one argument and
returns synchronously, so it does not share that contract.
One place builds a message, because staging a message without waking the
driver is not something the controller offers — that is the deliberate difference
between 排队 and 发送. It is mirrored exactly rather than guessed:
createUserMessage is createMessage({...input, role:'user'}), createMessage is
deepFreeze(structuredClone({...input, id})), and Message has exactly four
fields, so createLocalUserMessage is the whole of it — and it is a pure exported
function with its own assertions, including that it deep-freezes.
The controller is read with ctx.get and deliberately not added to inject:
a build without it must still load this plugin and answer with something
actionable rather than refusing to start.
A test scans every file in lib/ for a bare @deepseek-ai/ import — static,
dynamic, or require — so this cannot come back a third time.
GET /v1/models — advertises dsh-agent (start a conversation) plus one
entry per DSH session, labelled <title> · <sessionId>.POST /v1/chat/completions — streaming (SSE) and non-streaming.Point Chatbox at it with Settings → Model Provider → Add → Add Custom Provider → "OpenAI API Compatible":
| Field | Value |
|---|---|
| API Key | the value in $DSH_HOME/openai-bridge.token (or DSH_OPENAI_BRIDGE_TOKEN) |
| API Host | http://<this-machine-lan-ip>:19387/v1/ |
| API Path | /chat/completions |
| Model ID | dsh-agent |
The bridge is loopback-only by default. It mounts on the existing
webserver and inherits that server's bind, which is 127.0.0.1. This plugin
never widens the bind itself — and since @deepseek-ai/dsh-host-webserver
0.2.1-rc.2 a deployment cannot widen it either: the schema rejects a
wildcard host while resolving config, and because webserver is a required
plugin that rejection stops the whole boot
(webserver: host "0.0.0.0" is an unspecified (wildcard) address...). A reverse
proxy in front of DSH is the supported way to serve a phone over the LAN, but
only through trustedProxies: with it configured the gate judges the client the
proxy names, and a trusted proxy that forwards no X-Forwarded-For is refused
rather than mistaken for the local operator. Both cases are in
SETUP.zh.md §2.1.
The one route that answers before a token exists is /setup, and it is
restricted to this machine. It checks the peer address and returns 403 to
anything that is not loopback — which matters precisely because a deployment that
DID patch the bind to 0.0.0.0 is reachable from the whole LAN, so the bind is
not the gate. That page shows the token and can write the token file; while the
token is empty, nothing else is mounted.
Every session the bridge drives is pinned to the read-only permission
preset (sandbox: read-only + approval: ask, shipped in the base bundle).
A write attempt is refused by the sandbox and turns into an approval request.
That request is forwarded to connected browser clients, so it surfaces in the
local desktop GUI — a plain OpenAI chat client cannot render or answer one.
Three consequences worth being explicit about:
packages/sandbox/sandbox/src/index.ts, the sandbox is a file-effect
policy: "Network and process visibility are outside this vocabulary."
read-only blocks writes; it does not block reading your files,
executing commands, or network egress. Anyone holding the token can read and
exfiltrate, just not modify.Set through the bundle patch (cordis.patch.yml), each with an environment
override:
| Key | Env override | Default | Meaning |
|---|---|---|---|
token | DSH_OPENAI_BRIDGE_TOKEN | '' | Empty disables the bridge entirely. Fails closed. |
cwd | DSH_OPENAI_BRIDGE_CWD | process.cwd() | Workspace root for bridge-created sessions. |
permissionPreset | DSH_OPENAI_BRIDGE_PERMISSION | read-only | Preset pinned onto each newly created session. It is deliberately not re-pinned on later turns: doing that would silently reset a preset you changed. |
agentPreset | DSH_OPENAI_BRIDGE_PRESET | standard | Agent preset each session is composed from. |
panelPath | — | /bridge | Where the mobile panel mounts. |
fileRoot | DSH_OPENAI_BRIDGE_FILEROOT | cwd | Browse roots for the panel. A string is one root, a list is several, '*' is every fixed drive. See Browsing beyond the workspace. |
approvalScope | — | all | all lets the panel answer approvals for any session; bridge narrows it to sessions in cwd. |
permissionSwitch | — | true | false removes the phone's permission switch entirely — no button, and the API 404s. |
permissionPresets | — | ['read-only','workspace-write'] | Which presets the phone may request. danger-full-access is excluded on purpose. |
authorizationCode | — | '' | Non-empty switches the handshake to a fixed code you choose. Empty means the server mints a one-time code that is displayed on the desktop only. |
authorizationTtlMs | — | 180000 | How long a pending authorization stays confirmable. |
desktopPopup | — | true | Whether to auto-open the authorization popup on this PC. |
ipAllowlist | — | true | Refuse every peer that has not been approved, whatever token it holds. Loopback never pairs. See The per-client allowlist. |
pairingPath | — | /pair | Where a not-yet-approved device posts its code. The only route reachable from the LAN without approval. |
pairingTtlMs | — | 600000 | How long a minted pairing code stays valid. |
pairingAttempts | — | 5 | Wrong codes tolerated per code before it is dead for the rest of its TTL. |
pairingPopup | — | true | Open the setup page on this machine by itself when a device starts waiting. |
pairingPopupCooldownMs | — | 15000 | Shortest gap between automatic popups, so a LAN sweep cannot spray windows. |
pairingMaxPending | — | 16 | Waiting devices kept; past this the oldest is dropped. |
OpenAI's protocol is stateless and carries no conversation identity, and a
client's conversation list is its own local storage — so a conversation started
in the desktop GUI is invisible to Chatbox. The only selector the protocol
offers is the model list, which is why /v1/models enumerates sessions: picking
one is how you continue a desktop conversation from the phone.
There are two ways a request is routed to a session, in priority order:
model field embeds a DSH session
id, that session is used — resumed if it is not live. Advertised ids are
<title> · <sessionId> because an OpenAI model object has no display-name
field, so the id is what the picker shows. Only the trailing session id is
authoritative, and the whole string round-trips, so a client truncating the
label for display cannot misroute anything.model (notably the
synthetic dsh-agent) falls back to a SHA-256 of the first user message.
This is stable across turns and across restarts, but it is genuinely
ambiguous: two conversations that open with the same line collapse onto one
session and their turns interleave. Selecting a session explicitly is the
way to avoid that; the collision is recorded as a test rather than papered
over.Either way the bridge forwards only the newest user message — the harness already holds the prior turns in its own log — and editing a conversation's first message starts a new session.
The list is capped at the 40 most recent sessions and cached for 5 seconds, since every refresh re-reads the session corpus.
Import and setup failures degrade to "bridge disabled" rather than propagating:
a plugin that throws during load can take the whole composition down with it,
which would lock the operator out of the very GUI they need in order to remove
it. Check the host log for openai-bridge: lines.
session "..." already existsThree defects produced this message. The first two were about losing a race and then reporting it as a fact about the store; the third was the actual cause and the reason the message existed at all.
A guarded property read. The model selection was installed from inside the
setup callback, where the only way to name the agent is agentCtx.agent. A
scoped context exposes only the services it declared it needs, so that read
throws cannot get property "agent" without inject. The throw escaped setup,
which failed every resume of an existing conversation — so the phone could
never continue one. The bridge then fell through to create, which answered
session "..." already exists: a message about the store, for a bug in a
listener. The selection is now installed after the agent exists, on agent.ctx,
which is what the harness's own installer documents and what its session
controller passes. Model switching from the phone had never worked either, for
the same reason — the install never completed.
Concurrent acquisition. acquireAgent chose between resume and create by
asking whether the session was live, so two callers asking for the same cold
session at the same moment both saw "not live", both reached create, and the
loser was answered with the id being taken. Only the turn path was serialized.
Every caller now goes through ensureAgent, which shares one in-flight
acquisition per session id — the same guard the harness's own session controller
keeps.
A swallowed cause. When the session really was on disk but resume failed,
the fallthrough discarded the reason and let create report the id as taken. The
create failure is now compared with the resume failure, so a create that lost to
a resume names the real cause — which is how the guarded read above was found.
Any other create failure is rethrown untouched.
Exercised end-to-end against a running desktop profile:
| Check | Result |
|---|---|
GET /v1/models | 200; one dsh-agent entry before the session listing landed |
| Invalid token | 401 |
Missing Authorization header | 401 |
POST /v1/chat/completions (non-streaming) | 200, correct chat.completion shape |
POST /v1/chat/completions (stream: true) | Correct chat.completion.chunk frames, terminated by data: [DONE] |
read-only preset actually pinned | Agent self-reported its file policy as read-only |
| Second turn in the same conversation | Mapped to the same session, only the newest message forwarded |
0.0.0.0 bind | Server reachable on the LAN address, and from a phone — historical: measured before the wildcard rejection. That bind now prevents DSH from starting at all |
Panel sign-in (?token=) | 303 + Set-Cookie, then /bridge/ serves the shell |
Slashless /bridge | 308 to /bridge/, so relative API fetches resolve correctly |
| Panel auth fence | 401 without the cookie or bearer token |
| Session list / transcript APIs | 200 with real data; typeless titles gone |
| Symlink escape from the browse root | Rejected — ran once symlink() was permitted (it needs elevation on Windows); only the post-realpath re-check catches this, so the second fence is proven, not assumed |
| Typed transcript blocks, live | {"text":138,"reasoning":182,"tool-call":285} on one session — 467 of 605 blocks fold away |
test/panel.test.mjs | 45 assertions green; 47 when run elevated, where the two symlink-containment assertions execute instead of skipping |
test/permission.test.mjs | 53 assertions green — the code-confinement property is asserted negatively |
test/model.test.mjs | 41 assertions green — catalogue shaping, per-provider failure isolation, selection validation, and that the selection is never installed from the context setup receives |
test/context.test.mjs | 85 assertions green — the occupancy fold (including that an unmeasured context is null, not 0%), the ManualCompactionError code mapping, service resolution, and the route contract |
test/clients.test.mjs | 122 assertions green — an un-approved peer refused everything including the shell, the code never present in anything a remote peer can read, approval surviving a restart through $DSH_HOME, a dead code that expires instead of locking a device out forever, a manual replacement clearing that lock, one popup for a LAN sweep rather than one per address, and the same phone recognised when a dual-stack socket respells it |
test/output.test.mjs | 33 assertions green — reasoning on its own callback and never on the OpenAI face, the fold built lazily, the stop route keeping queued input unless asked, a copy that admits when it could not copy, and that nothing above the composer is a scroll container (which is what let streamed output push the input down the page) |
test/queue.test.mjs | 45 assertions green — every refusal and the target default, the panel route's wiring, that insert appends without waking the driver, that send-now removes then re-sends, that the plugin keeps no queue of its own, and that a poll cannot close the keyboard mid-edit |
test/fork.test.mjs | 23 assertions green — the cut at a completed turn and never mid-turn, landing on the next turn boundary with trailing events left behind, clamping a seq that runs past the array, the route's wiring, that the child is seeded and parented, and that the source sandbox is NOT inherited |
test/links.test.mjs | 16 assertions green — a relative or rooted path is never turned into a link, what counts as a path and what does not, that a quoted path keeps its spaces, and that a URL containing p:// is NOT mangled into a file chip |
test/recovery.test.mjs | 34 assertions green — in-flight acquisition is shared per key (including by a reentrant caller), a failed key is freed, and a create that lost to a resume reports the real cause |
test/browse.test.mjs | 37 assertions green over real HTTP: the virtual root, relative single-root backwards compatibility, and the refusals — traversal, out-of-root absolute paths, and reading outside every root |
test/setup.test.mjs | 56 assertions green — the token-file fallback, the served page's own script, and that /setup answers 403 to anything that is not loopback |
test/questions.test.mjs | 34 assertions green — the answer reaching the user-questions/request waterfall, a partial batch refused without resolving anything, a downstream refusal NOT ending the race, a desktop skip still counting as an answer, a question from another session still being shown, an abort ending the race rather than hanging, and an unchanged poll leaving the DOM alone so typing is not interrupted |
test/integration.test.mjs | 58 assertions green against a real node:http server mounting the real panel: the auth guard covers the new asset, the routes are actually wired, the cookie bootstrap preserves the popup's id/view while dropping the token, the full handshake completes, and every inline script the phone is actually served parses |
682 assertions across fourteen suites, plus a standalone guard (test/shell-guard.mjs)
for the panel's single-template shell.
Still unverified: how any of this renders on a real phone. The server half of
the approval card, the permission dialog, the model picker, the context meter and
the file browser is proven; the browser half has never been seen on a device. The
panel installs error and unhandledrejection handlers that render into a
dismissable bar at the top of the page, because a silent JS failure on a phone is
otherwise indistinguishable from a button that does not exist.
Two bugs were caught by the live API rather than by reading source, and are
worth remembering: readTitleSnapshot returns a { session, title } wrapper
(readTitle is the one returning the snapshot), and a tagged template yields
undefined for any segment holding an invalid escape — which silently degrades
the whole panel to the text "undefined" when its script contains a plain \s.
/bridge)A phone-friendly, self-contained read-only page. Sign in once by opening
http://<host>:19387/bridge?token=<token>; that exchanges the URL token for an
HttpOnly cookie and redirects to a clean /bridge/, so the credential stops
travelling in the address bar and history.
| Tab | What it does |
|---|---|
| 会话 | Every DSH session — title, time, workspace, running/loaded badge. Tap one to read its full message transcript. Includes desktop sessions, which Chatbox cannot show. |
| 文件 | Browse the workspace, preview text files, download any file. Upload from the phone into _inbox/. |
The transcript view carries a composer: type a message and the reply streams
into a live bubble. The panel drives sessions through the same runtime as the
OpenAI face (ensureAgent / driveTurn), so the permission preset and the
sandbox boundary cannot differ by entry point.
That gives you a choice of two ways to work:
The panel registers a prepended approval/request listener and asks the
panel and the desktop GUI simultaneously:
const downstream = next() // the desktop prompt is never delayed
const mine = new Promise(settle => pending.set(id, { decide: settle }))
return Promise.race([downstream, mine])
next() runs immediately rather than after the panel declines, so a person
already looking at the desktop prompt does not wait on a phone that happens to
be asleep. Whichever side answers first claims the decision, and the harness
discards a late answer, so the approval/asked / approval/decided audit pair
stays intact.
Scope. approvalScope is set to all here, and that is the setting that
matches the panel's own list: whatever you can open and send a message to, you
can also approve. The narrower bridge setting (only sessions whose workspace
is cwd) sounds safer, but it produces a genuinely confusing case — you open a
conversation in the panel, send it something that writes, and the prompt appears
only on the desktop with nothing on the phone to explain the silence. If you
want that narrowing anyway, set approvalScope: bridge.
Either way the desktop GUI is asked **in parall