From b99c663614f526517fa29b9de85e249cf4059d21 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 08:10:19 +0800 Subject: [PATCH] feat: enforce v02 runtime authority --- cmd/hwlab-device-pod/main.test.ts | 34 +++- cmd/hwlab-device-pod/main.ts | 185 +++++++++++++++--- deploy/deploy.json | 7 +- docs/reference/spec-device-pod.md | 2 +- docs/reference/spec-user-access.md | 8 +- docs/reference/spec-v02-hwlab-cloud-api.md | 19 +- docs/reference/spec-v02-hwlab-cloud-web.md | 2 +- .../spec-v02-hwlab-device-pod-service.md | 36 ++-- internal/cloud/access-control.ts | 49 +++++ internal/cloud/server-agent-chat.test.ts | 65 ++++++ internal/cloud/server-code-agent-http.ts | 60 +++++- internal/cloud/server-rest-payloads.ts | 27 ++- internal/cloud/server.ts | 53 +---- scripts/g14-gitops-render.mjs | 17 +- scripts/g14-gitops-render.test.ts | 8 + scripts/src/dev-cloud-workbench-smoke-lib.mjs | 16 +- web/hwlab-cloud-web/help.md | 2 +- web/hwlab-cloud-web/scripts/check.ts | 15 +- 18 files changed, 461 insertions(+), 144 deletions(-) diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index 62315364..6b4229d9 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -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"); -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"); try { const health = await fetchJson(`http://127.0.0.1:${service.port}/health/live`); assert.equal(health.serviceId, "hwlab-device-pod"); assert.equal(health.status, "live"); 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`); assert.equal(list.serviceId, "hwlab-device-pod"); assert.equal(list.selectedDevicePodId, "device-pod-test"); 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`); - assert.equal(status.devicePod.devicePodId, "device-pod-test"); - assert.equal(status.summary.targetId, "stm32f103-minsys-01"); + assert.equal(status.devicePodId, "device-pod-test"); + 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`); assert.equal(events.events.length, 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`); 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" }); 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 { await service.stop(); } diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index d2734ac0..ec757049 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -1,47 +1,174 @@ #!/usr/bin/env node import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; -import { jsonResponse, listen, parsePort } from "../../internal/sim/http.mjs"; -import { - DEFAULT_DEVICE_POD_ID, - DEVICE_POD_SERVICE_ID, - buildDevicePodRestPayload -} from "../../internal/device-pod/fake-data.mjs"; +import { jsonResponse, listen, parsePort, readJson } from "../../internal/sim/http.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 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) => { - const url = new URL(request.url ?? "/", "http://localhost"); - if (request.method !== "GET") { - jsonResponse(response, 405, { error: "method_not_allowed", method: request.method ?? "UNKNOWN" }); - return; +listen(createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://localhost"); + if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) { + 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); function healthPayload() { return { - serviceId: DEVICE_POD_SERVICE_ID, - environment: process.env.HWLAB_ENVIRONMENT || "dev", + serviceId: SERVICE_ID, + environment, status: "live", ready: true, - contractVersion: "device-pod-fake-v1", + contractVersion: CONTRACT_VERSION, devicePodId, observedAt: new Date().toISOString(), - role: "fake-device-pod-server", - note: "Only fake frontend data is served; no hardware connection is opened." + role: "device-pod-internal-executor", + 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." }; +} diff --git a/deploy/deploy.json b/deploy/deploy.json index 0c560d93..2112718e 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -203,7 +203,12 @@ "serviceId": "hwlab-cloud-api", "env": { "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" } } ] diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index ca1f6eba..ad5509d7 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -240,7 +240,7 @@ manages: many devicePodId - code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。 - `hwlab-device-pod` 不接受无内部服务凭据的 profile snapshot 或 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`。 ## 测试规格 diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 292d714a..0d9b047d 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -329,8 +329,8 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限 | 规格项 | 状态 | 说明 | | --- | --- | --- | -| admin/user 两角色模型 | 未完全实现 | 表结构和链路已定义,完整 cloud-api auth/authorization 仍待收敛。 | -| `users`、`user_sessions`、grant/lease 表 | 未完全实现 | 推荐迁移已定义,需实现 migration 和 runtime store。 | -| Code Agent owner 绑定 | 未完全实现 | `agent_sessions` 已存在,owner 字段和鉴权链路仍需落地。 | -| device pod 授权模型 | 未完全实现 | grant 语义已定义;当前 device-pod 仍主要是 fake/只读 payload。 | +| admin/user 两角色模型 | 部分实现 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only 路由。 | +| `users`、`user_sessions`、grant/lease 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants、leases 和 jobs;lease 互斥流程仍待接入真实执行链路。 | +| Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 | +| device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 | | 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index 825c1b38..e8e098ea 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -4,14 +4,15 @@ ## 在系统中的职责划分 -- 承担 runtime health、DB readiness、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、device-pod 只读代理和 live build inventory。 -- 是 `hwlab-cloud-web`、Code Agent session、device-pod 用户态操作和 gateway outbound poll 的唯一应用层收口点。 +- 承担 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-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、字节数或哈希指纹,不记录值。 ## 内部架构 - `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/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` 中。 @@ -26,13 +27,15 @@ | `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...` | 代理到 `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/diagnostics/gate`、`GET /v1/live-builds` | 诊断和 live build inventory。 | | `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 和取消。 | -用户、权限、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 -阅读 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 已接入。 | | Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 | | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | -| device-pod 正式权限/profile/job | 未完全实现 | 当前主要是只读 proxy/fake fallback;正式规格见 device-pod specs。 | -| v0.2 admin/user 权限模型 | 未完全实现 | 表结构和 API 规格已定义,完整应用层鉴权仍待实现。 | +| device-pod 正式权限/profile/job | 部分实现 | profile/grant/list/status/job 持久化在 cloud-api;无在线 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/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index 895fd513..0425d6c1 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -38,7 +38,7 @@ ## 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。 ## 规格的实现情况 diff --git a/docs/reference/spec-v02-hwlab-device-pod-service.md b/docs/reference/spec-v02-hwlab-device-pod-service.md index df6fe597..bf7ed218 100644 --- a/docs/reference/spec-v02-hwlab-device-pod-service.md +++ b/docs/reference/spec-v02-hwlab-device-pod-service.md @@ -4,46 +4,46 @@ ## 在系统中的职责划分 -- 承接 `cloud-api -> hwlab-device-pod -> gateway/device-host-cli` 的设备业务服务位置。 -- 当前阶段只为 Cloud Workbench 右侧面板提供 fake/只读 device-pod 数据,不能作为真实硬件 DEV-LIVE 证据。 +- 承接 `cloud-api -> hwlab-device-pod -> gateway/device-host-cli` 的内部执行服务位置。 +- 当前阶段只暴露 device-pod executor 边界;用户、profile、grant、lease 和 job authority 都在 `hwlab-cloud-api`,不能由该 Service 伪造或兜底。 - 普通用户和 Code Agent 不应直接调用该 Service;正式路径必须经过 `hwlab-cloud-api` 鉴权、grant 和 lease。 ## 内部架构 - `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。 -- `HWLAB_DEVICE_POD_ID` 可指定默认 devicePodId;当前不读取正式 `device_pods.profile_json`。 +- `cmd/hwlab-device-pod/main.ts` 返回 `contractVersion=device-pod-executor-v1`,并声明 `authority=hwlab-cloud-api`、`fake=false`。 +- `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 接口说明 | 接口 | 说明 | | --- | --- | -| `GET /health`、`GET /health/live` | 返回 `contractVersion=device-pod-fake-v1` 和 fake 服务说明。 | -| `GET /v1/device-pods` | 返回 device-pod 列表。 | -| `GET /v1/device-pods/{devicePodId}/status` | 返回 fake status/freshness/profileHash。 | -| `GET /v1/device-pods/{devicePodId}/events` | 返回 bounded fake event lines。 | -| `GET /v1/device-pods/{devicePodId}/debug-probe/chip-id` | 返回 fake chip-id。 | -| `GET /v1/device-pods/{devicePodId}/io-probe/uart/1`、`.../tail` | 返回 fake UART status/tail。 | +| `GET /health`、`GET /health/live` | 返回 `contractVersion=device-pod-executor-v1`、`authority=hwlab-cloud-api` 和 `fake=false`。 | +| `GET /v1/device-pods` | 返回 executor boundary 摘要,不表达用户可见 device pod authority。 | +| `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 executor boundary event,不伪造硬件事件。 | +| `POST /v1/device-pods/{devicePodId}/jobs` | 只接受 `hwlab-cloud-api` 内部调用;当前无 gateway/device-host-cli 连接时返回 `gateway_dispatch_unavailable`。 | -正式 `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 -阅读 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 -阅读 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 右侧面板迁移。 | -| bounded events/tail | 已实现 | fake 数据有限长输出。 | -| 正式 profile authority | 未实现 | 尚未读取 cloud-api DB profile。 | -| job lifecycle | 未实现 | `POST /jobs` 等正式 API 尚未实现。 | -| gateway/device-host-cli adapter | 未实现 | 当前不连接真实硬件。 | +| health 和 executor boundary REST | 已实现 | 服务声明内部 executor、cloud-api authority 和非 fake 来源。 | +| bounded events/status | 已实现 | 只返回 executor boundary/blocker,不伪造硬件事件。 | +| 正式 profile authority | 已在 cloud-api 实现 | `hwlab-device-pod` 不读取或覆盖 `device_pods.profile_json`。 | +| job lifecycle | 部分实现 | 用户态 job 由 cloud-api 创建和持久化;executor 侧当前只保留内部 job 边界。 | +| gateway/device-host-cli adapter | 部分实现 | cloud-api 已能向 gateway registry 分发;无在线 gateway 时返回 blocker。 | diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 68f41986..e0671db7 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -41,6 +41,20 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ expires_at TEXT NOT NULL, 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 conversation_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; } + 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 }) { const pod = await this.store.getDevicePod(devicePodId); if (!pod || pod.status !== "active") { @@ -555,6 +585,7 @@ class MemoryAccessStore { this.devicePods = new Map(); this.grants = new Set(); this.jobs = new Map(); + this.agentSessions = new Map(); } 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 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 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 { @@ -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 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 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) { @@ -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 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 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 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, ""]; })); } @@ -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 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 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 parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index bc69b9f6..d353e71e 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -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 () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-")); diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 60f282b6..3cb8a5a4 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -103,8 +103,10 @@ export async function handleCodeAgentChatHttp(request, response, 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) { @@ -221,6 +223,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) { try { const payload = await runCodeAgentChat(params, options); if (isCodeAgentResultCanceled(results.get(traceId))) return; + await recordCodeAgentSessionOwner({ payload, params, options, status: payload.status === "completed" ? "active" : payload.status }); results.set(traceId, annotateOwner(payload, params)); traceStore.append(traceId, { type: "result", @@ -561,10 +564,65 @@ export async function handleCodeAgentCancelHttp(request, response, options) { userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", 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 })); 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 = {}) { if (!params.ownerUserId) return payload; return { diff --git a/internal/cloud/server-rest-payloads.ts b/internal/cloud/server-rest-payloads.ts index 40ff9331..17e98011 100644 --- a/internal/cloud/server-rest-payloads.ts +++ b/internal/cloud/server-rest-payloads.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; import { buildMetadataFromEnv, imageTagFromReference, normalizeIsoTimestamp } from "../build-metadata.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../protocol/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 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 { - ...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: { route: url.pathname, upstream: configuredDevicePodUrl(options.env ?? process.env), - status: "fake-fallback", - reason: "hwlab-device-pod upstream is unavailable or not configured" + status: "blocked", + reason: "fake fallback is disabled" } }; } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index dbd67f9a..30d19b49 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -1,13 +1,10 @@ import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; -import { readFile } from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { buildMetadataFromEnv, - imageTagFromReference, normalizeIsoTimestamp } from "../build-metadata.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs"; @@ -22,7 +19,6 @@ import { handleJsonRpcRequest } from "./json-rpc.ts"; import { - createCodeAgentErrorPayload, describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.ts"; @@ -46,12 +42,8 @@ import { } from "./m3-io-control.ts"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts"; import { createAccessController } from "./access-control.ts"; -import { - buildDevicePodRestPayload, - buildDevicePodStatus -} from "../device-pod/fake-data.mjs"; import { createGatewayDemoRegistry } from "./gateway-demo-registry.ts"; -import { buildDevicePodCloudApiPayload, buildLiveBuildsPayload } from "./server-rest-payloads.ts"; +import { buildLiveBuildsPayload } from "./server-rest-payloads.ts"; import { createCodeAgentChatResultStore, handleCodeAgentCancelHttp, @@ -63,49 +55,6 @@ import { import { handleM3IoControlHttp } from "./server-m3-http.ts"; 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) { const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV; diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index 1e225882..347ddcff 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -64,7 +64,12 @@ const v02RemovedServiceIds = new Set([ const v02RemovedServiceEnvNames = new Set([ "HWLAB_M3_GATEWAY_SIMU_1_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 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-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-bootstrap-admin") return "hwlab-v02-bootstrap-admin"; return secretName; } @@ -737,7 +743,14 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix } if (serviceId === "hwlab-cloud-api") { 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_DEEPSEEK_MODEL", deepSeekProfileModel); upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL", deepSeekBaseUrl(namespace)); diff --git a/scripts/g14-gitops-render.test.ts b/scripts/g14-gitops-render.test.ts index 4d76a995..c3fbe922 100644 --- a/scripts/g14-gitops-render.test.ts +++ b/scripts/g14-gitops-render.test.ts @@ -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(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.match(pipeline, /hwlab-cloud-api-status/u); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 100b6c5a..a9519fd4 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -494,9 +494,9 @@ function runStaticSmoke() { 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", - 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.", { @@ -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.", { 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); @@ -1655,6 +1655,7 @@ function readStaticFiles() { help: readText("web/hwlab-cloud-web/help.md"), artifactPublisher: readText("scripts/artifact-publish.mjs"), cloudApiServer: readText("internal/cloud/server.ts"), + cloudApiPayloads: readText("internal/cloud/server-rest-payloads.ts"), devicePodData: readText("internal/device-pod/fake-data.mjs"), devicePodService: readText("cmd/hwlab-device-pod/main.ts"), 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 ( ["/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) && - /buildDevicePodCloudApiPayload/u.test(cloudApiServer) && + /buildDevicePodCloudApiPayload/u.test(cloudApiPayloads) && + /device_pod_authority_unavailable/u.test(cloudApiPayloads) && /buildDevicePodRestPayload/u.test(devicePodData) && - /buildDevicePodRestPayload/u.test(devicePodService) && - /DEVICE_POD_SERVICE_ID/u.test(devicePodService) && + /device-pod-executor-v1/u.test(devicePodService) && + /fake:\s*false/u.test(devicePodService) && /hwlab-device-pod/u.test(protocol) && /hwlab-device-pod/u.test(deployJson) && /hwlab-device-pod/u.test(artifactCatalog) diff --git a/web/hwlab-cloud-web/help.md b/web/hwlab-cloud-web/help.md index 9cf2d35a..98b3cf49 100644 --- a/web/hwlab-cloud-web/help.md +++ b/web/hwlab-cloud-web/help.md @@ -20,7 +20,7 @@ - 点击或键盘激活 summary 卡片后,通过弹窗查看 JSON 细节;弹窗关闭后返回原看板,不改变看板布局。 - `Pod Summary` 展示 `devicePodId`、`targetId`、`profile` 和 freshness;Target、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,不称为真实硬件通过。 ## 内部复核页 diff --git a/web/hwlab-cloud-web/scripts/check.ts b/web/hwlab-cloud-web/scripts/check.ts index 59794e92..965bf6dc 100644 --- a/web/hwlab-cloud-web/scripts/check.ts +++ b/web/hwlab-cloud-web/scripts/check.ts @@ -47,6 +47,7 @@ const faviconSvg = readWeb("favicon.svg"); const faviconIco = readWeb("favicon.ico"); const distContractScript = readWeb("scripts/dist-contract.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 devicePodService = readRepo("cmd/hwlab-device-pod/main.ts"); const protocol = readRepo("internal/protocol/index.mjs"); @@ -155,7 +156,7 @@ for (const forbidden of [ 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}`); } @@ -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, "buildDevicePodCloudApiPayload", "cloud-api must proxy/fallback Device Pod payloads"); -assertIncludes(devicePodData, "buildDevicePodRestPayload", "Device Pod fake data must provide REST payload helper"); -assertIncludes(devicePodService, "buildDevicePodRestPayload", "Device Pod service must use dynamic REST payload helper"); -assertIncludes(devicePodService, "DEVICE_POD_SERVICE_ID", "Device Pod service must report service id"); +assertIncludes(cloudApiPayloads, "buildDevicePodCloudApiPayload", "cloud-api payload module must keep the Device Pod compatibility payload helper"); +assertIncludes(cloudApiPayloads, "device_pod_authority_unavailable", "Device Pod compatibility helper must block instead of fake fallback"); +assertIncludes(devicePodData, "buildDevicePodRestPayload", "Legacy Device Pod fixtures must keep the REST payload helper for old smokes"); +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(deployJson, "hwlab-device-pod", "deploy manifest 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() { const files = new Set([