diff --git a/README.md b/README.md index bd882e68..afebdd7b 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,71 @@ # Remote Dev Skillkit -让 AI Agent 安全地在你的 Mac、Windows、Linux 主机上做真实开发工作。 +[中文](README.zh-CN.md) -**你遇到的场景**:Agent 有模型、有代码能力,但没有一台"能干活的主机"——或者你不想给 Agent 一把能碰所有东西的钥匙。Remote Dev Skillkit 是两者之间的受控通道:Agent 通过 MCP 提交**有边界、可审计、可中断**的任务,主机用**短期 join code** 加入会话、在本地策略内执行、回报事件与产物。 +**Remote control for AI agents.** Claude Code, Codex, Hermes, OpenCode — or any MCP-capable agent — operate your Mac, Windows, and Linux machines to get complex work done: coding, repair, ops, automation, anything that needs a real computer. ```text -Agent (MCP client) ──rdev mcp serve──> Control Plane ──long-poll──> Host - (gateway) (rdev host serve) - │ - └── 每个动作:策略 + 审计 + 事件推送 +Agent (MCP client) ── rdev mcp serve ──> Control Plane ── long-poll ──> Host + (rdev gateway) (rdev host serve) ``` -## 它解决什么 +## Why -- **给 Agent 一台受控主机**:临时任务用 join code,长期任务用 Windows 服务(浏览器 handoff 一键安装),全部策略约束。 -- **不给 Agent 不受限访问**:无入站端口、无隐藏持久化、不绕过本地安全控制(UAC/TCC/Gatekeeper)。 -- **Agent 能感知进展**:事件推送(webhook)让 Agent 及时知道主机上线、任务完成、产物就绪,而不是轮询。 +Agents have models and reasoning — but no machine of their own, and you shouldn't hand them the keys to everything. Remote Dev Skillkit is the controlled remote-control layer between them. -## 快速开始(按你的角色) +- **Scoped sessions** — hosts join with short-lived join codes; every task is bounded by policy and capability ceilings. +- **Audited & interruptible** — every action is recorded and can be interrupted or revoked at any time. +- **Managed hosts** — Windows hosts install as a service via a browser handoff (copy-paste PowerShell), auto-reconnect, control-plane host updates. +- **Event-driven** — agents get push events (webhooks) instead of polling for host status, task results, and artifacts. +- **No exposure** — outbound-only connections, no inbound ports, no hidden persistence, no bypassing UAC, TCC, Gatekeeper, or Defender. -### 🖥️ 我是开发者,想让 Agent 用我的电脑 +## Quick start + +**Host side** — let an agent use this machine: ```bash -go install github.com/EitanWong/remote-dev-skillkit/cmd/rdev@latest # Go 1.25+ +go install github.com/EitanWong/remote-dev-skillkit/cmd/rdev@latest rdev host serve --join-code CODE --gateway https://your-gateway ``` -- 一次性的临时支持:加 `--once`(打印连接状态后退出)。 -- 长期托管(Windows):让操作员发你一个浏览器 handoff 链接,页面自动生成可复制 PowerShell,粘贴 → 可见确认 → 装成服务,之后开机自启、断线重连。 - -### 🤖 我是 Agent(或运行 Agent 的人),想驱动远程主机 +**Agent side** — connect to hosts through the control plane: ```bash -rdev mcp serve # 本地控制平面 -rdev mcp serve --gateway-url URL --operator-token-file PATH # 代理到远程 gateway +rdev mcp serve --gateway-url URL --operator-token-file PATH ``` -然后把 `rdev mcp serve` 注册进你的 MCP 客户端(Claude Code / Codex / Hermes / OpenCode 均可)。工具自描述:每个工具都带 `safety` 说明和 `user_summary`/`agent_next_action` 引导,Agent 无需读文档即可正确使用。 +Register `rdev mcp serve` in your MCP client. Tools are self-describing with safety notes and agent guidance. -### 🏗️ 我是网关操作者,想跑一个多主机控制面 +**Gateway operator** — run a multi-host control plane: ```bash -rdev gateway serve --dev # 本机试验 +rdev gateway serve --dev # local trial rdev gateway serve --operator-auth-file ops.token --state-file state.json \ - --signing-key-file key.pem --public-base-url https://gw.example \ - --windows-amd64-host-binary rdev-host.exe # 生产形态 + --signing-key-file key.pem --public-base-url https://gw.example ``` -生产网关只监听 loopback,由你自己的 HTTPS 反向代理对外;operator 认证、持久化状态、签名密钥、审计全部显式配置。 - -## 2 分钟最小演示(全本机) - -```bash -# 终端 1:起本地 gateway -rdev gateway serve --dev --addr 127.0.0.1:8788 - -# 终端 2:Agent 视角——建会话、拿 join code(也可用 rdev mcp tools 查看完整 MCP) -curl -X POST http://127.0.0.1:8788/v1/sessions -H 'Content-Type: application/json' \ - -d '{"reason":"first demo"}' # 返回 session id + join code - -# 终端 3:主机视角——加入会话 -rdev host serve --join-code CODE --gateway http://127.0.0.1:8788 --once - -# 终端 2:看到 hello 事件,提交一个只读任务,收到结果事件 -``` - -## 安装 - -最快(一条命令,自动装 Go,无需管理员): +## Install ```bash curl -fsSL https://raw.githubusercontent.com/EitanWong/remote-dev-skillkit/main/scripts/install.sh | bash ``` -或手动(需要 Go 1.25+): - -```bash -go install github.com/EitanWong/remote-dev-skillkit/cmd/rdev@latest -``` - -Windows 目标主机无需手动下载:浏览器 handoff 会自动获取并校验主机二进制。 +Manual install requires Go 1.25+. Windows target hosts need no manual download — the browser handoff fetches and verifies the host binary. -## 排障速查 +## Security model -| 症状 | 原因与解法 | -|---|---| -| `bind: address already in use` | 端口被占用(如 Cloudflare 等常驻服务)。换端口:`--addr 127.0.0.1:8789` | -| host join 一直失败 | gateway 不可达或 join code 过期。join code 短时有效,重新建会话再试 | -| Agent 报 `403` | operator token 未配置/不匹配。确认 `--operator-token-file` 指向受保护文件 | -| Windows 上没装成服务 | handoff 链接需在**目标 Windows 主机**的浏览器打开,且同意 UAC | -| 收不到事件推送 | webhook 需 HTTPS(本机 Hermes 可用 loopback HTTP);见 `rdev.sessions.notify` | +- Outbound-only connections; no inbound public ports. +- Policy-bound, scoped, auditable, interruptible tasks; temporary sessions are non-persistent by default. +- Never bypasses local security controls (UAC, sudo, TCC, Gatekeeper, Windows Defender). -## 安全边界 +## Documentation -- 主机不暴露任何入站公网端口;所有连接由主机主动外连(long-poll)。 -- 每个任务都受策略约束、限定作用域、可审计、可中断;临时会话默认不持久。 -- 不绕过 UAC、sudo、TCC、Gatekeeper、Windows Defender 等本地安全控制。 +- Architecture: [SESSION_CONTROL_PLANE.md](docs/architecture/SESSION_CONTROL_PLANE.md) +- Safety boundaries: [BOUNDARIES.md](docs/security/BOUNDARIES.md) +- 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` -## 验证与文档 +## License -- 质量门禁:`./scripts/check.sh`(gofmt、测试、vet、覆盖率门禁、surface 审计、release smoke)。 -- 架构:[SESSION_CONTROL_PLANE.md](docs/architecture/SESSION_CONTROL_PLANE.md) -- 安全边界:[BOUNDARIES.md](docs/security/BOUNDARIES.md) -- 质量矩阵(live E2E 状态):[QUALITY_MATRIX.md](docs/development/QUALITY_MATRIX.md) -- 贡献:[CONTRIBUTING.md](CONTRIBUTING.md) +MIT — see [LICENSE](LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..024d32ba --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,71 @@ +# Remote Dev Skillkit + +[English](README.md) + +**给 AI Agent 用的远程电脑操控工具。** Claude Code、Codex、Hermes、OpenCode 或任意支持 MCP 的 Agent,可以操控你的 Mac、Windows、Linux 主机,帮你完成各种复杂任务 —— 写代码、修故障、跑运维、做自动化,凡是需要一台真实电脑的事。 + +```text +Agent (MCP client) ── rdev mcp serve ──> 控制面 ── long-poll ──> 主机 + (rdev gateway) (rdev host serve) +``` + +## 为什么 + +Agent 有模型、有推理能力,但没有一台"能干活的主机"——你也不该给它一把能碰所有东西的钥匙。Remote Dev Skillkit 就是它们之间的受控远程操控层。 + +- **有边界** —— 主机用短期 join code 加入会话,每个任务受策略与能力上限约束。 +- **可审计、可中断** —— 每个动作都有记录,随时可以中断或撤销。 +- **托管主机** —— Windows 主机通过浏览器 handoff(复制粘贴 PowerShell)装成服务,开机自启、断线重连、控制面远程升级。 +- **事件驱动** —— Agent 通过 webhook 收到推送(主机上线、任务完成、产物就绪),无需轮询。 +- **零暴露** —— 主机只外连,无入站端口、无隐藏持久化、不绕过 UAC / TCC / Gatekeeper / Defender。 + +## 快速开始 + +**主机侧** —— 让 Agent 用这台机器: + +```bash +go install github.com/EitanWong/remote-dev-skillkit/cmd/rdev@latest +rdev host serve --join-code CODE --gateway https://your-gateway +``` + +**Agent 侧** —— 通过控制面驱动远程主机: + +```bash +rdev mcp serve --gateway-url URL --operator-token-file PATH +``` + +把 `rdev mcp serve` 注册进你的 MCP 客户端即可。工具自带安全说明与 Agent 引导,无需读文档。 + +**网关运维** —— 跑一个多主机控制面: + +```bash +rdev gateway serve --dev # 本机试验 +rdev gateway serve --operator-auth-file ops.token --state-file state.json \ + --signing-key-file key.pem --public-base-url https://gw.example +``` + +## 安装 + +```bash +curl -fsSL https://raw.githubusercontent.com/EitanWong/remote-dev-skillkit/main/scripts/install.sh | bash +``` + +手动安装需 Go 1.25+。Windows 目标主机无需手动下载 —— 浏览器 handoff 会自动获取并校验主机二进制。 + +## 安全模型 + +- 仅出站连接,无入站公网端口。 +- 任务受策略约束、限定作用域、可审计、可中断;临时会话默认不持久。 +- 绝不绕过本地安全控制(UAC、sudo、TCC、Gatekeeper、Windows Defender)。 + +## 文档 + +- 架构:[SESSION_CONTROL_PLANE.md](docs/architecture/SESSION_CONTROL_PLANE.md) +- 安全边界:[BOUNDARIES.md](docs/security/BOUNDARIES.md) +- 质量矩阵(live E2E 状态):[QUALITY_MATRIX.md](docs/development/QUALITY_MATRIX.md) +- 主机更新手册:[UPDATE_RUNBOOK.md](docs/operations/UPDATE_RUNBOOK.md) +- 质量门禁:`./scripts/check.sh` + +## 许可证 + +MIT —— 见 [LICENSE](LICENSE)。 diff --git a/docs/development/QUALITY_MATRIX.md b/docs/development/QUALITY_MATRIX.md index c75f051f..94a6d2ce 100644 --- a/docs/development/QUALITY_MATRIX.md +++ b/docs/development/QUALITY_MATRIX.md @@ -26,18 +26,18 @@ Status: ✅ covered · 🟡 partial · ❌ gap · ⚙️ live-only (real host/sy | `internal/update` | version compare (pre-release, v-prefix, malformed, empty), URL building (trailing slash, escaping, bad repo), HTTP non-200 / bad JSON / unreachable, token header, asset selection (dash vs underscore slug, case, no match), digest presence, shell quoting of adversarial names, plan when no update | ✅ 98% | | `internal/operatorauth` | file load errors (missing/bad JSON/wrong schema), JWKS fetch failure at load, claim types (aud string/array/mixed/nil, exp float64/int64/Number/garbage), roles claim forms (`[]any`/`[]string`/space-separated string/non-string), hash validation (prefix/length/hex), clock skew boundaries (OIDC exp/nbf at skew edge; hosted exp/nbf strict), wrong audience/issuer, expired/nbf token, duplicate key IDs, SAML response corners (expired assertion, wrong recipient, bad signature, empty response) | ✅ 78.0% | | `internal/hosttrust` | noop store, file missing/corrupt/wrong schema, atomic write + 0600, rollback rejection, same-sequence content tamper, signature from stored root (not caller-supplied), protected-store backends (keychain/DPAPI/libsecret), malformed protected ref | ✅ 78.8% | -| `internal/httpapi` | session create/join/close/revoke, event replay after cursor, long-poll wait parsing, artifact write authorization (operator path + endpoint lease + task ownership), task resume (operator role, checkpoint/idempotency validation, unknown task), persist-state failure (failing StateStore → 500) | ✅ 71.0% — artifact auth (#19), join/resume, persist-failure covered; `persistStateNoResponse` removed as dead code | +| `internal/httpapi` | session create/join/close/revoke, event replay after cursor, long-poll wait parsing, artifact write authorization (operator path + endpoint lease + task ownership), task resume (operator role, checkpoint/idempotency validation, unknown task), persist-state failure (failing StateStore → 500), session-scoped host-update artifact route (lease auth, digest header, audit) | ✅ 71.0% — artifact auth (#19), join/resume, persist-failure covered; `persistStateNoResponse` removed as dead code | | `internal/protectedstore` | ref parsing (URL-like, missing account, unknown prefix), backend fallthrough, per-platform backends (keychain/DPAPI/libsecret/keyctl/TPM/MDM), empty service/account, backend error propagation | 🟡 36.8% — platform backends are ⚙️ live-only (real keyring/TPM) or need mock seam; parse/open/store logic ✅ | | `internal/policy` | capability checks, shell allow/deny, scoping, unknown capability handling | 🟡 73.2% | | `internal/audit` | chain integrity, JSONL append, redaction of secrets, tamper detection | ✅ 76.5% | | `internal/model` | trust bundle validity windows, key status transitions, hash consistency | ✅ 71.7% | -| `internal/contracts` | tool schema round-trip, required fields, enum constraints, MCP surface parity with `mcp/tools.json` | ✅ 77.6% | +| `internal/contracts` | tool schema round-trip, required fields, enum constraints, MCP surface parity with `mcp/tools.json`, adapter profiles document `workspace_root_required` + complete payload examples (incl. Windows `powershell_command` allowlist contract) | ✅ 77.6% | | `internal/hostidentity` | key generation, fingerprint, validation of malformed keys | 🟡 69.0% | | `internal/workspace` | worktree create/cleanup/rollback, lock contention, write-scope enforcement (absolute/`..`/drive-letter paths, escaping symlinks, scope membership), snapshot diffing (change detection, truncation at 200 files, .git exclusion, escaping scopes), dirty policy | ✅ 71.7% | | `internal/toolchain` + `internal/depsinstall` | node/toolchain bootstrap, idempotency, failure mid-install, archive security (zip-slip, escaping symlinks, byte limits, HTTPS-only sources + same-host redirects, SHA-256 verify), retry classification and retry loops, atomic copy | ✅ 67–69% — network fetch paths covered with httptest | -| `internal/hostcmd` | managed service start/stop/retry, route pool concurrency, exit codes | ✅ 75.7% | +| `internal/hostcmd` | managed service start/stop/retry, route pool concurrency, exit codes, service update (staged release verify, SCM switch, health window, auto-rollback, result marker), host version/commit join reporting | ✅ 75.7% | | `internal/gateway` + `internal/controlplane` | session state machine, lease binding, reconnect, revocation, persistence, snapshot/event sequencing | ✅ 80–81% | -| `internal/hostrunner` | engineering loop, progress, limits (duration/output/attempts), isolation, runtime profiles | ✅ 81.2% | +| `internal/hostrunner` | engineering loop, progress, limits (duration/output/attempts), isolation, runtime profiles, preflight denials carry agent-actionable `hint` (workspace/capability/allowlist classes), host-update adapter (lease-authed download, digest verify, idempotency, detached updater, workspace exemption) | ✅ 81.2% | | `internal/shelladapter` + `internal/powershelladapter` | process groups, redaction, output caps, verification commands | ✅ 68–78% | | `internal/hostawake` | wake on LAN / platform wake, error fallback | ⚙️ 15.8% — live-only | | `internal/acceptance` | managed Mac/Windows verification reports, session evidence | ⚙️ 7.3% — live E2E harness, exercised on real hosts | diff --git a/docs/operations/UPDATE_RUNBOOK.md b/docs/operations/UPDATE_RUNBOOK.md new file mode 100644 index 00000000..1286bfbc --- /dev/null +++ b/docs/operations/UPDATE_RUNBOOK.md @@ -0,0 +1,135 @@ +# Host & Gateway Update Runbook + +This runbook covers updating the Remote Dev Skillkit control plane (gateway) +and enrolled managed Windows hosts. Design goals: updates are explicit, +verifiable, idempotent, and never leave a host without a working connector. + +## Version layout + +- Gateway releases on the pilot host are immutable per-commit directories: + `/opt/rdev-gateway/releases/-/` containing `rdev-gateway`, + `rdev-host.exe`, `SHA256SUMS`, `COMMIT`, and optionally `MCP_TOOLS.json`. +- The active service command is chosen by a numbered systemd drop-in chain + (`/etc/systemd/system/rdev-gateway-pilot.service.d/NN-.conf`). The + highest-numbered drop-in wins; older ones are kept for rollback. +- Gateway state and signing keys live in `/var/lib/rdev-gateway/managed-v2/` + and are **never overwritten** by a rollout. +- Host connectors on a managed Windows host are digest-keyed release copies + under the protected service state root (`releases//rdev-host.exe`); + the running image is never overwritten in place. + +## Gateway cutover (operator) + +1. Build the release bundle from a verified commit (see below). +2. Stage it on the pilot host: `/opt/rdev-gateway/releases/-/` + with `COMMIT` and `SHA256SUMS`. Keep the old release directory intact. +3. Write the next drop-in (highest number) pointing at the new release: + `ExecStart=` (clear) followed by the new `ExecStart=` with the same + `--state-file`, `--signing-key-file`, `--signing-key-id`, + `--operator-auth-file`, `--public-base-url`, and + `--windows-amd64-host-binary` flags. +4. `systemctl daemon-reload && systemctl restart rdev-gateway-pilot`. + Long-poll connections blip; enrolled hosts reconnect within their + reconnect-grace window without operator action. +5. Verify, in order: + - `systemctl is-active` + MainPID changed; + - `rdev-gateway version` / commit of the new binary; + - `/healthz` and trust-bundle freshness; + - state file unchanged (mtime/hash) — persistence intact; + - served artifact digest == `SHA256SUMS` entry + (`sha256sum /opt/rdev-gateway/releases/-/rdev-host.exe`); + - authenticated lifecycle probe: `create -> close` via the configured MCP + launcher (a `403` is a credential-binding gate, not a reason to weaken + auth); + - hosts still listed with fresh `last_seen_at`. +6. Rollback: remove/point below the new drop-in and restart the service with + the previous release. State and signing identity are untouched, so the + previous binary resumes with the same sessions. + +## Host connector update (control-plane path, no human needed) + +`host-update` is a session task adapter: + +- Requires the `host.update` capability in the session ceiling and in the + task capabilities. +- The host downloads the artifact the gateway currently serves from + `GET /v1/sessions/{id}/artifacts/host-update` under its endpoint lease, + verifies SHA-256, stages a digest-keyed release, then runs a **detached** + updater (`rdev-host service update --release `). +- The updater re-verifies the staged digest, switches the SCM binary path + (never overwriting the running image), starts the new service, waits for it + to run, and **auto-rolls-back** to the previous release if the replacement + stops during the health window. +- The task result is posted before the service restarts; verify the outcome + from the reconnected endpoint's `host_version`/`host_commit` and the + `UPDATE_RESULT.json` marker in the staged release directory. +- Idempotent: an update to the digest the host already runs reports + `up-to-date` and changes nothing. +- Ordering: the host applies whatever the gateway serves, so **cut the + gateway over first**; pinning `expected_sha256` in the payload makes a + not-yet-cut-over gateway fail with a clear hint instead of applying a stale + build. + +## Re-enrolling an existing host into a new session + +A host service is bound to the join code in its service config. To move it to +a new session (e.g. to raise the capability ceiling with `host.update`), issue +a browser handoff for the new session and open it on the target Windows host: +the bootstrap performs `service install --replace-existing`, which preserves +identity/trust state and stages the gateway-served (current) connector. + +## Build the release bundle + +Use the script, not ad-hoc commands: `scripts/build-release.sh` builds all +three binaries and **hard-fails if `rdev-host.exe` is not a PE32+ image**. + +> Pitfall (observed 2026-08-04): a one-off inline build produced an ELF +> binary named `rdev-host.exe` because `GOOS` did not apply. On Windows the +> service cannot start it, so `service update` rolls back forever and the +> host can end up with the connector service stopped. Always build with +> `scripts/build-release.sh` and verify `file rdev-host.exe` shows PE32+. + +## If a Windows host stops responding after an update attempt + +The connector service may have been left stopped (SCM rollback failed or the +service is stuck). Have the host owner run in an elevated PowerShell: + +```powershell +Get-Service RemoteDevSkillkitHost +Restart-Service RemoteDevSkillkitHost -ErrorAction SilentlyContinue +Get-CimInstance Win32_Service -Filter "Name='RemoteDevSkillkitHost'" | Select State, PathName +``` + +The service rejoins its session automatically once running (join code is in +the service config; identity is preserved). After it is back, re-run the +host-update task to complete the rollout. + +```sh +git checkout # verified commit, must be an ancestor of origin/main +./scripts/check.sh # full gate +mkdir -p dist/- && cd dist/- +CGO_ENABLED=0 go build -o rdev-gateway ../../cmd/rdev-gateway +CGO_ENABLED=0 go build -o rdev ../../cmd/rdev +GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o rdev-host.exe ../../cmd/rdev-host +printf '%s\n' "$(sha256sum rdev-gateway | cut -d' ' -f1) rdev-gateway" > SHA256SUMS +# ... one line per artifact; plus COMMIT file with the full sha +``` + +Build metadata (`internal/buildinfo`) is injected by ldflags in operator +builds; verify with `rdev-gateway version` after staging. + +## Corner cases handled + +- Host offline at update time: the task stays queued; the host picks it up on + reconnect (standard task semantics). +- Corrupt download / digest mismatch: aborts before staging; the old service + is untouched. +- Staging race or corruption: the detached updater re-verifies the digest + sidecar before activation. +- New binary fails to boot: SCM health window expires/stopped → automatic + rollback to the previous release; the outcome is in `UPDATE_RESULT.json`. +- Concurrent updates: the update is a single task; the SCM replace path is + serialized by the service handle. +- Gateway update while hosts are joined: long-poll blips; hosts reconnect + under the reconnect grace; verify with fresh `last_seen_at` + a bounded task + receipt (a stale `online` snapshot is not evidence). diff --git a/internal/contracts/engineering_task.go b/internal/contracts/engineering_task.go index 701a4013..2dc3182f 100644 --- a/internal/contracts/engineering_task.go +++ b/internal/contracts/engineering_task.go @@ -79,10 +79,15 @@ type EngineeringTaskLimits struct { // AdapterTaskProfile is machine-readable metadata for an existing built-in // adapter. It describes the outer routing adapter, not a new MCP tool. type AdapterTaskProfile struct { - Adapter string `json:"adapter"` - SchemaVersion string `json:"schema_version"` - RequiredCapabilities []string `json:"required_capabilities"` - PayloadExample map[string]any `json:"payload_example"` + Adapter string `json:"adapter"` + SchemaVersion string `json:"schema_version"` + RequiredCapabilities []string `json:"required_capabilities"` + // WorkspaceRootRequired declares the host-runner invariant that every + // adapter task payload must carry an absolute workspace_root. It exists so + // Agents can validate a payload against the contract before submitting, + // instead of discovering the requirement through denial round trips. + WorkspaceRootRequired bool `json:"workspace_root_required"` + PayloadExample map[string]any `json:"payload_example"` } // DecodeEngineeringTask rejects unknown fields before normalizing and @@ -360,55 +365,70 @@ func cloneCommandMatrix(values [][]string) [][]string { func AdapterTaskProfiles() []AdapterTaskProfile { profiles := []AdapterTaskProfile{ { - Adapter: "shell", - SchemaVersion: "rdev.shell-result.v1", - RequiredCapabilities: []string{"shell.user"}, - PayloadExample: map[string]any{"argv": []string{"go", "test", "./..."}, "allow_commands": []string{"go"}}, + Adapter: "shell", + SchemaVersion: "rdev.shell-result.v1", + RequiredCapabilities: []string{"shell.user"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"argv": []string{"go", "test", "./..."}, "allow_commands": []string{"go"}, "workspace_root": "C:\\workspace\\repo"}, }, { - Adapter: "powershell", - SchemaVersion: "rdev.powershell-result.v1", - RequiredCapabilities: []string{"powershell.user"}, - PayloadExample: map[string]any{"command": "dotnet test", "allow_commands": []string{"dotnet"}}, + Adapter: "powershell", + SchemaVersion: "rdev.powershell-result.v1", + RequiredCapabilities: []string{"powershell.user"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"command": "dotnet test", "allow_commands": []string{"powershell.exe"}, "powershell_command": "powershell.exe", "workspace_root": "C:\\workspace\\repo"}, }, { - Adapter: "codex", - SchemaVersion: "rdev.codex-result.v1", - RequiredCapabilities: []string{"codex.run", "git.diff"}, - PayloadExample: map[string]any{"prompt": "Implement the accepted change.", "verification_commands": [][]string{{"go", "test", "./..."}}, "allow_verification_commands": []string{"go"}}, + Adapter: "codex", + SchemaVersion: "rdev.codex-result.v1", + RequiredCapabilities: []string{"codex.run", "git.diff"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"prompt": "Implement the accepted change.", "verification_commands": [][]string{{"go", "test", "./..."}}, "allow_verification_commands": []string{"go"}, "workspace_root": "C:\\workspace\\repo"}, }, { - Adapter: "claude-code", - SchemaVersion: "rdev.claude-code-result.v1", - RequiredCapabilities: []string{"claude-code.run", "git.diff"}, - PayloadExample: map[string]any{"prompt": "Implement the accepted change.", "verification_commands": [][]string{{"go", "test", "./..."}}, "allow_verification_commands": []string{"go"}}, + Adapter: "claude-code", + SchemaVersion: "rdev.claude-code-result.v1", + RequiredCapabilities: []string{"claude-code.run", "git.diff"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"prompt": "Implement the accepted change.", "verification_commands": [][]string{{"go", "test", "./..."}}, "allow_verification_commands": []string{"go"}, "workspace_root": "C:\\workspace\\repo"}, }, { - Adapter: "acpx", - SchemaVersion: "rdev.acpx-result.v1", - RequiredCapabilities: []string{"acpx.run", "git.diff"}, - PayloadExample: map[string]any{"prompt": "Implement the accepted change.", "verification_commands": [][]string{{"go", "test", "./..."}}, "allow_verification_commands": []string{"go"}}, + Adapter: "acpx", + SchemaVersion: "rdev.acpx-result.v1", + RequiredCapabilities: []string{"acpx.run", "git.diff"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"prompt": "Implement the accepted change.", "verification_commands": [][]string{{"go", "test", "./..."}}, "allow_verification_commands": []string{"go"}, "workspace_root": "C:\\workspace\\repo"}, }, { - Adapter: "file", - SchemaVersion: "rdev.file-result.v1", - RequiredCapabilities: []string{"file.transfer.read"}, - PayloadExample: map[string]any{"action": "read", "path": "README.md", "chunk_bytes": 4096}, + Adapter: "file", + SchemaVersion: "rdev.file-result.v1", + RequiredCapabilities: []string{"file.transfer.read"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"action": "read", "path": "README.md", "chunk_bytes": 4096, "workspace_root": "C:\\workspace\\repo"}, }, { - Adapter: "desktop", - SchemaVersion: "rdev.desktop-result.v1", - RequiredCapabilities: []string{"window.inspect"}, - PayloadExample: map[string]any{"action": "window.inspect"}, + Adapter: "desktop", + SchemaVersion: "rdev.desktop-result.v1", + RequiredCapabilities: []string{"window.inspect"}, + WorkspaceRootRequired: true, + PayloadExample: map[string]any{"action": "window.inspect", "workspace_root": "C:\\workspace\\repo"}, + }, + { + Adapter: "host-update", + SchemaVersion: "rdev.host-update-result.v1", + RequiredCapabilities: []string{"host.update"}, + WorkspaceRootRequired: false, + PayloadExample: map[string]any{"expected_sha256": ""}, }, } out := make([]AdapterTaskProfile, 0, len(profiles)) for _, profile := range profiles { out = append(out, AdapterTaskProfile{ - Adapter: profile.Adapter, - SchemaVersion: profile.SchemaVersion, - RequiredCapabilities: append([]string(nil), profile.RequiredCapabilities...), - PayloadExample: cloneAnyMap(profile.PayloadExample), + Adapter: profile.Adapter, + SchemaVersion: profile.SchemaVersion, + RequiredCapabilities: append([]string(nil), profile.RequiredCapabilities...), + WorkspaceRootRequired: profile.WorkspaceRootRequired, + PayloadExample: cloneAnyMap(profile.PayloadExample), }) } return out diff --git a/internal/contracts/engineering_task_test.go b/internal/contracts/engineering_task_test.go index 0ef3a6ba..8511ad9c 100644 --- a/internal/contracts/engineering_task_test.go +++ b/internal/contracts/engineering_task_test.go @@ -2,6 +2,7 @@ package contracts import ( "reflect" + "slices" "strings" "testing" ) @@ -112,7 +113,7 @@ func TestEngineeringTaskSchemaPublishesAllBuiltInAdapterProfiles(t *testing.T) { } want := map[string]bool{ "shell": true, "powershell": true, "codex": true, "claude-code": true, - "acpx": true, "file": true, "desktop": true, + "acpx": true, "file": true, "desktop": true, "host-update": true, } for _, profile := range profiles { if !want[profile.Adapter] { @@ -128,6 +129,54 @@ func TestEngineeringTaskSchemaPublishesAllBuiltInAdapterProfiles(t *testing.T) { } } +// TestAdapterProfilesDocumentWorkspaceRequirement guards the agent-facing +// contract against regressing to the state where a submitted task payload +// could only be validated through denial round trips: every built-in adapter +// requires an absolute workspace_root, and examples must show it. +func TestAdapterProfilesDocumentWorkspaceRequirement(t *testing.T) { + for _, profile := range AdapterTaskProfiles() { + if profile.Adapter == "host-update" { + if profile.WorkspaceRootRequired { + t.Fatalf("host-update must declare workspace_root_required=false (control-plane adapter)") + } + continue + } + if !profile.WorkspaceRootRequired { + t.Fatalf("adapter %s must declare workspace_root_required", profile.Adapter) + } + example := profile.PayloadExample + if _, ok := example["workspace_root"]; !ok { + t.Fatalf("adapter %s payload example must include workspace_root: %#v", profile.Adapter, example) + } + } +} + +// TestPowerShellAdapterProfileDocumentsWindowsExecutableContract pins the +// Windows allowlist quirk that cost multiple denial round trips: the example +// must declare the bare executable name in allow_commands AND set +// powershell_command, or LookPath-resolved full paths fail exact-match +// allowlisting on the host. +func TestPowerShellAdapterProfileDocumentsWindowsExecutableContract(t *testing.T) { + var profile AdapterTaskProfile + for _, p := range AdapterTaskProfiles() { + if p.Adapter == "powershell" { + profile = p + break + } + } + if profile.Adapter == "" { + t.Fatal("missing powershell adapter profile") + } + example := profile.PayloadExample + if example["powershell_command"] != "powershell.exe" { + t.Fatalf("powershell example must set powershell_command to the bare executable name: %#v", example) + } + allow, _ := example["allow_commands"].([]string) + if !slices.Contains(allow, "powershell.exe") { + t.Fatalf("powershell example allow_commands must include powershell.exe: %#v", allow) + } +} + func validEngineeringTask() map[string]any { return map[string]any{ "schema_version": EngineeringTaskSchemaVersion, diff --git a/internal/controlplane/session.go b/internal/controlplane/session.go index e9d6463d..ed2180b5 100644 --- a/internal/controlplane/session.go +++ b/internal/controlplane/session.go @@ -218,6 +218,8 @@ type Endpoint struct { ReceivedSeq uint64 `json:"received_seq"` ProcessedSeq uint64 `json:"processed_seq"` LastSeenAt time.Time `json:"last_seen_at"` + HostVersion string `json:"host_version,omitempty"` + HostCommit string `json:"host_commit,omitempty"` } type LeaseSpec struct { diff --git a/internal/controlplane/store.go b/internal/controlplane/store.go index 62c0a3b9..3f81e2f1 100644 --- a/internal/controlplane/store.go +++ b/internal/controlplane/store.go @@ -20,6 +20,11 @@ type EndpointSpec struct { RenewAfterMS int `json:"renew_after_ms"` RetryAfterMS int `json:"retry_after_ms"` PreviousLeaseSecret string `json:"previous_lease_secret"` + // HostVersion and HostCommit report the connector build that joined. The + // control plane surfaces them on the endpoint so operators can verify a + // remote host update by comparing them against the promoted release. + HostVersion string `json:"host_version,omitempty"` + HostCommit string `json:"host_commit,omitempty"` } type EventCursor struct { @@ -842,6 +847,10 @@ func (s *MemoryStore) joinEndpointLocked(session Session, spec EndpointSpec) (En endpoint.Transport = transport endpoint.State = state endpoint.LastSeenAt = s.now() + if spec.HostVersion != "" { + endpoint.HostVersion = spec.HostVersion + endpoint.HostCommit = spec.HostCommit + } session.Status = SessionStatusOnline session = session.WithEndpoint(endpoint, s.now()) s.sessions[session.ID] = session @@ -877,6 +886,8 @@ func (s *MemoryStore) joinEndpointLocked(session Session, spec EndpointSpec) (En Capabilities: constrainedEndpointCapabilities(session, role, spec.Capabilities, nil), State: state, Transport: transport, + HostVersion: spec.HostVersion, + HostCommit: spec.HostCommit, LastSeenAt: s.now(), } session.Status = SessionStatusOnline diff --git a/internal/gateway/memory.go b/internal/gateway/memory.go index 1c8ba3fd..1c43f196 100644 --- a/internal/gateway/memory.go +++ b/internal/gateway/memory.go @@ -208,3 +208,10 @@ func (g *MemoryGateway) appendAudit(actor, action, targetID, message string) { defer g.mu.Unlock() g.appendAuditLocked(actor, action, targetID, message) } + +// AppendAudit records an explicit audit event for transport-layer actions +// (e.g. endpoint artifact downloads) that are authorized outside the control +// plane's own mutation methods. +func (g *MemoryGateway) AppendAudit(actor, action, targetID, message string) { + g.appendAudit(actor, action, targetID, message) +} diff --git a/internal/hostcmd/capabilities.go b/internal/hostcmd/capabilities.go index 7b6d42ea..2d9de9ab 100644 --- a/internal/hostcmd/capabilities.go +++ b/internal/hostcmd/capabilities.go @@ -36,6 +36,7 @@ func RegistrationCapabilities(inventory hostcap.Inventory) []string { "url.open", "clipboard.read", "clipboard.write", + "host.update", ) } return capabilities diff --git a/internal/hostcmd/hostcmd.go b/internal/hostcmd/hostcmd.go index 58a52f57..0d3076b7 100644 --- a/internal/hostcmd/hostcmd.go +++ b/internal/hostcmd/hostcmd.go @@ -207,6 +207,8 @@ func (a App) runServe(ctx context.Context, opts serveOptions) error { LeaseTTLMS: 60_000, RenewAfterMS: 20_000, RetryAfterMS: 1_000, + HostVersion: buildinfo.Version, + HostCommit: buildinfo.Commit, } if opts.Transport == "poll" { endpointSpec.Transport = controlplane.TransportPoll @@ -707,7 +709,7 @@ func (a App) runSessionTaskWithRoutes(ctx context.Context, opts serveOptions, cl err = fmt.Errorf("task capabilities exceed the joined session ceiling") } else { progressReporter := a.engineeringProgressReporter(opts, client, sessionID, endpointID, leaseSecret, task, routes) - result, err = hostrunner.RunSessionTaskWithOptionsContext(ctx, sessionTaskSpec(task, endpointID, identityFingerprint), time.Now(), hostrunner.Options{ + result, err = hostrunner.RunSessionTaskWithOptionsContext(ctx, sessionTaskSpec(task, endpointID, identityFingerprint, sessionID, leaseSecret, opts.GatewayURL), time.Now(), hostrunner.Options{ IdentityFingerprint: identityFingerprint, WorkspaceLockStore: opts.WorkspaceLockStore, CaptureRuntimeFixture: opts.CaptureRuntimeFixture, @@ -825,7 +827,7 @@ func (a App) reportEngineeringProgress(ctx context.Context, opts serveOptions, c } } -func sessionTaskSpec(task controlplane.Task, endpointID, identityFingerprint string) hostrunner.SessionTaskSpec { +func sessionTaskSpec(task controlplane.Task, endpointID, identityFingerprint, sessionID, leaseSecret, gatewayURL string) hostrunner.SessionTaskSpec { payload := cloneStringAnyMap(task.Payload) workspaceRoot := stringValueFromAny(payload["workspace_root"]) writeScope := stringSliceFromAny(payload["write_scope"]) @@ -836,6 +838,9 @@ func sessionTaskSpec(task controlplane.Task, endpointID, identityFingerprint str IdentityFingerprint: identityFingerprint, Adapter: task.Adapter, Intent: task.Intent, + SessionID: sessionID, + LeaseSecret: leaseSecret, + GatewayURL: gatewayURL, Workspace: model.TaskWorkspace{ Root: workspaceRoot, WriteScope: writeScope, diff --git a/internal/hostcmd/hostcmd_test.go b/internal/hostcmd/hostcmd_test.go index 1c7e2178..e4f2265d 100644 --- a/internal/hostcmd/hostcmd_test.go +++ b/internal/hostcmd/hostcmd_test.go @@ -494,9 +494,17 @@ func TestRegistrationCapabilitiesAdvertisesWindowsDesktopSupportOnlyWhenManifest t.Fatalf("registered capabilities = %#v", got) } withoutDesktopGrant := ConstrainCapabilities(detected, []string{"shell.user"}, true) - if strings.Join(withoutDesktopGrant, ",") != "shell.user" { + if strings.Join(withoutDesktopGrant, ", ") != "shell.user" { t.Fatalf("manifest ceiling did not restrict desktop capabilities: %#v", withoutDesktopGrant) } + // The managed-host update capability is advertised on Windows and only + // granted when the session ceiling includes it. + if !capabilitySet(detected)["host.update"] { + t.Fatalf("Windows registration must advertise host.update: %#v", detected) + } + if constrained := ConstrainCapabilities(detected, []string{"host.update"}, true); strings.Join(constrained, ", ") != "host.update" { + t.Fatalf("host.update must be grantable from the ceiling: %#v", constrained) + } } func TestRegistrationCapabilitiesOmitsDesktopSupportOnNonWindows(t *testing.T) { @@ -1096,7 +1104,7 @@ func TestSessionTaskSpecMapsGitWorktreeFields(t *testing.T) { "isolation": "git-worktree", "dirty_policy": "require-clean", }, - }, "endpoint-worktree", "identity-worktree") + }, "endpoint-worktree", "identity-worktree", "ses-worktree", "lease-worktree", "https://gateway.example.test") if spec.Workspace.Root != "/workspace/repo" || spec.Workspace.Branch != "rdev/task-worktree" || spec.Workspace.BaseSHA != "0123456789abcdef0123456789abcdef01234567" || spec.Workspace.Isolation != "git-worktree" || spec.Workspace.DirtyPolicy != "require-clean" { t.Fatalf("session task spec lost Git worktree fields: %#v", spec.Workspace) } @@ -1113,7 +1121,7 @@ func TestSessionTaskSpecDoesNotInventWriteScopeForReadOnlyTask(t *testing.T) { "workspace_root": ".", "command": "Get-Location", }, - }, "endpoint-read-only", "identity-read-only") + }, "endpoint-read-only", "identity-read-only", "ses-read-only", "lease-read-only", "https://gateway.example.test") if len(spec.Workspace.WriteScope) != 0 { t.Fatalf("read-only task must not gain an implicit write scope: %#v", spec.Workspace) } diff --git a/internal/hostcmd/managed_service_windows.go b/internal/hostcmd/managed_service_windows.go index 97844040..aff32938 100644 --- a/internal/hostcmd/managed_service_windows.go +++ b/internal/hostcmd/managed_service_windows.go @@ -4,6 +4,7 @@ package hostcmd import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -20,11 +21,13 @@ import ( func (a App) service(ctx context.Context, args []string) error { if len(args) == 0 { - return fmt.Errorf("service action is required: install, run, or uninstall") + return fmt.Errorf("service action is required: install, update, run, or uninstall") } switch args[0] { case "install": return a.installManagedService(args[1:]) + case "update": + return a.updateManagedService(args[1:]) case "run": return a.runManagedService(ctx, args[1:]) case "uninstall": @@ -177,6 +180,233 @@ func managedServiceCommandLine(binaryPath, configPath string) string { return strings.Join([]string{syscall.EscapeArg(binaryPath), "service", "run", "--config", syscall.EscapeArg(configPath)}, " ") } +func (a App) updateManagedService(args []string) error { + fs := flag.NewFlagSet("rdev-host service update", flag.ContinueOnError) + fs.SetOutput(a.Stderr) + serviceName := fs.String("service-name", "", "Windows service name (default: discover by current executable)") + releaseDir := fs.String("release", "", "staged release directory containing rdev-host.exe and rdev-host.exe.sha256") + healthWaitSeconds := fs.Int("health-wait-seconds", 60, "bounded SCM health window before declaring the replacement healthy") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 0 { + return fmt.Errorf("unexpected service update arguments: %s", strings.Join(fs.Args(), " ")) + } + release := filepath.Clean(strings.TrimSpace(*releaseDir)) + if release == "" || release == "." { + return fmt.Errorf("release directory is required") + } + stagedBinary := filepath.Join(release, managedServiceBinaryFilename) + if err := verifyStagedManagedServiceRelease(release, stagedBinary); err != nil { + return err + } + + manager, err := mgr.Connect() + if err != nil { + return fmt.Errorf("connect to Windows service manager: %w", err) + } + defer manager.Disconnect() + + name := strings.TrimSpace(*serviceName) + if name == "" { + name, err = discoverManagedServiceByExecutable(manager) + if err != nil { + return err + } + } + service, err := manager.OpenService(name) + if err != nil { + return fmt.Errorf("open managed service %q: %w", name, err) + } + defer service.Close() + + previousSCMConfig, err := service.Config() + if err != nil { + return fmt.Errorf("read installed managed service settings: %w", err) + } + configPath, err := managedServiceConfigPathFromCommandLine(previousSCMConfig.BinaryPathName) + if err != nil { + return err + } + previousConfig, err := readManagedServiceConfig(configPath) + if err != nil { + return err + } + binaryPath, err := prepareManagedServiceRelease(previousConfig.StateRoot, stagedBinary) + if err != nil { + return fmt.Errorf("stage managed service release: %w", err) + } + if err := a.replaceManagedService(service, binaryPath, configPath, previousConfig); err != nil { + return err + } + if err := waitManagedServiceHealthy(service, binaryPath, *healthWaitSeconds); err != nil { + rollbackErr := rollbackManagedServiceReplacement(service, previousSCMConfig, configPath, previousConfig, err) + _ = writeManagedServiceUpdateResult(release, false, err, rollbackErr) + return rollbackErr + } + if err := writeManagedServiceUpdateResult(release, true, nil, nil); err != nil { + return err + } + _, err = fmt.Fprintf(a.Stdout, "managed service updated and running: %s (%s)\n", name, binaryPath) + return err +} + +// verifyStagedManagedServiceRelease confirms the staged binary matches the +// digest the updater recorded when it downloaded the artifact, so a corrupted +// or raced staging directory can never be activated. +func verifyStagedManagedServiceRelease(releaseDir, stagedBinary string) error { + expected, err := os.ReadFile(filepath.Join(releaseDir, managedServiceBinaryFilename+".sha256")) + if err != nil { + return fmt.Errorf("read staged release digest: %w", err) + } + actual, err := managedServiceFileSHA256(stagedBinary) + if err != nil { + return fmt.Errorf("hash staged release binary: %w", err) + } + if actual != strings.ToLower(strings.TrimSpace(string(expected))) { + return fmt.Errorf("staged release digest mismatch: got %s want %s", actual, strings.TrimSpace(string(expected))) + } + return nil +} + +// discoverManagedServiceByExecutable finds the SCM service whose binary path +// matches the current executable, so an unattended updater does not need to +// know the service name or its config path in advance. +func discoverManagedServiceByExecutable(manager *mgr.Mgr) (string, error) { + executable, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate host executable: %w", err) + } + executable, err = filepath.EvalSymlinks(executable) + if err != nil { + return "", fmt.Errorf("resolve host executable: %w", err) + } + names, err := manager.ListServices() + if err != nil { + return "", fmt.Errorf("list Windows services: %w", err) + } + for _, name := range names { + service, err := manager.OpenService(name) + if err != nil { + continue + } + cfg, err := service.Config() + _ = service.Close() + if err != nil { + continue + } + binary := managedServiceExecutablePath(cfg.BinaryPathName) + if binary != "" && strings.EqualFold(filepath.Base(binary), filepath.Base(executable)) { + return name, nil + } + } + return "", fmt.Errorf("no managed service running this executable was found") +} + +func managedServiceExecutablePath(commandLine string) string { + commandLine = strings.TrimSpace(commandLine) + if commandLine == "" { + return "" + } + if commandLine[0] == '"' { + if end := strings.Index(commandLine[1:], `"`); end >= 0 { + return commandLine[1 : 1+end] + } + return "" + } + if end := strings.IndexAny(commandLine, " \t"); end >= 0 { + return commandLine[:end] + } + return commandLine +} + +func managedServiceConfigPathFromCommandLine(commandLine string) (string, error) { + fields := splitManagedServiceCommandLine(commandLine) + for i := 0; i < len(fields)-1; i++ { + if fields[i] == "--config" { + return fields[i+1], nil + } + } + return "", fmt.Errorf("managed service command line does not declare --config") +} + +func splitManagedServiceCommandLine(commandLine string) []string { + var fields []string + var current strings.Builder + inQuotes := false + for _, r := range commandLine { + switch { + case r == '"': + inQuotes = !inQuotes + case (r == ' ' || r == '\t') && !inQuotes: + if current.Len() > 0 { + fields = append(fields, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + if current.Len() > 0 { + fields = append(fields, current.String()) + } + return fields +} + +// waitManagedServiceHealthy waits until SCM reports the replacement binary +// actually running. A replacement that stops during the window is a boot +// failure of the new build; the caller rolls back to the previous release. +func waitManagedServiceHealthy(service *mgr.Service, expectedBinaryPath string, seconds int) error { + if seconds <= 0 { + seconds = 60 + } + deadline := time.Now().Add(time.Duration(seconds) * time.Second) + for time.Now().Before(deadline) { + status, err := service.Query() + if err != nil { + time.Sleep(time.Second) + continue + } + cfg, err := service.Config() + if err != nil { + time.Sleep(time.Second) + continue + } + binary := managedServiceExecutablePath(cfg.BinaryPathName) + if status.State == svc.Running && strings.EqualFold(filepath.Clean(binary), filepath.Clean(expectedBinaryPath)) { + return nil + } + if status.State == svc.Stopped { + return fmt.Errorf("replacement managed service stopped during the health window") + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("replacement managed service did not run %s within %ds", expectedBinaryPath, seconds) +} + +// writeManagedServiceUpdateResult records the updater outcome next to the +// staged release so the operator can read it back after the service +// reconnects (the updater process is detached and its exit code is not +// otherwise observable). +func writeManagedServiceUpdateResult(releaseDir string, ok bool, updateErr, rollbackErr error) error { + result := map[string]any{ + "schema_version": "rdev.host-update-result.v1", + "ok": ok, + "at": time.Now().UTC().Format(time.RFC3339), + } + if updateErr != nil { + result["error"] = updateErr.Error() + } + if rollbackErr != nil { + result["rollback_error"] = rollbackErr.Error() + } + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(releaseDir, "UPDATE_RESULT.json"), content, 0o600) +} + func (a App) runManagedService(ctx context.Context, args []string) error { fs := flag.NewFlagSet("rdev-host service run", flag.ContinueOnError) fs.SetOutput(a.Stderr) diff --git a/internal/hostrunner/host_update.go b/internal/hostrunner/host_update.go new file mode 100644 index 00000000..b91bd0d1 --- /dev/null +++ b/internal/hostrunner/host_update.go @@ -0,0 +1,166 @@ +package hostrunner + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/buildinfo" +) + +const ( + hostUpdateResultSchema = "rdev.host-update-result.v1" + hostUpdateBinaryName = "rdev-host.exe" + hostUpdateMaxBytes = 128 << 20 + hostUpdateDownloadTime = 5 * time.Minute +) + +// hostUpdateHTTPClient fetches the connector artifact over HTTP/1.1 without +// environment proxies, mirroring the web-handoff bootstrap path verified +// against the production gateway, with a generous download window for slow +// operator links. +var hostUpdateHTTPClient = &http.Client{ + Timeout: hostUpdateDownloadTime, + Transport: &http.Transport{ + Proxy: nil, + ForceAttemptHTTP2: false, + DisableCompression: false, + }, +} + +// executeHostUpdate implements the host-update adapter. It fetches the host +// connector artifact the session gateway currently serves (authorized by the +// endpoint lease the host already uses for task fetch), verifies its SHA-256, +// stages it in a digest-bound release directory, and applies it through the +// detached `service update` updater. The updater replaces the service +// atomically (staged path + SCM switch, never overwriting the running image) +// and rolls back automatically if the replacement fails to boot; the task +// result is posted before the service restarts, and the operator verifies the +// outcome from the reconnected endpoint's host_version plus the +// UPDATE_RESULT.json marker. +func executeHostUpdate(ctx context.Context, envelope taskEnvelope) (string, error) { + if envelope.GatewayURL == "" || envelope.SessionID == "" || envelope.LeaseSecret == "" { + return "", fmt.Errorf("host update task is missing gateway/session/lease transport context") + } + expected := strings.TrimSpace(stringValue(envelope.Payload, "expected_sha256", "")) + currentDigest, err := executableSHA256() + if err != nil { + return "", fmt.Errorf("hash current host executable: %w", err) + } + + artifactURL := strings.TrimRight(envelope.GatewayURL, "/") + "/v1/sessions/" + envelope.SessionID + "/artifacts/host-update" + download, err := fetchHostUpdateArtifact(ctx, artifactURL, envelope.EndpointID, envelope.LeaseSecret) + if err != nil { + return "", err + } + if expected != "" && !strings.EqualFold(expected, download.SHA256) { + return "", fmt.Errorf("gateway host artifact digest %s does not match requested %s; cut the gateway over to the target release first", download.SHA256, expected) + } + if strings.EqualFold(download.SHA256, currentDigest) { + return hostUpdateResult(download.SHA256, "up-to-date", "") + } + releaseDir, err := stageHostUpdateRelease(download) + if err != nil { + return "", err + } + if err := launchDetachedHostUpdater(releaseDir); err != nil { + return "", err + } + return hostUpdateResult(download.SHA256, "applied, service restarting", releaseDir) +} + +type hostUpdateArtifact struct { + Content []byte + SHA256 string +} + +func fetchHostUpdateArtifact(ctx context.Context, url, endpointID, leaseSecret string) (hostUpdateArtifact, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return hostUpdateArtifact{}, fmt.Errorf("build host update artifact request: %w", err) + } + request.Header.Set("Authorization", "Bearer "+leaseSecret) + request.Header.Set(hostUpdateEndpointIDHeader, endpointID) + response, err := hostUpdateHTTPClient.Do(request) + if err != nil { + return hostUpdateArtifact{}, fmt.Errorf("fetch host update artifact: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return hostUpdateArtifact{}, fmt.Errorf("fetch host update artifact: gateway responded %s", response.Status) + } + declared := strings.ToLower(strings.TrimSpace(response.Header.Get("X-Rdev-Sha256"))) + content, err := io.ReadAll(io.LimitReader(response.Body, hostUpdateMaxBytes)) + if err != nil { + return hostUpdateArtifact{}, fmt.Errorf("read host update artifact: %w", err) + } + if len(content) == 0 { + return hostUpdateArtifact{}, fmt.Errorf("host update artifact is empty") + } + sum := sha256.Sum256(content) + actual := hex.EncodeToString(sum[:]) + if declared != "" && !strings.EqualFold(declared, actual) { + return hostUpdateArtifact{}, fmt.Errorf("host update artifact digest mismatch: got %s want %s", actual, declared) + } + return hostUpdateArtifact{Content: content, SHA256: actual}, nil +} + +// stageHostUpdateRelease writes the verified artifact into a fresh digest-keyed +// release directory with the digest sidecar the detached updater re-verifies +// before activation. +func stageHostUpdateRelease(artifact hostUpdateArtifact) (string, error) { + releaseDir, err := os.MkdirTemp("", "rdev-host-update-*") + if err != nil { + return "", fmt.Errorf("create host update staging directory: %w", err) + } + binaryPath := filepath.Join(releaseDir, hostUpdateBinaryName) + if err := os.WriteFile(binaryPath, artifact.Content, 0o700); err != nil { + _ = os.RemoveAll(releaseDir) + return "", fmt.Errorf("stage host update binary: %w", err) + } + if err := os.WriteFile(filepath.Join(releaseDir, hostUpdateBinaryName+".sha256"), []byte(artifact.SHA256+"\n"), 0o600); err != nil { + _ = os.RemoveAll(releaseDir) + return "", fmt.Errorf("stage host update digest: %w", err) + } + return releaseDir, nil +} + +func executableSHA256() (string, error) { + executable, err := os.Executable() + if err != nil { + return "", err + } + content, err := os.ReadFile(executable) + if err != nil { + return "", err + } + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]), nil +} + +func hostUpdateResult(digest, status, releaseDir string) (string, error) { + result := map[string]any{ + "schema_version": hostUpdateResultSchema, + "status": status, + "sha256": digest, + "host_version": buildinfo.Version, + "host_commit": buildinfo.Commit, + "at": time.Now().UTC().Format(time.RFC3339), + } + if releaseDir != "" { + result["release_dir"] = releaseDir + } + content, err := json.MarshalIndent(result, "", " ") + if err != nil { + return "", err + } + return string(content), nil +} diff --git a/internal/hostrunner/host_update_other.go b/internal/hostrunner/host_update_other.go new file mode 100644 index 00000000..02122929 --- /dev/null +++ b/internal/hostrunner/host_update_other.go @@ -0,0 +1,16 @@ +//go:build !windows + +package hostrunner + +import "fmt" + +// hostUpdateEndpointIDHeader must match the gateway's +// httpapi.hostUpdateEndpointHeader. +const hostUpdateEndpointIDHeader = "X-Rdev-Endpoint-Id" + +// launchDetachedHostUpdater rejects non-Windows hosts: host updates apply to +// the Windows managed service, and this stub keeps the adapter honest on +// other platforms (the download/staging steps are still exercised by tests). +func launchDetachedHostUpdater(releaseDir string) error { + return fmt.Errorf("host updater is only supported on Windows") +} diff --git a/internal/hostrunner/host_update_test.go b/internal/hostrunner/host_update_test.go new file mode 100644 index 00000000..a46801d8 --- /dev/null +++ b/internal/hostrunner/host_update_test.go @@ -0,0 +1,222 @@ +package hostrunner + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/EitanWong/remote-dev-skillkit/internal/model" +) + +// TestHostUpdateAdapterRequiresCapabilityButNotWorkspace pins the two +// preflight behaviors that make host-update usable as a control-plane +// operation: it demands the host.update capability, and it is exempt from the +// workspace_root requirement that every coding adapter enforces. +func TestHostUpdateAdapterRequiresCapabilityButNotWorkspace(t *testing.T) { + now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC) + + noCapability := SessionTaskSpec{ + TaskID: "task-host-update", + EndpointID: "endpoint_target", + Adapter: "host-update", + Intent: "host update smoke", + SessionID: "ses_update", + LeaseSecret: "lease_update", + GatewayURL: "https://gateway.example.test", + Capabilities: []string{}, + Limits: model.TaskLimits{MaxDurationSeconds: 60, MaxOutputBytes: 4096}, + Payload: map[string]any{}, + } + _, err := RunSessionTaskWithOptionsContext(context.Background(), noCapability, now, Options{}) + var denial DenialError + if !errors.As(err, &denial) { + t.Fatalf("expected DenialError for missing capability, got %T %v", err, err) + } + if denial.Explanation.Code != "missing_capability" || denial.Explanation.Capability != "host.update" { + t.Fatalf("expected host.update capability denial: %#v", denial.Explanation) + } + + // With the capability and no workspace_root the preflight must pass; the + // platform launcher gate then rejects the actual update on this host. + withCapability := noCapability + withCapability.Capabilities = []string{"host.update"} + withCapability.GatewayURL = artifactServer(t, "MZ-different-host-binary").URL + _, err = RunSessionTaskWithOptionsContext(context.Background(), withCapability, now, Options{}) + if err == nil || !strings.Contains(err.Error(), "host updater is only supported on Windows") { + t.Fatalf("expected platform launcher gate error, got %v", err) + } + + // A configured workspace lock store must not trip a workspace-less + // host-update task: there is no repo root to serialize on. + lockStore := filepath.Join(t.TempDir(), "locks") + _, err = RunSessionTaskWithOptionsContext(context.Background(), withCapability, now, Options{WorkspaceLockStore: lockStore}) + if err == nil || !strings.Contains(err.Error(), "host updater is only supported on Windows") { + t.Fatalf("workspace lock store must be skipped for host-update, got %v", err) + } +} + +// TestExecuteHostUpdateFlow covers the adapter's decision branches: transport +// context validation, expected-digest pinning, idempotent up-to-date short +// circuit, and the apply path that stages then launches the updater. +func TestExecuteHostUpdateFlow(t *testing.T) { + current, err := os.Executable() + if err != nil { + t.Fatal(err) + } + currentBytes, err := os.ReadFile(current) + if err != nil { + t.Fatal(err) + } + currentSum := sha256.Sum256(currentBytes) + currentDigest := hex.EncodeToString(currentSum[:]) + + envelope := taskEnvelope{ + TaskID: "task-host-update", + EndpointID: "endpoint_target", + SessionID: "ses_update", + LeaseSecret: "lease_update", + GatewayURL: "https://gateway.example.test", + Payload: map[string]any{}, + } + + // Missing transport context fails closed. + noContext := envelope + noContext.GatewayURL = "" + noContext.SessionID = "" + noContext.LeaseSecret = "" + if _, err := executeHostUpdate(context.Background(), noContext); err == nil || !strings.Contains(err.Error(), "transport context") { + t.Fatalf("expected transport context error, got %v", err) + } + + // The served artifact is the running connector: up-to-date, no updater. + envelope.GatewayURL = artifactServer(t, string(currentBytes)).URL + result, err := executeHostUpdate(context.Background(), envelope) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result, `"status": "up-to-date"`) || !strings.Contains(result, currentDigest) { + t.Fatalf("up-to-date result = %s", result) + } + + // Pinning an expected digest the gateway does not serve aborts the apply. + pinned := envelope + pinned.Payload = map[string]any{"expected_sha256": strings.Repeat("ab", 32)} + if _, err := executeHostUpdate(context.Background(), pinned); err == nil || !strings.Contains(err.Error(), "does not match requested") { + t.Fatalf("expected digest pin mismatch error, got %v", err) + } + + // A different artifact passes verification and reaches the launcher gate. + envelope.GatewayURL = artifactServer(t, "MZ-different-host-binary").URL + if _, err := executeHostUpdate(context.Background(), envelope); err == nil || !strings.Contains(err.Error(), "host updater is only supported on Windows") { + t.Fatalf("expected launcher gate error after staging, got %v", err) + } +} + +// artifactServer serves the given bytes as the host connector under the +// endpoint lease header the adapter sends. +func artifactServer(t *testing.T, content string) *httptest.Server { + t.Helper() + sum := sha256.Sum256([]byte(content)) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer lease_update" || r.Header.Get(hostUpdateEndpointIDHeader) != "endpoint_target" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-Rdev-Sha256", hex.EncodeToString(sum[:])) + _, _ = w.Write([]byte(content)) + })) + t.Cleanup(server.Close) + return server +} + +// TestFetchHostUpdateArtifactVerifiesDigest ensures the downloaded connector +// is checked against the gateway-declared digest before staging, and that a +// mismatched declaration aborts the update. +func TestFetchHostUpdateArtifactVerifiesDigest(t *testing.T) { + content := []byte("MZ-rdev-host-update") + sum := sha256.Sum256(content) + digest := hex.EncodeToString(sum[:]) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer lease_secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if r.Header.Get(hostUpdateEndpointIDHeader) != "endpoint_target" { + w.WriteHeader(http.StatusForbidden) + return + } + w.Header().Set("X-Rdev-Sha256", digest) + _, _ = w.Write(content) + })) + defer server.Close() + + artifact, err := fetchHostUpdateArtifact(context.Background(), server.URL, "endpoint_target", "lease_secret") + if err != nil { + t.Fatal(err) + } + if artifact.SHA256 != digest || string(artifact.Content) != string(content) { + t.Fatalf("artifact = %s %d bytes", artifact.SHA256, len(artifact.Content)) + } + + badServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Rdev-Sha256", strings.Repeat("0", 64)) + _, _ = w.Write(content) + })) + defer badServer.Close() + if _, err := fetchHostUpdateArtifact(context.Background(), badServer.URL, "endpoint_target", "lease_secret"); err == nil { + t.Fatal("digest mismatch must abort the fetch") + } +} + +// TestStageHostUpdateReleaseWritesDigestSidecar ensures the detached updater +// can re-verify the staged binary before activation. +func TestStageHostUpdateReleaseWritesDigestSidecar(t *testing.T) { + content := []byte("MZ-staged-release") + sum := sha256.Sum256(content) + digest := hex.EncodeToString(sum[:]) + releaseDir, err := stageHostUpdateRelease(hostUpdateArtifact{Content: content, SHA256: digest}) + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(releaseDir) + staged, err := os.ReadFile(filepath.Join(releaseDir, hostUpdateBinaryName)) + if err != nil { + t.Fatal(err) + } + if string(staged) != string(content) { + t.Fatalf("staged binary = %q", staged) + } + sidecar, err := os.ReadFile(filepath.Join(releaseDir, hostUpdateBinaryName+".sha256")) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(sidecar)) != digest { + t.Fatalf("digest sidecar = %q want %q", sidecar, digest) + } +} + +// TestHostUpdateResultArtifactSchema ensures the posted task result carries +// the fields an operator needs to verify the update from the control plane. +func TestHostUpdateResultArtifactSchema(t *testing.T) { + content, err := hostUpdateResult("abc123", "applied, service restarting", "C:\\staging\\release") + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(content), &decoded); err != nil { + t.Fatal(err) + } + if decoded["schema_version"] != hostUpdateResultSchema || decoded["status"] != "applied, service restarting" || decoded["sha256"] != "abc123" { + t.Fatalf("unexpected result artifact: %s", content) + } +} diff --git a/internal/hostrunner/host_update_windows.go b/internal/hostrunner/host_update_windows.go new file mode 100644 index 00000000..dae408b2 --- /dev/null +++ b/internal/hostrunner/host_update_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package hostrunner + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "golang.org/x/sys/windows" +) + +// hostUpdateEndpointIDHeader must match the gateway's +// httpapi.hostUpdateEndpointHeader. +const hostUpdateEndpointIDHeader = "X-Rdev-Endpoint-Id" + +// launchDetachedHostUpdater starts `rdev-host service update` as a fully +// detached process: the service stop performed by the updater kills this +// parent process, so the updater must survive without it. +func launchDetachedHostUpdater(releaseDir string) error { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("locate host executable: %w", err) + } + command := exec.Command(executable, "service", "update", "--release", releaseDir) + command.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, + } + if err := command.Start(); err != nil { + return fmt.Errorf("start detached host updater: %w", err) + } + _ = command.Process.Release() + return nil +} diff --git a/internal/hostrunner/runner.go b/internal/hostrunner/runner.go index f990d11c..6822bb99 100644 --- a/internal/hostrunner/runner.go +++ b/internal/hostrunner/runner.go @@ -44,6 +44,12 @@ type SessionTaskSpec struct { Capabilities []string Limits model.TaskLimits Payload map[string]any + // SessionID, LeaseSecret, and GatewayURL are transport context the host + // already holds for task fetch/append; adapters such as host-update reuse + // them to fetch gateway-served artifacts under the endpoint lease. + SessionID string + LeaseSecret string + GatewayURL string } type taskEnvelope struct { @@ -59,6 +65,9 @@ type taskEnvelope struct { Limits model.TaskLimits Payload map[string]any InterruptsRequired []string + SessionID string + LeaseSecret string + GatewayURL string } type taskRef struct { @@ -72,11 +81,14 @@ type DenialExplanation struct { Code string `json:"code"` Summary string `json:"summary"` Detail string `json:"detail,omitempty"` - TaskID string `json:"task_id,omitempty"` - EndpointID string `json:"endpoint_id,omitempty"` - Adapter string `json:"adapter,omitempty"` - Capability string `json:"capability,omitempty"` - Retryable bool `json:"retryable"` + // Hint is an actionable, agent-directed fix for the denial: the exact + // field to add or change so a retry succeeds without trial and error. + Hint string `json:"hint,omitempty"` + TaskID string `json:"task_id,omitempty"` + EndpointID string `json:"endpoint_id,omitempty"` + Adapter string `json:"adapter,omitempty"` + Capability string `json:"capability,omitempty"` + Retryable bool `json:"retryable"` } type DenialError struct { @@ -119,11 +131,12 @@ func RunSessionTaskWithOptionsContext(ctx context.Context, spec SessionTaskSpec, Retryable: true, }, fmt.Errorf("unsupported dev adapter %q", envelope.Adapter)) } - if envelope.Workspace.Root == "" { + if envelope.Workspace.Root == "" && adapterRequiresWorkspace(envelope.Adapter) { return denyTask(ref, denialSpec{ Code: "workspace_required", Summary: "Workspace root is required for adapter execution.", Detail: "Host adapters only run inside an explicit workspace root.", + Hint: "Add an absolute workspace_root to the task payload (required for every adapter, e.g. C:\\Users\\Public on Windows).", Adapter: envelope.Adapter, Retryable: true, }, fmt.Errorf("workspace root is required")) @@ -133,6 +146,7 @@ func RunSessionTaskWithOptionsContext(ctx context.Context, spec SessionTaskSpec, Code: "missing_capability", Summary: fmt.Sprintf("Task is missing the %s capability.", missing), Detail: fmt.Sprintf("The host requires %s before running the %s adapter.", missing, envelope.Adapter), + Hint: fmt.Sprintf("Add %q to the task-level capabilities list (the session already authorizes it).", missing), Adapter: envelope.Adapter, Capability: missing, Retryable: true, @@ -167,6 +181,7 @@ func RunSessionTaskWithOptionsContext(ctx context.Context, spec SessionTaskSpec, Code: "missing_capability", Summary: fmt.Sprintf("Task is missing the %s capability.", missing), Detail: "Git worktree isolation requires git.diff before host-local Git commands may run.", + Hint: fmt.Sprintf("Add %q to the task-level capabilities list.", missing), Adapter: envelope.Adapter, Capability: missing, Retryable: true, @@ -386,6 +401,9 @@ func sessionTaskEnvelope(spec SessionTaskSpec, now time.Time) taskEnvelope { Limits: limits, Payload: cloneMap(spec.Payload), InterruptsRequired: stringSliceValue(spec.Payload, "interrupts_required"), + SessionID: spec.SessionID, + LeaseSecret: spec.LeaseSecret, + GatewayURL: spec.GatewayURL, } } @@ -402,13 +420,26 @@ func cloneMap(source map[string]any) map[string]any { func supportedAdapter(adapter string) bool { switch adapter { - case "shell", "powershell", "codex", "claude-code", "acpx", "toolchain", "file", "desktop": + case "shell", "powershell", "codex", "claude-code", "acpx", "toolchain", "file", "desktop", "host-update": return true default: return false } } +// adapterRequiresWorkspace reports whether the adapter executes inside a +// workspace root. Control-plane adapters such as host-update operate on the +// host service itself and must not demand a workspace the operator cannot +// meaningfully provide. +func adapterRequiresWorkspace(adapter string) bool { + switch adapter { + case "host-update": + return false + default: + return true + } +} + func missingAdapterCapability(envelope taskEnvelope) string { switch envelope.Adapter { case "shell": @@ -446,6 +477,10 @@ func missingAdapterCapability(envelope taskEnvelope) string { } case "file": return missingFileCapability(envelope) + case "host-update": + if !hasCapability(envelope.Capabilities, "host.update") { + return "host.update" + } case "desktop": return missingDesktopCapability(envelope) } @@ -548,7 +583,9 @@ func normalizeAdapterAction(action string) string { } func acquireWorkspaceLock(hostID string, envelope taskEnvelope, opts Options, now time.Time) (func(), error) { - if strings.TrimSpace(opts.WorkspaceLockStore) == "" { + if strings.TrimSpace(opts.WorkspaceLockStore) == "" || strings.TrimSpace(envelope.Workspace.Root) == "" { + // No lock store, or the adapter is workspace-less (e.g. host-update): + // there is nothing to serialize. return func() {}, nil } ttl := opts.WorkspaceLockTTL @@ -689,6 +726,7 @@ type denialSpec struct { Code string Summary string Detail string + Hint string Adapter string Capability string Retryable bool @@ -700,6 +738,7 @@ func denyTask(task taskRef, spec denialSpec, cause error) (Result, error) { Code: spec.Code, Summary: spec.Summary, Detail: spec.Detail, + Hint: spec.Hint, TaskID: task.TaskID, EndpointID: task.EndpointID, Adapter: firstNonEmptyString(spec.Adapter, task.Adapter), @@ -888,6 +927,7 @@ func shellDenial(err error) (denialSpec, bool) { Code: "command_not_allowlisted", Summary: "Shell command is not allowlisted.", Detail: err.Error(), + Hint: "Add the executable name (e.g. sh, cmd, powershell.exe, pwsh) to payload.allow_commands.", Adapter: "shell", Retryable: true, }, true @@ -896,6 +936,7 @@ func shellDenial(err error) (denialSpec, bool) { Code: "workspace_escape", Summary: "Requested write scope escapes the workspace root.", Detail: err.Error(), + Hint: "Keep payload.write_scope inside workspace_root.", Adapter: "shell", Retryable: true, }, true @@ -904,6 +945,7 @@ func shellDenial(err error) (denialSpec, bool) { Code: "workspace_required", Summary: "Workspace root is required for shell execution.", Detail: err.Error(), + Hint: "Add an absolute workspace_root to the task payload.", Adapter: "shell", Retryable: true, }, true @@ -912,6 +954,7 @@ func shellDenial(err error) (denialSpec, bool) { Code: "workspace_invalid", Summary: "Workspace root is invalid.", Detail: err.Error(), + Hint: "workspace_root must be an absolute path (e.g. C:\\Users\\Public on Windows).", Adapter: "shell", Retryable: true, }, true @@ -928,6 +971,7 @@ func powershellDenial(err error) (denialSpec, bool) { Code: "command_not_allowlisted", Summary: "PowerShell executable is not allowlisted.", Detail: err.Error(), + Hint: "Add the PowerShell executable name (powershell.exe or pwsh) to payload.allow_commands; on Windows also set payload.powershell_command to the bare executable name.", Adapter: "powershell", Retryable: true, }, true @@ -936,6 +980,7 @@ func powershellDenial(err error) (denialSpec, bool) { Code: "workspace_escape", Summary: "Requested write scope escapes the workspace root.", Detail: err.Error(), + Hint: "Keep payload.write_scope inside workspace_root.", Adapter: "powershell", Retryable: true, }, true @@ -944,6 +989,7 @@ func powershellDenial(err error) (denialSpec, bool) { Code: "workspace_required", Summary: "Workspace root is required for PowerShell execution.", Detail: err.Error(), + Hint: "Add an absolute workspace_root to the task payload.", Adapter: "powershell", Retryable: true, }, true @@ -952,6 +998,7 @@ func powershellDenial(err error) (denialSpec, bool) { Code: "workspace_invalid", Summary: "Workspace root is invalid.", Detail: err.Error(), + Hint: "workspace_root must be an absolute path (e.g. C:\\Users\\Public on Windows).", Adapter: "powershell", Retryable: true, }, true diff --git a/internal/hostrunner/runner_test.go b/internal/hostrunner/runner_test.go index d8225d78..b0e4bf60 100644 --- a/internal/hostrunner/runner_test.go +++ b/internal/hostrunner/runner_test.go @@ -225,3 +225,50 @@ func assertDenial(t *testing.T, result Result, err error, code string) { t.Fatalf("expected denial artifact, got %s", result.ArtifactContent) } } + +// TestPreflightDenialsCarryAgentActionableHints pins the fix for the +// trial-and-error retry loop observed against a live Windows host: every +// common preflight denial must tell the Agent exactly which field to add, so +// a retry succeeds instead of guessing one missing field per round trip. +func TestPreflightDenialsCarryAgentActionableHints(t *testing.T) { + now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC) + repo := t.TempDir() + + // workspace_required: every adapter needs an absolute workspace_root. + noRoot := shellSessionTask(repo, []string{"shell.user"}) + noRoot.Workspace.Root = "" + _, err := RunSessionTaskWithOptionsContext(context.Background(), noRoot, now, Options{}) + var denial DenialError + if !errors.As(err, &denial) { + t.Fatalf("expected DenialError, got %T %v", err, err) + } + if denial.Explanation.Code != "workspace_required" || denial.Explanation.Hint == "" { + t.Fatalf("workspace_required denial must carry a hint: %#v", denial.Explanation) + } + + // missing_capability: hint names the exact capability to add. + noCap := shellSessionTask(repo, nil) + _, err = RunSessionTaskWithOptionsContext(context.Background(), noCap, now, Options{}) + if !errors.As(err, &denial) { + t.Fatalf("expected DenialError, got %T %v", err, err) + } + if denial.Explanation.Code != "missing_capability" || !strings.Contains(denial.Explanation.Hint, "shell.user") { + t.Fatalf("missing_capability denial must name the capability in its hint: %#v", denial.Explanation) + } + + // command_not_allowlisted: the executable-name hint must cover Windows + // PowerShell, whose allowlist additionally needs the bare executable name + // (LookPath-resolved full paths fail exact match). + for _, mapErr := range []struct { + name string + fn func(error) (denialSpec, bool) + }{{"shell", shellDenial}, {"powershell", powershellDenial}} { + spec, ok := mapErr.fn(errors.New("command \"powershell.exe\" is not allowlisted")) + if !ok || spec.Code != "command_not_allowlisted" || spec.Hint == "" { + t.Fatalf("%s not-allowlisted denial must carry a hint: %#v ok=%v", mapErr.name, spec, ok) + } + if mapErr.name == "powershell" && !strings.Contains(spec.Hint, "powershell_command") { + t.Fatalf("powershell not-allowlisted hint must mention powershell_command: %#v", spec) + } + } +} diff --git a/internal/hostrunner/runtime.go b/internal/hostrunner/runtime.go index a4072716..8eccf2c0 100644 --- a/internal/hostrunner/runtime.go +++ b/internal/hostrunner/runtime.go @@ -286,6 +286,12 @@ func executeJobAdapterDirectWithToolchainRoot(ctx context.Context, envelope task MaxOutputBytes: envelope.Limits.MaxOutputBytes, }) return execution.ArtifactContent(), err + case "host-update": + content, err := executeHostUpdate(ctx, envelope) + if err != nil { + return "", err + } + return content, nil default: execution, err := shelladapter.ExecuteContext(ctx, shelladapter.Spec{ WorkspaceRoot: envelope.Workspace.Root, diff --git a/internal/httpapi/host_update.go b/internal/httpapi/host_update.go new file mode 100644 index 00000000..2eb3ddb5 --- /dev/null +++ b/internal/httpapi/host_update.go @@ -0,0 +1,36 @@ +package httpapi + +import ( + "net/http" + "strconv" +) + +// hostUpdateEndpointHeader carries the endpoint ID of the requesting target. +// It pairs with the endpoint lease bearer token; together they are the same +// credentials the host already uses for task fetch and event append, so host +// update downloads introduce no new credential or ticket type. +const hostUpdateEndpointHeader = "X-Rdev-Endpoint-Id" + +// serveSessionHostUpdateArtifact serves the configured Windows host connector +// binary to the enrolled target of a session. Authorization is the endpoint +// lease; the response carries the artifact SHA-256 the host verifies before +// staging, and every download is recorded in the persisted audit log. +func (s Server) serveSessionHostUpdateArtifact(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + endpointID := r.Header.Get(hostUpdateEndpointHeader) + if err := s.Gateway.ValidateSessionLease(sessionID, endpointID, extractBearerToken(r)); err != nil { + writeControlPlaneError(w, err) + return + } + if len(s.webHandoff.windowsAMD64.Content) == 0 { + writeError(w, http.StatusServiceUnavailable, "windows-amd64 host artifact is not configured") + return + } + s.Gateway.AppendAudit("target", "session.host-update.fetch", endpointID, "endpoint fetched host update artifact") + writeWebHandoffSecurityHeaders(w) + w.Header().Set("Content-Type", "application/vnd.microsoft.portable-executable") + w.Header().Set("Content-Disposition", `attachment; filename="rdev-host.exe"`) + w.Header().Set("Content-Length", strconv.Itoa(len(s.webHandoff.windowsAMD64.Content))) + w.Header().Set("X-Rdev-Sha256", s.webHandoff.windowsAMD64.SHA256) + _, _ = w.Write(s.webHandoff.windowsAMD64.Content) +} diff --git a/internal/httpapi/host_update_test.go b/internal/httpapi/host_update_test.go new file mode 100644 index 00000000..b99e2bb0 --- /dev/null +++ b/internal/httpapi/host_update_test.go @@ -0,0 +1,87 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/EitanWong/remote-dev-skillkit/internal/gateway" +) + +// TestSessionHostUpdateArtifactServedUnderEndpointLease pins the host-update +// artifact route: the configured Windows connector is served only to the +// enrolled target under its endpoint lease, with digest metadata and an audit +// record, and no other credential can fetch it. +func TestSessionHostUpdateArtifactServedUnderEndpointLease(t *testing.T) { + asset, err := NewWindowsAMD64WebHandoffAsset("rdev-host.exe", []byte("MZ-host-update-fixture")) + if err != nil { + t.Fatal(err) + } + gw := gateway.NewMemoryGateway() + server, err := NewServerWithWebHandoff(gw, WebHandoffOptions{ + PublicBaseURL: "https://remote.example.test", + WindowsAMD64: asset, + }) + if err != nil { + t.Fatal(err) + } + handler := server.Handler() + created := createHTTPSession(t, handler) + joined := joinHTTPSession(t, handler, created.Session.JoinCode) + path := "/v1/sessions/" + url.PathEscape(created.Session.ID) + "/artifacts/host-update" + + // Missing or mismatched lease is rejected before any bytes leave. + unauthorized := []struct { + name string + endpointID string + secret string + }{ + {"no-credentials", joined.Endpoint.ID, ""}, + {"wrong-endpoint", "end_wrong", joined.Lease.Secret}, + } + for _, probe := range unauthorized { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set(hostUpdateEndpointHeader, probe.endpointID) + if probe.secret != "" { + req.Header.Set("Authorization", "Bearer "+probe.secret) + } + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusNotFound && rec.Code != http.StatusForbidden { + t.Fatalf("%s: status = %d body=%s", probe.name, rec.Code, rec.Body.String()) + } + if rec.Code == http.StatusOK { + t.Fatalf("%s: unauthorized fetch must not serve the artifact", probe.name) + } + } + + // The enrolled target under its lease receives the exact artifact. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", "Bearer "+joined.Lease.Secret) + req.Header.Set(hostUpdateEndpointHeader, joined.Endpoint.ID) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("authorized fetch status = %d body=%s", rec.Code, rec.Body.String()) + } + if rec.Body.String() != "MZ-host-update-fixture" { + t.Fatalf("artifact body = %q", rec.Body.String()) + } + if rec.Header().Get("X-Rdev-Sha256") != asset.SHA256 { + t.Fatalf("digest header = %q want %q", rec.Header().Get("X-Rdev-Sha256"), asset.SHA256) + } + if rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("artifact response must not be cached: %#v", rec.Header()) + } + + audited := false + for _, event := range gw.AuditEvents() { + if event.Action == "session.host-update.fetch" && event.TargetID == joined.Endpoint.ID { + audited = true + } + } + if !audited { + t.Fatalf("host-update artifact fetch must be audited: %#v", gw.AuditEvents()) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 6fa64723..d1ade921 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -114,6 +114,7 @@ func (s Server) Handler() http.Handler { mux.HandleFunc("GET /v1/sessions/", s.sessionRoute) mux.HandleFunc("POST /v1/sessions/", s.sessionRoute) + mux.HandleFunc("GET /v1/sessions/{id}/artifacts/host-update", s.serveSessionHostUpdateArtifact) mux.HandleFunc("GET /v1/audit", s.listAudit) mux.HandleFunc("GET /v1/hosts", s.listHosts) mux.HandleFunc("POST /v1/hosts/rename", s.renameHost) diff --git a/mcp/tools.json b/mcp/tools.json index 436b1cf5..a3bae1e5 100644 --- a/mcp/tools.json +++ b/mcp/tools.json @@ -397,6 +397,7 @@ "required_capabilities": [ "shell.user" ], + "workspace_root_required": true, "payload_example": { "allow_commands": [ "go" @@ -405,7 +406,8 @@ "go", "test", "./..." - ] + ], + "workspace_root": "C:\\workspace\\repo" } }, { @@ -414,11 +416,14 @@ "required_capabilities": [ "powershell.user" ], + "workspace_root_required": true, "payload_example": { "allow_commands": [ - "dotnet" + "powershell.exe" ], - "command": "dotnet test" + "command": "dotnet test", + "powershell_command": "powershell.exe", + "workspace_root": "C:\\workspace\\repo" } }, { @@ -428,6 +433,7 @@ "codex.run", "git.diff" ], + "workspace_root_required": true, "payload_example": { "allow_verification_commands": [ "go" @@ -439,7 +445,8 @@ "test", "./..." ] - ] + ], + "workspace_root": "C:\\workspace\\repo" } }, { @@ -449,6 +456,7 @@ "claude-code.run", "git.diff" ], + "workspace_root_required": true, "payload_example": { "allow_verification_commands": [ "go" @@ -460,7 +468,8 @@ "test", "./..." ] - ] + ], + "workspace_root": "C:\\workspace\\repo" } }, { @@ -470,6 +479,7 @@ "acpx.run", "git.diff" ], + "workspace_root_required": true, "payload_example": { "allow_verification_commands": [ "go" @@ -481,7 +491,8 @@ "test", "./..." ] - ] + ], + "workspace_root": "C:\\workspace\\repo" } }, { @@ -490,10 +501,12 @@ "required_capabilities": [ "file.transfer.read" ], + "workspace_root_required": true, "payload_example": { "action": "read", "chunk_bytes": 4096, - "path": "README.md" + "path": "README.md", + "workspace_root": "C:\\workspace\\repo" } }, { @@ -502,8 +515,21 @@ "required_capabilities": [ "window.inspect" ], + "workspace_root_required": true, + "payload_example": { + "action": "window.inspect", + "workspace_root": "C:\\workspace\\repo" + } + }, + { + "adapter": "host-update", + "schema_version": "rdev.host-update-result.v1", + "required_capabilities": [ + "host.update" + ], + "workspace_root_required": false, "payload_example": { - "action": "window.inspect" + "expected_sha256": "" } } ] diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 00000000..585e69e2 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Build the release bundle used for gateway rollouts and host updates. +# Produces /tmp/rdev-release-/{rdev-gateway,rdev,rdev-host.exe,SHA256SUMS} +# Usage: scripts/build-release.sh +set -euo pipefail + +cd "$(dirname "$0")/.." +FULL_SHA=$(git rev-parse HEAD) +BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) +OUT="/tmp/rdev-release-${FULL_SHA:0:7}" +rm -rf "$OUT" +mkdir -p "$OUT" + +build() { # build + local goos=$4 + local ldflags="-s -w -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Version=0.0.1-dev -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.Commit=${FULL_SHA} -X github.com/EitanWong/remote-dev-skillkit/internal/buildinfo.BuildTime=${BUILD_TIME}" + GOOS="$goos" GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "$ldflags" -o "$OUT/$3" "./$2" +} + +build gateway cmd/rdev-gateway rdev-gateway linux +build rdev cmd/rdev rdev linux +build host cmd/rdev-host rdev-host.exe windows + +# Hard gate: the Windows artifact must be a PE image, not an ELF. A silently +# wrong GOOS produced an ELF named rdev-host.exe once; on Windows that fails +# at service start and the host update rolls back forever. +if ! file "$OUT/rdev-host.exe" | grep -q "PE32+"; then + echo "FATAL: rdev-host.exe is not a PE32+ image" >&2 + exit 1 +fi + +(cd "$OUT" && sha256sum rdev-gateway rdev rdev-host.exe > SHA256SUMS && cat SHA256SUMS) +"$OUT/rdev" version +echo "RELEASE_READY $OUT"