feat: enforce v02 runtime authority

This commit is contained in:
Codex
2026-05-29 08:10:19 +08:00
parent bc816cea4b
commit b99c663614
18 changed files with 461 additions and 144 deletions
+28 -6
View File
@@ -5,28 +5,50 @@ import test from "node:test";
const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun"); const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun");
test("device pod fake service exposes health, list, status, events, and method guards", async () => { test("device pod executor exposes cloud-api authority boundary and method guards", async () => {
const service = await startDevicePod("device-pod-test"); const service = await startDevicePod("device-pod-test");
try { try {
const health = await fetchJson(`http://127.0.0.1:${service.port}/health/live`); const health = await fetchJson(`http://127.0.0.1:${service.port}/health/live`);
assert.equal(health.serviceId, "hwlab-device-pod"); assert.equal(health.serviceId, "hwlab-device-pod");
assert.equal(health.status, "live"); assert.equal(health.status, "live");
assert.equal(health.devicePodId, "device-pod-test"); assert.equal(health.devicePodId, "device-pod-test");
assert.equal(health.role, "fake-device-pod-server"); assert.equal(health.contractVersion, "device-pod-executor-v1");
assert.equal(health.role, "device-pod-internal-executor");
assert.equal(health.authority, "hwlab-cloud-api");
assert.equal(health.fake, false);
const list = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods`); const list = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods`);
assert.equal(list.serviceId, "hwlab-device-pod"); assert.equal(list.serviceId, "hwlab-device-pod");
assert.equal(list.selectedDevicePodId, "device-pod-test"); assert.equal(list.selectedDevicePodId, "device-pod-test");
assert.equal(list.devicePods.length, 1); assert.equal(list.devicePods.length, 1);
assert.equal(list.source.fake, false);
const status = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/status`); const status = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/status`);
assert.equal(status.devicePod.devicePodId, "device-pod-test"); assert.equal(status.devicePodId, "device-pod-test");
assert.equal(status.summary.targetId, "stm32f103-minsys-01"); assert.equal(status.status, "blocked");
assert.equal(status.summary.blocker.code, "gateway_dispatch_unavailable");
const events = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/events?limit=1`); const events = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/events?limit=1`);
assert.equal(events.events.length, 1); assert.equal(events.events.length, 1);
assert.equal(events.truncation.limit, 1); assert.equal(events.truncation.limit, 1);
assert.equal(events.truncation.truncated, true); assert.equal(events.truncation.truncated, false);
assert.equal(events.events[0].blocker.code, "gateway_dispatch_unavailable");
const externalJob = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ intent: "workspace.ls" })
});
assert.equal(externalJob.status, 403);
assert.equal((await externalJob.json()).error.code, "cloud_api_authority_required");
const internalJob = 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({ intent: "workspace.ls", traceId: "trc_device_pod_test" })
});
assert.equal(internalJob.status, 409);
assert.equal((await internalJob.json()).blocker.code, "gateway_dispatch_unavailable");
const missing = await fetch(`http://127.0.0.1:${service.port}/missing`); const missing = await fetch(`http://127.0.0.1:${service.port}/missing`);
assert.equal(missing.status, 404); assert.equal(missing.status, 404);
@@ -34,7 +56,7 @@ test("device pod fake service exposes health, list, status, events, and method g
const methodGuard = await fetch(`http://127.0.0.1:${service.port}/health`, { method: "POST" }); const methodGuard = await fetch(`http://127.0.0.1:${service.port}/health`, { method: "POST" });
assert.equal(methodGuard.status, 405); assert.equal(methodGuard.status, 405);
assert.deepEqual(await methodGuard.json(), { error: "method_not_allowed", method: "POST" }); assert.deepEqual(await methodGuard.json(), { error: "method_not_allowed", method: "POST", path: "/health" });
} finally { } finally {
await service.stop(); await service.stop();
} }
+156 -29
View File
@@ -1,47 +1,174 @@
#!/usr/bin/env node #!/usr/bin/env node
import { createServer } from "node:http"; import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { jsonResponse, listen, parsePort } from "../../internal/sim/http.mjs"; import { jsonResponse, listen, parsePort, readJson } from "../../internal/sim/http.mjs";
import {
DEFAULT_DEVICE_POD_ID,
DEVICE_POD_SERVICE_ID,
buildDevicePodRestPayload
} from "../../internal/device-pod/fake-data.mjs";
const SERVICE_ID = "hwlab-device-pod";
const CONTRACT_VERSION = "device-pod-executor-v1";
const DEFAULT_DEVICE_POD_ID = "device-pod-71-freq";
const port = parsePort(process.env.HWLAB_DEVICE_POD_PORT, parsePort(process.env.PORT, 7601)); const port = parsePort(process.env.HWLAB_DEVICE_POD_PORT, parsePort(process.env.PORT, 7601));
const devicePodId = process.env.HWLAB_DEVICE_POD_ID || DEFAULT_DEVICE_POD_ID; const devicePodId = process.env.HWLAB_DEVICE_POD_ID || DEFAULT_DEVICE_POD_ID;
const environment = process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PROFILE || "dev";
listen(createServer((request, response) => { listen(createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://localhost"); try {
if (request.method !== "GET") { const url = new URL(request.url ?? "/", "http://localhost");
jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN" }); if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
return; jsonResponse(response, 200, healthPayload());
return;
}
if (url.pathname === "/health" || url.pathname === "/health/live") {
jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN", path: url.pathname });
return;
}
if (request.method === "GET" && url.pathname === "/v1/device-pods") {
jsonResponse(response, 200, listPayload());
return;
}
const route = parseDevicePodPath(url.pathname);
if (!route) {
jsonResponse(response, 404, { error: "not_found", path: url.pathname });
return;
}
if (request.method === "GET" && route.subpath === "status") {
jsonResponse(response, 200, statusPayload(route.devicePodId));
return;
}
if (request.method === "GET" && route.subpath === "events") {
jsonResponse(response, 200, eventsPayload(route.devicePodId, url.searchParams));
return;
}
if (request.method === "POST" && route.subpath === "jobs") {
await handleInternalJob(request, response, route.devicePodId);
return;
}
jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN", path: url.pathname });
} catch (error) {
jsonResponse(response, 500, { error: "internal_error", message: error?.message ?? String(error), valuesRedacted: true });
} }
if (url.pathname === "/health" || url.pathname === "/health/live") {
jsonResponse(response, 200, healthPayload());
return;
}
const payload = buildDevicePodRestPayload(url.pathname, url.searchParams, {
devicePodId,
sourceKind: "FAKE-SERVICE"
});
if (!payload) {
jsonResponse(response, 404, { error: "not_found", path: url.pathname });
return;
}
jsonResponse(response, 200, payload);
}), port); }), port);
function healthPayload() { function healthPayload() {
return { return {
serviceId: DEVICE_POD_SERVICE_ID, serviceId: SERVICE_ID,
environment: process.env.HWLAB_ENVIRONMENT || "dev", environment,
status: "live", status: "live",
ready: true, ready: true,
contractVersion: "device-pod-fake-v1", contractVersion: CONTRACT_VERSION,
devicePodId, devicePodId,
observedAt: new Date().toISOString(), observedAt: new Date().toISOString(),
role: "fake-device-pod-server", role: "device-pod-internal-executor",
note: "Only fake frontend data is served; no hardware connection is opened." authority: "hwlab-cloud-api",
acceptsUserAuthority: false,
fake: false,
source: sourcePayload(),
note: "Profile, grant, lease, and user-facing job authority live in hwlab-cloud-api; this service only exposes the internal executor boundary."
}; };
} }
function listPayload() {
return {
serviceId: SERVICE_ID,
contractVersion: CONTRACT_VERSION,
status: "ok",
source: sourcePayload(),
selectedDevicePodId: devicePodId,
devicePods: [{
devicePodId,
status: "executor-ready",
authority: "hwlab-cloud-api",
fake: false,
routes: {
cloudAuthority: "/v1/device-pods",
internalJob: `/v1/device-pods/${encodeURIComponent(devicePodId)}/jobs`
}
}]
};
}
function statusPayload(id) {
return {
serviceId: SERVICE_ID,
contractVersion: CONTRACT_VERSION,
status: "blocked",
devicePodId: id,
observedAt: new Date().toISOString(),
source: sourcePayload(),
summary: {
devicePodId: id,
status: "blocked",
blocker: gatewayAdapterBlocker("device-host-cli dispatch is owned by hwlab-cloud-api gateway routing")
}
};
}
function eventsPayload(id, searchParams) {
const limit = Math.min(Math.max(Number.parseInt(searchParams.get("limit") ?? "80", 10) || 80, 1), 1000);
const event = {
eventId: `evt_${id}_executor_boundary`,
devicePodId: id,
ts: new Date().toISOString(),
level: "warn",
scope: "executor",
status: "blocked",
summary: "No standalone fake device events are emitted by hwlab-device-pod; use hwlab-cloud-api job authority.",
blocker: gatewayAdapterBlocker("standalone device-pod executor has no gateway session")
};
return {
serviceId: SERVICE_ID,
contractVersion: CONTRACT_VERSION,
status: "ok",
devicePodId: id,
source: sourcePayload(),
events: limit > 0 ? [event] : [],
truncation: { limit, returned: limit > 0 ? 1 : 0, truncated: false }
};
}
async function handleInternalJob(request, response, id) {
if (!isInternalCaller(request)) {
jsonResponse(response, 403, {
accepted: false,
status: "blocked",
error: {
code: "cloud_api_authority_required",
message: "Device Pod jobs must be authorized by hwlab-cloud-api before reaching the internal executor."
},
source: sourcePayload()
});
return;
}
const body = await readJson(request);
const traceId = typeof body.traceId === "string" && body.traceId.startsWith("trc_") ? body.traceId : `trc_devicepod_${randomUUID()}`;
jsonResponse(response, 409, {
accepted: false,
status: "blocked",
contractVersion: CONTRACT_VERSION,
devicePodId: id,
traceId,
operationId: typeof body.operationId === "string" ? body.operationId : `op_devicepod_${randomUUID()}`,
blocker: gatewayAdapterBlocker("gateway/device-host-cli adapter is not connected to this standalone executor service"),
source: sourcePayload()
});
}
function parseDevicePodPath(pathname) {
const prefix = "/v1/device-pods/";
if (!pathname.startsWith(prefix)) return null;
const [id, ...rest] = pathname.slice(prefix.length).split("/").filter(Boolean).map((part) => decodeURIComponent(part));
return id ? { devicePodId: id, subpath: rest.join("/") } : null;
}
function isInternalCaller(request) {
const header = request.headers["x-hwlab-internal-service"];
return String(Array.isArray(header) ? header[0] : header ?? "").trim() === "hwlab-cloud-api";
}
function sourcePayload() {
return { kind: "INTERNAL_EXECUTOR", serviceId: SERVICE_ID, authority: "hwlab-cloud-api", fake: false, devLiveEvidence: false };
}
function gatewayAdapterBlocker(summary) {
return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod executor boundary is present, but gateway/device-host-cli dispatch is not connected here." };
}
+6 -1
View File
@@ -203,7 +203,12 @@
"serviceId": "hwlab-cloud-api", "serviceId": "hwlab-cloud-api",
"env": { "env": {
"HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-v02-db/database-url", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-v02-db/database-url",
"HWLAB_CLOUD_DB_CONTRACT": "v02-redacted-presence-only" "HWLAB_CLOUD_DB_CONTRACT": "v02-redacted-presence-only",
"HWLAB_ACCESS_CONTROL_REQUIRED": "1",
"HWLAB_BOOTSTRAP_ADMIN_ID": "usr_v02_admin",
"HWLAB_BOOTSTRAP_ADMIN_USERNAME": "admin",
"HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME": "HWLAB v0.2 Admin",
"HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH": "secretRef:hwlab-v02-bootstrap-admin/password-hash"
} }
} }
] ]
+1 -1
View File
@@ -240,7 +240,7 @@ manages: many devicePodId
- code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。 - code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。
- `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 job 请求。 - `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 job 请求。
- `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。 - `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。
- fake fallback 只能标记为 fake/source,不得作为正式 device-pod DEV-LIVE 证据。 - cloud-api compatibility fallback 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。
- 强副作用 job 必须有 reason,并在物理互斥需要时获取 `device_leases` - 强副作用 job 必须有 reason,并在物理互斥需要时获取 `device_leases`
## 测试规格 ## 测试规格
+4 -4
View File
@@ -329,8 +329,8 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限
| 规格项 | 状态 | 说明 | | 规格项 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| admin/user 两角色模型 | 未完全实现 | 表结构和链路已定义,完整 cloud-api auth/authorization 仍待收敛。 | | admin/user 两角色模型 | 部分实现 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only 路由。 |
| `users``user_sessions`、grant/lease 表 | 未完全实现 | 推荐迁移已定义,需实现 migration 和 runtime store。 | | `users``user_sessions`、grant/lease 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants、leases 和 jobslease 互斥流程仍待接入真实执行链路。 |
| Code Agent owner 绑定 | 未完全实现 | `agent_sessions` 已存在,owner 字段和鉴权链路仍需落地。 | | Code Agent owner 绑定 | 实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidencetrace/result cache 也按 owner/admin 限制访问。 |
| device pod 授权模型 | 未完全实现 | grant 语义已定义;当前 device-pod 仍主要是 fake/只读 payload。 | | device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 |
| 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 | | 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 |
+11 -8
View File
@@ -4,14 +4,15 @@
## 在系统中的职责划分 ## 在系统中的职责划分
- 承担 runtime health、DB readiness、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod 只读代理和 live build inventory。 - 承担 runtime health、DB readiness、用户/session 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod authority/job 和 live build inventory。
- 是 `hwlab-cloud-web`、Code Agent session、device-pod 用户态操作和 gateway outbound poll 的唯一应用层收口点。 - 是 `hwlab-cloud-web`、Code Agent session、device-pod 用户态操作和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问内部 `hwlab-device-pod` Service
- 读取 `hwlab-cloud-api-v02-db/database-url``hwlab-v02-code-agent-provider/openai-api-key``hwlab-v02-code-agent-codex-auth/auth.json` 等 v02 独立 SecretRef;文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。 - 读取 `hwlab-cloud-api-v02-db/database-url``hwlab-v02-code-agent-provider/openai-api-key``hwlab-v02-code-agent-codex-auth/auth.json` 等 v02 独立 SecretRef;文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。
## 内部架构 ## 内部架构
- `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。 - `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。
- `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod proxy、gateway poll/result 和 Code Agent chat。 - `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod authority、gateway poll/result 和 Code Agent chat。
- `internal/cloud/access-control.ts` 负责 `/auth/*`、admin/user、device pod profile/grant、device job lifecycle 和 Code Agent owner binding。
- `internal/db/runtime-store.ts``internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。 - `internal/db/runtime-store.ts``internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。
- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。 - `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
- 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。 - 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。
@@ -26,13 +27,15 @@
| `GET /v1` | REST adapter 索引、RPC 方法、runtime readiness 和 device-pod/M3 能力摘要。 | | `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 /rpc``POST /json-rpc` | JSON-RPC 入口,支持 system、adapter、gateway、hardware、audit、evidence 和 M3 方法。 |
| `POST /v1/rpc/{method}` | REST 到 JSON-RPC 的桥接入口。 | | `POST /v1/rpc/{method}` | REST 到 JSON-RPC 的桥接入口。 |
| `GET /v1/device-pods...` | 代理到 `hwlab-device-pod`;上游不可用时当前会返回 fake fallback,并必须标明来源。 | | `GET /v1/device-pods...` | 经 cloud-api 鉴权后读取服务端 profile/grant/job authority;不会回退到 fake device pod 数据。 |
| `GET /auth/session``POST /auth/login``POST /auth/logout` | v0.2 本地用户 session 入口。 |
| `POST /v1/admin/users``POST/PUT /v1/admin/device-pods``POST/DELETE /v1/admin/device-pod-grants...` | `admin` 管理用户、device pod profile 和 grant 的入口。 |
| `GET /v1/m3/status``POST /v1/m3/io` | M3 只读/受控 IO 入口;写操作必须有明确 approval。 | | `GET /v1/m3/status``POST /v1/m3/io` | M3 只读/受控 IO 入口;写操作必须有明确 approval。 |
| `GET /v1/diagnostics/gate``GET /v1/live-builds` | 诊断和 live build inventory。 | | `GET /v1/diagnostics/gate``GET /v1/live-builds` | 诊断和 live build inventory。 |
| `GET /v1/gateway/sessions``POST /v1/gateway/poll``POST /v1/gateway/result` | gateway 主动出站注册、取任务和回传结果。 | | `GET /v1/gateway/sessions``POST /v1/gateway/poll``POST /v1/gateway/result` | gateway 主动出站注册、取任务和回传结果。 |
| `POST /v1/agent/chat``GET /v1/agent/chat/result/{traceId}``GET /v1/agent/chat/trace/{traceId}``POST /v1/agent/chat/cancel` | Code Agent 短连接提交、轮询、trace 和取消。 | | `POST /v1/agent/chat``GET /v1/agent/chat/result/{traceId}``GET /v1/agent/chat/trace/{traceId}``POST /v1/agent/chat/cancel` | Code Agent 短连接提交、轮询、trace 和取消。 |
用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md);当前实现尚未完整落地 admin/user 表和授权链路 用户、权限、device-pod 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-device-pod.md](spec-device-pod.md)。
## 测试规格 ## 测试规格
@@ -46,7 +49,7 @@
## T3 ## T3
阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:访问 `/v1/device-pods` 和一个 device-pod status route,确认响应标明来自 `hwlab-device-pod` 或明确 fake fallback,不得 fake fallback 写成真实硬件 DEV-LIVE 阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问 `/v1/device-pods` 必须返回认证错误;登录后访问 device-pod list/status 时必须显示 `contractVersion=device-pod-authority-v1``fake=false` 来源,不得出现 fake fallback。
## 规格的实现情况 ## 规格的实现情况
@@ -56,6 +59,6 @@
| Code Agent 短连接 submit/result/trace/cancel | 已实现 | repo-owned Codex stdio 和 provider profile 已接入。 | | Code Agent 短连接 submit/result/trace/cancel | 已实现 | repo-owned Codex stdio 和 provider profile 已接入。 |
| Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 | | Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 |
| gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 |
| device-pod 正式权限/profile/job | 未完全实现 | 当前主要是只读 proxy/fake fallback;正式规格见 device-pod specs。 | | device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;无在线 gateway/device-host-cli 时返回 blocker。 |
| v0.2 admin/user 权限模型 | 未完全实现 | 表结构和 API 规格已定义,完整应用层鉴权仍待实现。 | | v0.2 admin/user 权限模型 | 部分实现 | `/auth/*`、admin user/device-pod/grant API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 |
+1 -1
View File
@@ -38,7 +38,7 @@
## T3 ## T3
阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`fake 数据必须在 UI 或 payload 中可追溯 阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench device-pod 面板,确认 status/freshness/blocker 显示来自 `/v1/device-pods`未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE
## 规格的实现情况 ## 规格的实现情况
@@ -4,46 +4,46 @@
## 在系统中的职责划分 ## 在系统中的职责划分
- 承接 `cloud-api -> hwlab-device-pod -> gateway/device-host-cli`设备业务服务位置。 - 承接 `cloud-api -> hwlab-device-pod -> gateway/device-host-cli`内部执行服务位置。
- 当前阶段只为 Cloud Workbench 右侧面板提供 fake/只读 device-pod 数据,不能作为真实硬件 DEV-LIVE 证据 - 当前阶段只暴露 device-pod executor 边界;用户、profile、grant、lease 和 job authority 都在 `hwlab-cloud-api`,不能由该 Service 伪造或兜底
- 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、grant 和 lease。 - 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、grant 和 lease。
## 内部架构 ## 内部架构
- `cmd/hwlab-device-pod/main.ts` 提供 HTTP server,端口默认 `7601` - `cmd/hwlab-device-pod/main.ts` 提供 HTTP server,端口默认 `7601`
- `internal/device-pod/fake-data.mjs` 构造 list、status、events、chip-id、UART status/tail 等 fake payload - `cmd/hwlab-device-pod/main.ts` 返回 `contractVersion=device-pod-executor-v1`,并声明 `authority=hwlab-cloud-api``fake=false`
- `HWLAB_DEVICE_POD_ID` 可指定默认 devicePodId;当前不读取正式 `device_pods.profile_json` - `internal/device-pod/fake-data.mjs` 只保留为 legacy 前端/smoke fixture,不是 `hwlab-device-pod` 服务运行契约
- `HWLAB_DEVICE_POD_ID` 可指定默认 executor devicePodId;正式 profile 仍由 `hwlab-cloud-api``device_pods.profile_json` 管理。
## API 接口说明 ## API 接口说明
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /health``GET /health/live` | 返回 `contractVersion=device-pod-fake-v1` 和 fake 服务说明。 | | `GET /health``GET /health/live` | 返回 `contractVersion=device-pod-executor-v1``authority=hwlab-cloud-api``fake=false`。 |
| `GET /v1/device-pods` | 返回 device-pod 列表。 | | `GET /v1/device-pods` | 返回 executor boundary 摘要,不表达用户可见 device pod authority。 |
| `GET /v1/device-pods/{devicePodId}/status` | 返回 fake status/freshness/profileHash。 | | `GET /v1/device-pods/{devicePodId}/status` | 返回 blocked executor status;真实 profile/status 以 cloud-api `/v1/device-pods/{devicePodId}/status` 为准。 |
| `GET /v1/device-pods/{devicePodId}/events` | 返回 bounded fake event lines。 | | `GET /v1/device-pods/{devicePodId}/events` | 返回 bounded executor boundary event,不伪造硬件事件。 |
| `GET /v1/device-pods/{devicePodId}/debug-probe/chip-id` | 返回 fake chip-id。 | | `POST /v1/device-pods/{devicePodId}/jobs` | 只接受 `hwlab-cloud-api` 内部调用;当前无 gateway/device-host-cli 连接时返回 `gateway_dispatch_unavailable`。 |
| `GET /v1/device-pods/{devicePodId}/io-probe/uart/1``.../tail` | 返回 fake UART status/tail。 |
正式 `POST /jobs`、job output/cancel 和 admin profile/grant API 尚未在该服务实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。 用户态 `POST /jobs`、job output/cancel 和 admin profile/grant API `hwlab-cloud-api` 实现,应以 [spec-device-pod.md](spec-device-pod.md) 为目标。
## 测试规格 ## 测试规格
## T1 ## T1
阅读 docs/reference/spec-v02-hwlab-device-pod-service.md,然后用 cli 手动测试以下内容:访问 `/health/live``/v1/device-pods`,确认响应包含 `contractVersion=device-pod-fake-v1`,并明确 fake/source 信息 阅读 docs/reference/spec-v02-hwlab-device-pod-service.md,然后用 cli 手动测试以下内容:访问 `/health/live``/v1/device-pods`,确认响应包含 `contractVersion=device-pod-executor-v1``authority=hwlab-cloud-api``fake=false`
## T2 ## T2
阅读 docs/reference/spec-v02-hwlab-device-pod-service.md,然后用 cli 手动测试以下内容:通过 `hwlab-cloud-api /v1/device-pods` 访问同一数据,确认 cloud-api proxy 标明 upstream 或 fake fallback 状态 阅读 docs/reference/spec-v02-hwlab-device-pod-service.md,然后用 cli 手动测试以下内容:通过 `hwlab-cloud-api /v1/device-pods` 访问 device pod authority,确认未登录返回认证错误,登录后只返回 actor 可见 device pod
## 规格的实现情况 ## 规格的实现情况
| 规格项 | 状态 | 说明 | | 规格项 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| health 和只读 fake REST | 已实现 | 支持 Workbench 右侧面板迁移。 | | health 和 executor boundary REST | 已实现 | 服务声明内部 executor、cloud-api authority 和非 fake 来源。 |
| bounded events/tail | 已实现 | fake 数据有限长输出。 | | bounded events/status | 已实现 | 只返回 executor boundary/blocker,不伪造硬件事件。 |
| 正式 profile authority | 未实现 | 尚未读取 cloud-api DB profile。 | | 正式 profile authority | 已在 cloud-api 实现 | `hwlab-device-pod` 不读取或覆盖 `device_pods.profile_json`。 |
| job lifecycle | 实现 | `POST /jobs` 等正式 API 尚未实现。 | | job lifecycle | 部分实现 | 用户态 job 由 cloud-api 创建和持久化;executor 侧当前只保留内部 job 边界。 |
| gateway/device-host-cli adapter | 实现 | 当前不连接真实硬件。 | | gateway/device-host-cli adapter | 部分实现 | cloud-api 已能向 gateway registry 分发;无在线 gateway 时返回 blocker。 |
+49
View File
@@ -41,6 +41,20 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
revoked_at TEXT revoked_at TEXT
)`, )`,
`CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY,
project_id TEXT,
agent_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
started_at TEXT,
ended_at TEXT,
owner_user_id TEXT REFERENCES users(id),
conversation_id TEXT,
thread_id TEXT,
last_trace_id TEXT,
session_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT
)`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id)`, `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id)`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS conversation_id TEXT`, `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS conversation_id TEXT`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS thread_id TEXT`, `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS thread_id TEXT`,
@@ -326,6 +340,22 @@ class AccessController {
return (await this.store.countUsers()) === 0; return (await this.store.countUsers()) === 0;
} }
async recordAgentSessionOwner(input = {}) {
await this.ensureBootstrap();
const ownerUserId = textOr(input.ownerUserId, "");
const sessionId = agentSessionIdForRecord(input.sessionId, input.traceId);
if (!ownerUserId || !sessionId) return null;
return this.store.recordAgentSessionOwner?.({
...input,
sessionId,
ownerUserId,
projectId: textOr(input.projectId, "prj_v02_code_agent"),
agentId: textOr(input.agentId, "hwlab-code-agent"),
status: textOr(input.status, "active"),
now: this.now()
}) ?? null;
}
async requireGrantTargets({ devicePodId, userId }) { async requireGrantTargets({ devicePodId, userId }) {
const pod = await this.store.getDevicePod(devicePodId); const pod = await this.store.getDevicePod(devicePodId);
if (!pod || pod.status !== "active") { if (!pod || pod.status !== "active") {
@@ -555,6 +585,7 @@ class MemoryAccessStore {
this.devicePods = new Map(); this.devicePods = new Map();
this.grants = new Set(); this.grants = new Set();
this.jobs = new Map(); this.jobs = new Map();
this.agentSessions = new Map();
} }
async countUsers() { return this.users.size; } async countUsers() { return this.users.size; }
@@ -595,6 +626,14 @@ class MemoryAccessStore {
async updateDevicePodJob(devicePodId, jobId, patch) { const job = this.jobs.get(jobId); if (!job || job.devicePodId !== devicePodId) return null; const next = { ...job, ...patch, updatedAt: patch.updatedAt ?? this.now() }; this.jobs.set(jobId, next); return next; } async updateDevicePodJob(devicePodId, jobId, patch) { const job = this.jobs.get(jobId); if (!job || job.devicePodId !== devicePodId) return null; const next = { ...job, ...patch, updatedAt: patch.updatedAt ?? this.now() }; this.jobs.set(jobId, next); return next; }
async getDevicePodJob(devicePodId, jobId) { const job = this.jobs.get(jobId); return job?.devicePodId === devicePodId ? job : null; } async getDevicePodJob(devicePodId, jobId) { const job = this.jobs.get(jobId); return job?.devicePodId === devicePodId ? job : null; }
async listDevicePodJobs(devicePodId, limit) { return [...this.jobs.values()].filter((job) => job.devicePodId === devicePodId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); } async listDevicePodJobs(devicePodId, limit) { return [...this.jobs.values()].filter((job) => job.devicePodId === devicePodId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); }
async recordAgentSessionOwner(input) {
const now = input.now ?? this.now();
const existing = this.agentSessions.get(input.sessionId) ?? null;
const session = normalizeAgentSessionOwnerRecord(input, existing, now);
this.agentSessions.set(session.id, session);
return session;
}
async getAgentSession(id) { return this.agentSessions.get(id) ?? null; }
} }
class PostgresAccessStore extends MemoryAccessStore { class PostgresAccessStore extends MemoryAccessStore {
@@ -633,6 +672,13 @@ class PostgresAccessStore extends MemoryAccessStore {
async updateDevicePodJob(devicePodId, jobId, patch) { await this.ensureSchema(); const current = await this.getDevicePodJob(devicePodId, jobId); if (!current) return null; const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? this.now() }; await this.query("UPDATE device_pod_jobs SET status=$3, output_json=$4, blocker_json=$5, updated_at=$6, completed_at=$7 WHERE device_pod_id=$1 AND id=$2", [devicePodId, jobId, next.status, stableJson(next.output ?? {}), stableJson(next.blocker ?? {}), next.updatedAt, next.completedAt]); return next; } async updateDevicePodJob(devicePodId, jobId, patch) { await this.ensureSchema(); const current = await this.getDevicePodJob(devicePodId, jobId); if (!current) return null; const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? this.now() }; await this.query("UPDATE device_pod_jobs SET status=$3, output_json=$4, blocker_json=$5, updated_at=$6, completed_at=$7 WHERE device_pod_id=$1 AND id=$2", [devicePodId, jobId, next.status, stableJson(next.output ?? {}), stableJson(next.blocker ?? {}), next.updatedAt, next.completedAt]); return next; }
async getDevicePodJob(devicePodId, jobId) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 AND id = $2 LIMIT 1", [devicePodId, jobId]); return pgJob(result.rows?.[0]); } async getDevicePodJob(devicePodId, jobId) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 AND id = $2 LIMIT 1", [devicePodId, jobId]); return pgJob(result.rows?.[0]); }
async listDevicePodJobs(devicePodId, limit) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 ORDER BY updated_at DESC LIMIT $2", [devicePodId, limit]); return result.rows.map(pgJob); } async listDevicePodJobs(devicePodId, limit) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 ORDER BY updated_at DESC LIMIT $2", [devicePodId, limit]); return result.rows.map(pgJob); }
async recordAgentSessionOwner(input) {
await this.ensureSchema();
const now = input.now ?? this.now();
const session = normalizeAgentSessionOwnerRecord(input, null, now);
const result = await this.query("INSERT INTO agent_sessions (id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (id) DO UPDATE SET project_id = COALESCE(EXCLUDED.project_id, agent_sessions.project_id), agent_id = EXCLUDED.agent_id, status = EXCLUDED.status, ended_at = EXCLUDED.ended_at, owner_user_id = EXCLUDED.owner_user_id, conversation_id = COALESCE(EXCLUDED.conversation_id, agent_sessions.conversation_id), thread_id = COALESCE(EXCLUDED.thread_id, agent_sessions.thread_id), last_trace_id = COALESCE(EXCLUDED.last_trace_id, agent_sessions.last_trace_id), session_json = EXCLUDED.session_json, updated_at = EXCLUDED.updated_at RETURNING id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at", [session.id, session.projectId, session.agentId, session.status, session.startedAt, session.endedAt, session.ownerUserId, session.conversationId, session.threadId, session.lastTraceId, stableJson(session.session ?? {}), session.updatedAt]);
return pgAgentSession(result.rows?.[0]);
}
} }
function parseDevicePodPath(pathname) { function parseDevicePodPath(pathname) {
@@ -671,6 +717,8 @@ function publicJob(job) { return { id: job.id, devicePodId: job.devicePodId, own
function jobRefs(job) { return { jobId: job.id, traceId: job.traceId, operationId: job.operationId }; } function jobRefs(job) { return { jobId: job.id, traceId: job.traceId, operationId: job.operationId }; }
function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", event.scope?.toUpperCase() ?? "JOB", event.status, event.intent, event.summary, event.refs?.traceId ? `trace=${event.refs.traceId}` : null, event.blocker?.code ? `blocker=${event.blocker.code}` : null].filter(Boolean).join(" "); } function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", event.scope?.toUpperCase() ?? "JOB", event.status, event.intent, event.summary, event.refs?.traceId ? `trace=${event.refs.traceId}` : null, event.blocker?.code ? `blocker=${event.blocker.code}` : null].filter(Boolean).join(" "); }
function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; } function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; }
function normalizeAgentSessionOwnerRecord(input, existing, now) { return { id: input.sessionId, projectId: input.projectId ?? existing?.projectId ?? "prj_v02_code_agent", agentId: input.agentId ?? existing?.agentId ?? "hwlab-code-agent", status: input.status ?? existing?.status ?? "active", startedAt: existing?.startedAt ?? input.startedAt ?? now, endedAt: input.endedAt ?? existing?.endedAt ?? null, ownerUserId: input.ownerUserId, conversationId: input.conversationId ?? existing?.conversationId ?? null, threadId: input.threadId ?? existing?.threadId ?? null, lastTraceId: input.traceId ?? input.lastTraceId ?? existing?.lastTraceId ?? null, session: normalizeObject(input.session ?? existing?.session), updatedAt: now }; }
function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; }
function deviceHostCommand(profile, job) { const hostCli = textOr(profile.route?.hostCli, "node tools/device-host-cli.mjs"); return `${hostCli} ${job.intent} '${stableJson(job.args).replace(/'/gu, "'\\''")}'`; } function deviceHostCommand(profile, job) { const hostCli = textOr(profile.route?.hostCli, "node tools/device-host-cli.mjs"); return `${hostCli} ${job.intent} '${stableJson(job.args).replace(/'/gu, "'\\''")}'`; }
function sessionTokenFromRequest(request) { const auth = getHeader(request, "authorization"); if (/^Bearer\s+/iu.test(String(auth ?? ""))) return String(auth).replace(/^Bearer\s+/iu, "").trim(); const header = textOr(getHeader(request, "x-hwlab-session-token"), ""); if (header) return header; const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; } function sessionTokenFromRequest(request) { const auth = getHeader(request, "authorization"); if (/^Bearer\s+/iu.test(String(auth ?? ""))) return String(auth).replace(/^Bearer\s+/iu, "").trim(); const header = textOr(getHeader(request, "x-hwlab-session-token"), ""); if (header) return header; const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; }
function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); } function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); }
@@ -682,5 +730,6 @@ function pgUser(row) { return row ? { id: row.id, username: row.username, displa
function pgSession(row) { return { id: row.id, userId: row.user_id, tokenHash: row.session_token_hash, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, user: { id: row.user_id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.user_created_at, updatedAt: row.user_updated_at } }; } function pgSession(row) { return { id: row.id, userId: row.user_id, tokenHash: row.session_token_hash, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, user: { id: row.user_id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.user_created_at, updatedAt: row.user_updated_at } }; }
function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.status, profile: parseJson(row.profile_json, {}), profileHash: row.profile_hash, createdAt: row.created_at, updatedAt: row.updated_at }; } function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.status, profile: parseJson(row.profile_json, {}), profileHash: row.profile_hash, createdAt: row.created_at, updatedAt: row.updated_at }; }
function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: parseJson(row.blocker_json, null), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: parseJson(row.blocker_json, null), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; }
function pgAgentSession(row) { return row ? { id: row.id, projectId: row.project_id, agentId: row.agent_id, status: row.status, startedAt: row.started_at, endedAt: row.ended_at, ownerUserId: row.owner_user_id, conversationId: row.conversation_id, threadId: row.thread_id, lastTraceId: row.last_trace_id, session: parseJson(row.session_json, {}), updatedAt: row.updated_at } : null; }
function jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; } function jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; }
function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } } function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } }
+65
View File
@@ -137,6 +137,71 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async
} }
}); });
test("cloud api /v1/agent/chat records authenticated owner on agent session", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-"));
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-owner-codex-home-"));
const ownerRecords = [];
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
CODEX_HOME: codexHome,
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
HWLAB_CODE_AGENT_MODEL: "gpt-test",
OPENAI_API_KEY: "test-openai-key-material",
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
},
accessController: {
required: true,
async authenticate() {
return { ok: true, actor: { id: "usr_agent_owner", username: "agent-owner", displayName: "Agent Owner", role: "user", status: "active" } };
},
async recordAgentSessionOwner(input) {
ownerRecords.push(input);
return input;
}
},
codexStdioManager: {
describe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
async probe() { return { ...codexStdioReadyFixture({ workspace, codexHome }), sandbox: "danger-full-access" }; },
async chat(params = {}) {
return {
...codexStdioChatFixture({ workspace, codexHome, params }),
sandbox: "danger-full-access"
};
},
cancel() {},
reapIdle() {}
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const payload = await postAgent(port, {
conversationId: "cnv_server-test-owner",
traceId: "trc_server-test-owner",
message: "owner binding smoke"
});
validateCodeAgentChatSchema(payload);
assert.equal(payload.ownerUserId, "usr_agent_owner");
assert.equal(ownerRecords.length, 1);
assert.equal(ownerRecords[0].ownerUserId, "usr_agent_owner");
assert.equal(ownerRecords[0].sessionId, "ses_server_stdio_pwd");
assert.equal(ownerRecords[0].conversationId, "cnv_server-test-owner");
assert.equal(ownerRecords[0].traceId, "trc_server-test-owner");
assert.equal(ownerRecords[0].session.valuesRedacted, true);
assert.equal(JSON.stringify(ownerRecords).includes("test-openai-key-material"), false);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
await rm(workspace, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
});
test("cloud api /v1/agent/chat/inspect maps conversation session and thread details to trace evidence", async () => { test("cloud api /v1/agent/chat/inspect maps conversation session and thread details to trace evidence", async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-")); const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-"));
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-"));
+59 -1
View File
@@ -103,8 +103,10 @@ export async function handleCodeAgentChatHttp(request, response, options) {
} }
const payload = await runCodeAgentChat(chatParams, options); const payload = await runCodeAgentChat(chatParams, options);
await recordCodeAgentSessionOwner({ payload, params: chatParams, options, status: payload.status === "completed" ? "active" : payload.status });
const responsePayload = annotateOwner(payload, chatParams);
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload); sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload);
} }
function runCodeAgentChat(params, options) { function runCodeAgentChat(params, options) {
@@ -221,6 +223,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) {
try { try {
const payload = await runCodeAgentChat(params, options); const payload = await runCodeAgentChat(params, options);
if (isCodeAgentResultCanceled(results.get(traceId))) return; if (isCodeAgentResultCanceled(results.get(traceId))) return;
await recordCodeAgentSessionOwner({ payload, params, options, status: payload.status === "completed" ? "active" : payload.status });
results.set(traceId, annotateOwner(payload, params)); results.set(traceId, annotateOwner(payload, params));
traceStore.append(traceId, { traceStore.append(traceId, {
type: "result", type: "result",
@@ -561,10 +564,65 @@ export async function handleCodeAgentCancelHttp(request, response, options) {
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
await recordCodeAgentSessionOwner({ payload, params: { ...params, traceId, ownerUserId: options.actor?.id, ownerRole: options.actor?.role }, options, status: "canceled" });
options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role })); options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role }));
sendJson(response, 200, payload); sendJson(response, 200, payload);
} }
async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options = {}, status = "active" } = {}) {
const ownerUserId = options.actor?.id ?? params.ownerUserId;
if (!ownerUserId || !options.accessController?.recordAgentSessionOwner) return null;
const traceId = safeTraceId(payload.traceId ?? params.traceId);
const session = payload.session && typeof payload.session === "object" ? payload.session : null;
const sessionReuse = payload.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null;
const sessionId = safeSessionId(payload.sessionId ?? session?.sessionId ?? sessionReuse?.sessionId ?? params.sessionId) || null;
const conversationId = safeConversationId(payload.conversationId ?? session?.conversationId ?? sessionReuse?.conversationId ?? params.conversationId) || null;
const threadId = safeOpaqueId(session?.threadId ?? sessionReuse?.threadId ?? payload.threadId ?? params.threadId) || null;
try {
return await options.accessController.recordAgentSessionOwner({
ownerUserId,
ownerRole: options.actor?.role ?? params.ownerRole ?? null,
sessionId,
conversationId,
threadId,
traceId,
status,
session: codeAgentSessionOwnerEvidence(payload, params)
});
} catch (error) {
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
if (traceId) {
traceStore.append(traceId, {
type: "session-owner",
status: "degraded",
label: "session-owner:persist-failed",
errorCode: "agent_session_owner_persist_failed",
message: error?.message ?? "Code Agent session owner binding persistence failed.",
valuesPrinted: false
});
}
return null;
}
}
function codeAgentSessionOwnerEvidence(payload = {}, params = {}) {
return {
provider: payload.provider ?? null,
model: payload.model ?? null,
backend: payload.backend ?? null,
status: payload.status ?? null,
sessionMode: payload.sessionMode ?? payload.session?.sessionMode ?? null,
sessionLifecycleStatus: payload.sessionLifecycleStatus ?? null,
runnerKind: payload.runner?.kind ?? payload.runnerTrace?.runnerKind ?? null,
conversationId: payload.conversationId ?? params.conversationId ?? null,
sessionId: payload.sessionId ?? payload.session?.sessionId ?? payload.sessionReuse?.sessionId ?? params.sessionId ?? null,
threadId: payload.session?.threadId ?? payload.sessionReuse?.threadId ?? payload.threadId ?? params.threadId ?? null,
traceId: payload.traceId ?? params.traceId ?? null,
secretMaterialStored: false,
valuesRedacted: true
};
}
function annotateOwner(payload, params = {}) { function annotateOwner(payload, params = {}) {
if (!params.ownerUserId) return payload; if (!params.ownerUserId) return payload;
return { return {
+20 -7
View File
@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
import { buildMetadataFromEnv, imageTagFromReference, normalizeIsoTimestamp } from "../build-metadata.mjs"; import { buildMetadataFromEnv, imageTagFromReference, normalizeIsoTimestamp } from "../build-metadata.mjs";
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../protocol/index.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../protocol/index.mjs";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import { buildDevicePodRestPayload, buildDevicePodStatus } from "../device-pod/fake-data.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const LIVE_BUILD_TIMEOUT_MS = 900; const LIVE_BUILD_TIMEOUT_MS = 900;
@@ -104,16 +103,30 @@ export async function buildDevicePodCloudApiPayload(url, options = {}) {
} }
}; };
} }
const fallback = buildDevicePodRestPayload(url.pathname, url.searchParams, {
sourceKind: "FAKE-CLOUD-API"
}) ?? buildDevicePodStatus(undefined, { sourceKind: "FAKE-CLOUD-API" });
return { return {
...fallback, serviceId: CLOUD_API_SERVICE_ID,
contractVersion: "device-pod-authority-v1",
status: "blocked",
ready: false,
source: {
kind: "CLOUD_API_PROFILE_AUTHORITY",
serviceId: CLOUD_API_SERVICE_ID,
fake: false,
devLiveEvidence: false,
note: "Device Pod fallback is blocked instead of synthesized from fake data."
},
blocker: {
code: "device_pod_authority_unavailable",
layer: "cloud-api",
retryable: true,
summary: "Device Pod authority requires access-control profile and grant data from hwlab-cloud-api.",
userMessage: "Device Pod 权限和 profile 必须来自 cloud-api;不会回退到 fake 数据。"
},
cloudApiProxy: { cloudApiProxy: {
route: url.pathname, route: url.pathname,
upstream: configuredDevicePodUrl(options.env ?? process.env), upstream: configuredDevicePodUrl(options.env ?? process.env),
status: "fake-fallback", status: "blocked",
reason: "hwlab-device-pod upstream is unavailable or not configured" reason: "fake fallback is disabled"
} }
}; };
} }
+1 -52
View File
@@ -1,13 +1,10 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { import {
buildMetadataFromEnv, buildMetadataFromEnv,
imageTagFromReference,
normalizeIsoTimestamp normalizeIsoTimestamp
} from "../build-metadata.mjs"; } from "../build-metadata.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs";
@@ -22,7 +19,6 @@ import {
handleJsonRpcRequest handleJsonRpcRequest
} from "./json-rpc.ts"; } from "./json-rpc.ts";
import { import {
createCodeAgentErrorPayload,
describeCodeAgentAvailability, describeCodeAgentAvailability,
handleCodeAgentChat handleCodeAgentChat
} from "./code-agent-chat.ts"; } from "./code-agent-chat.ts";
@@ -46,12 +42,8 @@ import {
} from "./m3-io-control.ts"; } from "./m3-io-control.ts";
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts";
import { createAccessController } from "./access-control.ts"; import { createAccessController } from "./access-control.ts";
import {
buildDevicePodRestPayload,
buildDevicePodStatus
} from "../device-pod/fake-data.mjs";
import { createGatewayDemoRegistry } from "./gateway-demo-registry.ts"; import { createGatewayDemoRegistry } from "./gateway-demo-registry.ts";
import { buildDevicePodCloudApiPayload, buildLiveBuildsPayload } from "./server-rest-payloads.ts"; import { buildLiveBuildsPayload } from "./server-rest-payloads.ts";
import { import {
createCodeAgentChatResultStore, createCodeAgentChatResultStore,
handleCodeAgentCancelHttp, handleCodeAgentCancelHttp,
@@ -63,49 +55,6 @@ import {
import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
const LIVE_BUILD_TIMEOUT_MS = 900;
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "runtime-default"]);
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
"deepseek": "DeepSeek",
"codex-api": "Codex API",
"runtime-default": "运行默认"
});
const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5";
const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses";
const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
deployManifest: "deploy/deploy.json",
artifactCatalog: "deploy/artifact-catalog.dev.json"
});
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
"hwlab-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
"hwlab-patch-panel": Object.freeze({ urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
"hwlab-router": Object.freeze({ urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
"hwlab-tunnel-client": Object.freeze({ urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
"hwlab-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
});
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
Object.freeze({
serviceId: "hwlab-frpc",
serviceName: "hwlab-frpc",
kind: "external",
externalImage: true,
defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
externalReason: "外部镜像或非 HWLAB 构建产物"
})
]);
function runtimeEnvironment(env = process.env) { function runtimeEnvironment(env = process.env) {
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV; const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
+15 -2
View File
@@ -64,7 +64,12 @@ const v02RemovedServiceIds = new Set([
const v02RemovedServiceEnvNames = new Set([ const v02RemovedServiceEnvNames = new Set([
"HWLAB_M3_GATEWAY_SIMU_1_URL", "HWLAB_M3_GATEWAY_SIMU_1_URL",
"HWLAB_M3_GATEWAY_SIMU_2_URL", "HWLAB_M3_GATEWAY_SIMU_2_URL",
"HWLAB_M3_PATCH_PANEL_URL" "HWLAB_M3_PATCH_PANEL_URL",
"HWLAB_GATEWAY_SIMU_URL",
"HWLAB_BOX_SIMU_URL",
"HWLAB_PATCH_PANEL_URL",
"HWLAB_ROUTER_URL",
"HWLAB_TUNNEL_CLIENT_URL"
]); ]);
const moonBridgeSourceRef = process.env.HWLAB_G14_MOONBRIDGE_REF || "1b99888d3dae889b79ee602cb875c7907f7e76f2"; const moonBridgeSourceRef = process.env.HWLAB_G14_MOONBRIDGE_REF || "1b99888d3dae889b79ee602cb875c7907f7e76f2";
const moonBridgeImage = process.env.HWLAB_G14_MOONBRIDGE_IMAGE || `${defaultRegistryPrefix}/moonbridge:${moonBridgeSourceRef.slice(0, 12)}`; const moonBridgeImage = process.env.HWLAB_G14_MOONBRIDGE_IMAGE || `${defaultRegistryPrefix}/moonbridge:${moonBridgeSourceRef.slice(0, 12)}`;
@@ -586,6 +591,7 @@ function secretNameForProfile(secretName, profile) {
if (secretName === "hwlab-cloud-api-dev-db") return "hwlab-cloud-api-v02-db"; if (secretName === "hwlab-cloud-api-dev-db") return "hwlab-cloud-api-v02-db";
if (secretName === "hwlab-code-agent-provider") return "hwlab-v02-code-agent-provider"; if (secretName === "hwlab-code-agent-provider") return "hwlab-v02-code-agent-provider";
if (secretName === "hwlab-code-agent-codex-auth") return "hwlab-v02-code-agent-codex-auth"; if (secretName === "hwlab-code-agent-codex-auth") return "hwlab-v02-code-agent-codex-auth";
if (secretName === "hwlab-bootstrap-admin") return "hwlab-v02-bootstrap-admin";
return secretName; return secretName;
} }
@@ -737,7 +743,14 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix
} }
if (serviceId === "hwlab-cloud-api") { if (serviceId === "hwlab-cloud-api") {
syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag }); syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag });
if (profile === "v02") upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false"); if (profile === "v02") {
upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false");
upsertEnv(container.env, "HWLAB_ACCESS_CONTROL_REQUIRED", "1");
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_ID", "usr_v02_admin");
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_USERNAME", "admin");
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME", "HWLAB v0.2 Admin");
upsertEnvEntry(container.env, deployEnvEntry("HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH", "secretRef:hwlab-v02-bootstrap-admin/password-hash", namespace, profile));
}
upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "deepseek"); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "deepseek");
upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel);
upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace)); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace));
+8
View File
@@ -70,6 +70,14 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
assert.doesNotMatch(workloads, new RegExp(serviceId, "u")); assert.doesNotMatch(workloads, new RegExp(serviceId, "u"));
assert.doesNotMatch(services, new RegExp(serviceId, "u")); assert.doesNotMatch(services, new RegExp(serviceId, "u"));
} }
assert.match(workloads, /"name": "HWLAB_ACCESS_CONTROL_REQUIRED"[\s\S]{0,80}"value": "1"/u);
assert.match(workloads, /"name": "HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH"/u);
assert.match(workloads, /"name": "hwlab-v02-bootstrap-admin"/u);
assert.doesNotMatch(workloads, /HWLAB_GATEWAY_SIMU_URL/u);
assert.doesNotMatch(workloads, /HWLAB_BOX_SIMU_URL/u);
assert.doesNotMatch(workloads, /HWLAB_PATCH_PANEL_URL/u);
assert.doesNotMatch(workloads, /HWLAB_ROUTER_URL/u);
assert.doesNotMatch(workloads, /HWLAB_TUNNEL_CLIENT_URL/u);
assert.doesNotMatch(pipeline, /hwlab-gateway-simu-status/u); assert.doesNotMatch(pipeline, /hwlab-gateway-simu-status/u);
assert.match(pipeline, /hwlab-cloud-api-status/u); assert.match(pipeline, /hwlab-cloud-api-status/u);
@@ -494,9 +494,9 @@ function runStaticSmoke() {
evidence: ["device-event-scroll", "device-event-text", "setDeviceEventFollow", "deviceEventUserActive", "overscroll-behavior: contain"] evidence: ["device-event-scroll", "device-event-text", "setDeviceEventFollow", "deviceEventUserActive", "overscroll-behavior: contain"]
}); });
addCheck(checks, blockers, "device-pod-fake-service", devicePodFakeServiceContract(files), "The fake hwlab-device-pod service and Cloud API proxy/fallback expose the Device Pod REST surface.", { addCheck(checks, blockers, "device-pod-executor-authority", devicePodExecutorAuthorityContract(files), "The hwlab-device-pod executor and Cloud API compatibility payload expose the Device Pod REST surface without fake fallback.", {
blocker: "runtime_blocker", blocker: "runtime_blocker",
evidence: ["hwlab-device-pod", "/v1/device-pods", "/status", "/events", "buildDevicePodRestPayload"] evidence: ["hwlab-device-pod", "/v1/device-pods", "/status", "/events", "device-pod-executor-v1"]
}); });
addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", { addCheck(checks, blockers, "outer-scroll-contract", hasScrollLockContract(files.styles), "Outer page scrolling is locked and internal workbench panes own scrolling.", {
@@ -602,7 +602,7 @@ function runStaticSmoke() {
addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", { addCheck(checks, blockers, "source-not-dev-live", sourceEvidenceNotDevLive(source), "SOURCE/LOCAL/DRY-RUN/fixture evidence is not promoted to DEV-LIVE.", {
blocker: "observability_blocker", blocker: "observability_blocker",
evidence: ["SOURCE remains source-level", "Device Pod fake data is marked FAKE", "no DEV-LIVE claim from fixture data"] evidence: ["SOURCE remains source-level", "Device Pod compatibility fallback is blocked", "no DEV-LIVE claim from fixture data"]
}); });
const help = inspectHelpContract(files); const help = inspectHelpContract(files);
@@ -1655,6 +1655,7 @@ function readStaticFiles() {
help: readText("web/hwlab-cloud-web/help.md"), help: readText("web/hwlab-cloud-web/help.md"),
artifactPublisher: readText("scripts/artifact-publish.mjs"), artifactPublisher: readText("scripts/artifact-publish.mjs"),
cloudApiServer: readText("internal/cloud/server.ts"), cloudApiServer: readText("internal/cloud/server.ts"),
cloudApiPayloads: readText("internal/cloud/server-rest-payloads.ts"),
devicePodData: readText("internal/device-pod/fake-data.mjs"), devicePodData: readText("internal/device-pod/fake-data.mjs"),
devicePodService: readText("cmd/hwlab-device-pod/main.ts"), devicePodService: readText("cmd/hwlab-device-pod/main.ts"),
protocol: readText("internal/protocol/index.mjs"), protocol: readText("internal/protocol/index.mjs"),
@@ -2199,14 +2200,15 @@ function devicePodEventStreamContract({ html, app, styles }) {
); );
} }
function devicePodFakeServiceContract({ app, liveStatus, cloudApiServer, devicePodData, devicePodService, protocol, deployJson, artifactCatalog }) { function devicePodExecutorAuthorityContract({ app, liveStatus, cloudApiPayloads, devicePodData, devicePodService, protocol, deployJson, artifactCatalog }) {
return ( return (
["/v1/device-pods", "/status", "/events?limit=120", "/debug-probe/chip-id", "/io-probe/uart/1", "/io-probe/uart/1/tail?maxBytes=12000"].every((route) => app.includes(route)) && ["/v1/device-pods", "/status", "/events?limit=120", "/debug-probe/chip-id", "/io-probe/uart/1", "/io-probe/uart/1/tail?maxBytes=12000"].every((route) => app.includes(route)) &&
/function\s+classifyDevicePodProbe\s*\(/u.test(liveStatus) && /function\s+classifyDevicePodProbe\s*\(/u.test(liveStatus) &&
/buildDevicePodCloudApiPayload/u.test(cloudApiServer) && /buildDevicePodCloudApiPayload/u.test(cloudApiPayloads) &&
/device_pod_authority_unavailable/u.test(cloudApiPayloads) &&
/buildDevicePodRestPayload/u.test(devicePodData) && /buildDevicePodRestPayload/u.test(devicePodData) &&
/buildDevicePodRestPayload/u.test(devicePodService) && /device-pod-executor-v1/u.test(devicePodService) &&
/DEVICE_POD_SERVICE_ID/u.test(devicePodService) && /fake:\s*false/u.test(devicePodService) &&
/hwlab-device-pod/u.test(protocol) && /hwlab-device-pod/u.test(protocol) &&
/hwlab-device-pod/u.test(deployJson) && /hwlab-device-pod/u.test(deployJson) &&
/hwlab-device-pod/u.test(artifactCatalog) /hwlab-device-pod/u.test(artifactCatalog)
+1 -1
View File
@@ -20,7 +20,7 @@
- 点击或键盘激活 summary 卡片后,通过弹窗查看 JSON 细节;弹窗关闭后返回原看板,不改变看板布局。 - 点击或键盘激活 summary 卡片后,通过弹窗查看 JSON 细节;弹窗关闭后返回原看板,不改变看板布局。
- `Pod Summary` 展示 `devicePodId``targetId``profile` 和 freshnessTarget、Workspace、Debug、IO 四张卡片只展示关键一句话。 - `Pod Summary` 展示 `devicePodId``targetId``profile` 和 freshnessTarget、Workspace、Debug、IO 四张卡片只展示关键一句话。
- 事件流是一个不需要展开的纯文本窗口,持续更新时不会抢走用户正在滚动的位置;开启 `跟随` 或点击新事件提示后才跳到最新事件。 - 事件流是一个不需要展开的纯文本窗口,持续更新时不会抢走用户正在滚动的位置;开启 `跟随` 或点击新事件提示后才跳到最新事件。
- 当前 `hwlab-device-pod` 提供 fake 数据,供前端完成 Device Pod 信息模型、弹窗详情和事件流布局闭环;不把 fake 数据称为真实硬件通过。 - 当前 `hwlab-device-pod` 提供内部 executor 边界;Device Pod 的用户可见 profile、grant 和 job authority 由 `hwlab-cloud-api` 提供,未授权或无 gateway 时显示 blocker,不称为真实硬件通过。
## 内部复核页 ## 内部复核页
+9 -6
View File
@@ -47,6 +47,7 @@ const faviconSvg = readWeb("favicon.svg");
const faviconIco = readWeb("favicon.ico"); const faviconIco = readWeb("favicon.ico");
const distContractScript = readWeb("scripts/dist-contract.ts"); const distContractScript = readWeb("scripts/dist-contract.ts");
const cloudApiServer = readRepo("internal/cloud/server.ts"); const cloudApiServer = readRepo("internal/cloud/server.ts");
const cloudApiPayloads = readRepo("internal/cloud/server-rest-payloads.ts");
const devicePodData = readRepo("internal/device-pod/fake-data.mjs"); const devicePodData = readRepo("internal/device-pod/fake-data.mjs");
const devicePodService = readRepo("cmd/hwlab-device-pod/main.ts"); const devicePodService = readRepo("cmd/hwlab-device-pod/main.ts");
const protocol = readRepo("internal/protocol/index.mjs"); const protocol = readRepo("internal/protocol/index.mjs");
@@ -155,7 +156,7 @@ for (const forbidden of [
assertDoesNotInclude(activeFrontend, forbidden, `legacy frontend marker returned: ${forbidden}`); assertDoesNotInclude(activeFrontend, forbidden, `legacy frontend marker returned: ${forbidden}`);
} }
for (const helpTerm of ["Device Pod 看板", "summary", "弹窗", "纯文本事件流", "fake 数据", "跟随"]){ for (const helpTerm of ["Device Pod 看板", "summary", "弹窗", "纯文本事件流", "blocker", "跟随"]){
assertIncludes(helpMarkdown, helpTerm, `help.md missing ${helpTerm}`); assertIncludes(helpMarkdown, helpTerm, `help.md missing ${helpTerm}`);
} }
@@ -164,15 +165,17 @@ for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "work
} }
assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes"); assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes");
assertIncludes(cloudApiServer, "buildDevicePodCloudApiPayload", "cloud-api must proxy/fallback Device Pod payloads"); assertIncludes(cloudApiPayloads, "buildDevicePodCloudApiPayload", "cloud-api payload module must keep the Device Pod compatibility payload helper");
assertIncludes(devicePodData, "buildDevicePodRestPayload", "Device Pod fake data must provide REST payload helper"); assertIncludes(cloudApiPayloads, "device_pod_authority_unavailable", "Device Pod compatibility helper must block instead of fake fallback");
assertIncludes(devicePodService, "buildDevicePodRestPayload", "Device Pod service must use dynamic REST payload helper"); assertIncludes(devicePodData, "buildDevicePodRestPayload", "Legacy Device Pod fixtures must keep the REST payload helper for old smokes");
assertIncludes(devicePodService, "DEVICE_POD_SERVICE_ID", "Device Pod service must report service id"); assertIncludes(devicePodService, "device-pod-executor-v1", "Device Pod service must expose the executor contract");
assertIncludes(devicePodService, "hwlab-cloud-api", "Device Pod service must defer user-facing authority to cloud-api");
assertIncludes(devicePodService, "fake: false", "Device Pod service must not report fake runtime data");
assertIncludes(protocol, "hwlab-device-pod", "protocol service ids must include Device Pod"); assertIncludes(protocol, "hwlab-device-pod", "protocol service ids must include Device Pod");
assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service"); assertIncludes(deployJson, "hwlab-device-pod", "deploy manifest must include Device Pod service");
assertIncludes(artifactCatalog, "hwlab-device-pod", "artifact catalog must include Device Pod service"); assertIncludes(artifactCatalog, "hwlab-device-pod", "artifact catalog must include Device Pod service");
console.log("hwlab-cloud-web check ok: Device Pod summary sidebar, modal detail, simple event stream, TS build, and fake service contracts are present"); console.log("hwlab-cloud-web check ok: Device Pod summary sidebar, modal detail, simple event stream, TS build, and executor authority contracts are present");
function runJavaScriptSyntaxCheck() { function runJavaScriptSyntaxCheck() {
const files = new Set([ const files = new Set([