Files
Codeman/docs/deepseek-integration.md
T
Codeman maintainer 2034719d61 fix(deepseek): close the env-var clamp hole, bound the profile install, make the hook gate per-session
Three review findings on the DeepSeek Harness mode, plus one the third exposed.

1. The multi-user clamp was bypassable by a sibling field on the same request.
   clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but
   DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER
   _configureDeepSeek(), so a non-granted owner sending
   envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated
   instance: a session created with permissionMode "read-only" and that override
   ran with DSH_PERMISSION_MODE=danger-full-access in its pane.

   Every other CLI's bypass is a command-line flag reachable only through the
   per-CLI config, which is why the config clamp alone is the whole gate for
   them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted
   owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to
   what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on
   that list because it aims the launcher at a profile tree whose plugin code
   runs at boot, before any approval row can apply. Verified end to end in real
   multi-user mode: a non-granted user sending both now gets workspace-write and
   no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched.

2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout`
   signals only the direct child, and a plugin install fans out into
   package-manager children that keep the inherited stdio pipes open, so `close`
   never fires and the held-open request leaks with no route-level deadline.
   Reproduced: with a 1.5s built-in timeout the promise was still unsettled after
   6s and both fan-out children were alive. Now detached: true plus negative-pid
   SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a
   last-resort reap for a grandchild that escaped the group. Same probe after the
   change: close fires, direct child and both grandchildren dead.

3. hooksAvailableForMode() promised more than a dsh session can deliver.
   deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that
   triple is the only reason a dsh session posts hook events, so `until=stop` was
   accepted and then blocked for the caller's whole timeout: the exact
   infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now
   takes HookCapabilityOptions and every call site passes sessionHookOptions(),
   with the deepseek arm reading `!== false` so a forgotten one degrades to the
   old behaviour. The refusal names the setting rather than saying "no Claude
   Code hooks", which would send the caller hunting a bug that is really a
   setting they chose. Profile conformance stays unknowable at request time and
   is documented as such. The stale "True for `claude` and nothing else" docblock
   is corrected.

4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a
   claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent
   capture read Claude's own transcript, and adding deepseek silently widened
   both to a mode that has none. They compare mode === 'claude' directly now, and
   a static check pins them there.

Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the
wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 --
bridge off plus explicit until=stop is a 400 naming the setting, bridge off with
no `until` still 200s on idle/exit, bridge on accepts stop.
2026-08-24 16:01:02 +02:00

250 lines
12 KiB
Markdown

# DeepSeek Harness (`dsh`) in Codeman
Codeman can run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
Antigravity, Pi and Grok. It is the ninth run mode, and the one that is wired
least like the others, for two reasons worth understanding before you use it.
## 1. The agent is a profile, not the binary
`dsh` is a **launcher**, not an agent. It boots a *profile*: an ordered stack of
plugin-bundle patch layers under `$DSH_HOME/profiles/<name>` (`$DSH_HOME`
defaults to `~/.dsh`). DeepSeek ships three bundles and none of them is a
terminal agent:
| Profile | What it is | Can Codeman run it in a tab? |
| ------------ | --------------------------------- | ---------------------------- |
| `web` | the browser UI, served on :3080 | no — but see §5 |
| `headless` | answers one task and exits | no |
| (`base`) | the shared core, no app at all | no |
The interactive terminal front door is **always a third-party plugin**. So
"DeepSeek is installed" and "Codeman can start a DeepSeek session" are different
questions, and Codeman answers both separately:
```bash
curl -s localhost:3000/api/deepseek/status | jq
{
"available": true, # the `dsh` binary resolved and proved its identity
"runnable": false, # ...but nothing installed can drive a pane
"path": "/home/you/.local/bin",
"version": "0.1.1-rc.2",
"dshHome": "/home/you/.dsh",
"defaultProfile": null,
"profiles": [ { "name": "web", "kind": "web", "bundles": [...] } ]
}
```
### Installing a terminal profile
From the UI: open the **Run** dropdown. When `dsh` is installed but no
pane-capable profile is, the menu shows **DeepSeek — add a terminal profile…**.
One click installs one and the normal DeepSeek entry appears.
By hand, or to pick a different front door:
```bash
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui
```
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
margin the most used community TUI, it is MIT, and it implements the status
contract described in §3. It is a **default, not a requirement**: any profile
under `$DSH_HOME/profiles` that is not `web` or `headless` shows up in the
inventory and can be launched, including one you compose yourself. The endpoint
accepts any npm package name:
```bash
curl -sX POST localhost:3000/api/deepseek/install-profile \
-H 'Content-Type: application/json' \
-d '{"profile":"my-tui","package":"@someone/dsh-tui"}'
```
Installing a plugin is arbitrary code execution on the host, so in multi-user
mode this endpoint requires the can-bypass-permissions grant (the same bar as a
`shell` session). The request is held open while the package manager runs and is
bounded at five minutes; the install runs in its own process group, so hitting
that bound kills the whole tree rather than just the launcher.
> **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's
> shell", a distributed shell, which would answer `--version` convincingly.
> Codeman's resolver therefore demands the harness's own help banner before it
> will point a spawn line at a candidate, and `GET /api/deepseek/status` reports
> `path` and `version` so a misresolution is diagnosable rather than presenting
> as "the mode just doesn't work".
## 2. Permissions are an env var, not a flag
The harness has **no `--dangerously-skip-permissions` equivalent**. Its sandbox
and approval rows are configuration, driven by one documented input,
`DSH_PERMISSION_MODE`, with three presets (read off `dsh --dump-default-config`):
| `DSH_PERMISSION_MODE` | sandbox | approvals | notes |
| --------------------- | -------------------- | --------- | ------------------------- |
| `read-only` | `read-only` | ask | |
| `workspace-write` | `workspace-write` | ask | the harness's own default |
| `danger-full-access` | `danger-full-access` | **never** | what the Run button sends |
Codeman exports it via `tmux setenv`, never on the command line. Because the
harness reads it with `??`, it is a **soft default**: it sets the boot-time
preset and you can still change permission mode inside the session.
Omitting it entirely leaves the harness on `workspace-write`, which still asks —
which is why the multi-user clamp only needs to force a *sent* value down. A
non-granted owner's `danger-full-access` becomes `workspace-write`, not
`read-only`: the clamp removes privilege without breaking the session's ability
to edit its own workspace.
Because the switch is an env var rather than a flag, that clamp has a second half
no other CLI needs. `DSH_*` is an allowlisted `envOverrides` prefix (it has to be:
that is also how you set the harness's ordinary knobs), and env overrides are
applied *after* the permission export, so in multi-user mode a non-granted owner
sending
```json
{ "mode": "deepseek", "envOverrides": { "DSH_PERMISSION_MODE": "danger-full-access" } }
```
would otherwise hand back the privilege the config clamp just removed. For a
non-granted owner Codeman therefore **drops `DSH_PERMISSION_MODE` and `DSH_HOME`
from `envOverrides`**; dropping them falls through to the clamped config and the
server's own `DSH_HOME`. `DSH_HOME` is in that list because it points the
launcher at a profile tree, and a profile's plugin code runs at boot, before any
approval row can apply. Single-user installs and granted owners are unaffected.
## 3. Real idle detection (the interesting part)
Every other external CLI mode in Codeman is **readiness-guessed**: Codeman
watches the PTY go quiet and infers that a turn ended. Claude is the exception,
because Claude Code fires hooks.
DeepSeek is the second exception. The community terminal front door already
reports its own lifecycle to a supervising process through a generic,
env-var-gated contract (inherited from [Herdr](https://herdr.dev)): when
`HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, it shells out on
every state change with
```
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
--source custom:dsh-tui --agent dsh-tui \
--state idle|working|blocked [--message ...] --seq N
```
Codeman points `HERDR_BIN_PATH` at a small generated shim
(`~/.codeman/dsh-status-shim.mjs`, written at session create) which forwards each
report to `POST /api/hook-event`. The mapping:
| Harness state | Codeman hook event | What you get |
| ------------- | ------------------ | -------------------------------------------------------- |
| `blocked` | `permission_prompt`| red "needs you" tab alert + an Approvals Inbox item |
| `idle` | `stop` | definitive end-of-turn: respawn triggers, `wait` returns |
| `working` | `agent_working` | clears an alert answered in the terminal, at once |
So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait`
really can block on `stop` and `blocked` for it, and it is the only non-Claude
mode for which that is true (`hooksAvailableForMode`).
That is a per-*session* answer, not a per-mode one. Turning the bridge off with
`deepSeekConfig.statusReporting: false` means nothing will ever post a hook event
for that session, so an explicit `until=stop` is refused up front (with a message
naming the setting) rather than blocking for your whole timeout. Omitting `until`
never fails: the hook-only signals are dropped from the default set and you still
get `idle` and `exit`.
One limit worth knowing: whether the *profile* implements the contract cannot be
known at request time (Codeman deliberately treats an unrecognized profile as
launchable). A dsh session running a non-conforming TUI therefore still accepts
`until=stop` and will time out on it. `idle`/`exit` are the reliable pair there.
This is an interface implementation, not an impersonation — nothing on your
machine executes a real `herdr` binary. If you use a terminal profile that does
*not* implement the contract, the shim is simply never called and the mode falls
back to output-stabilization readiness like its siblings. Turn it off per session
with `deepSeekConfig.statusReporting: false`.
## 4. Starting a session
From the UI, pick **DeepSeek** in the Run dropdown (or the **Run DeepSeek**
welcome button) and press Run. Over the API:
```bash
curl -sX POST localhost:3000/api/quick-start \
-H 'Content-Type: application/json' \
-d '{
"caseName": "myproject",
"mode": "deepseek",
"deepSeekConfig": {
"profile": "dsh-tui",
"permissionMode": "danger-full-access"
}
}'
```
`deepSeekConfig` fields: `profile`, `permissionMode`, `resumeSession`,
`resumeSessionId`, `statusReporting`. Resume prefers an explicit id over the
most-recent form, and both are passed through to the profile's app, which is
where `--resume` is understood.
**Models are not a session field.** The model is a composition entry in the
profile's config tree (`agent-default-model`), not a CLI flag, so Codeman does
not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
profile. That is also how you point dsh at a local or third-party provider.
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
all flow through). Provider keys with *other* names are deliberately not: a dsh
`settings.yaml` can nominate any env var as a credential via `apiKeyEnv`, and
Codeman's allowlist is global, so admitting them would widen it for every mode at
once. Authenticate those the way dsh does, from the file or the server's own
environment.
## 5. The web UI as a tab
The browser UI is the one interactive surface DeepSeek ships itself, so it gets a
shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts
`dsh web --no-open --host 127.0.0.1 --port 3080 --trusted-host <codeman-host>` in
an ordinary shell session and opens `http://127.0.0.1:3080` as a Codeman web tab.
Nothing bespoke supervises it: the server is a normal shell session (visible,
scrollable, killable, dies with its tab) and the UI is a normal web tab. The
`--trusted-host` flag is load-bearing — dsh fences its `/api` behind a
browser-trust check on the request authority, and a Codeman web tab reaches it
through Codeman's own origin via the webview proxy, not directly. Without it the
page renders and every API call fails.
## 6. Docker and remote cases
Docker cases work: the agent image installs `dsh` and bootstraps a `dsh-tui`
profile into the container. Profiles are deliberately **not** seeded from the
host (each is a per-profile `node_modules` tree, host-arch-specific and far too
large to copy on every container start); only `~/.dsh/.env`, `settings.yaml` and
`cordis.patch.yml` are seeded, which is what carries auth and model composition
in. As with pi and grok, in-container sessions are invisible host-side:
`~/.dsh/sessions` inside a container is that container's own.
Remote SSH cases default to `dsh` through a login shell, which boots the remote
box's default profile. If the remote has several, name one with the per-host
`commands.deepseek` override — the local `deepSeekConfig` does not cross ssh.
## 7. What is not wired
Deliberately minimal, on the same reasoning as the grok integration: the harness
is a fast-moving developer preview and every flag added is a flag validated
forever.
- `--patch` overlays per session (the profile's own layers apply as normal).
- `dsh plugin` management beyond first-time profile install.
- The `headless` profile as a one-shot execution backend for Codeman's own
internal AI checks (today those are Claude-only).
- Reading `~/.dsh/sessions/**` into the response viewer, the way codex rollouts
are read back. DeepSeek sessions are JSONL and this is very achievable; it is
the highest-value follow-up.
- Model/provider selection from Session Options.
## Verified against
`dsh 0.1.1-rc.2` and `@deepseek-harness-tui/dsh-tui 0.9.0`. The permission
presets, the profile layout, and the supervisor contract above were all read off
the live install rather than from documentation.