diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index 62301705..45ac894e 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -173,6 +173,61 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada } }); +test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async () => { + let dispatchedBody = null; + const cloudApi = createServer(async (request, response) => { + dispatchedBody = await requestJson(request); + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ + ok: true, + result: { + status: "completed", + dispatch: { shellExecuted: true, dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 }, + auditId: "aud_options", + evidenceId: "ev_options" + } + })); + }); + await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); + const cloudPort = cloudApi.address().port; + const service = await startDevicePod("device-pod-test", { + HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` + }); + + try { + const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { + method: "POST", + headers: { "content-type": "application/json", "x-hwlab-internal-service": "hwlab-cloud-api" }, + body: JSON.stringify({ + jobId: "job_options_test", + intent: "debug.download", + args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 }, + profileHash: "sha256:profile", + profile: { + devicePodId: "device-pod-test", + target: { id: "target-test" }, + route: { gatewaySessionId: "gws_test", resourceId: "res_test", capabilityId: "cap_test", hostCli: "node tools/device-host-cli.mjs" } + } + }) + }); + assert.equal(jobResponse.status, 202); + await waitForJobStatus(service.port, "device-pod-test", "job_options_test", "completed"); + const command = dispatchedBody.params.input.command; + const encoded = command.match(/"([A-Za-z0-9+/=]+)"$/u)[1]; + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + assert.deepEqual(decoded.args, [ + "debug-probe", "download", "start", + "--capture-uart", "uart/1", + "--capture-duration-ms", "8000", + "--port", "COM4", + "--baud-rate", "921600" + ]); + } finally { + await service.stop(); + await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); + } +}); + async function startDevicePod(devicePodId, extraEnv = {}) { const port = await freePort(); const child = spawn(bunCommand, ["run", "cmd/hwlab-device-pod/main.ts"], { diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index 7910740e..b0e4a675 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -399,20 +399,38 @@ function deviceHostArgs(intent, args = {}) { } if (intent === "workspace.rg") { const pattern = textOr(args.pattern ?? args.query, ""); - return pattern ? ["workspace", "rg", pattern, textOr(args.path, ".")] : null; + return pattern ? ["workspace", "rg", pattern, textOr(args.path, "."), ...hostOptionArgs(args, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])] : null; } if (intent === "workspace.apply-patch") return ["workspace", "apply-patch", textOr(args.base, "."), "--patch-b64", patchBase64(args)]; - if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start")]; + if (intent === "workspace.build") return ["workspace", "build", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs"]), ...jobIdArgs(args)]; if (intent === "debug.status") return ["debug-probe", "status"]; if (intent === "debug.chip-id") return ["debug-probe", "chip-id"]; - if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start")]; + if (intent === "debug.download") return ["debug-probe", "download", textOr(args.action, "start"), ...hostOptionArgs(args, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"]), ...jobIdArgs(args)]; if (intent === "debug.reset") return ["debug-probe", "reset"]; if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"]; - if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000))]; - if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), textOr(args.message ?? args.text ?? args.data, "")]; + if (intent === "io.uart.read") return ["io-probe", textOr(args.uartId, "uart/1"), "read", "--duration-ms", String(numberOr(args.durationMs ?? args["duration-ms"], 1000)), ...hostOptionArgs(args, ["port", "baudRate"])] ; + if (intent === "io.uart.read-after-launch-flash") return ["io-probe", textOr(args.uartId, "uart/1"), "read-after-launch-flash", ...hostOptionArgs(args, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])] ; + if (intent === "io.uart.write") return ["io-probe", textOr(args.uartId, "uart/1"), "write", ...(args.hex ? ["--hex"] : []), ...hostOptionArgs(args, ["port", "baudRate"]), textOr(args.message ?? args.text ?? args.data, "")]; return null; } +function jobIdArgs(args = {}) { + const jobId = textOr(args.jobId, ""); + return jobId ? [jobId] : []; +} + +function hostOptionArgs(args = {}, keys = []) { + const out = []; + for (const key of keys) { + const value = args[key]; + if (value === undefined || value === null || value === "" || value === false) continue; + const name = `--${key.replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`)}`; + if (value === true) out.push(name); + else out.push(name, String(value)); + } + return out; +} + function patchBase64(args = {}) { if (typeof args.patchB64 === "string") return args.patchB64; if (typeof args["patch-b64"] === "string") return args["patch-b64"]; diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 4dbac0ef..b182741e 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -26,7 +26,7 @@ device-pod-cli or cloud-web - 用最少组件把 `device-pod-cli` 从“本地 profile + RPC/gateway 调用”迁到“1:1 REST 请求”。 - `cloud-api` 是用户身份、device grant、profile authority 和 lease 判断入口。 - `hwlab-device-pod` 承接设备业务:profile 校验后的运行、job 生命周期、freshness、blocker、bounded output 和 gateway 调用。 -- `device-pod-cli` 只做 selector 解析、REST 请求和 JSON 输出,不再保存或上传权威 profile。 +- `device-pod-cli` 只做 selector 解析、cloud-api REST 请求和 JSON 输出;默认正式模式不读取 `.device-pod/*.json`,不保存、不上传、不修改权威 profile。 - 第一阶段只部署一个 `hwlab-device-pod` Deployment/Service,管理多个逻辑 `devicePodId`,避免为每台设备创建独立 k8s Service/Deployment。 - 普通用户和 code agent session 不获得 Kubernetes 用户、Service 直连权限、gateway route 或 host workspace route。 @@ -200,6 +200,7 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId} - `debug.reset` - `io.ports` - `io.uart.read` +- `io.uart.read-after-launch-flash` - `io.uart.write` 所有响应必须包含 `devicePodId`、`targetId`、`profileHash`、`traceId`、`operationId`、`status`、`freshness`、`blocker` 和 bounded output metadata。真实硬件响应不得把 fake、dry-run、SOURCE、LOCAL 或过期缓存标为 `DEV-LIVE`。 @@ -210,7 +211,7 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId} | --- | --- | | `hwlab-cloud-api` | 用户身份、admin/user、device grant、lease、profile authority、用户态 REST API、转发到内部 device-pod。 | | `hwlab-device-pod` | 多 `devicePodId` 运行 registry、profile runtime validation、job store、freshness、bounded output、gateway/device-host-cli adapter。 | -| `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不直连 gateway。 | +| `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不读取本地 profile 作为默认 authority、不直连 gateway。 | | `device-host-cli` | Windows host 侧自包含业务工具,负责 Keil、pyOCD、UART、workspace 文件操作。 | | `hwlab-gateway` | 只做受控 transport,不理解用户权限和 device-pod 授权。 | | `hwlab-cloud-web` | 展示用户可见 device pod、admin 管理 profile/grant、显示 job/status/freshness。 | @@ -248,6 +249,15 @@ manages: many devicePodId - `POST /v1/device-pods/{devicePodId}/leases` 只对已授权 actor 创建或刷新互斥租约,响应只返回一次性 `leaseToken`;后续强副作用 job 必须通过 `leaseToken` 或 `x-hwlab-device-lease-token` 证明持有租约。 - 撤销 device pod grant 必须释放该用户对同一 `devicePodId` 的活动 lease。 +## CLI 实现口径 + +`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现,稳定 runner 入口仍是 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`。该 `.mjs` 只作为兼容启动器,通过 Bun 运行 TypeScript CLI。正式 CLI 的默认行为是: + +- `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。 +- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。 +- mutating job 继续由 cloud-api 侧强制 lease/reason;CLI 只转发 `reason` 和 `leaseToken`,不在本地绕过。 +- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。 + ## 测试规格 ## T1 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index 50fff68e..9e9b58b4 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -13,6 +13,7 @@ - `tools/hwlab-cli/bin/hwlab-cli.mjs` 是唯一 CLI bin 入口。 - `tools/hwlab-cli/lib/cli.mjs` 负责参数解析、JSON 输出、legacy cicd 拒绝、M3 Skill CLI 转发和 MVP e2e dry-run plan。 - `hwlab-cli-template` 是 `suspend: true` 的 Kubernetes Job template,运行时通过 env 注入 endpoint、commit 和 image metadata。 +- Device Pod 专用 CLI 是 `tools/device-pod-cli.ts`,稳定 skill wrapper 为 `skills/device-pod-cli/scripts/device-pod-cli.mjs`;它属于 device-pod 受控 REST 客户端,不是 `hwlab-cli` 的 CICD/运维入口。 ## API 接口说明 @@ -44,4 +45,5 @@ | legacy cicd 删除 | 已实现 | `cicd` 子命令返回明确错误。 | | v02 Job template | 已实现 | `hwlab-cli-template` 存在且 `suspend: true`。 | | live e2e 执行 | 未完全实现 | 当前 live path 在 blocker 或未实现时拒绝执行。 | +| Device Pod REST CLI | 已实现 | `device-pod-cli` 已迁到 Bun+TS 默认 REST authority;旧本地 profile/gateway RPC 路径不再作为正式默认入口。 | diff --git a/docs/reference/spec-v02-hwlab-device-pod-service.md b/docs/reference/spec-v02-hwlab-device-pod-service.md index 54440cda..8d14dedb 100644 --- a/docs/reference/spec-v02-hwlab-device-pod-service.md +++ b/docs/reference/spec-v02-hwlab-device-pod-service.md @@ -27,7 +27,7 @@ | `POST /v1/device-pods/{devicePodId}/jobs` | 只接受 `hwlab-cloud-api` 内部调用;创建内部 executor job 并返回 job、freshness、output/cancel URL;有可用 profile route 和 `HWLAB_CLOUD_API_INTERNAL_URL` 时通过 cloud-api gateway dispatch 下发到 device-host-cli,否则返回 `gateway_dispatch_unavailable`。 | | `GET /v1/device-pods/{devicePodId}/jobs/{jobId}`、`GET /output`、`POST /cancel` | 只接受 `hwlab-cloud-api` 内部调用,用于查询内部 job、bounded output 和取消非终态 job;普通用户仍必须走 cloud-api 用户态 API。 | -用户态 `POST /jobs`、job output/cancel 和 admin profile/grant API 由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。 +用户态 `POST /jobs`、job output/cancel、admin profile/grant API 和正式 `device-pod-cli` REST 调用由 `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。`hwlab-device-pod` 不接受 CLI、浏览器或 Code Agent 直接上传 profile snapshot。 ## 测试规格 @@ -48,4 +48,5 @@ | 正式 profile authority | 已在 cloud-api 实现 | `hwlab-device-pod` 不读取或覆盖 `device_pods.profile_json`。 | | job lifecycle | 部分实现 | 用户态 job 由 cloud-api 鉴权、授权、lease 校验和持久化;executor 侧已提供内部 job create/get/output/cancel lifecycle,并能把 job dispatch 结果写回 bounded output。 | | gateway/device-host-cli adapter | 部分实现 | executor 已通过 cloud-api internal dispatch 接入 gateway poll/result 和 device-host-cli 命令映射;真实执行仍依赖 profile route、在线 gateway 和 host CLI。 | +| device-pod-cli REST authority | 已实现 | `tools/device-pod-cli.ts` 默认只走 cloud-api REST,不读取 `.device-pod/*.json` 作为 profile authority;旧 `.mjs` 入口只负责启动 TypeScript CLI。 | diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 1545b5e2..da378a6f 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -19,6 +19,7 @@ const DEVICE_JOB_INTENTS = new Set([ "debug.reset", "io.ports", "io.uart.read", + "io.uart.read-after-launch-flash", "io.uart.write" ]); const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ @@ -111,6 +112,7 @@ const MUTATING_INTENTS = new Set([ "workspace.build", "debug.download", "debug.reset", + "io.uart.read-after-launch-flash", "io.uart.write" ]); const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60; diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index a76de297..8eecc9f9 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -77,8 +77,10 @@ export const checkProfiles = Object.freeze({ { id: "check-058-tools-hwlab-gateway-shell", group: "tools", command: ["node","--check","tools/hwlab-gateway-shell.mjs"] }, { id: "check-059-tools-hwlab-gateway-tran", group: "tools", command: ["node","--check","tools/hwlab-gateway-tran.mjs"] }, { id: "check-060-tools-tran", group: "tools", command: ["node","--check","tools/tran.mjs"] }, - { id: "check-061-tools-device-pod-cli", group: "tools", command: ["node","--check","tools/device-pod-cli.mjs"] }, - { id: "check-062-tools-device-pod-cli", group: "tools", command: ["node","--check","skills/device-pod-cli/scripts/device-pod-cli.mjs"] }, + { id: "check-061-tools-device-pod-cli", group: "tools", command: ["node","scripts/run-bun.mjs","build","tools/device-pod-cli.ts","tools/device-pod-cli.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, + { id: "check-061a-tools-device-pod-cli-test", group: "tools", command: ["node","scripts/run-bun.mjs","test","tools/device-pod-cli.test.ts"] }, + { id: "check-062-tools-device-pod-cli", group: "tools", command: ["node","--check","tools/device-pod-cli.mjs"] }, + { id: "check-062a-tools-device-pod-cli-skill-wrapper", group: "tools", command: ["node","--check","skills/device-pod-cli/scripts/device-pod-cli.mjs"] }, { id: "check-063-tools-device-host-cli", group: "tools", command: ["node","--check","skills/device-pod-cli/assets/device-host-cli.mjs"] }, { id: "check-064-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","scripts/cloud-api-runtime-smoke.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-065-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","scripts/code-agent-chat-smoke.mjs","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 7a4f5d9d..d5a6068e 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -1,447 +1,162 @@ --- name: device-pod-cli -description: Use the HWLAB internal device-pod CLI from a HWLAB code agent runner to create/register device-pod profiles and operate a profiled target workspace, debug probe, and I/O probe through hwlab-gateway and device-host-cli. -version: 0.1.0-mvp +description: Use the HWLAB v0.2 internal device-pod CLI from a HWLAB code agent runner to operate authorized Device Pods through cloud-api REST, leases, hwlab-device-pod, gateway, and device-host-cli. +version: 0.2.0-rest --- # Device Pod CLI Skill(cli-spec) -Scope: this skill is only for HWLAB-internal code agent runners inside the -HWLAB runtime, such as `/workspace/hwlab` sessions running from the G14 DEV or -PROD images. It does not apply to external UniDesk developer workspaces or -third-party machines; those environments must not treat this skill as a general -hardware control contract. +Scope: this skill is only for HWLAB-internal code agent runners inside the HWLAB runtime, such as `/workspace/hwlab` sessions running from the G14 DEV, PROD, or v0.2 images. It does not apply to external UniDesk developer workspaces or third-party machines. -Use this skill when the task mentions `device-pod-cli`, `device-pod`, -`device-host-cli`, HWLAB device-pod profile files, Keil build/download through a -device pod, debug probe control, or I/O probe reads such as UART boot logs. +Use this skill when the task mentions `device-pod-cli`, `device-pod`, `device-host-cli`, Keil build/download through a Device Pod, debug probe control, workspace operations, or I/O probe reads such as UART boot logs. -Canonical skill location: `/app/skills/device-pod-cli/SKILL.md`. Do not look -for or create duplicate skill copies under `/root/.agents/skills`, -`/home/ubuntu/.agents/skills`, or `/workspace/hwlab/skills`; those locations are -not the HWLAB runner contract and cause stale instructions. - -## DeepSeek Operation Friction Rules - -- For build, download, UART, and workspace actions, use `device-pod-cli` first. - Do not start by calling `/app/tools/hwlab-gateway-tran.mjs` directly, do not - hunt for Windows-side Keil skills, and do not call `keil-cli.py` directly when - a matching device-pod profile exists. -- When a profile, selector, host route, or upload step is ambiguous, run - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ` - before trying lower-level commands. Treat the `doctor` JSON as the source of - truth for profile path, profile hash, route, host profile path, selectors, and - the exact upload/host-health commands. -- The profile already carries `hostWorkspaceRoot`, `projectWorkspace`, - `debugInterface.uv4Path`, probe UID, UART port, and gateway route. Let - `device-pod-cli -> device-host-cli` apply those values; do not rediscover the - Windows project path with `dir`, `Get-ChildItem`, or ad hoc `cmd` probes. -- If a command returns `profile-missing`, run profile bootstrap. If it returns a - device-host-cli JSON failure, fix the named device-pod operation or host CLI; - do not bypass the profile with generic gateway shell. -- If a command returns a JSON `next` array or object, follow those CLI-owned next - commands in order. Do not discard the failure context, slash the route into an - ad hoc command, or replace `device-pod-cli` with direct gateway shell unless - the `next` field explicitly instructs that bootstrap/repair action. -- Before rerouting, gateway repair, or host asset repair, run - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id --probe`. - If it reports `gateway-session-mismatch`, `gateway-session-unavailable`, or - `host-cmd-unavailable`, report that blocker and its `next` field; do not keep - retrying `/app/tools/hwlab-gateway-tran.mjs` or raw gateway shell commands. -- Workspace search follows a compact ripgrep-like shape: - `device-pod-71-freq:workspace:/ rg "printf|main" projects/01_baseline -g "*.c" -g "*.h" -l`. - The optional path after the pattern is honored as the search root; use it to - avoid scanning vendor PDFs or generated text outside the project under test. -- For debug probe jobs, `download start` is mutating and requires - `--approved --reason`, but `download status ` is read-only and must be - queried without approval friction. If `download status` includes UART capture, - read the top-level `hostUartCapture` field first before falling back to job - files or direct `tran`. -- I/O probe selectors are strict path tokens. Always write - `device-pod-71-freq:io-probe:/uart/1 read` as one selector token; do not write - `io-probe:/uart / 1 read`, `io-probe:/uart/ 1 read`, or other spaced path - variants. The CLI should fail these forms with an `invalid-request` hint - instead of normalizing the path or forwarding the malformed command to the - Windows host. - -Fast build smoke for `device-pod-71-freq`: - -```sh -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build status -``` - -The build status JSON must be treated as the evidence. Report -`hostJson.status`, `hostJson.result.success`, `hostJson.result.exitCode`, -`hostJson.result.project`, `hostJson.result.target`, and the output artifact -paths from the profile; do not describe Windows Keil skill config as the source -of truth for a device-pod build. - -## DeepSeek Profile-Recreate Task - -When a HWLAB runner asks DeepSeek or another code agent to delete and recreate a -profile, this SKILL.md is the operating procedure. Do not stop after listing the -workspace or describing files. Execute the profile bootstrap and validation -commands, then report the resulting JSON evidence. - -For `device-pod-71-00075-11`, the required order is: - -1. Prove the profile is missing with - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11`. -2. Create `/workspace/hwlab/.device-pod/device-pod-71-00075-11.json` with - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force`. -3. Run `doctor --pod-id device-pod-71-00075-11 --probe` and follow its - `next.uploadHostCli`, `next.uploadProfile`, and `next.hostHealth` commands; - do not hard-code a gateway session id from old examples. -4. Verify Windows host health through `node tools\device-host-cli.mjs --profile - .device-pod\device-pod-71-00075-11.json --pod-id device-pod-71-00075-11 - health`. -5. Run `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list` - and report the profile `count` plus the hash of each profile. -6. Verify runner health, selector dry-runs, workspace `ls`, build start/status, - download start/status, and UART read/write/read through `device-pod-cli`. - -Any failure must be reported as `FAIL` with the exact command and the JSON -`blocker` or stderr/stdout summary. A successful bootstrap report must include -runner profile path, profile SHA-256, `profile list` count, host health or the -observed gateway blocker, runner health, and both selector dry-run results. -Full hardware validation reports must additionally include build job id, -download job id, download `Verify OK` evidence, and UART byte count/summary. - -For the first bootstrap turn, run this block exactly. It intentionally performs -only missing-state proof, runner profile creation, host asset/profile upload, -host health, runner health, and selector dry-run checks; run build/download/UART -in a second turn after this block succeeds. - -Preferred one-line flow for DeepSeek runners: use the CLI-owned profile template -instead of hand-copying JSON. This avoids heredoc/quoting drift in model-run -shells and still creates the same explicit profile under the persistent profile -directory. - -```sh -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-00075-11 --probe -# Follow the JSON next.uploadHostCli / next.uploadProfile / next.hostHealth commands from doctor. -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11 -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-00075-11:workspace:/ ls --dry-run -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --dry-run -``` - -The lower-level JSON block below is a reference for the CLI template and for -manual recovery only. - -```sh -set -eu -skill_file=/app/skills/device-pod-cli/SKILL.md -grep -n "DeepSeek Profile-Recreate Task" "$skill_file" - -set +e -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11 -missing_exit=$? -set -e -if [ "$missing_exit" -eq 0 ]; then - echo "FAIL: expected profile-missing before bootstrap" >&2 - exit 10 -fi - -profile_dir="${DEVICE_POD_PROFILE_DIR:-/workspace/hwlab/.device-pod}" -profile_file="$profile_dir/device-pod-71-00075-11.json" -mkdir -p "$profile_dir" -cat > "$profile_file" <<'JSON' -{ - "schemaVersion": 1, - "podId": "device-pod-71-00075-11", - "devicePodId": "device-pod-71-00075-11", - "workspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11", - "target": { - "id": "constart-71-00075-11", - "mcu": "STM32H723ZGTx", - "flashBase": "0x08000000" - }, - "projectWorkspace": { - "projectPath": "FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx", - "targetName": "FREQ_Controller_FW", - "hexPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex", - "mapPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map" - }, - "debugInterface": { - "id": "debug-probe", - "type": "cmsis-dap", - "probeUid": "3FD750C63E342E24", - "uv4Path": "C:\\Keil_v5\\UV4\\UV4.exe", - "programBackend": "keil-headless", - "autoBindUvoptx": true - }, - "ioInterface": { - "uart": [ - { - "id": "uart/1", - "scope": "external", - "port": "COM4", - "baudRate": 921600, - "encoding": "utf8", - "description": "ConStart debug USART1 via CMSIS-DAP CDC UART (verified COM4)" - } - ] - }, - "cloudApiUrl": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "gatewaySessionId": "gws_YUAN", - "resourceId": "res_windows_host", - "capabilityId": "cap_windows_cmd_exec", - "hostWorkspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11", - "hostCli": "node tools\\device-host-cli.mjs" -} -JSON -sha256sum "$profile_file" - -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-00075-11 --probe -# Follow the JSON next.uploadHostCli / next.uploadProfile / next.hostHealth commands from doctor. - -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11 -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-00075-11:workspace:/ ls --dry-run -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --dry-run -``` +Canonical skill location: `/app/skills/device-pod-cli/SKILL.md`. Do not create duplicate skill copies under `/root/.agents/skills`, `/home/ubuntu/.agents/skills`, or `/workspace/hwlab/skills`; those locations cause stale instructions. ## Runtime Contract -- The preinstalled CLI entrypoint is - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs`. -- The canonical implementation is `/app/tools/device-pod-cli.mjs`; the skill - script is only a stable preinstalled wrapper for code agent runners. -- The packaged Windows host CLI asset is - `/app/skills/device-pod-cli/assets/device-host-cli.mjs`. It is bundled with - this skill so a runner can send the exact tested host-side CLI to a new - Windows hardware PC before the first device-pod operation. -- Profiles are read from `DEVICE_POD_PROFILE_DIR/.json` when that - directory is set, otherwise from `.device-pod/.json` in the - current code agent workspace; `DEVICE_POD_PROFILE` may point to one explicit - profile file. G14 device-agent runners should keep shared profiles in the - persistent `/workspace/hwlab/.device-pod` directory rather than only in - ephemeral `/app/.device-pod`. -- Multiple profiles may coexist in the same runner workspace. Use the selector - prefix, such as `device-pod-71-freq:...` or `device-pod-71-00075-11:...`, as - the source of truth for the active pod. `device-pod-cli` automatically passes - that selected profile to Windows `device-host-cli` as `--profile - .device-pod\\.json --pod-id `, so host-side commands - must not fall back to the default `device-pod-71-freq` profile when another - pod is selected. -- The CLI calls HWLAB cloud-api `hardware.invoke.shell`, which dispatches to an - outbound `hwlab-gateway` session and then to the user PC side - `device-host-cli`. Do not call gateway, serial ports, Keil, pyOCD, DAPLink, or - Windows shell directly when the profiled device-pod path is available. -- `device-host-cli` is expected to be self-contained on the user PC. Keil, - serial-monitor, mklink, and other skills can be implementation references, but - are not runtime dependencies of this HWLAB runner skill. -- `device-host-cli` is not just a copied preinstall script. It is the hot - development tool that HWLAB internal code agents, including DeepSeek-backed - runners, may keep improving on the target Windows workspace when hardware, - Keil, probe, UART, or filesystem operations are not smooth enough. Hot fixes - are expected whenever host-side behavior is awkward or missing; validate them - through the real device-pod/gateway route first, then copy the result back into - the HWLAB repo asset for the next CI/CD preinstall. +- The stable runner entrypoint is `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs`. +- The canonical implementation is `/app/tools/device-pod-cli.ts`; `/app/tools/device-pod-cli.mjs` and the skill script are compatibility wrappers that launch the Bun+TypeScript CLI. +- All commands emit JSON. A command with no output is a failure. +- Formal v0.2 mode is cloud-api authority: the CLI calls `/v1/device-pods...` REST APIs and must not read `.device-pod/*.json` as the source of truth for gateway session, workspace root, probe UID, UART port, or host CLI. +- Server-side profile, grant, lease, job authority, and profile hash are owned by `hwlab-cloud-api`; `hwlab-device-pod` and `device-host-cli` execute only after cloud-api authorization. -## Profile Bootstrap +## Configuration -A missing profile is a normal bootstrap state. Do not switch to another profile -and do not guess defaults. Create the selected device-pod profile explicitly in -both places below, then verify it before running build, download, or UART I/O. - -- Runner profile: `/workspace/hwlab/.device-pod/.json`. This is - the persistent HWLAB code agent workspace profile used by `device-pod-cli`. -- Windows host profile: `\.device-pod\.json`. - This is the matching profile loaded by `device-host-cli` after the gateway - command reaches the Windows hardware PC. -- Windows host CLI: `\tools\device-host-cli.mjs`. Upload the - packaged `/app/skills/device-pod-cli/assets/device-host-cli.mjs` asset when a - new profile is bootstrapped or after the host CLI has been hot-fixed. - -Minimum profile fields are `schemaVersion`, `podId` or `devicePodId`, -`target.id`, `cloudApiUrl`, `gatewaySessionId`, `resourceId`, `capabilityId`, -`hostWorkspaceRoot`, and `hostCli`. Keep the project, debug probe, and I/O probe -sections explicit so later agents do not depend on hidden CLI overrides. - -For G14 DEV ConStart `device-pod-71-00075-11`, use this known hardware profile. -This example is intentionally complete so a DeepSeek runner can recreate the -profile after `/workspace/hwlab/.device-pod/device-pod-71-00075-11.json` and the -matching Windows profile have been deleted. +Pass the API base URL with one of these sources: ```sh -profile_dir="${DEVICE_POD_PROFILE_DIR:-/workspace/hwlab/.device-pod}" -profile_file="$profile_dir/device-pod-71-00075-11.json" -mkdir -p "$profile_dir" -cat > "$profile_file" <<'JSON' -{ - "schemaVersion": 1, - "podId": "device-pod-71-00075-11", - "devicePodId": "device-pod-71-00075-11", - "workspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11", - "target": { - "id": "constart-71-00075-11", - "mcu": "STM32H723ZGTx", - "flashBase": "0x08000000" - }, - "projectWorkspace": { - "projectPath": "FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx", - "targetName": "FREQ_Controller_FW", - "hexPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex", - "mapPath": "FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map" - }, - "debugInterface": { - "id": "debug-probe", - "type": "cmsis-dap", - "probeUid": "3FD750C63E342E24", - "uv4Path": "C:\\Keil_v5\\UV4\\UV4.exe", - "programBackend": "keil-headless", - "autoBindUvoptx": true - }, - "ioInterface": { - "uart": [ - { - "id": "uart/1", - "scope": "external", - "port": "COM4", - "baudRate": 921600, - "encoding": "utf8", - "description": "ConStart debug USART1 via CMSIS-DAP CDC UART (verified COM4)" - } - ] - }, - "cloudApiUrl": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "gatewaySessionId": "gws_YUAN", - "resourceId": "res_windows_host", - "capabilityId": "cap_windows_cmd_exec", - "hostWorkspaceRoot": "F:\\Work\\ConStart\\projects\\71-00075-11", - "hostCli": "node tools\\device-host-cli.mjs" -} -JSON - -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-00075-11 -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-00075-11 --probe -# Follow the JSON next.uploadHostCli / next.uploadProfile / next.hostHealth commands from doctor. +--api-base-url http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667 +HWLAB_DEVICE_POD_API_URL=http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667 +HWLAB_CLOUD_API_URL=http://hwlab-cloud-api.hwlab-v02.svc.cluster.local:6667 +HWLAB_CLI_ENDPOINT=http://74.48.78.17:19667 ``` -After bootstrap, validate selector isolation before touching hardware: +Pass authentication with one of these sources: -```text -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-00075-11:workspace:/ ls --dry-run -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --dry-run +```sh +--session-token +--cookie 'hwlab_session=' +--bearer-token +HWLAB_DEVICE_POD_SESSION_TOKEN= +HWLAB_CLOUD_API_SESSION_TOKEN= +HWLAB_SESSION_TOKEN= +HWLAB_SESSION_COOKIE='hwlab_session=' ``` -The two dry-run outputs must reference different `--profile -.device-pod\.json` host arguments and different profile hashes. +Use `login` only when a username/password credential is explicitly available: -## Windows Host CLI Bootstrap - -Do not run a separate installer. To bring up a new Windows hardware PC, send the -packaged asset to the target workspace through the existing cmd passthrough path, -then start it through the same cmd passthrough path. - -Target layout: - -```text -\tools\device-host-cli.mjs -\.device-pod\.json +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs login --api-base-url --username --password ``` -When the UniDesk Windows maintenance route is available, the file transfer may -use the same remote channel that backs cmd passthrough. In HWLAB coder images, -`/app/tools/tran.mjs` is the stable contract entrypoint; -`/app/tools/hwlab-gateway-tran.mjs` is only the compatible explicit wrapper for -the same transport. If the compatible wrapper exists but `/app/tools/tran.mjs` -is missing, report a CI/CD preinstall gap instead of adding a device-pod -workaround. In HWLAB k8s runner pods, `tran.mjs` auto-selects the in-cluster -`hwlab-cloud-api..svc.cluster.local:6667` API endpoint; outside k8s it -falls back to local `127.0.0.1:6667`, and non-standard deployments may still pass -`--api-base-url`. `tran.mjs upload` uses bounded base64 chunks tuned for the -Windows cmd passthrough limit; do not raise the chunk size above the documented -default to hide slow transfers. If upload/download becomes awkward again, fix -`tran.mjs` or the gateway transport first. +The `setCookie` value in the JSON output can be passed back as `--cookie` or `HWLAB_SESSION_COOKIE`. -```text -node /app/tools/tran.mjs :/d/Work/HWLAB-workspace upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs -node /app/tools/tran.mjs :/d/Work/HWLAB-workspace cmd -- node tools\\device-host-cli.mjs --profile .device-pod\\.json --pod-id health +## Primary Commands + +List visible Device Pods through cloud-api authority: + +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list --api-base-url --session-token ``` -When only HWLAB gateway `hardware.invoke.shell` is available, send the asset -bytes as bounded base64 chunks through cmd/PowerShell into -`tools\device-host-cli.mjs`, then verify with: +Show one visible Device Pod status and redacted server profile summary: -```text -node tools\device-host-cli.mjs --profile .device-pod\.json --pod-id health +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-freq --api-base-url --session-token ``` -After that, `device-pod-cli` should use the profile's `hostCli`, normally -`node tools\device-host-cli.mjs`, and should not depend on any Windows skill -directory at runtime. +Diagnose auth, grant, lease, and gateway blockers without touching local profiles: -## Host CLI Hot Development - -When a profiled operation is awkward or missing, prefer improving the Windows -workspace copy of `tools\device-host-cli.mjs` instead of bypassing the device-pod -model with arbitrary cmd snippets. The intended loop is: - -```text -edit tools\device-host-cli.mjs in the Windows device-pod workspace -node tools\device-host-cli.mjs health -node tools\device-host-cli.mjs -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs :... -copy the validated change back to skills/device-pod-cli/assets/device-host-cli.mjs +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq --api-base-url --session-token ``` -The host CLI should stay self-contained and named-operation oriented: add -`debug-probe launch-flash` or `io-probe uart/1 read-after-launch-flash` style -device semantics, not a generic shell escape. If gateway/cmd transfer itself is -the friction, fix `tran.mjs` or the gateway transport separately instead of -embedding transport workarounds into `device-host-cli`. +Acquire and inspect a lease for mutating jobs: -## Commands - -All commands emit JSON and must remain short-running. Use `build start` / -`build status` and `download start` / `download status` style host jobs for -long operations instead of holding one gateway request open. - -```text -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ rg main -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --approved --reason "DEV edit" < patch.diff -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build status -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --approved --reason "DEV smoke" -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download status -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --capture-uart uart/1 --capture-duration-ms 8000 --approved --reason "DEV boot log capture" -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe launch-flash --approved --reason "manual flash-vector launch" -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --port COM4 --baud-rate 921600 --duration-ms 5000 -node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read-after-launch-flash --approved --reason "boot log capture" +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs lease acquire --pod-id device-pod-71-freq --reason "build smoke" --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs lease current --pod-id device-pod-71-freq --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs lease release --pod-id device-pod-71-freq --lease-token --api-base-url --session-token ``` -Use `--port COMx` and `--baud-rate N` only as a short-lived UART discovery -override. After the real port is proven, write the stable value back into the -device-pod profile so later agents do not depend on a hidden command override. +Run read-only workspace, debug, and I/O jobs: -For boot logs that only appear during reset/download, prefer -`debug-probe download start --capture-uart uart/1 --capture-duration-ms N` so -the host opens the UART before Keil starts flashing and stores the captured -bytes in the download job status under `result.uartCapture`. +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/src rg main --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 --api-base-url --session-token +``` + +Run mutating jobs only with reason and lease token when required by cloud-api: + +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --reason "DEV edit" --lease-token --api-base-url --session-token < patch.diff +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start --reason "build smoke" --lease-token --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --reason "DEV smoke" --lease-token --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe reset --reason "reset smoke" --lease-token --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 write "AT" --reason "UART write smoke" --lease-token --api-base-url --session-token +``` + +Inspect or cancel jobs through cloud-api: + +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs job status --pod-id device-pod-71-freq --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs job output --pod-id device-pod-71-freq --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs job cancel --pod-id device-pod-71-freq --api-base-url --session-token +``` + +Selector aliases also support job status/output/cancel after build/download: + +```sh +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build status --api-base-url --session-token +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download output --api-base-url --session-token +``` + +## Removed Legacy Path + +`profile create` is removed from formal v0.2 mode. If a profile is missing or wrong, an admin must update cloud-api with the admin Device Pod APIs and then grant users. The CLI returns `legacy_profile_create_removed` instead of creating `.device-pod/.json`. + +Do not treat local files such as `.device-pod/device-pod-71-freq.json` or `DEVICE_POD_PROFILE_DIR` as authority. They are ignored by the formal CLI and may only exist as obsolete caches or hardware-host bootstrap artifacts. + +## Operation Rules + +- Use `device-pod-cli` first for workspace, build, download, reset, UART, and debug-probe tasks. +- Do not start by calling `/app/tools/hwlab-gateway-tran.mjs`, Windows-side Keil skills, serial-monitor skills, `keil-cli.py`, or generic shell when the Device Pod REST path is available. +- If the CLI returns an auth, grant, lease, executor, or gateway blocker, fix that named blocker in the cloud-api/device-pod path; do not bypass the profile with generic gateway shell. +- The server-side profile already carries `hostWorkspaceRoot`, `projectWorkspace`, `debugInterface.uv4Path`, probe UID, UART port, and gateway route. Let `cloud-api -> hwlab-device-pod -> device-host-cli` apply those values. +- Keep I/O selectors as strict path tokens, for example `device-pod-71-freq:io-probe:/uart/1 read`; do not split `/uart/1` across argv. +- Use `--dry-run` to inspect the REST job request without dispatching it. + +## Host Asset Bootstrap + +The packaged Windows host CLI asset remains `/app/skills/device-pod-cli/assets/device-host-cli.mjs`. Upload it only when cloud-api/gateway dispatch reaches a Windows host and reports that `tools\device-host-cli.mjs` is missing or broken. + +Do not upload or recreate runner-side `.device-pod/*.json` as the formal profile source. The profile must come from cloud-api. Host-side profile snapshots, when needed, are written by `hwlab-device-pod` from the cloud-api-provided server profile before invoking `device-host-cli`. ## Safety -- Mutating operations such as workspace patching, download, reset, - `launch-flash`, `read-after-launch-flash`, and I/O write require - `--approved --reason ` before the CLI dispatches anything. -- `io-probe:/inner/...` is intentionally distinct from external physical I/O - paths such as `io-probe:/uart/1`; do not use internal target state as evidence - for external UART/GPIO observations. -- Keep the gateway as transport only. Do not add a generic `cmd` operation to - `device-pod-cli`; add a named device-pod operation instead. -- If the CLI returns `profile-missing`, `profile-invalid`, or - `approval-required`, fix that blocker first rather than guessing a default - device or falling back to arbitrary shell commands. +- Mutating operations require a human/business reason and may require an active lease. Cloud-api is the enforcement point; the CLI only forwards `reason` and `leaseToken`. +- `hwlab-gateway` is transport only. Do not add a generic `cmd` operation to `device-pod-cli`; add a named Device Pod operation instead. +- `SOURCE`, `LOCAL`, `DRY-RUN`, fixture data, and local profile files cannot be reported as `DEV-LIVE` hardware evidence. + +## Manual Tests + +## T1 + +阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:在未设置 API URL 时运行 `profile list`,确认输出 JSON 且错误码为 `api_base_url_required`。 + +## T2 + +阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:设置 `HWLAB_DEVICE_POD_API_URL` 和有效 session 后运行 `profile list`、`profile show --pod-id `、`doctor --pod-id `,确认输出来自 cloud-api authority,并且没有泄露 `gatewaySessionId`、`hostWorkspaceRoot`、probe UID 或 UART port。 + +## T3 + +阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:创建本地 `.device-pod/.json`,写入假的 `gatewaySessionId`,再运行 `profile list` 和 selector `--dry-run`;确认 JSON 输出标记 `localProfileAuthority=false` 且没有读取本地 fake route。 + +## T4 + +阅读 `/app/skills/device-pod-cli/SKILL.md`,然后用 cli 手动测试以下内容:运行 `profile create --pod-id `,确认返回 `legacy_profile_create_removed`,并提示使用 cloud-api admin API 管理服务端 profile/grant。 diff --git a/tools/device-pod-cli.mjs b/tools/device-pod-cli.mjs index 02f08af8..44758e58 100644 --- a/tools/device-pod-cli.mjs +++ b/tools/device-pod-cli.mjs @@ -1,686 +1,44 @@ #!/usr/bin/env node -import { createHash, randomUUID } from 'node:crypto'; -import fs from 'node:fs'; -import http from 'node:http'; -import https from 'node:https'; -import path from 'node:path'; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const VERSION = '0.1.0-mvp'; -const DEFAULT_TIMEOUT_MS = 30000; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const runBun = path.join(root, "scripts", "run-bun.mjs"); +const cli = path.join(root, "tools", "device-pod-cli.ts"); -function defaultProfileDir() { - if (process.env.DEVICE_POD_PROFILE_DIR) return process.env.DEVICE_POD_PROFILE_DIR; - const candidates = ['/workspace/hwlab/.device-pod', path.resolve(process.cwd(), '.device-pod'), '/app/.device-pod']; - return candidates.find((dir) => fs.existsSync(dir)) || path.resolve(process.cwd(), '.device-pod'); -} - -const PROFILE_DIR = defaultProfileDir(); - -function nowIso() { return new Date().toISOString(); } -function stripSlash(value) { return String(value || '').replace(/\/+$/u, ''); } -function sha256(text) { return createHash('sha256').update(text).digest('hex'); } -function print(body, exitCode = 0) { - console.log(JSON.stringify({ generatedAt: nowIso(), cli: 'device-pod-cli', version: VERSION, ...body }, null, 2)); - process.exitCode = exitCode; -} -function fail(action, error, details = {}) { - const message = error instanceof Error ? error.message : String(error); - const blocker = classifyBlocker(message); - print({ ok: false, action, status: 'failed', blocker, error: message, details, next: nextForBlocker(blocker, { ...details, message }) }, 1); -} -function classifyBlocker(message) { - if (/profile not found/u.test(message)) return 'profile-missing'; - if (/profile invalid|requires/u.test(message)) return 'profile-invalid'; - if (/approval/u.test(message)) return 'approval-required'; - if (/resource is not registered under the requested gateway session|resourceGatewaySessionId/u.test(message)) return 'gateway-session-mismatch'; - if (/gateway session .*not found|gateway session .*not registered|gateway.*not.*registered/u.test(message)) return 'gateway-session-unavailable'; - if (/spawn C:\\Windows\\system32\\cmd\.exe ENOENT|cmd\.exe ENOENT/u.test(message)) return 'host-cmd-unavailable'; - if (/unsupported|invalid target selector|invalid io-probe (?:path|selector)/u.test(message)) return 'invalid-request'; - return 'operation-failed'; -} - -function nextForBlocker(blocker, details = {}) { - const podId = details.podId || podIdFromMessage(details.message) || process.env.DEVICE_POD_ID || ''; - if (blocker === 'profile-missing') { - return [ - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`, - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id ${podId} --force`, - 'upload the generated profile and device-host-cli asset using the doctor next.upload commands' - ]; - } - if (blocker === 'profile-invalid') { - const names = availableProfileNames(); - if (names.length > 1) return profileChoiceNext(names); - return [ - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`, - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id ${podId}` - ]; - } - if (blocker === 'approval-required') { - return ['rerun the mutating operation with --approved --reason after the user explicitly requested it']; - } - if (blocker === 'invalid-request') { - return [ - 'keep the device selector as one token, for example device-pod-71-00075-11:io-probe:/uart/1 read', - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}` - ]; - } - if (blocker === 'gateway-session-mismatch') { - return [ - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId} --probe --full`, - 'update the profile gatewaySessionId to the resourceGatewaySessionId reported by cloud-api, then rerun doctor --probe', - 'do not bypass device-pod-cli with generic gateway shell' - ]; - } - if (blocker === 'gateway-session-unavailable') { - return [ - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId} --probe --full`, - 'restart or reconnect the target hwlab-gateway session, then rerun the same device-pod-cli command', - 'do not guess another gateway session without updating the profile' - ]; - } - if (blocker === 'host-cmd-unavailable') { - return [ - `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId} --probe --full`, - 'the gateway route was reached but cannot spawn Windows cmd.exe; repair the Windows gateway host or use a profile bound to a Windows cmd-capable gateway', - 'stop retrying lower-level gateway shell commands until doctor --probe is green' - ]; - } - return [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${podId}`]; -} - -function profileChoiceNext(names) { - const script = 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs'; - return [ - `${script} profile list`, - ...names.map((name) => `${script} doctor --pod-id ${name}`), - ...names.map((name) => `${script} health --pod-id ${name}`) - ]; -} - -function podIdFromMessage(message) { - const match = String(message || '').match(/([A-Za-z0-9_.-]+)\.json\b/u); - return match?.[1] || null; -} - -function parseTarget(raw) { - if (!raw || raw === '--help' || raw === 'help') return { kind: 'help' }; - if (raw === 'doctor') return { kind: 'doctor' }; - if (raw === 'health') return { kind: 'health' }; - if (raw === 'profile') return { kind: 'profile' }; - const match = String(raw).trim().match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u); - if (!match) throw new Error(`invalid target selector: ${raw}`); - const surface = match[2]; - const selectorPath = surface === 'io-probe' ? normalizeProbePath(match[3] || '') : normalizeDevicePath(match[3] || ''); - return { kind: 'invoke', podId: match[1], surface, path: selectorPath }; -} - -function normalizeDevicePath(value) { - const normalized = String(value || '') - .trim() - .replace(/\\/gu, '/') - .replace(/\s*\/\s*/gu, '/') - .replace(/\/+/gu, '/') - .replace(/^\/+/u, '') - .replace(/\/+$/u, ''); - return normalized || '.'; -} - -function joinDevicePath(base, child) { - const left = normalizeDevicePath(base); - const right = normalizeDevicePath(child); - if (!right || right === '.') return left; - if (String(child || '').trim().startsWith('/')) return right; - if (!left || left === '.') return right; - return normalizeDevicePath(`${left}/${right}`); -} - -function normalizeProbePath(value) { - const raw = String(value || '').trim().replace(/\\/gu, '/'); - if (!raw) return ''; - if (/\s/u.test(raw)) { - throw new Error(`invalid io-probe path: "${raw}". Use io-probe:/uart/1 with no spaces; do not write io-probe:/uart / 1`); - } - return raw.replace(/^\/+/u, '').replace(/\/+$/u, ''); -} - -const WORKSPACE_COMMANDS = new Set(['ls', 'cat', 'rg', 'apply-patch', 'build']); -const IO_PROBE_COMMANDS = new Set(['ports', 'read', 'write', 'read-after-launch-flash']); - -function normalizeWorkspaceSelector(selector, parsed) { - let workspacePath = normalizeDevicePath(selector.path || '.'); - if (parsed._.length >= 2 && WORKSPACE_COMMANDS.has(parsed._[1])) { - const maybePath = String(parsed._[0] || '').trim(); - if (maybePath === '/' || maybePath === '.' || maybePath.startsWith('/') || maybePath.includes('/')) { - workspacePath = joinDevicePath(workspacePath, maybePath); - parsed._ = parsed._.slice(1); - } - } - return workspacePath; -} - -function parseOptions(argv) { - const out = { _: [] }; - for (let i = 0; i < argv.length; i += 1) { - const item = argv[i]; - if (!item.startsWith('--')) { out._.push(item); continue; } - const eq = item.indexOf('='); - const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2); - const key = rawKey.replace(/-([a-z])/gu, (_, c) => c.toUpperCase()); - if (eq >= 0) { out[key] = item.slice(eq + 1); continue; } - const next = argv[i + 1]; - if (next && !next.startsWith('--')) { out[key] = next; i += 1; } - else out[key] = true; - } - return out; -} - -function resolvePodId(requested) { - if (requested) return requested; - if (process.env.DEVICE_POD_ID) return process.env.DEVICE_POD_ID; - const names = availableProfileNames(); - if (names.length === 1) return names[0]; - throw new Error(`profile invalid: pass : or set DEVICE_POD_ID when multiple/no profiles exist; available profiles: ${names.join(', ') || '(none)'}`); -} - -function availableProfileNames() { - return fs.existsSync(PROFILE_DIR) - ? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).map((name) => path.basename(name, '.json')).sort() - : []; -} - -function readProfile(podId) { - const requestedPodId = podId || process.env.DEVICE_POD_ID || null; - const explicitFile = process.env.DEVICE_POD_PROFILE || null; - const devicePodId = explicitFile ? requestedPodId : resolvePodId(requestedPodId); - const file = explicitFile || path.join(PROFILE_DIR, `${devicePodId}.json`); - if (!fs.existsSync(file)) throw new Error(`profile not found: ${file}`); - const raw = fs.readFileSync(file, 'utf8'); - const profile = JSON.parse(raw); - const resolvedPodId = profile.devicePodId || profile.podId || devicePodId || path.basename(file, '.json'); - const targetId = profile.targetId || profile.target?.id || profile.deviceTarget?.targetId || profile.deviceTarget?.id; - const required = ['cloudApiUrl', 'gatewaySessionId', 'resourceId', 'capabilityId', 'hostWorkspaceRoot', 'hostCli']; - const missing = required.filter((key) => !profile[key]); - if (missing.length) throw new Error(`profile invalid: missing ${missing.join(', ')}`); - if (devicePodId && resolvedPodId !== devicePodId) throw new Error(`profile invalid: podId mismatch selector=${devicePodId} profile=${resolvedPodId}`); - if (!targetId) throw new Error('profile invalid: missing target.id'); - return { ...profile, podId: resolvedPodId, targetId, __profilePath: file, __profileHash: sha256(raw) }; -} - -function profileSummary(profile) { - return { profilePath: profile.__profilePath, profileHash: profile.__profileHash, devicePodId: profile.podId, targetId: profile.targetId }; -} -function routeSummary(profile) { - return { cloudApiUrl: profile.cloudApiUrl, gatewaySessionId: profile.gatewaySessionId, resourceId: profile.resourceId, capabilityId: profile.capabilityId, hostWorkspaceRoot: profile.hostWorkspaceRoot }; -} - -function cmdQuote(value) { - const text = String(value); - if (/^[A-Za-z0-9_./:\\-]+$/u.test(text)) return text; - return `"${text.replace(/"/g, '\\"')}"`; -} - -function hostProfilePath(profile) { - return profile.hostProfilePath || `.device-pod\\${profile.podId}.json`; -} - -function writeJsonFile(file, value) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); -} - -function conStart710007511Profile(overrides = {}) { - return { - schemaVersion: 1, - podId: 'device-pod-71-00075-11', - devicePodId: 'device-pod-71-00075-11', - workspaceRoot: 'F:\\Work\\ConStart\\projects\\71-00075-11', - target: { - id: 'constart-71-00075-11', - mcu: 'STM32H723ZGTx', - flashBase: '0x08000000' - }, - projectWorkspace: { - projectPath: 'FirmWare/MDK-ARM/FREQ_Controller_FW.uvprojx', - targetName: 'FREQ_Controller_FW', - hexPath: 'FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.hex', - mapPath: 'FirmWare/MDK-ARM/FREQ_Controller_FW/FREQ_Controller_FW.map' - }, - debugInterface: { - id: 'debug-probe', - type: 'cmsis-dap', - probeUid: '3FD750C63E342E24', - uv4Path: 'C:\\Keil_v5\\UV4\\UV4.exe', - programBackend: 'keil-headless', - autoBindUvoptx: true - }, - ioInterface: { - uart: [ - { - id: 'uart/1', - scope: 'external', - port: 'COM4', - baudRate: 921600, - encoding: 'utf8', - description: 'ConStart debug USART1 via CMSIS-DAP CDC UART (verified COM4)' - } - ] - }, - cloudApiUrl: 'http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667', - gatewaySessionId: 'gws_YUAN', - resourceId: 'res_windows_host', - capabilityId: 'cap_windows_cmd_exec', - hostWorkspaceRoot: 'F:\\Work\\ConStart\\projects\\71-00075-11', - hostCli: 'node tools\\device-host-cli.mjs', - ...overrides - }; -} - -function profileTemplate(podId) { - if (podId === 'device-pod-71-00075-11') return conStart710007511Profile(); - throw new Error(`unsupported profile template: ${podId}`); -} - -function createProfile(parsed) { - const subcommand = parsed._[0] || ''; - if (subcommand !== 'create') throw new Error(`unsupported profile command: ${subcommand || '(empty)'}`); - const podId = parsed.podId || parsed._[1]; - if (!podId) throw new Error('profile create requires --pod-id '); - const profile = profileTemplate(podId); - const file = parsed.output || path.join(PROFILE_DIR, `${profile.devicePodId}.json`); - if (fs.existsSync(file) && parsed.force !== true) throw new Error(`profile already exists: ${file}; pass --force to overwrite`); - writeJsonFile(file, profile); - const raw = fs.readFileSync(file, 'utf8'); - print({ ok: true, action: 'profile.create', status: 'succeeded', profilePath: file, profileHash: sha256(raw), devicePodId: profile.devicePodId, targetId: profile.target.id, route: routeSummary(profile), next: [ - `node /app/tools/tran.mjs ${profile.gatewaySessionId}:/f/Work/ConStart/projects/71-00075-11 upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs`, - `node /app/tools/tran.mjs ${profile.gatewaySessionId}:/f/Work/ConStart/projects/71-00075-11 upload ${file} .device-pod/${profile.devicePodId}.json`, - `node /app/tools/tran.mjs ${profile.gatewaySessionId}:/f/Work/ConStart/projects/71-00075-11 cmd -- node tools\\device-host-cli.mjs --profile .device-pod\\${profile.devicePodId}.json --pod-id ${profile.devicePodId} health` - ] }); -} - -function listProfiles() { - const entries = fs.existsSync(PROFILE_DIR) - ? fs.readdirSync(PROFILE_DIR).filter((name) => name.endsWith('.json')).sort() - : []; - const items = entries.map((name) => { - const file = path.join(PROFILE_DIR, name); - try { - const raw = fs.readFileSync(file, 'utf8'); - const profile = JSON.parse(raw); - const devicePodId = profile.devicePodId || profile.podId || path.basename(name, '.json'); - const targetId = profile.targetId || profile.target?.id || profile.deviceTarget?.targetId || profile.deviceTarget?.id || null; - return { ok: true, devicePodId, targetId, profilePath: file, profileHash: sha256(raw), gatewaySessionId: profile.gatewaySessionId || null, hostWorkspaceRoot: profile.hostWorkspaceRoot || profile.workspaceRoot || null }; - } catch (error) { - return { ok: false, devicePodId: path.basename(name, '.json'), profilePath: file, error: error instanceof Error ? error.message : String(error) }; - } - }); - print({ ok: true, action: 'profile.list', status: 'succeeded', profileDir: PROFILE_DIR, count: items.length, items }); -} - -function showProfile(parsed) { - const profile = readProfile(parsed.podId || parsed._[1]); - print({ ok: true, action: 'profile.show', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile), projectWorkspace: profile.projectWorkspace || null, debugInterface: profile.debugInterface || null, ioInterface: profile.ioInterface || null, hostProfilePath: hostProfilePath(profile) }); -} - -function profileMissingDoctor(podId, file) { - const templateAvailable = Boolean(podId); - return { +if (!existsSync(runBun) || !existsSync(cli)) { + console.log(JSON.stringify({ + generatedAt: new Date().toISOString(), + cli: "device-pod-cli", ok: false, - action: 'doctor', - status: 'failed', - blocker: 'profile-missing', - profileDir: PROFILE_DIR, - devicePodId: podId || null, - expectedProfilePath: file || (podId ? path.join(PROFILE_DIR, `${podId}.json`) : null), - next: { - create: templateAvailable ? `node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id ${podId} --force` : 'pass --pod-id or set DEVICE_POD_ID', - list: 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list' - }, - valuesRedacted: true, - secretMaterialStored: false - }; -} - -async function doctor(parsed) { - const podId = parsed.podId || parsed._[0] || process.env.DEVICE_POD_ID || null; - try { - const profile = readProfile(podId); - const script = 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs'; - const selector = `${profile.podId}:workspace:/`; - const hostProfile = hostProfilePath(profile); - const hostHealthCommand = buildHostHealthCommand(profile); - const probe = parsed.probe === true - ? await probeHostHealth(profile, hostHealthCommand, parsed) - : undefined; - const uploadRoot = `${profile.gatewaySessionId}:${String(profile.hostWorkspaceRoot || '').replace(/^[A-Za-z]:/u, (drive) => `/${drive[0].toLowerCase()}`).replace(/\\/gu, '/')}`; - print({ - ok: true, - action: 'doctor', - status: probe?.ok === false ? 'degraded' : 'succeeded', - ...profileSummary(profile), - profileDir: PROFILE_DIR, - route: routeSummary(profile), - hostProfilePath: hostProfile, - selectors: { - workspace: `${profile.podId}:workspace:/`, - debugProbe: `${profile.podId}:debug-probe`, - ioProbe: `${profile.podId}:io-probe:/uart/1` - }, - next: { - show: `${script} profile show --pod-id ${profile.podId}`, - health: `${script} health --pod-id ${profile.podId}`, - plan: `${script} ${selector} ls --dry-run`, - probe: `${script} doctor --pod-id ${profile.podId} --probe`, - uploadHostCli: `node /app/tools/tran.mjs ${uploadRoot} upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs`, - uploadProfile: `node /app/tools/tran.mjs ${uploadRoot} upload ${profile.__profilePath} .device-pod/${profile.podId}.json`, - hostHealth: `node /app/tools/tran.mjs ${uploadRoot} cmd -- node tools\\device-host-cli.mjs --profile ${hostProfile} --pod-id ${profile.podId} health` - }, - probe, - valuesRedacted: true, - secretMaterialStored: false - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - const missingMatch = message.match(/profile not found: (.+)$/u); - if (classifyBlocker(message) === 'profile-missing') { - print(profileMissingDoctor(podId, missingMatch?.[1] || null), 1); - return; + status: "failed", + error: { + code: "device_pod_cli_entrypoint_missing", + message: "device-pod-cli TypeScript entrypoint or Bun runner is missing", + runBun, + cli } - fail('doctor', error, { podId }); - } -} - -function buildHostHealthCommand(profile) { - const hostArgs = ['--profile', hostProfilePath(profile), '--pod-id', profile.podId, 'health']; - return `${profile.hostCli} ${hostArgs.map(cmdQuote).join(' ')}`; -} - -async function probeHostHealth(profile, command, parsed) { - const result = await invoke(profile, command, { - ...parsed, - timeoutMs: Number(parsed.timeoutMs || 10000), - traceId: parsed.traceId || `trc_device_pod_doctor_${Date.now()}` + }, null, 2)); + process.exitCode = 127; +} else { + const cliArgs = process.argv.slice(2).map((arg) => (arg === "--help" || arg === "-h" ? "help" : arg)); + const result = spawnSync(process.execPath, [runBun, cli, ...cliArgs], { + cwd: root, + env: process.env, + stdio: "inherit" }); - const diagnosis = diagnoseInvokeFailure(profile, result, { operation: 'doctor.host-health' }); - return { - ok: result.status === 'succeeded', - status: result.status, - command, - traceId: result.traceId, - operationId: result.operationId, - requestId: result.requestId, - httpStatus: result.httpStatus, - blocker: diagnosis.blocker, - next: diagnosis.next, - dispatch: parsed.full ? result.dispatch : compactDispatch(result.dispatch), - hostJson: result.hostJson, - evidence: { auditId: result.dispatch.auditId, evidenceId: result.dispatch.evidenceId }, - gatewayBody: parsed.full ? result.gatewayBody : undefined - }; -} - -function handleProfileCommand(parsed) { - const subcommand = parsed._[0] || 'list'; - if (subcommand === 'list') return listProfiles(); - if (subcommand === 'show') return showProfile(parsed); - if (subcommand === 'create') return createProfile(parsed); - throw new Error(`unsupported profile command: ${subcommand}`); -} - -const CONTROL_OPTION_KEYS = new Set([ - 'approved', - 'reason', - 'dryRun', - 'full', - 'timeoutMs', - 'traceId', - 'podId', - 'patch', - 'patchB64', -]); - -function camelToKebab(value) { - return String(value).replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`); -} - -function hostOptionArgs(parsed) { - const out = []; - for (const [key, value] of Object.entries(parsed)) { - if (key === '_' || CONTROL_OPTION_KEYS.has(key) || value === undefined || value === false) continue; - const name = `--${camelToKebab(key)}`; - if (value === true) out.push(name); - else out.push(name, String(value)); - } - return out; -} - -function ioProbeHint(selector, probePath, parsed) { - const parts = parsed._ || []; - if ((parts[0] === '/' || parts[0] === '\\') && parts[1]) { - return `${selector.podId}:io-probe:/${normalizeProbePath(`${probePath}/${parts[1]}`)} ${parts[2] || 'read'}`; - } - if (parts[0] && !IO_PROBE_COMMANDS.has(parts[0]) && parts[1] && IO_PROBE_COMMANDS.has(parts[1])) { - return `${selector.podId}:io-probe:/${normalizeProbePath(`${probePath}/${parts[0]}`)} ${parts[1]}`; - } - return `${selector.podId}:io-probe:/${probePath || 'uart/1'} ${IO_PROBE_COMMANDS.has(parts[0]) ? parts[0] : 'read'}`; -} - -function validateIoProbeCommand(selector, probePath, operation, parsed) { - if ((parsed._[0] === '/' || parsed._[0] === '\\') || (parsed._[0] && !IO_PROBE_COMMANDS.has(parsed._[0]) && parsed._[1] && IO_PROBE_COMMANDS.has(parsed._[1]))) { - throw new Error(`invalid io-probe selector path: path appears split across argv near /${probePath}. Use ${ioProbeHint(selector, probePath, parsed)}; do not put spaces around '/' in selector paths`); - } - if (probePath === 'uart') { - throw new Error(`invalid io-probe path: /uart is not a concrete endpoint. Use ${selector.podId}:io-probe:/uart/1 ${operation || 'read'}`); - } - if (!IO_PROBE_COMMANDS.has(operation)) { - throw new Error(`unsupported io-probe operation: ${operation || '(empty)'}. Use ${ioProbeHint(selector, probePath, parsed)}; paths must be a single selector token with no spaces`); - } -} - -async function buildHostCommand(selector, parsed) { - const args = []; - const options = hostOptionArgs(parsed); - const operation = parsed._[0] || (selector.surface === 'workspace' ? 'ls' : selector.surface === 'debug-probe' ? 'status' : 'read'); - const mutating = isMutating(selector.surface, operation, parsed); - if (mutating) requireApproval(parsed, `${selector.surface}.${operation}`); - - if (selector.surface === 'workspace') { - const workspacePath = normalizeWorkspaceSelector(selector, parsed); - if (operation === 'ls' || operation === 'cat') args.push('workspace', operation, joinDevicePath(workspacePath, parsed._[1] || ''), ...parsed._.slice(2), ...options); - else if (operation === 'rg') { - const pattern = parsed._[1] || ''; - const rootArg = parsed._[2] && !String(parsed._[2]).startsWith('-') ? parsed._[2] : ''; - const restStart = rootArg ? 3 : 2; - args.push('workspace', 'rg', pattern, joinDevicePath(workspacePath, rootArg), ...parsed._.slice(restStart), ...options); - } else if (operation === 'apply-patch') { - const baseArg = parsed._[1] && !String(parsed._[1]).startsWith('-') ? parsed._[1] : ''; - args.push('workspace', 'apply-patch', joinDevicePath(workspacePath, baseArg), '--patch-b64', Buffer.from(await resolvePatchText(parsed), 'utf8').toString('base64')); - } - else if (operation === 'build') args.push('workspace', 'build', ...(parsed._.slice(1).length ? parsed._.slice(1) : ['start']), ...options); - else throw new Error(`unsupported workspace command: ${operation}`); - } else if (selector.surface === 'debug-probe') { - args.push('debug-probe', operation, ...parsed._.slice(1), ...options); - } else if (selector.surface === 'io-probe') { - const probePath = normalizeProbePath(selector.path) || 'uart/1'; - validateIoProbeCommand(selector, probePath, operation, parsed); - if (probePath.startsWith('inner/')) throw new Error(`unsupported io-probe path in MVP: ${probePath}`); - args.push('io-probe', probePath, operation, ...parsed._.slice(1), ...options); - } else { - throw new Error(`unsupported surface: ${selector.surface}`); - } - return { operation, mutating, commandArgs: args }; -} - -function isMutating(surface, operation, parsed = { _: [] }) { - return (surface === 'workspace' && operation === 'apply-patch') - || (surface === 'debug-probe' && (operation === 'reset' || operation === 'launch-flash' || (operation === 'download' && !['status', 'wait'].includes(String(parsed._[1] || 'start'))))) - || (surface === 'io-probe' && (operation === 'write' || operation === 'read-after-launch-flash')); -} -function requireApproval(parsed, operation) { - if (parsed.approved === true && typeof parsed.reason === 'string' && parsed.reason.trim()) return; - throw new Error(`approval required for ${operation}: pass --approved --reason `); -} - -async function resolvePatchText(parsed) { - if (typeof parsed.patch === 'string') return parsed.patch; - if (typeof parsed.patchB64 === 'string') return Buffer.from(parsed.patchB64, 'base64').toString('utf8'); - const text = await readAllStdin(); - if (!text.trim()) throw new Error('workspace apply-patch requires patch text on stdin or --patch-b64'); - return text; -} -async function readAllStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString('utf8'); -} - -async function invoke(profile, command, parsed) { - const timeoutMs = Number(parsed.timeoutMs || DEFAULT_TIMEOUT_MS); - const requestId = `req_device_pod_cli_${randomUUID()}`; - const operationId = `op_device_pod_cli_${randomUUID()}`; - const traceId = parsed.traceId || `trc_device_pod_cli_${Date.now()}`; - const response = await requestJson(`${stripSlash(profile.cloudApiUrl)}/v1/rpc/hardware.invoke.shell`, { - timeoutMs: timeoutMs + 10000, - headers: { 'x-trace-id': traceId, 'x-request-id': requestId, 'x-actor-id': 'usr_device_pod_cli', 'x-source-service-id': 'hwlab-cloud-api' }, - body: { projectId: profile.projectId || 'prj_mvp_topology', operationId, gatewaySessionId: profile.gatewaySessionId, resourceId: profile.resourceId, capabilityId: profile.capabilityId, input: { command, cwd: profile.hostWorkspaceRoot, timeoutMs } } - }); - const dispatch = response.body?.result?.dispatch || response.body?.result || {}; - const hostJson = parseMaybeJson(dispatch.stdout); - const hostFailed = hostJson && hostJson.ok === false; - const status = response.body?.error || dispatch.exitCode !== 0 || dispatch.status === 'failed' || hostFailed ? 'failed' : 'succeeded'; - return { requestId, operationId, traceId, httpStatus: response.status, status, dispatch, hostJson, gatewayBody: response.body }; -} - -function diagnoseInvokeFailure(profile, result, context = {}) { - if (result.status === 'succeeded') { - return { ok: true, blocker: null, next: [] }; - } - const text = JSON.stringify({ - error: result.gatewayBody?.error, - dispatch: result.dispatch, - hostJson: result.hostJson - }); - const blocker = classifyBlocker(text); - const next = nextForBlocker(blocker, { podId: profile.podId, message: text }); - return { - ok: false, - blocker, - next, - operation: context.operation || null, - summary: summarizeFailureText(text), - route: routeSummary(profile), - valuesRedacted: true, - secretMaterialStored: false - }; -} - -function summarizeFailureText(text) { - return String(text || '') - .replace(/\s+/gu, ' ') - .slice(0, 500); -} - -function requestJson(url, options = {}) { - const body = JSON.stringify(options.body || {}); - const target = new URL(url); - const client = target.protocol === 'https:' ? https : http; - const headers = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), ...(options.headers || {}) }; - return new Promise((resolve, reject) => { - const req = client.request(target, { method: 'POST', headers }, (res) => { - let text = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { text += chunk; }); - res.on('end', () => { - try { resolve({ status: res.statusCode || 0, body: text ? JSON.parse(text) : null }); } - catch (error) { reject(new Error(`invalid JSON response from gateway API: ${error.message}; body=${text.slice(0, 500)}`)); } - }); - }); - const timer = setTimeout(() => req.destroy(new Error(`request timeout after ${options.timeoutMs || DEFAULT_TIMEOUT_MS}ms`)), options.timeoutMs || DEFAULT_TIMEOUT_MS); - req.on('error', reject); - req.on('close', () => clearTimeout(timer)); - req.write(body); - req.end(); - }); -} - -function parseMaybeJson(text) { - if (!text) return null; - try { return JSON.parse(text); } catch { return null; } -} -function compactDispatch(dispatch) { - return { status: dispatch.status || dispatch.dispatchStatus, exitCode: dispatch.exitCode, timedOut: dispatch.timedOut, durationMs: dispatch.durationMs, stdoutTruncated: dispatch.stdoutTruncated, stderrTruncated: dispatch.stderrTruncated, stderr: dispatch.stderr || '', auditId: dispatch.auditId, evidenceId: dispatch.evidenceId }; -} - -function hostJsonSummary(hostJson) { - if (!hostJson || typeof hostJson !== 'object') return {}; - const data = hostJson.data && typeof hostJson.data === 'object' ? hostJson.data : {}; - const result = hostJson.result && typeof hostJson.result === 'object' - ? hostJson.result - : data.result && typeof data.result === 'object' - ? data.result - : {}; - const jobId = data.jobId || hostJson.jobId || hostJson.job_id || result.jobId || result.job_id || null; - const status = data.status || hostJson.status || result.status || null; - const uartCapture = result.uartCapture || data.uartCapture || hostJson.uartCapture || undefined; - return { - hostAction: hostJson.action || null, - hostJobId: jobId, - hostStatus: status, - hostSuccess: typeof hostJson.ok === 'boolean' ? hostJson.ok : result.success, - hostError: hostJson.error || data.error || result.error || undefined, - hostUartCapture: uartCapture, - hostResult: result && Object.keys(result).length > 0 ? result : undefined - }; -} - -function help() { - print({ ok: true, action: 'help', scope: 'HWLAB internal code agents only; not for external UniDesk workspaces.', usage: [ - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id device-pod-71-freq --probe', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile list', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile show --pod-id device-pod-71-freq', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs profile create --pod-id device-pod-71-00075-11 --force', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs health --pod-id device-pod-71-freq', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ ls', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ apply-patch --approved --reason "DEV edit" < patch.diff', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build start', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:workspace:/ build status ', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --approved --reason "DEV smoke"', - 'node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000' - ] }); -} - -async function main() { - const [rawTarget, ...argv] = process.argv.slice(2); - const selector = parseTarget(rawTarget); - if (selector.kind === 'help') return help(); - const parsed = parseOptions(argv); - if (selector.kind === 'doctor') return doctor(parsed); - if (selector.kind === 'profile') return handleProfileCommand(parsed); - if (selector.kind === 'health' && !parsed.podId && !process.env.DEVICE_POD_ID) { - const names = availableProfileNames(); - if (names.length !== 1) return print({ + if (result.error) { + console.log(JSON.stringify({ + generatedAt: new Date().toISOString(), + cli: "device-pod-cli", ok: false, - action: 'health', - status: 'failed', - blocker: 'profile-invalid', - profileDir: PROFILE_DIR, - availableProfiles: names, - next: profileChoiceNext(names), - valuesRedacted: true, - secretMaterialStored: false - }, 1); + status: "failed", + error: { code: "device_pod_cli_spawn_failed", message: result.error.message } + }, null, 2)); + process.exitCode = 127; + } else { + process.exitCode = result.status ?? 0; } - const profile = readProfile(selector.podId || parsed.podId); - if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile), next: [`node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs doctor --pod-id ${profile.podId}`] }); - const built = await buildHostCommand(selector, parsed); - const hostArgs = ['--profile', hostProfilePath(profile), '--pod-id', profile.podId, ...built.commandArgs]; - const command = `${profile.hostCli} ${hostArgs.map(cmdQuote).join(' ')}`; - if (parsed.dryRun) return print({ ok: true, action: 'device-pod.plan', status: 'planned', selector, operation: built.operation, mutating: built.mutating, command, ...profileSummary(profile), route: routeSummary(profile) }); - const result = await invoke(profile, command, parsed); - const ok = result.status === 'succeeded'; - const diagnosis = ok ? null : diagnoseInvokeFailure(profile, result, { operation: built.operation }); - print({ ok, action: 'device-pod.invoke', status: result.status, selector, operation: built.operation, mutating: built.mutating, command, ...profileSummary(profile), route: routeSummary(profile), traceId: result.traceId, operationId: result.operationId, requestId: result.requestId, httpStatus: result.httpStatus, blocker: diagnosis?.blocker, next: diagnosis?.next, diagnosis, ...hostJsonSummary(result.hostJson), dispatch: parsed.full ? result.dispatch : compactDispatch(result.dispatch), hostJson: result.hostJson, evidence: { auditId: result.dispatch.auditId, evidenceId: result.dispatch.evidenceId }, gatewayBody: parsed.full ? result.gatewayBody : undefined }, ok ? 0 : 1); } - -main().catch((error) => fail('device-pod-cli', error)); diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts new file mode 100644 index 00000000..60e8e12a --- /dev/null +++ b/tools/device-pod-cli.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "bun:test"; + +import { runDevicePodCli } from "./src/device-pod-cli-lib.ts"; + +test("device-pod-cli lists cloud-api authority and ignores local profile routes", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-")); + const profileDir = path.join(root, ".device-pod"); + await mkdir(profileDir, { recursive: true }); + await writeFile(path.join(profileDir, "device-pod-71-freq.json"), JSON.stringify({ gatewaySessionId: "gws_local_should_not_be_read", hostWorkspaceRoot: "F:\\legacy" }), "utf8"); + const seen: any[] = []; + const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "profile", "list"], { + env: { DEVICE_POD_PROFILE_DIR: profileDir }, + fetchImpl: async (url, init) => { + seen.push({ url: String(url), init }); + return jsonResponse(200, { ok: true, contractVersion: "device-pod-authority-v1", devicePods: [{ devicePodId: "device-pod-71-freq", profileHash: "sha256:abc", profile: { route: { gatewaySessionId: "redacted" } } }] }); + }, + now: () => "2026-05-29T00:00:00.000Z" + }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.localProfileAuthority, false); + assert.equal(result.payload.localProfileRead, false); + assert.equal(seen[0].url, "http://cloud.test/v1/device-pods"); + assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a"); + assert.equal(JSON.stringify(result.payload).includes("gws_local_should_not_be_read"), false); +}); + +test("device-pod-cli selector creates REST job instead of gateway RPC", async () => { + const seen: any[] = []; + const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "device-pod-71-freq:workspace:/src", "ls"], { + fetchImpl: async (url, init) => { + seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); + return jsonResponse(202, { ok: true, status: "running", job: { id: "job_1", status: "running" }, profileHash: "sha256:abc" }); + }, + now: () => "2026-05-29T00:00:00.000Z" + }); + assert.equal(result.exitCode, 0); + assert.equal(seen[0].url, "http://cloud.test/v1/device-pods/device-pod-71-freq/jobs"); + assert.deepEqual(seen[0].body, { intent: "workspace.ls", args: { path: "src" } }); + assert.equal(JSON.stringify(seen[0].body).includes("gatewaySessionId"), false); +}); + +test("device-pod-cli sends reason and lease token for mutating jobs", async () => { + const seen: any[] = []; + const result = await runDevicePodCli(["--api-base-url", "http://cloud.test", "--session-token", "session-a", "--lease-token", "lease-a", "--reason", "reset smoke", "device-pod-71-freq:debug-probe", "reset"], { + fetchImpl: async (url, init) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return jsonResponse(202, { ok: true, status: "running", job: { id: "job_reset", status: "running" } }); + }, + now: () => "2026-05-29T00:00:00.000Z" + }); + assert.equal(result.exitCode, 0); + assert.deepEqual(seen[0].body, { intent: "debug.reset", args: {}, reason: "reset smoke", leaseToken: "lease-a" }); +}); + +test("device-pod-cli preserves common v0.1 hardware options in REST job args", async () => { + const seen: any[] = []; + const result = await runDevicePodCli([ + "--api-base-url", "http://cloud.test", + "--session-token", "session-a", + "--reason", "boot log", + "--lease-token", "lease-a", + "--capture-uart", "uart/1", + "--capture-duration-ms", "8000", + "--port", "COM4", + "--baud-rate", "921600", + "device-pod-71-freq:debug-probe", + "download", + "start" + ], { + fetchImpl: async (url, init) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return jsonResponse(202, { ok: true, status: "running", job: { id: "job_download", status: "running" } }); + }, + now: () => "2026-05-29T00:00:00.000Z" + }); + assert.equal(result.exitCode, 0); + assert.deepEqual(seen[0].body, { + intent: "debug.download", + args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 }, + reason: "boot log", + leaseToken: "lease-a" + }); +}); + +test("device-pod-cli rejects legacy local profile create in formal mode", async () => { + const result = await runDevicePodCli(["profile", "create", "--pod-id", "device-pod-71-freq"], { now: () => "2026-05-29T00:00:00.000Z" }); + assert.equal(result.exitCode, 1); + assert.equal(result.payload.error.code, "legacy_profile_create_removed"); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} diff --git a/tools/device-pod-cli.ts b/tools/device-pod-cli.ts new file mode 100644 index 00000000..faa9ec22 --- /dev/null +++ b/tools/device-pod-cli.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env bun +import { main } from "./src/device-pod-cli-lib.ts"; + +await main(); diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts new file mode 100644 index 00000000..08896f8a --- /dev/null +++ b/tools/src/device-pod-cli-lib.ts @@ -0,0 +1,266 @@ +const VERSION = "0.2.0-rest"; +const CLI_NAME = "device-pod-cli"; +const DEFAULT_TIMEOUT_MS = 30000; + +type FetchLike = typeof fetch; +type EnvLike = Record; +type ParsedArgs = Record & { _: string[] }; + +export async function main(argv = process.argv.slice(2), options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) { + const result = await runDevicePodCli(argv, options); + console.log(JSON.stringify(result.payload, null, 2)); + process.exitCode = result.exitCode; +} + +export async function runDevicePodCli(argv: string[], options: { env?: EnvLike; fetchImpl?: FetchLike; stdinText?: string; now?: () => string } = {}) { + const env = options.env ?? process.env; + const fetchImpl = options.fetchImpl ?? fetch; + const now = options.now ?? (() => new Date().toISOString()); + try { + const parsed = parseOptions(argv); + const target = parsed._[0] || "help"; + const rest = parsed._.slice(1); + const context = { parsed, rest, env, fetchImpl, stdinText: options.stdinText, now }; + const payload = await dispatch(target, context); + return { exitCode: payload.ok === false ? 1 : 0, payload: withMeta(payload, now) }; + } catch (error) { + return { exitCode: 1, payload: withMeta(failure("device-pod-cli", error), now) }; + } +} + +async function dispatch(target: string, context: any) { + if (["help", "--help", "-h"].includes(target)) return help(); + if (target === "login") return login(context); + if (target === "doctor") return doctor(context); + if (target === "health") return health(context); + if (target === "profile") return profileCommand(context); + if (target === "lease" || target === "leases") return leaseCommand(context); + if (target === "job" || target === "jobs") return jobCommand(context); + const selector = parseSelector(target); + if (!selector) throw cliError("invalid_target_selector", `invalid target selector: ${target}`); + return invokeSelector(selector, context); +} + +function help() { + return ok("help", { + version: VERSION, + contractVersion: "device-pod-rest-cli-v1", + profileAuthority: "hwlab-cloud-api", + localProfileAuthority: false, + configuration: { + apiBaseUrl: "--api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL/HWLAB_CLI_ENDPOINT", + auth: "--session-token, --cookie, --bearer-token, or matching HWLAB_* env" + }, + usage: [ + "device-pod-cli login --api-base-url URL --username USER --password PASS", + "device-pod-cli profile list --api-base-url URL --session-token TOKEN", + "device-pod-cli profile show --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN", + "device-pod-cli device-pod-71-freq:workspace:/ ls --api-base-url URL --session-token TOKEN", + "device-pod-cli device-pod-71-freq:workspace:/ build start --reason TEXT --lease-token TOKEN", + "device-pod-cli job output --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN", + "device-pod-cli lease acquire --pod-id device-pod-71-freq --reason TEXT --api-base-url URL --session-token TOKEN" + ] + }); +} + +async function login({ parsed, env, fetchImpl }: any) { + const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/auth/login", body: { username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"), password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD") }, auth: false }); + return responsePayload("login", response, { setCookie: response.headers.get("set-cookie") ?? null }); +} + +async function doctor({ parsed, env, fetchImpl }: any) { + const podId = text(parsed.podId ?? parsed._[1]); + const probes = []; + for (const path of ["/v1/access/status", "/auth/session", "/v1/device-pods", podId ? `/v1/device-pods/${encodeURIComponent(podId)}/status` : ""].filter(Boolean)) { + try { + const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path }); + probes.push({ path, httpStatus: response.status, ok: response.status < 400, body: compactBody(response.body) }); + } catch (error) { + probes.push({ path, ok: false, blocker: errorSummary(error) }); + } + } + const hardFailed = probes.some((probe) => probe.ok === false && Number(probe.httpStatus ?? 0) >= 500); + return ok("doctor", { status: hardFailed ? "degraded" : "succeeded", profileAuthority: "hwlab-cloud-api", localProfileAuthority: false, localProfileRead: false, devicePodId: podId || null, probes }, hardFailed ? "degraded" : "succeeded"); +} + +async function health({ parsed, rest, env, fetchImpl }: any) { + const podId = text(parsed.podId ?? rest[0]); + const path = podId ? `/v1/device-pods/${encodeURIComponent(podId)}/status` : "/health/live"; + const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path, auth: Boolean(podId) }); + return responsePayload("health", response, { route: { method: "GET", path }, devicePodId: podId || null, localProfileAuthority: false }); +} + +async function profileCommand({ parsed, rest, env, fetchImpl }: any) { + const subcommand = rest[0] || "list"; + if (subcommand === "create") { + throw cliError("legacy_profile_create_removed", "local device-pod profile creation is removed from v0.2 formal mode; use cloud-api admin device-pod APIs", { next: ["POST /v1/admin/device-pods", "POST /v1/admin/device-pod-grants"] }); + } + if (subcommand === "list") { + const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path: "/v1/device-pods" }); + return responsePayload("profile.list", response, { localProfileAuthority: false, localProfileRead: false }); + } + if (subcommand === "show") { + const podId = requiredText(parsed.podId ?? rest[1], "podId"); + const path = `/v1/device-pods/${encodeURIComponent(podId)}/status`; + const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path }); + return responsePayload("profile.show", response, { route: { method: "GET", path }, devicePodId: podId, localProfileAuthority: false, localProfileRead: false }); + } + throw cliError("unsupported_profile_command", `unsupported profile command: ${subcommand}`); +} + +async function leaseCommand({ parsed, rest, env, fetchImpl }: any) { + const subcommand = rest[0] || "current"; + const podId = requiredText(parsed.podId ?? rest[1], "podId"); + const basePath = `/v1/device-pods/${encodeURIComponent(podId)}/leases`; + if (subcommand === "acquire" || subcommand === "create") { + const body = clean({ reason: text(parsed.reason), ttlSeconds: numberOption(parsed.ttlSeconds), agentSessionId: text(parsed.agentSessionId), projectId: text(parsed.projectId) }); + const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: basePath, body }); + return responsePayload("lease.acquire", response, { devicePodId: podId }); + } + if (subcommand === "current" || subcommand === "show") { + const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path: `${basePath}/current` }); + return responsePayload("lease.current", response, { devicePodId: podId }); + } + if (subcommand === "release" || subcommand === "delete") { + const leaseToken = text(parsed.leaseToken ?? env.HWLAB_DEVICE_LEASE_TOKEN); + const response = await requestJson({ parsed, env, fetchImpl, method: "DELETE", path: `${basePath}/current`, extraHeaders: leaseToken ? { "x-hwlab-device-lease-token": leaseToken } : {} }); + return responsePayload("lease.release", response, { devicePodId: podId, leaseTokenProvided: Boolean(leaseToken) }); + } + throw cliError("unsupported_lease_command", `unsupported lease command: ${subcommand}`); +} + +async function jobCommand({ parsed, rest, env, fetchImpl }: any) { + const subcommand = rest[0] || "status"; + const podId = requiredText(parsed.podId, "podId"); + const jobId = requiredText(rest[1] ?? parsed.jobId, "jobId"); + const suffix = subcommand === "output" ? "/output" : subcommand === "cancel" ? "/cancel" : ""; + const method = subcommand === "cancel" ? "POST" : "GET"; + const path = `/v1/device-pods/${encodeURIComponent(podId)}/jobs/${encodeURIComponent(jobId)}${suffix}`; + const response = await requestJson({ parsed, env, fetchImpl, method, path }); + return responsePayload(`job.${subcommand}`, response, { route: { method, path }, devicePodId: podId, jobId }); +} + +async function invokeSelector(selector: any, context: any) { + const { parsed, rest } = context; + const operation = rest[0] || defaultOperation(selector.surface); + const jobRoute = jobRouteFromSelector(selector, operation, rest, parsed); + if (jobRoute) return jobCommand({ ...context, parsed: { ...parsed, podId: selector.podId }, rest: jobRoute }); + if (selector.surface === "debug-probe" && operation === "status") return probeGet("debug.status", selector, "/status", context); + const job = await buildJob(selector, operation, rest, parsed, context.stdinText); + if (parsed.dryRun === true) return ok("device-pod.plan", { route: { method: "POST", path: `/v1/device-pods/${encodeURIComponent(selector.podId)}/jobs` }, request: redactJobRequest(job), selector, localProfileAuthority: false, localProfileRead: false }); + const response = await requestJson({ parsed, env: context.env, fetchImpl: context.fetchImpl, method: "POST", path: `/v1/device-pods/${encodeURIComponent(selector.podId)}/jobs`, body: job }); + return responsePayload("device-pod.invoke", response, { selector, intent: job.intent, mutating: MUTATING_INTENTS.has(job.intent), localProfileAuthority: false, localProfileRead: false }); +} + +async function probeGet(action: string, selector: any, suffix: string, { parsed, env, fetchImpl }: any) { + const path = `/v1/device-pods/${encodeURIComponent(selector.podId)}${suffix}`; + const response = await requestJson({ parsed, env, fetchImpl, method: "GET", path }); + return responsePayload(action, response, { route: { method: "GET", path }, selector, localProfileAuthority: false }); +} + +async function buildJob(selector: any, operation: string, rest: string[], parsed: ParsedArgs, stdinText?: string) { + const args = clean({}); + let intent = ""; + if (selector.surface === "workspace") { + const basePath = selector.path || "."; + if (operation === "ls") { intent = "workspace.ls"; args.path = joinPath(basePath, rest[1] || ""); } + else if (operation === "cat") { intent = "workspace.cat"; args.path = joinPath(basePath, rest[1] || ""); args.maxBytes = numberOption(parsed.maxBytes ?? parsed.limit); } + else if (operation === "rg") { intent = "workspace.rg"; args.pattern = requiredText(rest[1], "pattern"); args.path = joinPath(basePath, rest[2] || ""); Object.assign(args, passthroughOptions(parsed, ["glob", "g", "filesWithMatches", "l", "ignoreCase", "i", "maxCount"])); } + else if (operation === "apply-patch") { intent = "workspace.apply-patch"; args.path = joinPath(basePath, rest[1] || ""); args.patch = await patchText(parsed, stdinText); } + else if (operation === "build") { intent = "workspace.build"; args.action = rest[1] || "start"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs"])); } + else throw cliError("unsupported_workspace_operation", `unsupported workspace operation: ${operation}`); + } else if (selector.surface === "debug-probe") { + if (operation === "chip-id") intent = "debug.chip-id"; + else if (operation === "download") { intent = "debug.download"; args.action = rest[1] || "start"; Object.assign(args, passthroughOptions(parsed, ["target", "timeoutMs", "captureUart", "captureDurationMs", "durationMs", "port", "baudRate"])); } + else if (operation === "reset") intent = "debug.reset"; + else throw cliError("unsupported_debug_operation", `unsupported debug-probe operation: ${operation}`); + } else if (selector.surface === "io-probe") { + const uartId = normalizeProbePath(selector.path || "uart/1"); + if (operation === "ports") { intent = "io.ports"; args.uartId = uartId; } + else if (operation === "read") { intent = "io.uart.read"; args.uartId = uartId; Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate"])); } + else if (operation === "read-after-launch-flash") { intent = "io.uart.read-after-launch-flash"; args.uartId = uartId; Object.assign(args, passthroughOptions(parsed, ["durationMs", "port", "baudRate", "flashBase", "skipHardwareReset", "connectMode", "timeoutMs"])); } + else if (operation === "write") { intent = "io.uart.write"; args.uartId = uartId; args.message = text(rest[1] ?? parsed.message ?? parsed.text ?? parsed.data); args.hex = parsed.hex === true; Object.assign(args, passthroughOptions(parsed, ["port", "baudRate"])); } + else throw cliError("unsupported_io_operation", `unsupported io-probe operation: ${operation}`); + } + return clean({ intent, args: clean(args), reason: text(parsed.reason), leaseToken: text(parsed.leaseToken) }); +} + +function jobRouteFromSelector(selector: any, operation: string, rest: string[], parsed: ParsedArgs) { + if (operation === "status" || operation === "output" || operation === "cancel") return [operation, requiredText(rest[1] ?? parsed.jobId, "jobId")]; + if ((selector.surface === "workspace" && operation === "build") || (selector.surface === "debug-probe" && operation === "download")) { + if (["status", "output", "cancel"].includes(rest[1])) return [rest[1], requiredText(rest[2] ?? parsed.jobId, "jobId")]; + } + return null; +} + +async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = true, extraHeaders = {} }: any) { + const baseUrl = apiBaseUrl(parsed, env); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS); + try { + const response = await fetchImpl(`${baseUrl}${path}`, { + method, + headers: clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(auth ? authHeaders(parsed, env) : {}), ...extraHeaders }), + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal + }); + const textBody = await response.text(); + return { status: response.status, headers: response.headers, body: parseJson(textBody) }; + } finally { + clearTimeout(timeout); + } +} + +function parseOptions(argv: string[]): ParsedArgs { + const out: ParsedArgs = { _: [] }; + for (let i = 0; i < argv.length; i += 1) { + const item = argv[i] ?? ""; + if (!item.startsWith("--")) { out._.push(item); continue; } + const eq = item.indexOf("="); + const rawKey = eq >= 0 ? item.slice(2, eq) : item.slice(2); + const key = rawKey.replace(/-([a-z])/gu, (_, c) => String(c).toUpperCase()); + if (eq >= 0) { out[key] = item.slice(eq + 1); continue; } + const next = argv[i + 1]; + if (next && !next.startsWith("--")) { out[key] = next; i += 1; } + else out[key] = true; + } + return out; +} + +function parseSelector(raw: string) { + const match = raw.match(/^([^:]+):(workspace|debug-probe|io-probe)(?::(.*))?$/u); + return match ? { podId: match[1], surface: match[2], path: match[2] === "io-probe" ? normalizeProbePath(match[3] || "") : normalizePath(match[3] || ".") } : null; +} + +function passthroughOptions(parsed: ParsedArgs, keys: string[]) { + const out: Record = {}; + for (const key of keys) { + const value = parsed[key]; + if (value === undefined || value === false || value === "") continue; + out[key] = typeof value === "string" && /^\d+$/u.test(value) ? Number.parseInt(value, 10) : value; + } + return out; +} + +const MUTATING_INTENTS = new Set(["workspace.apply-patch", "workspace.build", "debug.download", "debug.reset", "io.uart.read-after-launch-flash", "io.uart.write"]); +function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; } +function apiBaseUrl(parsed: ParsedArgs, env: EnvLike) { const value = text(parsed.apiBaseUrl ?? parsed.apiUrl ?? env.HWLAB_DEVICE_POD_API_URL ?? env.HWLAB_CLOUD_API_URL ?? env.HWLAB_CLI_ENDPOINT); if (!value) throw cliError("api_base_url_required", "device-pod-cli requires --api-base-url or HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL/HWLAB_CLI_ENDPOINT"); return value.replace(/\/+$/u, ""); } +function authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); } +function responsePayload(action: string, response: any, extra: Record = {}) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; return { ok: success, action, status: success ? "succeeded" : "failed", httpStatus: response.status, ...extra, body: response.body }; } +function withMeta(payload: any, now: () => string) { return { generatedAt: now(), cli: CLI_NAME, version: VERSION, ...payload }; } +function ok(action: string, data: Record = {}, status = "succeeded") { return { ok: true, action, status, ...data }; } +function failure(action: string, error: any) { return { ok: false, action, status: "failed", error: errorSummary(error), ...(error?.details ? { details: error.details } : {}) }; } +function cliError(code: string, message: string, details: Record = {}) { return Object.assign(new Error(message), { code, details }); } +function errorSummary(error: any) { return { code: error?.code ?? "device_pod_cli_error", message: error?.message ?? String(error), ...(error?.details ? { details: error.details } : {}) }; } +function requiredOption(parsed: ParsedArgs, env: EnvLike, key: string, envKey: string) { return requiredText(parsed[key] ?? env[envKey], key); } +function requiredText(value: unknown, field: string) { const valueText = text(value); if (!valueText) throw cliError("missing_required_value", `${field} is required`, { field }); return valueText; } +function text(value: unknown) { return String(value ?? "").trim(); } +function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; } +function clean>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false)) as T; } +function normalizePath(value: string) { return String(value || ".").trim().replace(/\\/gu, "/").replace(/\s*\/\s*/gu, "/").replace(/\/+/gu, "/").replace(/^\/+|\/+$/gu, "") || "."; } +function normalizeProbePath(value: string) { const normalized = normalizePath(value); if (normalized === ".") return "uart/1"; if (normalized === "uart") throw cliError("invalid_io_probe_path", "io-probe path must be concrete, for example /uart/1"); return normalized; } +function joinPath(base: string, child: string) { const left = normalizePath(base); const right = normalizePath(child || "."); if (!right || right === ".") return left; if (child.startsWith("/")) return right; return normalizePath(`${left}/${right}`); } +async function patchText(parsed: ParsedArgs, stdinText?: string) { if (typeof parsed.patch === "string") return parsed.patch; if (typeof parsed.patchB64 === "string") return Buffer.from(parsed.patchB64, "base64").toString("utf8"); if (stdinText !== undefined) return stdinText; const chunks = []; for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk)); const textValue = Buffer.concat(chunks).toString("utf8"); if (!textValue.trim()) throw cliError("patch_text_required", "workspace apply-patch requires patch text on stdin or --patch/--patch-b64"); return textValue; } +function redactJobRequest(job: any) { return { ...job, args: job.args?.patch ? { ...job.args, patch: undefined, patchBytes: Buffer.byteLength(job.args.patch, "utf8") } : job.args }; } +function parseJson(value: string) { if (!value) return null; try { return JSON.parse(value); } catch { return { rawText: value.slice(0, 2000), parseError: true }; } } +function compactBody(body: any) { if (!body || typeof body !== "object") return body; return { ok: body.ok, status: body.status, authenticated: body.authenticated, actor: body.actor, contractVersion: body.contractVersion, devicePodCount: Array.isArray(body.devicePods) ? body.devicePods.length : undefined, selectedDevicePodId: body.selectedDevicePodId, error: body.error, summary: body.summary }; }