feat: route device pod probes through jobs

This commit is contained in:
Codex
2026-05-29 12:31:38 +08:00
parent 34f04d10e3
commit 6367ea2e16
4 changed files with 135 additions and 26 deletions
+3 -1
View File
@@ -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 probecloud-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`
## 微服务职责
+2 -2
View File
@@ -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 authorityprobe 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。 |
+75
View File
@@ -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" },
+55 -23
View File
@@ -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}`)}`; }