AprilCrystal/dsh-remote

dsh-remote

为 DSH 会话提供 OpenAI 兼容的 HTTP 接口与手机面板:读会话、浏览文件、发送或排队消息,并在手机上处理审批与提问。

未分類deepseek-harness

dsh-openai-bridge

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 — 思考/复制/停止、插入消息与双端同步、分支对话、问答卡片:怎么用、边界在哪、哪些还没在真机上验证过。

Install

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.

The one rule that keeps it portable

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:

WasIs now
import('@deepseek-ai/dsh-llm') + createUserMessage + agent.followupagent.followup(createLocalUserMessage(text)) — the same call, with the message built locally
inbox.replace(id, newMessage) with a freshly built messagesessionController.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

Why turn submission does not go through the controller

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.

What it gives you

  • 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":

FieldValue
API Keythe value in $DSH_HOME/openai-bridge.token (or DSH_OPENAI_BRIDGE_TOKEN)
API Hosthttp://<this-machine-lan-ip>:19387/v1/
API Path/chat/completions
Model IDdsh-agent

Safety model — read this before exposing it

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:

  1. This protects integrity, not confidentiality. Per 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.
  2. The approval prompt needs a GUI that has the session open. If nothing is listening, the request sits pending until a client answers it.
  3. Plain HTTP. Over Wi-Fi the bearer token and any session cookie travel in cleartext. Do not run this on an untrusted network.

Configuration

Set through the bundle patch (cordis.patch.yml), each with an environment override:

KeyEnv overrideDefaultMeaning
tokenDSH_OPENAI_BRIDGE_TOKEN''Empty disables the bridge entirely. Fails closed.
cwdDSH_OPENAI_BRIDGE_CWDprocess.cwd()Workspace root for bridge-created sessions.
permissionPresetDSH_OPENAI_BRIDGE_PERMISSIONread-onlyPreset pinned onto each newly created session. It is deliberately not re-pinned on later turns: doing that would silently reset a preset you changed.
agentPresetDSH_OPENAI_BRIDGE_PRESETstandardAgent preset each session is composed from.
panelPath—/bridgeWhere the mobile panel mounts.
fileRootDSH_OPENAI_BRIDGE_FILEROOTcwdBrowse roots for the panel. A string is one root, a list is several, '*' is every fixed drive. See Browsing beyond the workspace.
approvalScope—allall lets the panel answer approvals for any session; bridge narrows it to sessions in cwd.
permissionSwitch—truefalse 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—180000How long a pending authorization stays confirmable.
desktopPopup—trueWhether to auto-open the authorization popup on this PC.
ipAllowlist—trueRefuse every peer that has not been approved, whatever token it holds. Loopback never pairs. See The per-client allowlist.
pairingPath—/pairWhere a not-yet-approved device posts its code. The only route reachable from the LAN without approval.
pairingTtlMs—600000How long a minted pairing code stays valid.
pairingAttempts—5Wrong codes tolerated per code before it is dead for the rest of its TTL.
pairingPopup—trueOpen the setup page on this machine by itself when a device starts waiting.
pairingPopupCooldownMs—15000Shortest gap between automatic popups, so a LAN sweep cannot spray windows.
pairingMaxPending—16Waiting devices kept; past this the oldest is dropped.

Session mapping

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:

  1. An explicitly selected session. If the 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.
  2. Inference from the opening message. Any other 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.

Failure behaviour

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 exists

Three 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.

Verified

Exercised end-to-end against a running desktop profile:

CheckResult
GET /v1/models200; one dsh-agent entry before the session listing landed
Invalid token401
Missing Authorization header401
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 pinnedAgent self-reported its file policy as read-only
Second turn in the same conversationMapped to the same session, only the newest message forwarded
0.0.0.0 bindServer 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 /bridge308 to /bridge/, so relative API fetches resolve correctly
Panel auth fence401 without the cookie or bearer token
Session list / transcript APIs200 with real data; typeless titles gone
Symlink escape from the browse rootRejected — 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.mjs45 assertions green; 47 when run elevated, where the two symlink-containment assertions execute instead of skipping
test/permission.test.mjs53 assertions green — the code-confinement property is asserted negatively
test/model.test.mjs41 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.mjs85 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.mjs122 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.mjs33 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.mjs45 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.mjs23 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.mjs16 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.mjs34 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.mjs37 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.mjs56 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.mjs34 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.mjs58 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.

The mobile panel (/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.

TabWhat 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/.

Driving a session from the panel

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:

  • Everything in the panel — read, chat, and approve in one page.
  • Chat in Chatbox, approve in the panel — keep your existing client.

Approvals

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