From 6367ea2e16f55119babe25e8ad4c3383716f4ab7 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 12:31:38 +0800 Subject: [PATCH] feat: route device pod probes through jobs --- docs/reference/spec-device-pod.md | 4 +- docs/reference/spec-v02-hwlab-cloud-api.md | 4 +- internal/cloud/access-control.test.ts | 75 +++++++++++++++++++++ internal/cloud/access-control.ts | 78 +++++++++++++++------- 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 076c0b3a..3fb4ec75 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -203,7 +203,9 @@ DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId} - `io.uart.read-after-launch-flash` - `io.uart.write` -所有 job/status/output 响应必须包含 `devicePodId`、`targetId`、`profileHash`、`traceId`、`operationId`、`status`、`freshness`、`blocker` 和 bounded output metadata。job output 文本默认最大 12000 bytes;超出时必须设置 `truncation.truncated=true`、`truncation.originalBytes`,并避免把完整 executor/gateway 原始输出嵌回 JSON。真实硬件响应不得把 fake、dry-run、SOURCE、LOCAL 或过期缓存标为 `DEV-LIVE`。 +`GET /debug-probe/chip-id`、`GET /io-probe/uart/1` 和 `GET /io-probe/uart/1/tail` 是用户态便捷 REST surface,但不能停留在静态面板或 fake probe;cloud-api 必须在完成 authenticate/grant 后创建对应只读 job,再经 `hwlab-device-pod` executor/gateway/device-host-cli 执行或返回同一套 blocker。 + +所有 job/status/output 和 probe GET 响应必须包含 `devicePodId`、`targetId`、`profileHash`、`traceId`、`operationId`、`status`、`freshness`、`blocker` 和 bounded output metadata。job output 文本默认最大 12000 bytes;超出时必须设置 `truncation.truncated=true`、`truncation.originalBytes`,并避免把完整 executor/gateway 原始输出嵌回 JSON。真实硬件响应不得把 fake、dry-run、SOURCE、LOCAL 或过期缓存标为 `DEV-LIVE`。 ## 微服务职责 diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index 748e23cf..ddf03360 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -27,7 +27,7 @@ | `GET /v1` | REST adapter 索引、RPC 方法、runtime readiness 和 device-pod/M3 能力摘要。 | | `POST /rpc`、`POST /json-rpc` | JSON-RPC 入口,支持 system、adapter、gateway、hardware、audit、evidence 和 M3 方法。 | | `POST /v1/rpc/{method}` | REST 到 JSON-RPC 的桥接入口。 | -| `GET /v1/device-pods...` | 经 cloud-api 鉴权后读取服务端 profile/grant/job authority;不会回退到 fake device pod 数据。 | +| `GET /v1/device-pods...` | 经 cloud-api 鉴权后读取服务端 profile/grant/job authority;probe GET 会创建只读 device job 并经 `hwlab-device-pod` executor/gateway 执行或返回同源 blocker,不会回退到 fake device pod 数据。 | | `GET /auth/session`、`POST /auth/login`、`POST /auth/logout` | v0.2 本地用户 session 入口。 | | `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | v0.2 用户/session/setup 的 REST 状态和兼容入口;响应不得暴露 password hash、session token 原文或 Secret 值。 | | `POST /v1/admin/users`、`POST/PUT /v1/admin/device-pods`、`POST/DELETE /v1/admin/device-pod-grants...` | `admin` 管理用户、device pod profile 和 grant 的入口。 | @@ -61,6 +61,6 @@ | Code Agent 短连接 submit/result/trace/cancel | 已实现 | repo-owned Codex stdio 和 provider profile 已接入。 | | Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 | | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | -| device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 | +| device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;已提供内部 gateway dispatch route 供 `hwlab-device-pod` executor 下发到 device-host-cli,无在线 gateway/device-host-cli 时返回 blocker。 | | v0.2 admin/user 权限模型 | 部分实现 | `/auth/*`、admin user/device-pod/grant API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 7954eb64..787d6504 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -364,6 +364,81 @@ test("cloud api bounds device-pod job output payloads", async () => { } }); +test("cloud api routes device-pod probe GET requests through executor jobs", async () => { + const executorRequests = []; + const executor = createServer(async (request, response) => { + const body = await requestJson(request); + executorRequests.push({ method: request.method, url: request.url, body }); + const text = body.intent === "debug.chip-id" ? "chip-id: 0x12345678" : "uart tail output"; + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify({ + accepted: true, + status: "completed", + contractVersion: "device-pod-executor-v1", + devicePodId: body.devicePodId, + traceId: body.traceId, + operationId: body.operationId, + job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent }, + output: { text }, + text + })); + }); + await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); + const executorPort = executor.address().port; + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", + HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` + }, + now: () => "2026-05-28T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie); + await postJson(port, "/v1/admin/device-pods", { + devicePodId: "device-pod-71-freq", + profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } + }, adminLogin.cookie); + await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie); + const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); + + const chip = await getJson(port, "/v1/device-pods/device-pod-71-freq/debug-probe/chip-id", aliceLogin.cookie); + assert.equal(chip.status, 200); + assert.equal(chip.body.interface, "debug-probe"); + assert.equal(chip.body.intent, "debug.chip-id"); + assert.equal(chip.body.job.intent, "debug.chip-id"); + assert.equal(chip.body.output.text, "chip-id: 0x12345678"); + assert.equal(chip.body.source.fake, false); + + const uart = await getJson(port, "/v1/device-pods/device-pod-71-freq/io-probe/uart/1/tail?durationMs=250&maxBytes=20", aliceLogin.cookie); + assert.equal(uart.status, 200); + assert.equal(uart.body.interface, "io-probe"); + assert.equal(uart.body.intent, "io.uart.read"); + assert.equal(uart.body.output.text, "uart tail output"); + assert.equal(uart.body.truncation.maxBytes, 20); + + assert.equal(executorRequests.length, 2); + assert.equal(executorRequests[0].method, "POST"); + assert.equal(executorRequests[0].body.intent, "debug.chip-id"); + assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id); + assert.equal(executorRequests[1].body.intent, "io.uart.read"); + assert.deepEqual(executorRequests[1].body.args, { uartId: "uart/1", durationMs: 250, maxBytes: 20 }); + + const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceLogin.cookie); + assert.equal(events.status, 200); + assert.ok(events.body.events.some((event) => event.intent === "debug.chip-id")); + assert.ok(events.body.events.some((event) => event.intent === "io.uart.read")); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api internal device-pod gateway dispatch is service-only and fail-closed without online gateway", async () => { const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index ea3e28e2..70feca55 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -333,9 +333,9 @@ class AccessController { if (request.method === "GET" && parsed.route === "status") return sendJson(response, 200, this.devicePodStatus(pod, auth.actor)); if (request.method === "GET" && parsed.route === "events") return sendJson(response, 200, await this.devicePodEvents(pod, url.searchParams)); - if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return sendJson(response, 200, this.devicePodProbe(pod, "debug.chip-id")); - if (request.method === "GET" && parsed.route === "io-probe/uart/1") return sendJson(response, 200, this.devicePodProbe(pod, "io.uart.status")); - if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return sendJson(response, 200, this.devicePodOutputProbe(pod, url.searchParams)); + if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "debug-probe", intent: "debug.chip-id" }); + if (request.method === "GET" && parsed.route === "io-probe/uart/1") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.ports", args: { uartId: "uart/1" } }); + if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.uart.read", args: uartTailArgs(url.searchParams, "uart/1"), outputMaxBytes: boundedOutputMaxBytes(url.searchParams) }); if (request.method === "POST" && parsed.route === "leases") return this.acquireDevicePodLease(request, response, pod, auth.actor); if (request.method === "GET" && parsed.route === "leases/current") return this.getDevicePodLease(response, pod, auth.actor); if (request.method === "DELETE" && parsed.route === "leases/current") return this.releaseDevicePodLease(request, response, pod, auth.actor); @@ -590,27 +590,56 @@ class AccessController { }; } - devicePodProbe(pod, intent) { - const status = this.devicePodStatus(pod, { id: "system_probe", role: "admin" }); - return { - ...status, - interface: intent.startsWith("debug") ? "debug-probe" : "io-probe", + async createDevicePodProbeJob(response, pod, actor, { interfaceName, intent, args = {}, outputMaxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES }) { + const result = await this.createReadOnlyDevicePodJob({ pod, actor, intent, args }); + const payload = this.jobOutputPayload(result.job, pod, { maxBytes: outputMaxBytes }); + return sendJson(response, result.httpStatus, { + ...payload, + interface: interfaceName, intent, - blocker: status.summary.blocker, - refs: { traceId: `trc_${pod.id}_${intent.replace(/[^a-z0-9]+/giu, "_")}` } - }; + source: authoritySource() + }); } - devicePodOutputProbe(pod, searchParams) { - return { - ...this.devicePodProbe(pod, "io.uart.read"), - text: "", - bytes: 0, - truncation: { - maxBytes: Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), 12000), - truncated: false - } - }; + async createReadOnlyDevicePodJob({ pod, actor, intent, args = {} }) { + if (!DEVICE_JOB_INTENTS.has(intent) || MUTATING_INTENTS.has(intent)) { + throw Object.assign(new Error(`Device probe intent ${intent} is not supported as a read-only probe`), { statusCode: 400, code: "unsupported_device_probe_intent" }); + } + const traceId = `trc_devicepod_${randomUUID()}`; + const operationId = `op_devicepod_${randomUUID()}`; + const now = this.now(); + const job = await this.store.createDevicePodJob({ + id: `job_devicepod_${randomUUID()}`, + devicePodId: pod.id, + ownerUserId: actor.id, + status: "running", + intent, + args: normalizeObject(args), + reason: "", + traceId, + operationId, + output: { text: "" }, + blocker: null, + now, + completedAt: null + }); + if (this.devicePodExecutorUrl) return this.dispatchDevicePodExecutorJob({ job, pod, actor }); + + const route = pod.profile.route ?? {}; + const gatewaySessionId = textOr(route.gatewaySessionId, ""); + const gatewayOnline = gatewaySessionId && this.gatewayRegistry?.isOnline?.(gatewaySessionId) === true; + const blocker = gatewayOnline ? null : gatewayDispatchBlocker(gatewaySessionId ? "gateway session is not connected" : "profile route.gatewaySessionId is missing"); + if (blocker) { + const blocked = await this.store.updateDevicePodJob(pod.id, job.id, { + status: "blocked", + blocker, + completedAt: now, + updatedAt: now + }); + return { job: blocked ?? job, httpStatus: 409 }; + } + this.dispatchDevicePodJob({ job, pod, actor }); + return { job, httpStatus: 202 }; } async createDevicePodJob(request, response, pod, actor) { @@ -865,8 +894,8 @@ class AccessController { }; } - jobOutputPayload(job, pod) { - const bounded = boundedOutput(job.output, DEVICE_JOB_OUTPUT_MAX_BYTES); + jobOutputPayload(job, pod, { maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES } = {}) { + const bounded = boundedOutput(job.output, maxBytes); return { ...this.jobPayload(job, pod), output: bounded.output, @@ -1049,6 +1078,9 @@ function textOr(value, fallback) { const text = String(value ?? "").trim(); retu function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } function normalizeBaseUrl(value) { const text = textOr(value, "").replace(/\/+$/u, ""); return /^https?:\/\//u.test(text) ? text : ""; } function boundedLeaseTtlSeconds(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_DEVICE_LEASE_TTL_SECONDS, 1), MAX_DEVICE_LEASE_TTL_SECONDS); } +function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), DEVICE_JOB_OUTPUT_MAX_BYTES); } +function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : fallback, 1), 60000); } +function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function leaseTokenFromRequest(request, body) { return textOr(body?.leaseToken, "") || textOr(getHeader(request, "x-hwlab-device-lease-token"), ""); } function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; }