The dsh example runner reads DSH_SEAT_BIN and DSH_SEAT_HOME, which a member's env: can set (OPENRIG_* stays reserved for OpenRig's own per-launch values and is still read first when exported). It doesn't read dsh's own DSH_HOME: a seat's shell runs the person's startup files after the seat's env:, so an exported DSH_HOME would rewrite their personal dsh profile and skip the uploads-off patch; any inherited DSH_HOME is still replaced with the seat's home. The launch record's producer takes dsh's version from dsh --version, killed with SIGKILL at a 5-second timeout so a wrapper that ignores SIGTERM can't hold the seat's start, falling back to the handshake's version, labelled as such. Not verified: a real dsh's --version output and whether it makes a network call. Runner +23/-6; README, one preset limit line and the dsh example test.
6.4 KiB
An example runner for DeepSeek Harness (dsh)
openrig-dsh-runner.mjs is a small runner for the draft harness and runner contract.
It runs in the seat's pane, hosts dsh --profile acp over ACP on stdio, and does the runner's part of each contract
section:
- identity: the launch record, written once the conversation is open;
- one prompt per block: each submitted block becomes one
session/prompt, using bracketed paste in a TTY; - turn boundaries: reported as activity hooks, with a working beat every 10 s through a long, quiet turn;
- cancel:
/abortor Ctrl-C sendssession/cancel, and a turn reads cancelled only when dsh says so. Type/abortin the seat's pane: one sent withrig sendarrives inside a From/To envelope, so it's an ordinary prompt; - permissions: each request goes to a person through OpenRig;
- resume: it reopens the exact conversation, or says honestly that it couldn't.
It has no dependencies, and it isn't installed by OpenRig. Copy it with dsh.yaml and maintain both as your
own. bpamiri/dsh-seat is a fuller dsh runner by its own maintainer.
Set it up
- Install dsh and Node. Install Node 22 or later, then
npm i -g @deepseek-ai/[email protected]. This is the version it was written for, and it has been checked only against a stand-in agent. - Install the runner and preset. Make the runner executable and put it on the seat's
PATH. Copydsh.yamlto$OPENRIG_HOME/community-harnesses/. Then declare a member withruntime: dsh. - Give the seat credentials. Each seat gets its own dsh home, at
$OPENRIG_HOME/state/dsh/<node>/home, because dsh rewrites its profile there on every start. Put a.credentials.yamlthere, or put a provider key in the seat'senv:. To use a home you prepared yourself, setDSH_SEAT_HOMEin the member'senv:;DSH_SEAT_BINthere names anotherdshbinary. The runner doesn't take dsh's ownDSH_HOMEfrom its environment, because your shell's startup files run after the seat'senv:and could set it. A member'senv:can't setOPENRIG_*names, which OpenRig reserves, but the runner still readsOPENRIG_DSH_HOMEandOPENRIG_DSHwhen something exports them to it, such as a presetcommandwrapper.
The runner's own defaults for dsh:
- Uploads: the seat home gets a
cordis.patch.ymlthat turns off dsh's session-log and plugin-list uploads to DeepSeek, and the runner starts dsh withDSH_TELEMETRY_DISABLED=1. - Permission mode:
DSH_PERMISSION_MODEisworkspace-writeunless you set it. - The hook token: OpenRig's activity token isn't passed to dsh, whose shell tool inherits the runner's environment.
- Its version: the launch record names dsh's version from
dsh --version. If that fails or takes over 5 seconds, it names the version dsh's ACP handshake reports, marked as such: dsh 0.2.1-rc.2 reports 0.0.1 there.
Permissions
The runner submits each request to OpenRig, which asks one person:
- the default person;
- or the person chosen for the seat with
rig seat set-permissions-to <seat> --to <person> --reason "…".
That person answers one of the options dsh offered, and the runner gives that answer to the same live request, once.
When the person answers in their own words rather than with a choice, a yes or a no, OpenRig permits by default and
passes their words back as said. The runner gives dsh its option as usual, shows the words in the pane, and queues
them like any other message, so dsh gets them after the current turn and after any message already waiting. The words
are never the answer.
When no one can be asked, the request is permitted with dsh's own allow option: allow_once when dsh offers it,
else allow_always. That's OpenRig's default. The runner does the same when OpenRig can't answer: an older OpenRig
without this path, an OpenRig it can't reach, or a refusal other than --deny.
allow_always creates a standing grant in dsh. OpenRig doesn't record or revoke it, which is why the runner prefers
allow_once. Where dsh keeps the grant, and for how long, isn't checked.
The runner refuses only when the seat's operator opted in to refusal, with rig seat set-permissions-to <seat> --deny, or when dsh offered no allow option. A refused request is shown in the pane, and the seat reads needs input
until the next prompt.
The runner answers cancelled, holding nothing, when the person says no and dsh offered no reject option, or when OpenRig says the request isn't the seat's current occupant's (for example, after a relaunch).
Before you declare resume
The preset leaves resume out, so a restore stops and asks, and a fresh launch starts a new conversation.
rig seat restart --keep-conversation is for Codex seats only. The runner reopens a conversation by the method dsh
advertises: session/resume, or session/load. When dsh advertises neither, it tries session/resume. It never starts
a new conversation on a resume.
Telling a missing conversation from one that won't reopen. dsh 0.2.1-rc.2 answers -32602 both for a
conversation it doesn't have and for one already active elsewhere, so the runner never reads that error as missing.
- Only dsh's own not-found error (
-32002) counts as missing. Any other failure leaves the resume unconfirmed. - When dsh advertises
session/list(it does), the runner first asks the list, following its pages. A conversation the list names but that won't reopen stays unconfirmed, even on a not-found. - An unlisted conversation is still reopened. dsh's list can leave out a conversation another running dsh holds unsaved, so it can't show a conversation is gone.
Limit: dsh answers -32602, not -32002, for a conversation it doesn't have, so the runner can't report a missing
dsh conversation as missing. It reads unconfirmed.
Add resume: true after a real dsh has reopened a conversation and recalled what was said in it.
Check it without a model
Point dsh's DEEPSEEK_BASE_URL at a local server that speaks DeepSeek's Anthropic-compatible Messages API. Then run the
contract's real check:
- a seat reads ready only after the handshake;
- one multi-line message becomes one prompt;
- the seat reads working, then idle;
- a refused permission reads needs input;
/abortends a turn as cancelled.
packages/daemon/test/community-harness-dsh-example.test.ts runs the same cases against a stand-in agent.