dsh-deepseek-harness-ops
Operate dsh on WSL systemd — boot, autostart, market proxy.
SKILL.mdskills/devops/dsh-deepseek-harness-ops/SKILL.md
GitHubDeepSeek Harness (dsh) operations
DeepSeek Harness (dsh, installed at ~/.npm-global/bin/dsh, versioned e.g. 0.1.1-rc.2) is a Cordis-based agent harness. On this box it runs as a user systemd service (~/.config/systemd/user/dsh-web.service), web UI on 127.0.0.1:3080. WSL user manager has Linger=yes, so it starts at boot without any login session.
The three recurring failure modes (and root causes)
1. dsh web exits instantly when launched from Hermes/background/non-TTY shell
dsh boot loads a dsh-tui plugin (@deepseek-harness-tui/dsh-tui) which refuses to load unless process.stdout.isTTY:
Error: dsh-tui requires an interactive terminal (stdout must be a TTY).
Only visible with pty=true in Hermes — with background=true (no TTY) the process dies silently and the error is swallowed.
Fix (permanent): disable dsh-tui via the profile patch layer ~/.dsh/profiles/web/cordis.patch.yml:
- id: dsh-tui
disabled: true
dsh web --dump-config shows entry ids (- id: dsh-tui). This is the documented seam ("Edit cordis.patch.yml, not cordis.yml").
2. Plugin market "插件目录加载失败,稍后重试 / The operation was aborted due to timeout (30s, 2 attempts)"
dshmarket fetches its catalog from https://awesome-dsh-plugin.com/plugins.json. Two traps:
- Node's global
fetchIGNORESHTTP_PROXY/HTTPS_PROXYentirely. dshmarket'smarketFetch(innode_modules/dshmarket/lib/net.js) deliberately routes through undici'sEnvHttpProxyAgent— but ONLY when those env vars are present (configuredProxy()reads them). Unset → falls back to bare globalfetch(direct connection). - systemd user services do NOT inherit exported proxy env from your shell. The service runs with only the env you hardcode via
Environment=.
So a service started from an interactive shell that happens to have proxy env exported works; one started by systemd with no Environment= lines has neither proxy nor a reliable direct route and times out.
Fix: add to ~/.config/systemd/user/dsh-web.service:
Environment=HTTP_PROXY=http://127.0.0.1:7890
Environment=HTTPS_PROXY=http://127.0.0.1:7890
then systemctl --user daemon-reload && systemctl --user restart dsh-web. Env is read once at process start.
Verify without screenshotting the UI — hit the registry endpoint directly:
curl -sS http://127.0.0.1:3080/dsh-market/registry -w "%{http_code} %{time_total}s\n"
# expect 200, fast — ~0.4s — and a JSON blob with "count":1884
The catalog is fetched fresh (no bundled fallback) — network failure returns HTTP 502 with the message, matching the UI error.
3. Duplicate enabled services → EADDRINUSE crash loop
Two units had both been enabled against default.target (dsh.service old + dsh-web.service new); both try port 3080. The loser restarts forever with listen EADDRINUSE. A shell-launched orphan can also hold the port and starve systemd.
Fix: inspect systemctl --user is-enabled <unit>, remove the stale unit's symlink + file, daemon-reload, confirm only one enabled. Kill any orphan node .../dsh web not owned by systemd before checking status.
4. Browser "bundle script /plugins/<pkg>/client.js?rev=… failed to load" — the named plugin is usually innocent
dsh-client-modules loads every client bundle via <script src="/plugins/<id>/client.js?rev=<sha1-12>">; a script error event (HTTP 404/connection refused) surfaces as client-modules: bundle script … failed to load and the UI blames the first plugin in the boot manifest. The /plugins/* route 404s when clientPath isn't in the registry — which happens when host boot died earlier on another plugin's import error, so nothing got registered. The rev is sha1(bundle).slice(0,12); verify the bundle itself is fine by hashing lib/client.js — if it matches the URL's rev, stop suspecting that plugin.
Fix: reproduce host-side to see the real exception:
cd ~/.dsh/profiles/web && timeout 30 dsh web --host 127.0.0.1 --port 3081 --no-open
# look for: "plugin tree failed to load: failed to import loader entry <id> (<pkg>): <real error>"
Observed root cause: a plugin linked into the profile (node_modules/@scope/pkg -> /home/user/projects/pkg) whose lib/index.js imports a peer dep (@deepseek-ai/schemastery) — pnpm's strict layout resolves a linked package's peers against its real path, not the profile's node_modules, so the peer is invisible.
Verified fix paths (2026-08-31):
dsh plugin --profile web add link:<path>— does NOT fix it. pnpm 11link:is a pure symlink, no peer wiring, same error after reinstall.- Working: symlink the missing peers into
<project>/node_modules/yourself. Profile root only has@deepseek-ai/{cordis,schemastery,cosmokit}; the rest (dsh-settings,dsh-host-webserver,dsh-client-runtime,dsh-client-ui-settings,dsh-client-locale,dsh-web, …) live under the global dsh install:~/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/<pkg>. Link the whole peerDependencies list in one pass — fixing one at a time just surfaces the next. - Verify by actually booting and curling the bundle, not just process-alive:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<port>/plugins/<@scope>/<pkg>/client.js→ 200. dsh web needs a TTY; under Hermesbackground=trueit dies silently, wrap withexecute_code+subprocess.Popen(..., start_new_session=True)to health-check.
5. dsh 升级到 0.1.2-rc.x 后 linked 插件 disabled: true 也炸 boot("plugin(s) failed to load: <name>, <name>")
新版 dsh-app-boot 的 assertEntriesLoaded 审计所有 fiber 为空的 entry;linked 插件(link: dep)在升级后 fiber 永远为空,即使 patch 里 disabled: true 也一样(dump-config 显示 entry 正常、disabled 生效,但 boot 仍 reject,且名字重复出现两次)。这不是配置写错,是上游 loader 回归。
Fix(2026-09-05 验证): 别试图在 cordis.patch.yml 里 disable —— 直接把插件从 ~/.dsh/profiles/<profile>/package.json 的 dependencies 和 dsh.profile.bundles 两处删掉,再删掉 patch 里对应的 disabled entry。dsh web 试 boot 验证。
同类: @nanmicoder/dsh-agent-teams ≤0.1.15 调 ctx.subagents.registerContinuableSetup(新版 dsh 已移除)——同样必须整个移除,patch disable 无效。升级 dsh 后所有第三方 bundle 都要按这个模式过一遍:boot 挂 → 看 journal 里 "failed to apply loader entry <id>" → 从 package.json 两处删掉。
同类: 两个插件注册同一个 webserver 路由前缀也会整个炸 boot(webserver: duplicate prefix route "<prefix>",例:套件包里的子插件与 dsh-better-sidebar 同抢 /sidebar/api)——无法 disable 其一绕过,同样整个移除冲突的那方。另外手动 dsh web 测试实例与 systemd 实例共用 $DSH_HOME 会触发 dsh-im-gateway 检测到另一个 DSH 进程…并发写坏 session log —— 测试 boot 必须设独立 DSH_HOME。
同类: 插件 import 的命名导出被新版 dsh 子包删掉(The requested module '@deepseek-ai/<pkg>' does not provide an export named '<x>')——不是配置问题,是插件版本 < dsh 版本。查该插件 npm 新版是否已修(npm view <pkg> peerDependencies 看 range 是否覆盖当前 dsh 版本),有就升,没有就移除。例:dshmarket ≤1.36 import installSettingsSection(dsh-settings 0.1.2 已删),1.37+ 修好。
0.1.7 新症状(2026-09-29 验证): link: 插件即使 patch 里早有 disabled: true,升级后首次 boot 会以随机 hash id(如 c2eccf5f)被强制激活——若它注册 webserver 路由则报 duplicate exact route(entry id 是 hash 不是 patch 里的 id,这就是 disable 没拦住的证据);之后 boot 变成 failed to import 警告(fiber undefined 但 entry.disabled 求值为 falsy,dump-config 却显示 disabled: true——dump 与运行时对 link: 条目不一致)。修法不变:三处(dependencies + bundles + patch)整个移除。
0.1.7 session format v4:发消息时报「本轮运行失败 format v4 message requires a producer-owned source kind」——插件注入消息还在用 v3 旧 source 形状 {kind:"plugin", plugin:"..."},v4 追加事件时拒收。运行期错误不进 journal,只在 UI。排查:grep 插件目录 kind: "plugin"。修法:升插件(例:@openviking/dsh-memory-plugin 0.3.x→0.5.8 已修,peer 覆盖 ^0.1.7-rc.2;0.x caret 不自动跨 minor,要手动改 package.json 版本再 pnpm install --config.minimumReleaseAge=0)。没新版就手改:source 改 kind: "plugin:<name>" 并同步改插件里所有 kind === "plugin" 判断点。
7. 升 0.1.7-rc.2 后插件页出现「异常」+ 市场 React #130 崩(2026-09-29 验证)
查法: UI 里 设置→插件→点异常条目的「查看」读「原因」。dsh 0.1.7 新增 plugin manager 兼容审计(evaluatePluginCompatibility,读插件 package.json 的 peerDependencies dsh range)——peer 不满足整包判 problem,组件不加载。
@linxin666/dsh-web-all@0.4.4是为未发布的 dsh 0.2.0-rc.1 提前发的(peer>=0.2.0-rc.1,0.2.0 还是 next 不是 latest)。^0.4.3会解析到 0.4.4 → 与 0.1.7-rc.2 不兼容 → 异常,市场 UI(其组件dsh-client-ui-market)整包不加载。修法:package.json 钉死"@linxin666/dsh-web-all": "0.4.3"(去 caret)再 install。注意:市场里「全部更新」会把它升回 0.4.4 再炸——dsh 0.2.0 进 latest 前别接它。- dshmarket 1.66.x 会在更新前弹同类兼容确认框(“X 声明需要 DSH >=0.2.0-rc.1,你运行 0.1.7-rc.2,已停止”)——这是拦截成功不是 bug,别点「仍可继续更新」。消除提示:市场「已安装」页将该包 屏蔽/忽略(「已屏蔽」页可找回)。
- dshmarket 1.45.1 客户端对 0.1.7 的 client runtime 崩
Minified React error #130(组件 import 到 undefined,ui primitive 变了)。升级 →pnpm add dshmarket@latest --config.minimumReleaseAge=0(1.66.5,2026-09-28 发,晚于 dsh 0.1.7-rc.2,修好了)。 - 验证市场 UI 不要靠猜:市场界面在 设置→插件市场(
settings.sectionslot 注入,不在插件页)。「添加插件」按钮弹的是官方包名安装对话框,不是市场——两件事。 - peer 警告里
@linxin666/* peer @deepseek-ai/dsh/ super-injector / find-plugin 的 "missing peer" 是 profile 布局的老问题(全局 dsh 运行时提供),无视。
pnpm minimumReleaseAge 拦截: pnpm 11 该策略会拒装/拒验证最近发布的包,报错 was published at ... within the minimumReleaseAge cutoff,且 pnpm config get 各处都显示 undefined(来源不明,行为在)。一次性绕过:pnpm install --config.minimumReleaseAge=0(此 flag 是 dshmarket 自己的 RELEASE_AGE_OVERRIDE 用的同一个,安全)。
link: 插件的 peer symlink 会在 profile pnpm install 后失效——peer symlink 若指向 profile 的 node_modules/@deepseek-ai/<pkg>,重装即被清。一律指向全局 dsh 安装目录 ~/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/<pkg>(随 dsh 本体存在,稳定)。
6. "401 authentication required" ≠ 坏了 —— token 是一次性登录,不是每次都要
用户报"又进不去了"时先区分 401 和 crash loop:curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3080/ 返回 401 表示服务健康(只是要登录),别去翻 plugin 错误。dsh-client-connection 的认证流程:launch token 只在 GET / 被接受一次,验证后签发 30 天有效的签名 cookie(cookieMaxAgeDays: 30,密钥存 ~/.dsh/.credentials.yaml,重启不变),然后 302 到干净的 /。所以正确姿势是带 token 开一次 → 之后收藏裸 http://127.0.0.1:3080/。需要重登的触发条件只有:cookie 过期(30 天)、清了浏览器 cookie、删了 .credentials.yaml。不要建议关认证——它是防 DNS rebinding 的唯一屏障。取当前 token 一行:journalctl --user -u dsh-web --no-pager | grep "dsh web: http" | tail -1 | grep -oP 'http\S+'。
Diagnostic order (fastest first)
- 先分清 401 vs crash:
curl -sS -o /dev/null -w "%{http_code}\n" --max-time 3 http://127.0.0.1:3080/返回 401 = 服务健康(只是要登录),问题在 token/cookie,不翻 plugin 错误;000/拒绝连接 = 真崩了,继续往下。 curl ...(同上)— is it even up?systemctl --user is-active dsh-web; systemctl --user is-enabled dsh-webjournalctl --user -u dsh-web -n 30 --no-pager— crash reason (EADDRINUSE vs plugin-load vs TTY)ss -tlnp | grep 3080+lsof -i :3080— who holds the port (orphan node vs systemd)
Health commands
systemctl --user status dsh-web --no-pager # active/running + Main PID
loginctl show-user po | grep Linger # must be yes for boot autostart
Support files
references/node-proxy-and-systemd-env.md— the Node-fetch-ignores-proxy lesson generalized (any service whose child reads HTTP_PROXY from env, not asEnvironment=).references/cli-plugin-add-failures.md— three traps whendsh plugin addfails: pnpm-store version, lockfileresolution: {integrity}missing for GitHub-release tarballs, anddsh pluginCLI not forwardingRELEASE_AGE_OVERRIDE(with theAbortSignal.timeout(string)landmine).
Pitfalls
dsh webfrom Hermesbackground=truedies silently (no TTY). Usepty=truefor a one-shot foreground check.- Proxy env is read at process start;
exportafter start (orsystemctl restartwithout env) won't help. Confirm via/proc/<pid>/environ. cordis.patch.ymluses!!jsfor expressions — adisabled: !!js !process.stdout.isTTYYAML-parse error was hit ("duplication of a tag property") — use plaindisabled: true.- Don't strip
--no-openin systemd ExecStart or the server tries to launch a browser. dsh plugin adderrors are deceptively generic ("pnpm failed"). Three independent root causes (store version, lockfile integrity, missing CLI override) all surface the same exit. See the reference for triage — start withpnpm --versionandgrep '^ resolution:' pnpm-lock.yaml | grep -v integrity.--config.fetchTimeout=<ms>in pnpm 11 traps you — pnpm passes the value as a string toAbortSignal.timeout(), which throwsERR_INVALID_ARG_TYPEand retries forever. Do not add this flag to override lists; pnpm's default 60s is fine.dsh pluginCLI gap vs dshmarket UI:bin.jsdoes not forward--config.minimumReleaseAge=0. UI routes do (seedshmarket/src/install.ts). CLI users get hit bypnpm-workspace.yaml's 1-day cutoff. Until upstream patches this,bin.jsneeds a local override (see reference).
Pitfalls
dsh webfrom Hermesbackground=truedies silently (no TTY). Usepty=truefor a one-shot foreground check.- Proxy env is read at process start;
exportafter start (orsystemctl restartwithout env) won't help. Confirm via/proc/<pid>/environ. cordis.patch.ymluses!!jsfor expressions — adisabled: !!js !process.stdout.isTTYYAML-parse error was hit ("duplication of a tag property") — use plaindisabled: true.- Don't strip
--no-openin systemd ExecStart or the server tries to launch a browser.