diff --git a/README.md b/README.md index afebdd7..020d9df 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Manual install requires Go 1.25+. Windows target hosts need no manual download - Quality matrix (live E2E status): [QUALITY_MATRIX.md](docs/development/QUALITY_MATRIX.md) - Host update runbook: [UPDATE_RUNBOOK.md](docs/operations/UPDATE_RUNBOOK.md) - Quality gate: `./scripts/check.sh` +- DeepSeek Harness integration: [integrations/deepseek-harness](integrations/deepseek-harness/README.md) ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 024d32b..36e5d79 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,6 +65,7 @@ curl -fsSL https://raw.githubusercontent.com/EitanWong/remote-dev-skillkit/main/ - 质量矩阵(live E2E 状态):[QUALITY_MATRIX.md](docs/development/QUALITY_MATRIX.md) - 主机更新手册:[UPDATE_RUNBOOK.md](docs/operations/UPDATE_RUNBOOK.md) - 质量门禁:`./scripts/check.sh` +- DeepSeek Harness 集成:[integrations/deepseek-harness](integrations/deepseek-harness/README.zh-CN.md) ## 许可证 diff --git a/integrations/deepseek-harness/README.md b/integrations/deepseek-harness/README.md new file mode 100644 index 0000000..116f81d --- /dev/null +++ b/integrations/deepseek-harness/README.md @@ -0,0 +1,105 @@ +# Remote Dev in DeepSeek Harness + +[中文](README.zh-CN.md) + +This experimental Bundle connects DeepSeek Harness to Remote Dev Skillkit +through Harness's shipped `@deepseek-ai/dsh-mcp-client`. It adds one stdio MCP +server named `rdev`; it does not copy rdev's tools or create another control +plane. + +## Prerequisites + +- DeepSeek Harness developer preview `0.1.0-rc.8`, using its supported Node + range `^22.19.0 || >=24.0.0`, with `dsh` and pnpm 11 on `PATH`. +- `rdev` installed. The Bundle checks `RDEV_BIN`, then + `~/.local/bin/rdev`, then `PATH`. + +Pin the verified Harness preview before installing the Bundle: + +```sh +pnpm add --global @deepseek-ai/dsh@0.1.0-rc.8 +dsh --version # must print 0.1.0-rc.8 +``` + +If pnpm reports that its global bin directory is missing, run `pnpm setup`, +restart the shell, and repeat the two commands. Do not use an unversioned +`@deepseek-ai/dsh`: npm `latest` is still rc.7 while this Bundle is verified +against rc.8. + +From the Remote Dev Skillkit checkout, install both pieces: + +```sh +./scripts/install.sh +dsh plugin --profile web add ./integrations/deepseek-harness +``` + +The Bundle is intentionally private and is not published to npm yet. Restart a +running Profile after adding or removing it. + +## Connect a gateway + +For an existing managed host, point Harness at the operator-managed HTTPS +gateway. Store the bearer token in an owner-readable file; set only its path: + +```sh +export RDEV_GATEWAY_URL=https://gateway.example +export RDEV_GATEWAY_OPERATOR_TOKEN_FILE=/protected/rdev-operator.token +dsh --profile web --dump-config +dsh web +``` + +For a loopback development journey, start the gateway separately, then launch +Harness from another terminal: + +```sh +rdev gateway serve --dev +RDEV_GATEWAY_URL=http://127.0.0.1:8787 dsh web +``` + +With no `RDEV_GATEWAY_URL`, rdev uses an isolated in-process control plane. That +is useful for MCP contract checks, but a remote host cannot join it. + +The config dump must contain exactly one `id: rdev` row. Successful Profile +startup proves the MCP process connected and synchronized its tool list because +the Bundle enables `failOnStartupError`. Harness exposes the tools under the +`mcp__rdev__` prefix; rdev's dotted raw names receive Harness's deterministic +hash suffix. + +Headless use is opt-in: + +```sh +dsh plugin --profile headless add ./integrations/deepseek-harness +dsh --profile headless "List the online remote hosts and summarize their state." +``` + +## Remove + +```sh +dsh plugin --profile web remove rdev-deepseek-harness +``` + +## Troubleshooting + +| Symptom | Action | +| --- | --- | +| `spawn rdev ENOENT` | Set `RDEV_BIN` to the absolute `rdev` path. | +| `read operator token file` | Check the path and owner-only read permission. | +| Profile startup fails after a Harness update | Re-test against the pinned rc.8 contract; preview APIs may break. | +| Session waits with no host | Confirm a reachable gateway is configured, then inspect host heartbeat freshness. | + +The Bundle contract is part of `go test ./...` and the repository's normal +`./scripts/check.sh` gate. Maintainers with `dsh` installed can run +`integrations/deepseek-harness/live-smoke.sh` for an isolated loopback-gateway +and Profile boot; it never writes to the normal `~/.dsh` directory. + +## Security and compatibility + +The Bundle starts `rdev mcp serve` only. Session policy, operator authorization, +audit, interruption, and target-host controls stay in rdev. No token value is +embedded in the package, no inbound target port is opened, and MCP stdout stays +protocol-only. + +This Bundle is pinned and tested against DeepSeek Harness `0.1.0-rc.8`, which +upstream labels a developer preview with breaking changes expected. Custom +native tool names and purpose-built host/session/task cards are intentionally +deferred until that plugin UI contract stabilizes. diff --git a/integrations/deepseek-harness/README.zh-CN.md b/integrations/deepseek-harness/README.zh-CN.md new file mode 100644 index 0000000..efd3961 --- /dev/null +++ b/integrations/deepseek-harness/README.zh-CN.md @@ -0,0 +1,98 @@ +# 在 DeepSeek Harness 中使用 Remote Dev + +[English](README.md) + +这是一个实验性 Bundle。它复用 DeepSeek Harness 自带的 +`@deepseek-ai/dsh-mcp-client`,通过 stdio 接入 `rdev`,不会复制工具定义, +也不会创建第二套控制面。 + +## 前置条件 + +- DeepSeek Harness developer preview `0.1.0-rc.8`,使用其支持的 Node 范围 + `^22.19.0 || >=24.0.0`,并且 `dsh`、pnpm 11 已在 `PATH` 中。 +- 已安装 `rdev`。Bundle 会依次查找 `RDEV_BIN`、`~/.local/bin/rdev` 和 + `PATH`。 + +先固定到已经验证过的 Harness 版本,再安装 Bundle: + +```sh +pnpm add --global @deepseek-ai/dsh@0.1.0-rc.8 +dsh --version # 必须输出 0.1.0-rc.8 +``` + +如果 pnpm 提示 global bin 目录未配置,先执行 `pnpm setup`,重启终端后再 +重复上面两条命令。不要使用不带版本的 `@deepseek-ai/dsh`:npm 的 +`latest` 目前仍是 rc.7,而本 Bundle 按 rc.8 验证。 + +在 Remote Dev Skillkit 仓库根目录执行: + +```sh +./scripts/install.sh +dsh plugin --profile web add ./integrations/deepseek-harness +``` + +当前 Bundle 刻意保持 private,尚未发布到 npm。新增或移除 Bundle 后, +需要重启正在运行的 Profile。 + +## 连接网关 + +要操作已托管主机,请连接 operator 管理的 HTTPS gateway。Bearer token +保存在仅 owner 可读的文件中,只传文件路径: + +```sh +export RDEV_GATEWAY_URL=https://gateway.example +export RDEV_GATEWAY_OPERATOR_TOKEN_FILE=/protected/rdev-operator.token +dsh --profile web --dump-config +dsh web +``` + +本机开发时,先单独启动 loopback gateway,再从另一个终端启动 Harness: + +```sh +rdev gateway serve --dev +RDEV_GATEWAY_URL=http://127.0.0.1:8787 dsh web +``` + +不设置 `RDEV_GATEWAY_URL` 时,`rdev` 使用 MCP 进程内的隔离控制面,只适合 +协议检查,远程主机无法加入。 + +配置输出中应恰好出现一个 `id: rdev`。Bundle 设置了 +`failOnStartupError`,所以 Profile 成功启动即代表 MCP 子进程已连接并同步 +工具列表。工具名以 `mcp__rdev__` 开头;原始名称中的点号会触发 Harness +添加确定性的 hash 后缀。 + +Headless 用法: + +```sh +dsh plugin --profile headless add ./integrations/deepseek-harness +dsh --profile headless "列出当前在线的远程主机,并概括状态。" +``` + +## 移除 + +```sh +dsh plugin --profile web remove rdev-deepseek-harness +``` + +## 常见问题 + +| 现象 | 处理 | +| --- | --- | +| `spawn rdev ENOENT` | 将 `RDEV_BIN` 设置为 `rdev` 的绝对路径。 | +| `read operator token file` | 检查路径和 owner-only 读取权限。 | +| Harness 更新后 Profile 启动失败 | 按 rc.8 契约重新验证;preview API 可能破坏兼容。 | +| 会话一直等待主机 | 确认配置了可达网关,再核对主机心跳是否新鲜。 | + +Bundle 契约已纳入 `go test ./...` 和仓库标准 `./scripts/check.sh` 门禁。维护者 +安装 `dsh` 后可运行 `integrations/deepseek-harness/live-smoke.sh`,它使用临时 +loopback gateway 和 Profile,不会写入正常的 `~/.dsh`。 + +## 安全与兼容边界 + +Bundle 只启动 `rdev mcp serve`。会话策略、operator 授权、审计、中断和目标 +主机控制仍由 rdev 负责。包内不写入 token 值,不开放目标主机入站端口, +MCP stdout 始终只承载协议。 + +当前版本锁定并验证 DeepSeek Harness `0.1.0-rc.8`。上游仍将其标记为 +developer preview,并明确可能发生破坏性变更。专属 clean tool names 以及 +主机、会话、任务原生卡片等 UI,等上游插件 UI 契约稳定后再接入。 \ No newline at end of file diff --git a/integrations/deepseek-harness/cordis.patch.yml b/integrations/deepseek-harness/cordis.patch.yml new file mode 100644 index 0000000..bb27572 --- /dev/null +++ b/integrations/deepseek-harness/cordis.patch.yml @@ -0,0 +1,17 @@ +- insert: + - id: rdev + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: rdev + transport: stdio + command: !!js >- + process.env.RDEV_BIN?.trim() || ((candidate) => process.getBuiltinModule('node:fs').existsSync(candidate) ? candidate : 'rdev')(process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.local', 'bin', process.platform === 'win32' ? 'rdev.exe' : 'rdev')) + args: !!js >- + ['mcp', 'serve'].concat(process.env.RDEV_GATEWAY_URL?.trim() ? ['--gateway-url', process.env.RDEV_GATEWAY_URL.trim()].concat(process.env.RDEV_GATEWAY_OPERATOR_TOKEN_FILE?.trim() ? ['--operator-token-file', process.env.RDEV_GATEWAY_OPERATOR_TOKEN_FILE.trim()] : []) : []) + failOnStartupError: true + toolCallTimeoutMs: 60000 + reconnect: + enabled: true + initialDelayMs: 500 + maxDelayMs: 10000 + maxAttempts: 8 diff --git a/integrations/deepseek-harness/live-smoke.sh b/integrations/deepseek-harness/live-smoke.sh new file mode 100755 index 0000000..97b8ed2 --- /dev/null +++ b/integrations/deepseek-harness/live-smoke.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BUNDLE="${ROOT}/integrations/deepseek-harness" +TMP="$(mktemp -d)" +DSH_BIN="${DSH_BIN:-dsh}" +DSH_PID="" +GWPID="" + +cleanup() { + if [[ -n "${DSH_PID}" ]]; then + kill "${DSH_PID}" 2>/dev/null || true + wait "${DSH_PID}" 2>/dev/null || true + fi + if [[ -n "${GWPID}" ]]; then + kill "${GWPID}" 2>/dev/null || true + wait "${GWPID}" 2>/dev/null || true + fi + rm -rf "${TMP}" +} +trap cleanup EXIT + +command -v "${DSH_BIN}" >/dev/null 2>&1 || { printf 'live smoke: dsh not found; set DSH_BIN\n' >&2; exit 1; } +command -v pnpm >/dev/null 2>&1 || { printf 'live smoke: pnpm not found on PATH\n' >&2; exit 1; } +command -v npm >/dev/null 2>&1 || { printf 'live smoke: npm not found on PATH\n' >&2; exit 1; } +command -v node >/dev/null 2>&1 || { printf 'live smoke: node not found on PATH\n' >&2; exit 1; } +command -v curl >/dev/null 2>&1 || { printf 'live smoke: curl not found on PATH\n' >&2; exit 1; } + +go build -o "${TMP}/rdev" ./cmd/rdev +(cd "${BUNDLE}" && npm pack --dry-run --json >"${TMP}/pack.json") + +GW_PORT="$(node -e "const net=require('node:net');const s=net.createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})")" +GW="http://127.0.0.1:${GW_PORT}" +"${TMP}/rdev" gateway serve --dev --addr "127.0.0.1:${GW_PORT}" >"${TMP}/gateway.json" 2>"${TMP}/gateway.err" & +GWPID="$!" +for _ in $(seq 1 40); do + if curl -fsS "${GW}/v1/hosts" >/dev/null 2>&1; then break; fi + sleep 0.25 +done +curl -fsS "${GW}/v1/hosts" >/dev/null || { printf 'live smoke: gateway API did not become ready\n' >&2; sed -n '1,120p' "${TMP}/gateway.err" >&2; exit 1; } + +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dsh-live-smoke","version":"1"}}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + | "${TMP}/rdev" mcp serve >"${TMP}/mcp.jsonl" +node - "${TMP}/mcp.jsonl" <<'NODE' +const fs = require('node:fs') +const lines = fs.readFileSync(process.argv[2], 'utf8').trim().split('\n').map(JSON.parse) +const tools = lines.find(line => line.id === 2)?.result?.tools +if (!Array.isArray(tools) || tools.length !== 11) throw new Error(`expected 11 rdev MCP tools, got ${tools?.length}`) +NODE + +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dsh-live-smoke-remote","version":"1"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"rdev.hosts.list","arguments":{}}}' \ + | "${TMP}/rdev" mcp serve --gateway-url "${GW}" >"${TMP}/mcp-remote.jsonl" +node - "${TMP}/mcp-remote.jsonl" <<'NODE' +const fs = require('node:fs') +const lines = fs.readFileSync(process.argv[2], 'utf8').trim().split('\n').map(JSON.parse) +const hosts = lines.find(line => line.id === 3)?.result?.structuredContent?.hosts +if (!Array.isArray(hosts)) throw new Error('remote rdev.hosts.list did not return a hosts array') +NODE + +export DSH_HOME="${TMP}/dsh-home" +"${DSH_BIN}" plugin --profile web add "${BUNDLE}" +RDEV_BIN="${TMP}/rdev" RDEV_GATEWAY_URL="${GW}" "${DSH_BIN}" --profile web --dump-config >"${TMP}/config.yml" +[[ "$(grep -c 'id: rdev' "${TMP}/config.yml")" == 1 ]] || { printf 'live smoke: composed config does not contain exactly one rdev row\n' >&2; exit 1; } + +PORT="$(node -e "const net=require('node:net');const s=net.createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})")" +RDEV_BIN="${TMP}/rdev" RDEV_GATEWAY_URL="${GW}" "${DSH_BIN}" web --no-open --port "${PORT}" >"${TMP}/dsh.log" 2>&1 & +DSH_PID="$!" + +for _ in $(seq 1 120); do + if curl -fsS "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then + printf 'deepseek-harness live smoke: ok (loopback gateway, remote MCP call, 11 tools, Profile ready)\n' + exit 0 + fi + if ! kill -0 "${DSH_PID}" 2>/dev/null; then + printf 'live smoke: dsh exited before readiness\n' >&2 + sed -n '1,200p' "${TMP}/dsh.log" >&2 + exit 1 + fi + sleep 0.25 +done + +printf 'live smoke: timed out waiting for dsh web\n' >&2 +sed -n '1,200p' "${TMP}/dsh.log" >&2 +exit 1 \ No newline at end of file diff --git a/integrations/deepseek-harness/package.json b/integrations/deepseek-harness/package.json new file mode 100644 index 0000000..9e659a1 --- /dev/null +++ b/integrations/deepseek-harness/package.json @@ -0,0 +1,10 @@ +{ + "name": "rdev-deepseek-harness", + "version": "0.1.0-rc.1", + "description": "Remote Dev Skillkit MCP tools for DeepSeek Harness", + "license": "MIT", + "private": true, + "files": ["cordis.patch.yml", "README.md", "README.zh-CN.md"], + "engines": { "node": "^22.19.0 || >=24.0.0" }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/scripts/deepseek_harness_test.go b/scripts/deepseek_harness_test.go new file mode 100644 index 0000000..c3ee3de --- /dev/null +++ b/scripts/deepseek_harness_test.go @@ -0,0 +1,81 @@ +package scripts_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDeepSeekHarnessBundleContract(t *testing.T) { + root := filepath.Dir(mustGetwd(t)) + dir := filepath.Join(root, "integrations", "deepseek-harness") + + var manifest struct { + License string `json:"license"` + Private bool `json:"private"` + Files []string `json:"files"` + Engines map[string]string `json:"engines"` + DSH struct { + Bundle struct { + Patch string `json:"patch"` + } `json:"bundle"` + } `json:"dsh"` + } + content, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(content, &manifest); err != nil { + t.Fatal(err) + } + if !manifest.Private || manifest.License != "MIT" || manifest.Engines["node"] != "^22.19.0 || >=24.0.0" || manifest.DSH.Bundle.Patch != "./cordis.patch.yml" { + t.Fatalf("invalid private bundle manifest: %#v", manifest) + } + for _, name := range []string{"cordis.patch.yml", "README.md", "README.zh-CN.md"} { + if !containsString(manifest.Files, name) { + t.Fatalf("bundle manifest does not package %s", name) + } + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Fatalf("packaged file %s: %v", name, err) + } + } + + patchBytes, err := os.ReadFile(filepath.Join(dir, "cordis.patch.yml")) + if err != nil { + t.Fatal(err) + } + patch := string(patchBytes) + if strings.Count(patch, "- id: rdev") != 1 { + t.Fatalf("expected exactly one rdev row:\n%s", patch) + } + for _, required := range []string{ + "@deepseek-ai/dsh-mcp-client", + "transport: stdio", + "process.env.RDEV_BIN", + "'.local', 'bin'", + "RDEV_GATEWAY_URL", + "--operator-token-file", + "RDEV_GATEWAY_OPERATOR_TOKEN_FILE", + "failOnStartupError: true", + "toolCallTimeoutMs: 60000", + "maxAttempts: 8", + } { + if !strings.Contains(patch, required) { + t.Fatalf("bundle patch does not contain %q", required) + } + } + if strings.Contains(patch, "\n env:") || strings.Contains(patch, "Bearer ") { + t.Fatal("bundle patch embeds a child env block or bearer material") + } +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/scripts/ux-smoke.sh b/scripts/ux-smoke.sh index 22ef6e5..4e9314b 100755 --- a/scripts/ux-smoke.sh +++ b/scripts/ux-smoke.sh @@ -41,9 +41,10 @@ step "starting gateway on ${GW}" "$WORK/rdev" gateway serve --dev --addr "127.0.0.1:${PORT}" >"$WORK/gw.json" 2>"$WORK/gw.err" & GWPID=$! for _ in $(seq 1 40); do - curl -sf "${GW}/health" >/dev/null 2>&1 && break + curl -sf "${GW}/v1/hosts" >/dev/null 2>&1 && break sleep 0.25 done +curl -sf "${GW}/v1/hosts" >/dev/null || { bad "gateway API did not become ready"; exit 1; } if python3 -c "import json,sys; d=json.load(open('$WORK/gw.json')); assert d.get('schema_version')=='rdev.gateway-ready.v2', d; assert d.get('url')=='$GW', d" 2>"$WORK/gwcheck.err"; then ok "gateway ready JSON parseable (rdev.gateway-ready.v2)" else