diff --git a/AGENTS.md b/AGENTS.md index ab06aed8..d4cbebca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,9 +70,8 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - 规格文档是微服务、稳定外部服务、短连接 CLI 和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 `docs/reference/spec-*.md`,再修改实现或测试。 - v0.2 服务规格总览、保留服务清单、稳定外部服务边界和废弃范围:[docs/reference/spec-v02-services.md](docs/reference/spec-v02-services.md);`hwlab-gateway-simu`、`hwlab-box-simu` 和 `hwlab-patch-panel` 已废弃,不再保留 spec。 - v0.2 登录与鉴权规格、Keycloak OIDC、Web session 和 CLI API key:[docs/reference/spec-v02-auth.md](docs/reference/spec-v02-auth.md)。 -- v0.2 用户和权限管理规格、code agent session 归属和 hwpod 授权:[docs/reference/spec-user-access.md](docs/reference/spec-user-access.md)。 +- v0.2 用户和权限管理规格、code agent session 归属和工具能力授权:[docs/reference/spec-user-access.md](docs/reference/spec-user-access.md)。 - v0.2 OpenFGA 细粒度授权、Admin Access 管理页和同路径 CLI 规格:[docs/reference/spec-v02-openfga-authorization.md](docs/reference/spec-v02-openfga-authorization.md)。 -- HWPOD Harness 当前规格,定义 `hwpod`、`hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli`、`hwpod-node-ops` 和 `hwpod-node`:[docs/reference/spec-hwpod-harness.md](docs/reference/spec-hwpod-harness.md)。 - v0.2 CI/CD 加法 lane、`v0.2-gitops`、`hwlab-v02` 和 `19666/19667` 规格:[docs/reference/spec-v02-cicd.md](docs/reference/spec-v02-cicd.md)。 - v0.2 `hwlab-cloud-api` API 核心服务规格:[docs/reference/spec-v02-hwlab-cloud-api.md](docs/reference/spec-v02-hwlab-cloud-api.md)。 - v0.2 `hwlab-cloud-web` 浏览器工作台规格:[docs/reference/spec-v02-hwlab-cloud-web.md](docs/reference/spec-v02-hwlab-cloud-web.md)。 @@ -80,7 +79,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - v0.2 Code Agent 由 `hwlab-cloud-api` 接入 AgentRun v0.1 共享执行基础设施,不再保留 HWLAB 自有 agent manager/worker 控制面:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md)。 - v0.2 `hwlab-agent-skills` 技能包服务规格:[docs/reference/spec-v02-hwlab-agent-skills.md](docs/reference/spec-v02-hwlab-agent-skills.md)。 - v0.2 `hwlab-cli` 固定 repo 短连接 client 规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。 -- Device Pod 迁移对照只用于识别旧命名和 API 残留,不能作为当前概念或新开发规格:[docs/reference/spec-device-pod.md](docs/reference/spec-device-pod.md)。 +- HWPOD Harness 快速迭代规格,定义 workspace-local `hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli`、`hwpod-node-ops` 和 `hwpod-node`:[docs/reference/spec-hwpod-harness.md](docs/reference/spec-hwpod-harness.md)。 - v0.2 `hwlab-gateway` 硬件 transport 边界规格:[docs/reference/spec-v02-hwlab-gateway.md](docs/reference/spec-v02-hwlab-gateway.md)。 - v0.2 `hwlab-edge-proxy` API edge proxy 规格:[docs/reference/spec-v02-hwlab-edge-proxy.md](docs/reference/spec-v02-hwlab-edge-proxy.md)。 - v0.2 Observability Monitoring 接入规格,应用侧 `/metrics`、ServiceMonitor、PrometheusRule 和 G14 共享监控边界:[docs/reference/spec-v02-observability-monitoring.md](docs/reference/spec-v02-observability-monitoring.md)。 @@ -105,7 +104,6 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - AgentRun 手动调度装配、UniDesk SSH passthrough 与 GitHub tool credential 边界:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md) - DEV runtime hotfix runbook 与只读审计:[docs/reference/dev-runtime-hotfix-runbook.md](docs/reference/dev-runtime-hotfix-runbook.md) - Gateway 主动出站 demo、poll/result 和本地 smoke:[docs/reference/gateway-outbound-demo.md](docs/reference/gateway-outbound-demo.md) -- Device Pod 旧链接兼容入口,只指向 HWPOD 当前规格和迁移对照:[docs/reference/device-pod.md](docs/reference/device-pod.md) - MVP E2E 验收测试与带编号测试报告 issue 规则:[docs/reference/MVP-e2e-acceptance.md](docs/reference/MVP-e2e-acceptance.md) - 指挥官协作、PR 和 runner 交接:[docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md) - M3 闭环发布运行手册:[docs/reference/m3-loop-rollout-runbook.md](docs/reference/m3-loop-rollout-runbook.md) @@ -124,7 +122,6 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - Cloud Web 静态检查:`npm run web:check` - Cloud Web 构建:`npm run web:build` - Cloud Web M3 只读护栏:`npm run web:m3-readonly` -- Cloud Workbench 布局/遮挡 smoke:`npm run web:layout`;local-build 用 `npm run web:layout:build`;DEV deploy 后用 `npm run web:layout:live`。 - G14 artifact build helper:`node scripts/g14-artifact-publish.mjs --publish ...`,只能由 G14 Tekton task 携带 CI artifact identity 调用;人工发布走 G14 poller/GitOps,不走 legacy CLI CD。 - G14 monorepo 组件计划:`node scripts/g14-ci-plan.mjs --base-ref --target-ref --pretty`,只读分析 affected/reused services,基于内建 service-path component model 与 `deploy/deploy.json`;细则见 [docs/reference/g14-gitops-cicd.md](docs/reference/g14-gitops-cicd.md)。 - G14 GitOps 渲染:`npm run g14:gitops:render`;source 分支不再运行生成物 drift check,发布态由 Tekton 写入 `G14-gitops`。 diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts deleted file mode 100644 index 07e63d46..00000000 --- a/cmd/hwlab-device-pod/main.test.ts +++ /dev/null @@ -1,611 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { createServer } from "node:http"; -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 INTERNAL_TOKEN = "test-internal-token"; - -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.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.devicePodId, "device-pod-test"); - assert.equal(status.status, "blocked"); - assert.equal(status.summary.blocker.code, "gateway_dispatch_unavailable"); - assert.equal(status.targetId, null); - assert.equal(status.profileHash, ""); - assert.match(status.traceId, /^trc_devicepod_/u); - assert.match(status.operationId, /^op_devicepod_/u); - assert.equal(status.freshness.stale, true); - assert.equal(status.truncation.truncated, false); - assert.match(status.output.summary, /device-host-cli dispatch is owned/u); - - 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, 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 headerOnlyJob = 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" }) - }); - assert.equal(headerOnlyJob.status, 403); - assert.equal((await headerOnlyJob.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: internalHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ jobId: "job_devicepod_test", intent: "workspace.ls", traceId: "trc_device_pod_test", operationId: "op_device_pod_test" }) - }); - assert.equal(internalJob.status, 409); - const internalJobPayload = await internalJob.json(); - assert.equal(internalJobPayload.blocker.code, "gateway_dispatch_unavailable"); - assert.equal(internalJobPayload.job.id, "job_devicepod_test"); - assert.equal(internalJobPayload.outputUrl, "/v1/device-pods/device-pod-test/jobs/job_devicepod_test/output"); - - const storedJob = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test`, { - headers: internalHeaders() - }); - assert.equal(storedJob.job.id, "job_devicepod_test"); - assert.equal(storedJob.status, "blocked"); - - const storedOutput = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test/output`, { - headers: internalHeaders() - }); - assert.equal(storedOutput.job.id, "job_devicepod_test"); - assert.equal(storedOutput.output.summary, "HWLAB_CLOUD_API_INTERNAL_URL is not configured for gateway dispatch"); - assert.equal(storedOutput.truncation.truncated, false); - assert.equal(storedOutput.truncation.originalBytes, 0); - - const externalJobRead = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test`); - assert.equal(externalJobRead.status, 403); - assert.equal((await externalJobRead.json()).error.code, "cloud_api_authority_required"); - - const cancelStored = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs/job_devicepod_test/cancel`, { - method: "POST", - headers: internalHeaders() - }); - assert.equal(cancelStored.status, 200); - assert.equal((await cancelStored.json()).job.id, "job_devicepod_test"); - - const missing = await fetch(`http://127.0.0.1:${service.port}/missing`); - assert.equal(missing.status, 404); - assert.deepEqual(await missing.json(), { error: "not_found", path: "/missing" }); - - 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", path: "/health" }); - } finally { - await service.stop(); - } -}); - -test("device pod executor lists and isolates multiple logical device pods", async () => { - const service = await startDevicePod("device-pod-main", { - HWLAB_DEVICE_POD_IDS: "device-pod-main, device-pod-alt" - }); - - try { - const list = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods`); - assert.equal(list.selectedDevicePodId, "device-pod-main"); - assert.deepEqual(list.devicePods.map((pod) => pod.devicePodId), ["device-pod-main", "device-pod-alt"]); - assert.equal(list.devicePods[1].routes.internalJob, "/v1/device-pods/device-pod-alt/jobs"); - - const mainJobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-main/jobs`, { - method: "POST", - headers: { "content-type": "application/json", ...internalHeaders() }, - body: JSON.stringify({ jobId: "job_multi_main", intent: "workspace.ls", traceId: "trc_multi_main", operationId: "op_multi_main" }) - }); - assert.equal(mainJobResponse.status, 409); - const altJobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-alt/jobs`, { - method: "POST", - headers: { "content-type": "application/json", ...internalHeaders() }, - body: JSON.stringify({ jobId: "job_multi_alt", intent: "workspace.ls", traceId: "trc_multi_alt", operationId: "op_multi_alt" }) - }); - assert.equal(altJobResponse.status, 409); - - const mainJob = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-main/jobs/job_multi_main`, { headers: internalHeaders() }); - assert.equal(mainJob.job.devicePodId, "device-pod-main"); - const altJob = await fetchJson(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-alt/jobs/job_multi_alt`, { headers: internalHeaders() }); - assert.equal(altJob.job.devicePodId, "device-pod-alt"); - const crossRead = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-alt/jobs/job_multi_main`, { headers: internalHeaders() }); - assert.equal(crossRead.status, 404); - } finally { - await service.stop(); - } -}); - -test("device-pod executor bounds gateway output text", async () => { - const longText = "z".repeat(65000); - const cloudApi = createServer(async (request, response) => { - const body = await requestJson(request); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - jsonrpc: "2.0", - id: body.id, - result: { - accepted: true, - status: "completed", - dispatch: { shellExecuted: true, dispatchStatus: "succeeded", stdout: longText, exitCode: 0 } - } - })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { - HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` - }); - - try { - const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { - method: "POST", - headers: internalHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ - jobId: "job_bounded_output_test", - intent: "workspace.ls", - args: { path: "src" }, - traceId: "trc_bounded_output_test", - operationId: "op_bounded_output_test", - targetId: "target-device-pod-test", - profileHash: "sha256:test", - profile: { - schemaVersion: 1, - devicePodId: "device-pod-test", - target: { id: "target-device-pod-test" }, - route: { gatewaySessionId: "gws_devicepod_test", resourceId: "res_devicepod_test", capabilityId: "cap_device_host_cli" } - } - }) - }); - assert.equal(jobResponse.status, 202); - const output = await waitForJobStatus(service.port, "device-pod-test", "job_bounded_output_test", "completed"); - assert.equal(output.output.text.length, 64000); - assert.equal(output.text.length, 64000); - assert.equal(output.bytes, 64000); - assert.equal(output.truncation.truncated, true); - assert.equal(output.truncation.originalBytes, 65000); - assert.equal(output.output.omitted.reason, "device_job_output_truncated"); - assert.equal(JSON.stringify(output).includes(longText), false); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("device pod executor dispatches internal jobs through cloud-api gateway adapter", async () => { - const dispatches = []; - const cloudApi = createServer(async (request, response) => { - const body = await requestJson(request); - dispatches.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], internalToken: request.headers["x-hwlab-internal-token"], body }); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - jsonrpc: "2.0", - id: body.id, - result: { - accepted: true, - status: "completed", - operationId: body.params.operationId, - traceId: body.params.traceId, - gatewaySessionId: body.params.gatewaySessionId, - resourceId: body.params.resourceId, - capabilityId: body.params.capabilityId, - dispatch: { - shellExecuted: true, - dispatchStatus: "succeeded", - exitCode: 0 - }, - shellExecuted: true, - dispatchStatus: "succeeded", - stdout: "device-host-cli ok", - stderr: "", - exitCode: 0, - stdoutTruncated: false, - stderrTruncated: false, - auditId: "aud_devicepod_test", - evidenceId: "evd_devicepod_test", - gateway: { gatewayId: "gtw_devicepod_test" } - } - })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { - HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` - }); - - try { - const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { - method: "POST", - headers: internalHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ - jobId: "job_devicepod_gateway_test", - intent: "workspace.ls", - args: { path: "src" }, - traceId: "trc_device_pod_gateway_test", - operationId: "op_device_pod_gateway_test", - targetId: "target-device-pod-test", - profileHash: "sha256:test", - ownerUserId: "usr_device_pod_test", - profile: { - schemaVersion: 1, - devicePodId: "device-pod-test", - target: { id: "target-device-pod-test" }, - projectWorkspace: { workspaceRoot: "F:\\Work\\Project" }, - route: { - gatewaySessionId: "gws_devicepod_test", - resourceId: "res_devicepod_test", - capabilityId: "cap_device_host_cli", - hostWorkspaceRoot: "F:\\Work\\Project", - hostCli: "node tools\\device-host-cli.mjs" - } - } - }) - }); - assert.equal(jobResponse.status, 202); - const job = await jobResponse.json(); - assert.equal(job.status, "running"); - assert.equal(job.targetId, "target-device-pod-test"); - assert.equal(job.profileHash, "sha256:test"); - - const output = await waitForJobStatus(service.port, "device-pod-test", "job_devicepod_gateway_test", "completed"); - assert.equal(output.status, "completed"); - assert.equal(output.output.text, "device-host-cli ok"); - assert.equal(output.output.dispatch.stdoutBytes, 18); - assert.equal(output.output.dispatch.exitCode, 0); - assert.equal(output.output.dispatch.stdoutTruncated, false); - assert.equal(output.output.auditId, "aud_devicepod_test"); - assert.equal(dispatches.length, 1); - assert.equal(dispatches[0].internalService, "hwlab-device-pod"); - assert.equal(dispatches[0].internalToken, INTERNAL_TOKEN); - assert.equal(dispatches[0].body.params.gatewaySessionId, "gws_devicepod_test"); - assert.equal(dispatches[0].body.params.input.cwd, "F:\\Work\\Project"); - assert.match(dispatches[0].body.params.input.command, /^node tools\\device-host-cli\.mjs --profile-json-b64 /u); - assert.equal(dispatches[0].body.params.input.command.includes("node -e"), false); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("device-pod executor maps v0.1 CLI options to device-host-cli argv", async () => { - let dispatchedBody = null; - const cloudApi = createServer(async (request, response) => { - dispatchedBody = await requestJson(request); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - ok: true, - result: { - status: "completed", - dispatch: { shellExecuted: true, dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 }, - auditId: "aud_options", - evidenceId: "ev_options" - } - })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { - HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` - }); - - try { - const jobResponse = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { - method: "POST", - headers: internalHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ - jobId: "job_options_test", - intent: "debug.download", - args: { action: "start", captureUart: "uart/1", captureDurationMs: 8000, port: "COM4", baudRate: 921600 }, - profileHash: "sha256:profile", - profile: { - devicePodId: "device-pod-test", - target: { id: "target-test" }, - route: { gatewaySessionId: "gws_test", resourceId: "res_test", capabilityId: "cap_test", hostCli: "node tools/device-host-cli.mjs" } - } - }) - }); - assert.equal(jobResponse.status, 202); - await waitForJobStatus(service.port, "device-pod-test", "job_options_test", "completed"); - const command = dispatchedBody.params.input.command; - assert.equal(command.includes("node -e"), false); - assert.match(command, /^node tools\/device-host-cli\.mjs --profile-json-b64 /u); - assert.match(command, / --pod-id device-pod-test debug-probe download start /u); - assert.match(command, / --capture-uart uart\/1 --capture-duration-ms 8000 --port COM4 --baud-rate 921600$/u); - const encoded = command.match(/--profile-json-b64 ([A-Za-z0-9+/=]+)/u)[1]; - const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); - assert.equal(decoded.devicePodId, "device-pod-test"); - assert.equal(decoded.route.gatewaySessionId, "gws_test"); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("device-pod executor forwards workspace rg patterns through base64 for Windows cmd", async () => { - let dispatchedBody = null; - const cloudApi = createServer(async (request, response) => { - dispatchedBody = await requestJson(request); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - jsonrpc: "2.0", - id: dispatchedBody.id, - result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } } - })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { - HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` - }); - - try { - await submitAndWait(service.port, "workspace.rg", { - pattern: "FREQ_Controller_FW\\.(axf|bin|hex)$", - path: "projects/71-00075-11/FirmWare/MDK-ARM", - nameOnly: true, - filesWithMatches: true - }, "job_rg_quote", { - profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools\\device-host-cli.mjs" } } - }); - const command = dispatchedBody.params.input.command; - const encoded = Buffer.from("FREQ_Controller_FW\\.(axf|bin|hex)$", "utf8").toString("base64"); - assert.match(command, /^node tools\\device-host-cli\.mjs --profile-json-b64 /u); - assert.match(command, new RegExp(` workspace rg --pattern-b64 ${encoded.replace(/[+]/gu, "\\+")} --path projects/71-00075-11/FirmWare/MDK-ARM --files-with-matches --name-only$`, "u")); - assert.equal(command.includes("FREQ_Controller_FW\\\\."), false); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("device-pod executor returns build verify synchronously for one-call artifact verification (HWLAB #821)", async () => { - let dispatchedBody = null; - const cloudApi = createServer(async (request, response) => { - dispatchedBody = await requestJson(request); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - jsonrpc: "2.0", - id: dispatchedBody.id, - result: { - status: "completed", - dispatch: { - dispatchStatus: "completed", - stdout: JSON.stringify({ ok: true, action: "workspace.build.verify", data: { buildJob: { jobId: "keil-job-1" }, artifacts: [], missing: [] } }), - exitCode: 0 - } - } - })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` }); - - try { - const response = await fetch(`http://127.0.0.1:${service.port}/v1/device-pods/device-pod-test/jobs`, { - method: "POST", - headers: internalHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ - jobId: "job_verify_sync", - intent: "workspace.evidence", - args: { kind: "verify", expected: "axf,hex", headBytes: 16 }, - profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } } - }) - }); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.status, "completed"); - assert.equal(body.job.id, "job_verify_sync"); - const verify = JSON.parse(body.output.text); - assert.equal(verify.action, "workspace.build.verify"); - assert.equal(verify.data.buildJob.jobId, "keil-job-1"); - assert.match(dispatchedBody.params.input.command, / workspace evidence verify --expected "axf,hex" --head-bytes 16$/u); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("device-pod executor maps absorbed G14 device-pod operations to host CLI argv", async () => { - const commands = []; - const cloudApi = createServer(async (request, response) => { - const body = await requestJson(request); - commands.push(body.params.input.command); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - jsonrpc: "2.0", - id: body.id, - result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } } - })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` }); - const baseBody = { - profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } } - }; - - try { - await submitAndWait(service.port, "workspace.bootsharp", { - path: ".", - depth: 2, - limit: 120 - }, "job_map_bootsharp", baseBody); - await submitAndWait(service.port, "workspace.put", { - path: "User/new.c", - contentB64: Buffer.from("int x;\n").toString("base64"), - createDirs: true - }, "job_map_put", baseBody); - await submitAndWait(service.port, "workspace.keil", { - action: "add-source", - base: "projects/app", - path: "User/new.c", - group: "User" - }, "job_map_keil", baseBody); - await submitAndWait(service.port, "workspace.evidence", { - kind: "verify", - expected: "axf,hex", - headBytes: 16, - target: "FREQ_Controller_FW" - }, "job_map_verify", baseBody); - await submitAndWait(service.port, "io.uart.jsonrpc", { - uartId: "uart/1", - method: "gpio.read", - params: "{\"pin\":\"PB5\"}", - requireJsonrpcResult: true, - expectResultField: ["pin", "value"] - }, "job_map_jsonrpc", baseBody); - - assert.match(commands[0], / workspace bootsharp \. --depth 2 --limit 120$/u); - assert.match(commands[1], / workspace put User\/new\.c --content-b64 /u); - assert.match(commands[1], / --create-dirs$/u); - assert.match(commands[2], / workspace keil add-source User\/new\.c --base projects\/app --group User$/u); - assert.match(commands[3], / workspace evidence verify --target FREQ_Controller_FW --expected "axf,hex" --head-bytes 16$/u); - assert.match(commands[4], / io-probe uart\/1 jsonrpc gpio\.read /u); - assert.match(commands[4], / --params "\{\\"pin\\":\\"PB5\\"\}" /u); - assert.match(commands[4], / --require-jsonrpc-result --expect-result-field pin --expect-result-field value$/u); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("device-pod executor fails mismatched gateway JSON-RPC response ids", async () => { - const cloudApi = createServer(async (_request, response) => { - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ jsonrpc: "2.0", id: "wrong-id", result: { status: "completed", dispatch: { dispatchStatus: "succeeded", stdout: "ok", exitCode: 0 } } })); - }); - await new Promise((resolve) => cloudApi.listen(0, "127.0.0.1", resolve)); - const cloudPort = cloudApi.address().port; - const service = await startDevicePod("device-pod-test", { HWLAB_CLOUD_API_INTERNAL_URL: `http://127.0.0.1:${cloudPort}` }); - - try { - const output = await submitAndWait(service.port, "workspace.ls", { path: "." }, "job_bad_id", { - profile: { devicePodId: "device-pod-test", target: { id: "target-test" }, route: { gatewaySessionId: "gws_test", hostCli: "node tools/device-host-cli.mjs" } } - }, "failed"); - assert.equal(output.status, "failed"); - assert.match(output.blocker.summary, /JSON-RPC id mismatch/u); - } finally { - await service.stop(); - await new Promise((resolve, reject) => cloudApi.close((error) => (error ? reject(error) : resolve()))); - } -}); - -async function submitAndWait(port, intent, args, jobId, extraBody = {}, expectedStatus = "completed") { - const response = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-test/jobs`, { - method: "POST", - headers: internalHeaders({ "content-type": "application/json" }), - body: JSON.stringify({ jobId, intent, args, traceId: `trc_${jobId}`, operationId: `op_${jobId}`, ...extraBody }) - }); - assert.ok([200, 202].includes(response.status), `unexpected job create status ${response.status}`); - return await waitForJobStatus(port, "device-pod-test", jobId, expectedStatus); -} - -async function startDevicePod(devicePodId, extraEnv = {}) { - const port = await freePort(); - const child = spawn(bunCommand, ["run", "cmd/hwlab-device-pod/main.ts"], { - cwd: process.cwd(), - env: { - ...process.env, - HWLAB_DEVICE_POD_PORT: String(port), - HWLAB_DEVICE_POD_ID: devicePodId, - HWLAB_ENVIRONMENT: "v02", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - ...extraEnv - }, - stdio: ["ignore", "pipe", "pipe"] - }); - await waitForListening(child, "device pod"); - return { - port, - stop: () => stopChild(child) - }; -} - -async function fetchJson(url, options = {}) { - const response = await fetch(url, options); - assert.equal(response.ok, true, `${url} returned ${response.status}`); - return response.json(); -} - -async function waitForJobStatus(port, devicePodId, jobId, status) { - let last; - for (let attempt = 0; attempt < 30; attempt += 1) { - last = await fetchJson(`http://127.0.0.1:${port}/v1/device-pods/${devicePodId}/jobs/${jobId}/output`, { - headers: internalHeaders() - }); - if (last.status === status) return last; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - assert.equal(last?.status, status); - return last; -} - -function internalHeaders(extra = {}) { - return { ...extra, "x-hwlab-internal-service": "hwlab-cloud-api", "x-hwlab-internal-token": INTERNAL_TOKEN }; -} - -async function requestJson(request) { - const chunks = []; - for await (const chunk of request) chunks.push(chunk); - const text = Buffer.concat(chunks).toString("utf8").trim(); - return text ? JSON.parse(text) : {}; -} - -async function freePort() { - const server = createServer(); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const port = server.address().port; - await new Promise((resolve) => server.close(resolve)); - return port; -} - -async function waitForListening(child, label) { - let stderr = ""; - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${label} did not start: ${stderr}`)), 5000); - child.stdout.on("data", (chunk) => { - if (chunk.toString().includes("listening")) { - clearTimeout(timer); - resolve(); - } - }); - child.on("exit", (code) => { - clearTimeout(timer); - reject(new Error(`${label} exited early code=${code}: ${stderr}`)); - }); - }); -} - -async function stopChild(child) { - if (child.exitCode !== null) return; - child.kill("SIGTERM"); - await new Promise((resolve) => child.once("exit", resolve)); -} diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts deleted file mode 100644 index 004ce51e..00000000 --- a/cmd/hwlab-device-pod/main.ts +++ /dev/null @@ -1,808 +0,0 @@ -#!/usr/bin/env node -import { createServer } from "node:http"; -import { randomUUID } from "node:crypto"; - -import { runtimeIdentityFromEnv } from "../../internal/build-metadata.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 devicePodIds = configuredDevicePodIds(process.env.HWLAB_DEVICE_POD_IDS, devicePodId); -const environment = process.env.HWLAB_ENVIRONMENT || process.env.HWLAB_GITOPS_PROFILE || "dev"; -const cloudApiInternalUrl = normalizeBaseUrl(process.env.HWLAB_CLOUD_API_INTERNAL_URL || process.env.HWLAB_CLOUD_API_URL); -const internalToken = textOr(process.env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); -const dispatchTimeoutMs = numberOr(process.env.HWLAB_DEVICE_POD_GATEWAY_DISPATCH_TIMEOUT_MS, 120000); -const DEVICE_JOB_OUTPUT_MAX_BYTES = 64000; -const jobs = new Map(); - -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; - } - if (request.method === "GET" && route.subpath.startsWith("jobs/")) { - handleGetInternalJob(request, response, route.devicePodId, route.subpath); - return; - } - if (request.method === "POST" && route.subpath.startsWith("jobs/") && route.subpath.endsWith("/cancel")) { - handleCancelInternalJob(request, response, route.devicePodId, route.subpath); - 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 }); - } -}), port); - -function healthPayload() { - return { - serviceId: SERVICE_ID, - environment, - status: "live", - ready: true, - contractVersion: CONTRACT_VERSION, - devicePodId, - devicePodIds, - observedAt: new Date().toISOString(), - role: "device-pod-internal-executor", - authority: "hwlab-cloud-api", - acceptsUserAuthority: false, - fake: false, - gatewayAdapter: gatewayAdapterState(), - runtime: runtimeIdentityFromEnv(process.env), - 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: devicePodIds.map((id) => executorDevicePodSummary(id)) - }; -} - -function executorDevicePodSummary(id) { - return { - devicePodId: id, - status: "executor-ready", - authority: "hwlab-cloud-api", - fake: false, - gatewayAdapter: gatewayAdapterState(), - routes: { - cloudAuthority: "/v1/device-pods", - internalJob: `/v1/device-pods/${encodeURIComponent(id)}/jobs` - } - }; -} - -function statusPayload(id) { - const blocker = gatewayAdapterBlocker("device-host-cli dispatch is owned by hwlab-cloud-api gateway routing"); - const observedAt = new Date().toISOString(); - const output = boundedOutput({ text: blocker.summary, summary: blocker.summary }); - const freshnessPayload = freshness(observedAt, blocker); - return { - serviceId: SERVICE_ID, - contractVersion: CONTRACT_VERSION, - status: "blocked", - devicePodId: id, - targetId: null, - profileHash: "", - traceId: `trc_devicepod_${randomUUID()}`, - operationId: `op_devicepod_${randomUUID()}`, - observedAt, - source: sourcePayload(), - blocker, - freshness: freshnessPayload, - output, - text: output.text, - bytes: output.bytes, - truncation: output.truncation, - summary: { - devicePodId: id, - targetId: null, - profileHash: "", - status: "blocked", - freshness: freshnessPayload, - blocker - } - }; -} - -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()}`; - const operationId = typeof body.operationId === "string" ? body.operationId : `op_devicepod_${randomUUID()}`; - const now = new Date().toISOString(); - const profile = normalizeObject(body.profile); - const route = normalizeObject(profile.route); - const args = normalizeObject(body.args); - const jobId = typeof body.jobId === "string" && body.jobId.startsWith("job_") ? body.jobId : `job_devicepod_${randomUUID()}`; - const command = deviceHostCommand(profile, { - id: jobId, - devicePodId: id, - intent: typeof body.intent === "string" ? body.intent : "unknown", - args - }); - const dispatchBlocker = gatewayDispatchPreflightBlocker(route, command); - const job = { - id: jobId, - devicePodId: id, - status: dispatchBlocker ? "blocked" : "running", - intent: typeof body.intent === "string" ? body.intent : "unknown", - args, - reason: typeof body.reason === "string" ? body.reason : "", - traceId, - operationId, - createdAt: now, - updatedAt: now, - completedAt: dispatchBlocker ? now : null, - targetId: typeof body.targetId === "string" ? body.targetId : targetIdFromProfile(profile), - profileHash: typeof body.profileHash === "string" ? body.profileHash : "", - ownerUserId: typeof body.ownerUserId === "string" ? body.ownerUserId : "", - command, - output: dispatchBlocker ? boundedOutput({ text: "", summary: dispatchBlocker.summary }) : boundedOutput({ text: "", summary: "gateway/device-host-cli dispatch queued" }), - blocker: dispatchBlocker, - source: sourcePayload() - }; - jobs.set(jobKey(id, job.id), job); - if (!dispatchBlocker && shouldSynchronouslyDispatch(job)) { - const dispatched = await dispatchGatewayJob({ job, route }); - const finalJob = dispatched ?? job; - jsonResponse(response, finalJob.status === "completed" ? 200 : 409, jobOutputPayload(finalJob)); - return; - } - if (!dispatchBlocker) dispatchGatewayJob({ job, route }); - jsonResponse(response, dispatchBlocker ? 409 : 202, jobPayload(job, { accepted: !dispatchBlocker })); -} - -function shouldSynchronouslyDispatch(job) { - return job.intent === "workspace.evidence" && textOr(job.args?.kind, "") === "verify"; -} - -function handleGetInternalJob(request, response, id, subpath) { - if (!isInternalCaller(request)) return rejectExternalJobRoute(response); - const parts = subpath.split("/"); - const job = jobs.get(jobKey(id, decodeURIComponent(parts[1] ?? ""))); - if (!job) { - jsonResponse(response, 404, { error: { code: "device_job_not_found", message: "Device Pod executor job was not found" }, source: sourcePayload() }); - return; - } - if (parts[2] === "output") { - jsonResponse(response, 200, jobOutputPayload(job)); - return; - } - jsonResponse(response, 200, jobPayload(job)); -} - -function handleCancelInternalJob(request, response, id, subpath) { - if (!isInternalCaller(request)) return rejectExternalJobRoute(response); - const jobId = decodeURIComponent(subpath.split("/")[1] ?? ""); - const job = jobs.get(jobKey(id, jobId)); - if (!job) { - jsonResponse(response, 404, { error: { code: "device_job_not_found", message: "Device Pod executor job was not found" }, source: sourcePayload() }); - return; - } - if (terminalJobStatus(job.status)) { - jsonResponse(response, 200, jobPayload(job)); - return; - } - const now = new Date().toISOString(); - const canceled = { ...job, status: "canceled", updatedAt: now, completedAt: now, blocker: { code: "device_job_canceled", layer: "device-pod", retryable: false, summary: "Device Pod executor job was canceled by cloud-api" } }; - jobs.set(jobKey(id, jobId), canceled); - jsonResponse(response, 200, jobPayload(canceled)); -} - -async function dispatchGatewayJob({ job, route }) { - const target = `${cloudApiInternalUrl}/v1/internal/device-pod/gateway-dispatch`; - const requestId = `req_${job.id}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), dispatchTimeoutMs + 10000); - try { - const response = await fetch(target, { - method: "POST", - signal: controller.signal, - headers: { - accept: "application/json", - "content-type": "application/json", - "x-hwlab-internal-service": SERVICE_ID, - "x-hwlab-internal-token": internalToken, - "x-trace-id": job.traceId, - "x-request-id": requestId - }, - body: JSON.stringify({ - id: requestId, - actorId: job.ownerUserId || "svc_hwlab-device-pod", - params: { - projectId: "prj_v02_device_pod", - gatewaySessionId: route.gatewaySessionId, - resourceId: route.resourceId ?? "res_device_pod", - capabilityId: route.capabilityId ?? "cap_device_host_cli", - operationId: job.operationId, - traceId: job.traceId, - input: { - command: job.command, - cwd: route.hostWorkspaceRoot, - timeoutMs: dispatchTimeoutMs - } - } - }) - }); - const payload = await response.json().catch(() => ({})); - const current = jobs.get(jobKey(job.devicePodId, job.id)); - if (current && terminalJobStatus(current.status)) return; - const next = jobFromGatewayDispatch(job, payload, response.status); - jobs.set(jobKey(job.devicePodId, job.id), next); - return next; - } catch (error) { - const current = jobs.get(jobKey(job.devicePodId, job.id)); - if (current && terminalJobStatus(current.status)) return; - const now = new Date().toISOString(); - const next = { - ...job, - status: "failed", - updatedAt: now, - completedAt: now, - output: boundedOutput({ text: "", error: error?.message ?? "gateway dispatch failed" }), - blocker: gatewayDispatchFailedBlocker(error?.message ?? "gateway dispatch failed") - }; - jobs.set(jobKey(job.devicePodId, job.id), next); - return next; - } finally { - clearTimeout(timer); - } -} - -function jobPayload(job, { accepted = !["blocked", "failed"].includes(job.status) } = {}) { - return { - accepted, - status: job.status, - contractVersion: CONTRACT_VERSION, - devicePodId: job.devicePodId, - targetId: job.targetId ?? null, - profileHash: job.profileHash ?? "", - traceId: job.traceId, - operationId: job.operationId, - job: publicJob(job), - blocker: job.blocker, - freshness: freshness(job.updatedAt, job.blocker), - outputUrl: `/v1/device-pods/${encodeURIComponent(job.devicePodId)}/jobs/${encodeURIComponent(job.id)}/output`, - cancelUrl: `/v1/device-pods/${encodeURIComponent(job.devicePodId)}/jobs/${encodeURIComponent(job.id)}/cancel`, - source: job.source ?? sourcePayload() - }; -} - -function rejectExternalJobRoute(response) { - 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() - }); -} - -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) { - if (!internalToken) return false; - const serviceHeader = request.headers["x-hwlab-internal-service"]; - const tokenHeader = request.headers["x-hwlab-internal-token"]; - return String(Array.isArray(serviceHeader) ? serviceHeader[0] : serviceHeader ?? "").trim() === "hwlab-cloud-api" - && String(Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader ?? "").trim() === internalToken; -} - -function sourcePayload() { - return { - kind: "INTERNAL_EXECUTOR", - serviceId: SERVICE_ID, - authority: "hwlab-cloud-api", - fake: false, - devLiveEvidence: false, - runtime: runtimeIdentityFromEnv(process.env) - }; -} - -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." }; -} - -function gatewayDispatchFailedBlocker(summary) { - return { code: "gateway_dispatch_failed", layer: "device-pod", retryable: true, summary: String(summary || "gateway/device-host-cli dispatch failed"), userMessage: "Device Pod gateway/device-host-cli dispatch failed." }; -} - -function gatewayDispatchPreflightBlocker(route, command) { - if (!cloudApiInternalUrl) return gatewayAdapterBlocker("HWLAB_CLOUD_API_INTERNAL_URL is not configured for gateway dispatch"); - if (!route.gatewaySessionId) return gatewayAdapterBlocker("profile route.gatewaySessionId is missing"); - if (!command) return gatewayAdapterBlocker("device-host-cli command mapping is not available for this intent"); - return null; -} - -function gatewayAdapterState() { - return { mode: cloudApiInternalUrl ? "cloud-api-internal-dispatch" : "blocked", cloudApiInternalUrlConfigured: Boolean(cloudApiInternalUrl) }; -} - -function jobFromGatewayDispatch(job, payload, httpStatus) { - const expectedId = `req_${job.id}`; - if (payload && Object.hasOwn(payload, "id") && String(payload.id) !== expectedId) { - const blocker = gatewayDispatchFailedBlocker(`gateway JSON-RPC id mismatch: expected ${expectedId}, got ${String(payload.id)}`); - const now = new Date().toISOString(); - return { ...job, status: "failed", updatedAt: now, completedAt: now, output: boundedOutput({ text: "", httpStatus, summary: blocker.summary }), blocker }; - } - const result = normalizeObject(payload.result); - const dispatch = normalizeObject(result.dispatch); - const dispatchText = gatewayDispatchText(result, dispatch); - const blocker = payload.blocker ?? result.blocker ?? (payload.error ? gatewayDispatchFailedBlocker(payload.error.message ?? "gateway dispatch failed") : null); - const completed = !blocker && httpStatus < 400 && (result.status === "completed" || result.status === "succeeded" || dispatch.dispatchStatus === "succeeded" || dispatch.dispatchStatus === "completed"); - const status = completed ? "completed" : blocker?.code === "gateway_dispatch_unavailable" ? "blocked" : "failed"; - const failureSummary = blocker?.summary || dispatch.message || dispatch.stderr || dispatchText || result.status || "gateway/device-host-cli dispatch failed"; - const now = new Date().toISOString(); - return { - ...job, - status, - updatedAt: now, - completedAt: now, - output: boundedOutput({ - text: dispatchText, - httpStatus, - dispatch: gatewayDispatchSummary(result, dispatch), - gateway: result.gateway ?? null, - auditId: result.auditId ?? null, - evidenceId: result.evidenceId ?? null, - summary: completed ? "gateway/device-host-cli dispatch completed" : failureSummary - }), - blocker: completed ? null : (blocker?.summary ? blocker : gatewayDispatchFailedBlocker(failureSummary)) - }; -} - -function gatewayDispatchText(result, dispatch) { - if (typeof result.stdout === "string" && result.stdout) return result.stdout; - if (typeof result.stderr === "string" && result.stderr) return result.stderr; - if (typeof dispatch.stdout === "string" && dispatch.stdout) return dispatch.stdout; - if (typeof dispatch.stderr === "string" && dispatch.stderr) return dispatch.stderr; - const evidence = normalizeObject(result.evidence ?? dispatch.evidence); - if (typeof evidence.text === "string" && evidence.text) return evidence.text; - if (typeof evidence.logTail === "string" && evidence.logTail) return evidence.logTail; - if (typeof evidence.summary === "string" && evidence.summary) return evidence.summary; - if (typeof dispatch.message === "string" && dispatch.message && dispatch.dispatchStatus === "completed") return dispatch.message; - if (typeof dispatch.summary === "string" && dispatch.summary) return dispatch.summary; - if (typeof result.summary === "string" && result.summary) return result.summary; - if (typeof dispatch.buildSummary === "string" && dispatch.buildSummary) return dispatch.buildSummary; - if (typeof result.text === "string") return result.text; - if (result && Object.keys(result).length > 0) return JSON.stringify(result); - return ""; -} - -function gatewayDispatchSummary(result, dispatch) { - const value = (key) => dispatch[key] ?? result[key]; - return pruneUndefined({ - shellExecuted: value("shellExecuted"), - dispatchStatus: value("dispatchStatus"), - status: value("status"), - exitCode: value("exitCode"), - signal: value("signal"), - timedOut: value("timedOut"), - cwd: value("cwd"), - command: value("command"), - stdoutBytes: textBytes(value("stdout")), - stderrBytes: textBytes(value("stderr")), - stdoutTruncated: value("stdoutTruncated"), - stderrTruncated: value("stderrTruncated"), - durationMs: value("durationMs") - }); -} - -function deviceHostCommand(profile, job) { - const args = deviceHostArgs(job.intent, job.args); - if (!args) return null; - const payload = { - devicePodId: profile.devicePodId || job.devicePodId, - profile, - hostCli: profile.route?.hostCli || "node tools/device-host-cli.mjs", - args - }; - const profileJsonB64 = Buffer.from(JSON.stringify(payload.profile), "utf8").toString("base64"); - return shellJoin([...splitCommand(payload.hostCli), "--profile-json-b64", profileJsonB64, "--pod-id", payload.devicePodId, ...payload.args]); -} - -function splitCommand(value) { - const out = []; - let quote = null; - let buffer = ""; - for (const char of String(value || "")) { - if (quote) { - if (char === quote) quote = null; - else buffer += char; - } else if (char === "'" || char === '"') { - quote = char; - } else if (/\s/u.test(char)) { - if (buffer) { - out.push(buffer); - buffer = ""; - } - } else { - buffer += char; - } - } - if (buffer) out.push(buffer); - return out.length ? out : ["node", "tools/device-host-cli.mjs"]; -} - -function shellJoin(parts) { - return parts.map(shellQuote).join(" "); -} - -function shellQuote(value) { - const text = String(value ?? ""); - if (/^[A-Za-z0-9_./:=@\\-]+$/u.test(text)) return text; - return `"${text.replace(/(["\\])/gu, "\\$1")}"`; -} - -function deviceHostArgs(intent, args = {}) { - if (intent === "workspace.bootsharp") return ["workspace", "bootsharp", textOr(args.path, "."), ...hostOptionArgs(args, ["depth", "limit", "agentsLimit"])]; - if (intent === "workspace.ls") return ["workspace", "ls", textOr(args.path, ".")]; - if (intent === "workspace.cat") { - const path = textOr(args.path, ""); - return path ? ["workspace", "cat", path, "--limit", String(numberOr(args.limit ?? args.maxBytes, 40000))] : null; - } - if (intent === "workspace.rg") { - const pattern = textOr(args.pattern ?? args.query, ""); - return pattern ? ["workspace", "rg", "--pattern-b64", Buffer.from(pattern, "utf8").toString("base64"), "--path", textOr(args.path, "."), ...hostOptionArgs(args, ["glob", "g", "filesWithMatches", "nameOnly", "names", "l", "ignoreCase", "i", "maxCount", "limit"])] : null; - } - if (intent === "workspace.put") { - const path = textOr(args.path, ""); - return path - ? ["workspace", "put", path, ...hostOptionArgs(args, [ - "contentB64", - "textB64", - "text", - "encoding", - "charset", - "createOnly", - "createDirs", - "updateOnly" - ])] - : null; - } - if (intent === "workspace.rm") { - const path = textOr(args.path, ""); - return path ? ["workspace", "rm", path, ...hostOptionArgs(args, ["missingOk"])] : null; - } - if (intent === "workspace.rmdir") { - const path = textOr(args.path, ""); - return path ? ["workspace", "rmdir", path] : null; - } - if (intent === "workspace.keil") { - const action = textOr(args.action, ""); - return action - ? [ - "workspace", - "keil", - action, - ...optionalValue(args.path), - ...hostOptionArgs(args, ["base", "group", "groupName", "target", "timeoutMs"]) - ] - : null; - } - if (intent === "workspace.apply-patch") return ["workspace", "apply-patch", textOr(args.base, "."), "--patch-b64", patchBase64(args)]; - if (intent === "workspace.build") { - return [ - "workspace", - "build", - textOr(args.action, "start"), - ...jobIdArgs(args), - ...hostOptionArgs(args, ["target", "timeoutMs", "clean", "dryRun"]), - ]; - } - if (intent === "debug.status") return ["debug-probe", "status"]; - if (intent === "debug.chip-id") return ["debug-probe", "chip-id"]; - if (intent === "debug.download") { - return [ - "debug-probe", - "download", - textOr(args.action, "start"), - ...jobIdArgs(args), - ...hostOptionArgs(args, [ - "target", - "timeoutMs", - "captureUart", - "captureDurationMs", - "durationMs", - "port", - "baudRate" - ]) - ]; - } - if (intent === "debug.reset") return ["debug-probe", "reset"]; - if (intent === "workspace.evidence") { - return [ - "workspace", - "evidence", - textOr(args.kind, "build"), - ...jobIdArgs(args), - ...hostOptionArgs(args, ["tail", "full", "path", "target", "expected", "headBytes"]) - ]; - } - if (intent === "debug.evidence") { - return [ - "debug-probe", - "evidence", - textOr(args.kind, "download"), - ...jobIdArgs(args), - ...hostOptionArgs(args, ["tail", "full", "path", "target"]) - ]; - } - if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"]; - if (intent === "io.uart.read") { - return [ - "io-probe", - textOr(args.uartId, "uart/1"), - "read", - "--duration-ms", - String(numberOr(args.durationMs ?? args["duration-ms"], 1000)), - ...hostOptionArgs(args, ["port", "baudRate"]) - ]; - } - if (intent === "io.uart.read-after-launch-flash") { - return [ - "io-probe", - textOr(args.uartId, "uart/1"), - "read-after-launch-flash", - ...hostOptionArgs(args, uartLaunchFlashOptionKeys) - ]; - } - if (intent === "io.uart.write") { - return [ - "io-probe", - textOr(args.uartId, "uart/1"), - "write", - ...(args.hex ? ["--hex"] : []), - ...hostOptionArgs(args, ["port", "baudRate"]), - textOr(args.message ?? args.text ?? args.data, "") - ]; - } - if (intent === "io.uart.jsonrpc") { - return [ - "io-probe", - textOr(args.uartId, "uart/1"), - "jsonrpc", - ...optionalValue(args.method), - ...hostOptionArgs(args, uartJsonRpcOptionKeys) - ]; - } - return null; -} - -const uartLaunchFlashOptionKeys = [ - "durationMs", - "port", - "baudRate", - "flashBase", - "skipHardwareReset", - "connectMode", - "timeoutMs" -]; -const uartJsonRpcOptionKeys = [ - "id", - "request", - "requestB64", - "params", - "paramsB64", - "responseTimeoutMs", - "durationMs", - "retry", - "retries", - "retryDelayMs", - "lineEnding", - "noNewline", - "lineDelimited", - "discardBefore", - "requireResponse", - "requireJson", - "requireJsonrpc", - "requireJsonrpcResult", - "allowIdMismatch", - "expectResultField", - "port", - "baudRate" -]; - -function optionalValue(value) { - const result = textOr(value, ""); - return result ? [result] : []; -} - -function jobIdArgs(args = {}) { - const jobId = textOr(args.jobId, ""); - return jobId ? [jobId] : []; -} - -function hostOptionArgs(args = {}, keys = []) { - const out = []; - for (const key of keys) { - const value = args[key]; - if (value === undefined || value === null || value === "" || value === false) continue; - const name = `--${key.replace(/[A-Z]/gu, (match) => `-${match.toLowerCase()}`)}`; - const values = Array.isArray(value) ? value : [value]; - for (const item of values) { - if (item === undefined || item === null || item === "" || item === false) continue; - if (item === true) out.push(name); - else out.push(name, String(item)); - } - } - return out; -} - -function patchBase64(args = {}) { - if (typeof args.patchB64 === "string") return args.patchB64; - if (typeof args["patch-b64"] === "string") return args["patch-b64"]; - return Buffer.from(String(args.patch ?? ""), "utf8").toString("base64"); -} - -function targetIdFromProfile(profile = {}) { - return profile.target?.id ?? profile.targetId ?? null; -} - -function normalizeObject(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; -} - -function textOr(value, fallback) { - return typeof value === "string" && value.trim() ? value : fallback; -} - -function numberOr(value, fallback) { - const parsed = Number.parseInt(String(value ?? ""), 10); - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; -} - -function normalizeBaseUrl(value) { - const text = String(value ?? "").trim(); - return text ? text.replace(/\/+$/u, "") : ""; -} - -function configuredDevicePodIds(value, fallbackId) { - const ids = String(value ?? "") - .split(",") - .map((item) => item.trim()) - .filter(Boolean); - const unique = []; - for (const id of [fallbackId, ...ids]) { - if (!unique.includes(id)) unique.push(id); - } - return unique; -} - -function jobOutputPayload(job) { - return { - ...jobPayload(job), - output: job.output, - text: job.output.text, - bytes: job.output.bytes, - truncation: job.output.truncation - }; -} - -function publicJob(job) { - return { - id: job.id, - devicePodId: job.devicePodId, - status: job.status, - intent: job.intent, - reason: job.reason, - traceId: job.traceId, - operationId: job.operationId, - createdAt: job.createdAt, - updatedAt: job.updatedAt, - completedAt: job.completedAt - }; -} - -function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) { - const text = String(output?.text ?? ""); - const buffer = Buffer.from(text, "utf8"); - const clipped = buffer.length > maxBytes; - const boundedText = clipped ? buffer.subarray(0, maxBytes).toString("utf8") : text; - const bounded = { ...output, text: boundedText }; - if (clipped) { - delete bounded.dispatch; - bounded.omitted = { reason: "device_job_output_truncated", originalBytes: buffer.length }; - } - return { ...bounded, bytes: Math.min(buffer.length, maxBytes), truncation: { maxBytes, truncated: clipped, originalBytes: buffer.length } }; -} - -function pruneUndefined(value) { - return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); -} - -function textBytes(value) { - return typeof value === "string" ? Buffer.byteLength(value, "utf8") : undefined; -} - -function freshness(observedAt, blocker) { - return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "device-pod-executor" }; -} - -function terminalJobStatus(status) { - return ["completed", "failed", "blocked", "canceled"].includes(status); -} - -function jobKey(id, jobId) { - return `${id}\u0000${jobId}`; -} diff --git a/cmd/hwlab-edge-proxy/main.test.ts b/cmd/hwlab-edge-proxy/main.test.ts index 945d7fa9..6c0e4d5b 100644 --- a/cmd/hwlab-edge-proxy/main.test.ts +++ b/cmd/hwlab-edge-proxy/main.test.ts @@ -30,13 +30,6 @@ test("edge proxy reports local health and proxies live health to upstream", asyn assert.equal(upstream.captured.method, "GET"); assert.equal(upstream.captured.url, "/health/live"); - const internal = await fetch(`http://127.0.0.1:${proxy.port}/v1/internal/device-pod/gateway-dispatch`, { - method: "POST", - headers: { "x-hwlab-internal-service": "hwlab-device-pod" } - }); - assert.equal(internal.status, 404); - assert.equal((await internal.json()).error.code, "not_found"); - assert.notEqual(upstream.captured.url, "/v1/internal/device-pod/gateway-dispatch"); } finally { await proxy.stop(); await upstream.stop(); diff --git a/cmd/hwlab-gateway/main.test.ts b/cmd/hwlab-gateway/main.test.ts index 06ee90c5..9f6996c4 100644 --- a/cmd/hwlab-gateway/main.test.ts +++ b/cmd/hwlab-gateway/main.test.ts @@ -143,14 +143,14 @@ function shellRequest({ id, traceId, command }: { id: string; traceId: string; c id, method: "hardware.invoke.shell", params: { - projectId: "prj_v02_device_pod", + projectId: "prj_v02_hwpod", gatewaySessionId: "gws_gateway_test", resourceId: "res_d601_windows_host", capabilityId: "cap_d601_windows_cmd_exec", operationId: `op_${id}`, input: { command, cwd: process.cwd(), timeoutMs: 3000 } }, - meta: { traceId, actorId: "svc_hwlab-device-pod", serviceId: "hwlab-cloud-api", environment: "v02" } + meta: { traceId, actorId: "svc_hwpod-node", serviceId: "hwlab-cloud-api", environment: "v02" } }; } diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index 157433fe..9c28e6c7 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -136,36 +136,6 @@ }, "reusedFrom": "fdd27830a0cef173fc07a2cab8cd469d67aaff9da12bb0195f188e323854f93a" }, - { - "serviceId": "hwlab-device-pod", - "commitId": "278bbe1", - "image": "127.0.0.1:5000/hwlab/hwlab-device-pod:278bbe1", - "imageTag": "278bbe1", - "digest": "not_published", - "publishState": "skeleton-only", - "profile": "dev", - "namespace": "hwlab-dev", - "healthPath": "/health/live", - "sourceState": "source-present", - "publishEnabled": true, - "artifactRequired": true, - "artifactScope": "required", - "notPublishedReason": "publish_not_run", - "buildCreatedAt": null, - "buildSource": null, - "componentCommitId": null, - "componentInputHash": null, - "dockerfileHash": "8b2595a09276479b8809e70e99516c1539a6a7cc200e73266d247176b56be962", - "baseImageReference": "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim", - "baseImageDigest": null, - "buildArgsHash": null, - "ciAffected": true, - "ciReason": [ - "new-service" - ], - "reuse": null, - "reusedFrom": null - }, { "serviceId": "hwlab-gateway", "commitId": "278bbe1", @@ -280,7 +250,6 @@ "requiredServiceIds": [ "hwlab-cloud-api", "hwlab-cloud-web", - "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", "hwlab-agent-skills" @@ -309,17 +278,6 @@ "entrypoint": "web/hwlab-cloud-web/index.html", "disabledReason": null }, - { - "serviceId": "hwlab-device-pod", - "publishEnabled": true, - "artifactRequired": true, - "artifactScope": "required", - "runtimeKind": "node-command", - "implementationState": "repo-entrypoint", - "sourceState": "source-present", - "entrypoint": "cmd/hwlab-device-pod/main.ts", - "disabledReason": null - }, { "serviceId": "hwlab-gateway", "publishEnabled": true, diff --git a/deploy/deploy.json b/deploy/deploy.json index 64d8db88..4721878c 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -82,13 +82,6 @@ "port": 7430, "targetPort": "http" }, - { - "serviceId": "hwlab-device-pod", - "name": "hwlab-device-pod", - "namespace": "hwlab-dev", - "port": 7601, - "targetPort": "http" - }, { "serviceId": "hwlab-edge-proxy", "name": "hwlab-edge-proxy", @@ -130,12 +123,10 @@ "runtimePath": "deploy/gitops/g14/runtime-v02", "imageTagMode": "full", "envReuseServices": [ - "hwlab-cloud-web", - "hwlab-device-pod" + "hwlab-cloud-web" ], "bootScripts": { - "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh", - "hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh" + "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh" }, "services": [ { @@ -156,14 +147,7 @@ "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID": "G14", "HWLAB_CODE_AGENT_AGENTRUN_RUNNER_NAMESPACE": "agentrun-v01", "HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE": "agentrun-v01", - "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL": "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git", - "HWLAB_DEVICE_POD_INTERNAL_TOKEN": "secretRef:hwlab-v02-device-pod-internal/token" - } - }, - { - "serviceId": "hwlab-device-pod", - "env": { - "HWLAB_DEVICE_POD_INTERNAL_TOKEN": "secretRef:hwlab-v02-device-pod-internal/token" + "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL": "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git" } } ] @@ -210,8 +194,7 @@ "HWLAB_CODE_AGENT_CODEX_API_MODEL": "gpt-5.5", "HWLAB_CODE_AGENT_CODEX_API_BASE_URL": "http://127.0.0.1:49280/responses", "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL": "https://hyueapi.com", - "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT": "49280", - "HWLAB_DEVICE_POD_URL": "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" + "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT": "49280" } }, { @@ -226,19 +209,6 @@ "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "1260000" } }, - { - "serviceId": "hwlab-device-pod", - "namespace": "hwlab-dev", - "healthPath": "/health/live", - "profile": "dev", - "replicas": 1, - "env": { - "HWLAB_ENVIRONMENT": "dev", - "HWLAB_CLOUD_API_INTERNAL_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "HWLAB_DEVICE_POD_ID": "device-pod-71-freq", - "HWLAB_DEVICE_POD_PORT": "7601" - } - }, { "serviceId": "hwlab-gateway", "namespace": "hwlab-dev", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index d326de67..e7591b8e 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -67,7 +67,6 @@ "enum": [ "hwlab-cloud-api", "hwlab-cloud-web", - "hwlab-device-pod", "hwlab-gateway", "hwlab-edge-proxy", "hwlab-agent-skills" diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 1cabbc83..0a903928 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -103,31 +103,6 @@ ] } }, - { - "apiVersion": "v1", - "kind": "Service", - "metadata": { - "name": "hwlab-device-pod", - "namespace": "hwlab-dev", - "labels": { - "app.kubernetes.io/name": "hwlab-device-pod", - "hwlab.pikastech.local/service-id": "hwlab-device-pod" - } - }, - "spec": { - "type": "ClusterIP", - "selector": { - "app.kubernetes.io/name": "hwlab-device-pod" - }, - "ports": [ - { - "name": "http", - "port": 7601, - "targetPort": "http" - } - ] - } - }, { "apiVersion": "v1", "kind": "Service", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index ccdb8d68..f7e5875c 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -269,10 +269,6 @@ { "name": "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", "value": "49280" - }, - { - "name": "HWLAB_DEVICE_POD_URL", - "value": "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" } ], "readinessProbe": { @@ -498,74 +494,6 @@ } } }, - { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "name": "hwlab-device-pod", - "namespace": "hwlab-dev", - "labels": { - "app.kubernetes.io/name": "hwlab-device-pod", - "hwlab.pikastech.local/service-id": "hwlab-device-pod" - } - }, - "spec": { - "replicas": 1, - "selector": { - "matchLabels": { - "app.kubernetes.io/name": "hwlab-device-pod" - } - }, - "template": { - "metadata": { - "labels": { - "app.kubernetes.io/name": "hwlab-device-pod", - "hwlab.pikastech.local/service-id": "hwlab-device-pod" - } - }, - "spec": { - "containers": [ - { - "name": "hwlab-device-pod", - "image": "127.0.0.1:5000/hwlab/hwlab-device-pod:af46386", - "ports": [ - { - "name": "http", - "containerPort": 7601 - } - ], - "env": [ - { - "name": "HWLAB_ENVIRONMENT", - "value": "dev" - }, - { - "name": "HWLAB_DEVICE_POD_ID", - "value": "device-pod-71-freq" - }, - { - "name": "HWLAB_DEVICE_POD_PORT", - "value": "7601" - } - ], - "readinessProbe": { - "httpGet": { - "path": "/health/live", - "port": "http" - } - }, - "livenessProbe": { - "httpGet": { - "path": "/health/live", - "port": "http" - } - } - } - ] - } - } - } - }, { "apiVersion": "apps/v1", "kind": "Deployment", diff --git a/deploy/k8s/dev/health-contract.yaml b/deploy/k8s/dev/health-contract.yaml index bbe5d48e..11bad513 100644 --- a/deploy/k8s/dev/health-contract.yaml +++ b/deploy/k8s/dev/health-contract.yaml @@ -16,7 +16,6 @@ "cloud-web": "GET /health/live on hwlab-cloud-web:8080; consumes cloud-api only", "agent": "hwlab-cloud-api authorizes Code Agent sessions and dispatches execution to AgentRun v0.1; no HWLAB-owned agent manager/worker service is deployed", "edge-proxy": "hwlab-edge-proxy:6667 exposes /health/live and proxies DEV endpoint traffic to hwlab-cloud-api", - "runtime-substitute-policy": "Do not replace HWLAB runtime with UniDesk backend, provider-gateway, or microservice proxy", - "device-pod": "hwlab-device-pod:7601 exposes /health/live and serves Device Pod profile/job state through cloud-api authority" + "runtime-substitute-policy": "Do not replace HWLAB runtime with UniDesk backend, provider-gateway, or microservice proxy" } } diff --git a/deploy/master-edge/README.md b/deploy/master-edge/README.md index d67cf86f..be09ec44 100644 --- a/deploy/master-edge/README.md +++ b/deploy/master-edge/README.md @@ -14,9 +14,10 @@ Health ownership: - `hwlab-cloud-web`: reads cloud APIs only; it does not control hardware. - Code Agent sessions are authorized by `hwlab-cloud-api` and dispatched to AgentRun v0.1; HWLAB no longer owns an agent manager/worker service pair. -- `hwlab-device-pod` and `hwlab-gateway`: own the current hardware-facing - runtime boundaries; removed simulator/router/tunnel services are not part of - the default DEV/v0.2 runtime contract. +- `hwlab-gateway`: remains a transport boundary; HWPOD hardware operations are + driven by workspace tools and host/edge `hwpod-node`, not by an extra cloud + runtime service. Removed simulator/router/tunnel services are not part of the + default DEV/v0.2 runtime contract. PROD is intentionally gated off for this task. diff --git a/deploy/runtime/boot/hwlab-device-pod.sh b/deploy/runtime/boot/hwlab-device-pod.sh deleted file mode 100644 index 3cd6f15d..00000000 --- a/deploy/runtime/boot/hwlab-device-pod.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -set -eu - -export HWLAB_SERVICE_ID="hwlab-device-pod" -export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}" -export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}" -export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}" -export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}" -export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}" - -bun_bin="${HWLAB_BUN_COMMAND:-}" -if [ -z "$bun_bin" ]; then - if command -v bun >/dev/null 2>&1; then - bun_bin="$(command -v bun)" - elif [ -x /usr/local/bin/bun ]; then - bun_bin="/usr/local/bin/bun" - elif [ -x ./node_modules/.bin/bun ]; then - bun_bin="./node_modules/.bin/bun" - else - echo "hwlab-device-pod boot failed: bun not found" >&2 - exit 127 - fi -fi - -exec "$bun_bin" cmd/hwlab-device-pod/main.ts diff --git a/deploy/runtime/launcher/hwlab-env-reuse-launcher.ts b/deploy/runtime/launcher/hwlab-env-reuse-launcher.ts index 2acb8bcf..63c5c003 100644 --- a/deploy/runtime/launcher/hwlab-env-reuse-launcher.ts +++ b/deploy/runtime/launcher/hwlab-env-reuse-launcher.ts @@ -24,7 +24,7 @@ rmSync(checkoutDir, { recursive: true, force: true }); run("git", ["clone", "--no-checkout", "--single-branch", "--branch", bootRef, readUrl, checkoutDir], "/"); run("git", ["checkout", "--detach", bootCommit], checkoutDir); linkNodeModules(); -installDevicePodAlias(); +installHwpodAliases(); const scriptPath = path.join(checkoutDir, bootSh); if (!existsSync(scriptPath)) fail(`boot script not found: ${bootSh}`); @@ -49,10 +49,17 @@ function linkNodeModules(): void { symlinkSync(runtimeNodeModules, target, "dir"); } -function installDevicePodAlias(): void { - const wrapper = "/usr/local/bin/hwpod"; - const cli = path.join(checkoutDir, "tools", "device-pod-cli.mjs"); - writeFileSync(wrapper, `#!/usr/bin/env sh\nexec node ${shellArg(cli)} "$@"\n`, { encoding: "utf8" }); +function installHwpodAliases(): void { + installHwpodAlias("hwpod", "tools/hwpod-cli.ts"); + installHwpodAlias("hwpod-ctl", "tools/hwpod-ctl.ts"); + installHwpodAlias("hwpod-compiler", "tools/hwpod-compiler-cli.ts"); +} + +function installHwpodAlias(commandName: string, relativeCli: string): void { + const wrapper = path.join("/usr/local/bin", commandName); + const runner = path.join(checkoutDir, "scripts", "run-bun.mjs"); + const cli = path.join(checkoutDir, ...relativeCli.split("/")); + writeFileSync(wrapper, `#!/usr/bin/env sh\nexec node ${shellArg(runner)} ${shellArg(cli)} "$@"\n`, { encoding: "utf8" }); chmodSync(wrapper, 0o755); } diff --git a/docs/reference/agentrun-code-agent-dispatch.md b/docs/reference/agentrun-code-agent-dispatch.md index 451cd0ff..d646c031 100644 --- a/docs/reference/agentrun-code-agent-dispatch.md +++ b/docs/reference/agentrun-code-agent-dispatch.md @@ -29,24 +29,17 @@ - UniDesk SSH passthrough 通过 `toolCredentials[].tool=unidesk-ssh` 注入,默认 SecretRef 是 `agentrun-v01-tool-unidesk-ssh` key `UNIDESK_SSH_CLIENT_TOKEN`。 - `UNIDESK_SSH_CLIENT_TOKEN` 只授予 UniDesk frontend `/ws/ssh` scoped client 能力;route allowlist 由 UniDesk frontend 配置控制。HWLAB 不持有 provider token、主 server SSH key 或完整 frontend 登录态。 - `runnerJob.transientEnv` 只能承接本次 runner job 需要的短期执行上下文,例如 owner-scoped `HWLAB_API_KEY`、`HWLAB_RUNTIME_API_URL` 和 `UNIDESK_MAIN_SERVER_IP`;敏感项必须标记 sensitive,不得承载 GitHub token、UniDesk SSH client token、provider key、长期 SSH key 或 registry token。 -- HWLAB v0.2 HWPOD 能力在 runner 内只通过 `hwpod -> HWLAB_RUNTIME_API_URL -> hwlab-cloud-api` 进入。`HWLAB_RUNTIME_API_URL` 指向当前 namespace 的 `hwlab-cloud-api` Service;`HWLAB_RUNTIME_WEB_URL` 仅供需要浏览器同源语义的工具查看 Cloud Web。AgentRun 不以 Cloud Web 代理替代设备 API。 - -## 当前权限装配状态 - -- Runner 中的 HWLAB 用户凭据只允许是 cloud-api 为当前 Code Agent session owner 选择或创建的用户级 `HWLAB_API_KEY`。该 key 恢复为同一个 `users.id`,不等同于残留执行链路内部 service token、OpenFGA token、Keycloak token 或 Web cookie。 -- Tool surface 由 OpenFGA tool capability 决定。缺少 `tool:hwpod#can_use` 时不暴露可用 `hwpod` 环境;缺少 `tool:unidesk_ssh#can_use` 时不注入 UniDesk SSH tool credential;缺少 `tool:trans_cmd#can_use` 时不暴露 trans cmd 透传入口。 -- `tool:trans_cmd` 只允许进入 UniDesk 受控 passthrough 命令面;实际 route、operation、写操作和 Secret 可见性仍受 UniDesk CLI/route 边界以及当前任务授权限制。 -- 即使 runner 侧误尝试调用未授权工具,cloud-api 仍是 HWPOD、Code Agent session 和 Admin Access 的最终 enforcement point,必须返回结构化 authorization blocker。 +- HWLAB v0.2 HWPOD 能力在 runner 内只通过 `hwpod -> hwpod-compiler-cli -> HWLAB_RUNTIME_API_URL -> hwlab-cloud-api /v1/hwpod-node-ops` 进入。`HWLAB_RUNTIME_API_URL` 指向当前 namespace 的 `hwlab-cloud-api` Service;`HWLAB_RUNTIME_WEB_URL` 仅供需要浏览器同源语义的工具查看 Cloud Web。AgentRun 不以 Cloud Web 代理替代 HWPOD API。 ## ResourceBundle - AgentRun run 必须使用 Git-only `resourceBundleRef`,默认 repo 是 `http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git`。 - `commitId` 必须是完整 40 字符小写 SHA,不接受 branch、tag、`HEAD` 或短 SHA。 - `workspaceRef` 只描述目标 repo/branch,默认 branch 是 `v0.2`;实际 checkout 身份以 `resourceBundleRef.repoUrl + commitId` 为准。 -- 默认 `toolAliases` 会把迁移期 CLI shim 暴露为 `hwpod`,并把 `tools/unidesk-ssh.mjs` 暴露为 runner shell 内的 `unidesk-ssh` 命令。HWPOD、D601-F103-V2、Keil build/download、job status/output 和 UART/debug-probe 操作必须优先走 `hwpod`;`unidesk-ssh` 只用于 UniDesk passthrough 任务,不能替代 HWPOD 正式路径。 -- HWPOD lease 不是独立 runner 控制命令;标准 `hwpod` surface 只提交命名 HWPOD job。需要 lease 时,token 只能作为 `--lease-token` 跟随实际 mutating job 请求转发给 cloud-api。Agent 不得调用 `hwpod lease ...`,也不得新增 gateway shell 或其他 fallback 来绕过 job authority。 +- 默认 `toolAliases` 会把 `tools/hwpod-cli.ts` 暴露为 `hwpod`,把 `tools/hwpod-ctl.ts` 暴露为 `hwpod-ctl`,并把 `tools/unidesk-ssh.mjs` 暴露为 runner shell 内的 `unidesk-ssh` 命令。HWPOD inspect、workspace、Keil build/download、reset 和 UART/debug-probe 操作必须优先走 `hwpod`;`unidesk-ssh` 只用于 UniDesk passthrough 任务,不能替代 HWPOD 标准路径。 +- HWPOD 快速阶段没有独立 lease 控制命令;标准 `hwpod` surface 只提交 HWPOD intent,经 compiler 生成 node-ops。Agent 不得新增 gateway shell 或其他 fallback 来绕过 `hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node`。 - 默认 `promptRefs` 指向 `internal/agent/prompts/hwlab-v02-runtime.md`,`inject=thread-start` 且 `required=true`。该 prompt 只在 AgentRun/Codex stdio 新 thread 首轮注入;后续 turn 必须依赖原生 `thread/resume`,不得在 command payload 中拼接历史、旧 skill 列表或长业务 prompt。 -- 默认 `skillRefs` 仍可引用迁移期 `skills/device-pod-cli/SKILL.md` 和 `skills/hwlab-agent-runtime/SKILL.md`,但 runner 中的当前业务入口只暴露为 `hwpod`;这两个 skill 由 AgentRun 聚合到当前 workspace `.agents/skills//SKILL.md`。HWLAB 不再依赖 `/app/skills`、hostPath、默认 Codex skill registry、ConfigMap 或用户长 prompt 作为 skill 注入 fallback。 +- 默认 `skillRefs` 指向 `skills/hwpod-cli/SKILL.md`、`skills/hwpod-ctl/SKILL.md` 和 `skills/hwlab-agent-runtime/SKILL.md`,都为 `required=true`,由 AgentRun 聚合到当前 workspace `.agents/skills//SKILL.md`。HWLAB 不再依赖 `/app/skills`、hostPath、默认 Codex skill registry、ConfigMap 或用户长 prompt 作为 skill 注入 fallback。 - AgentRun runtime image 已预装 `gh`,结合 `tool=github` 注入的 `GH_TOKEN` 即可访问 HWLAB/UniDesk PR 与 issue;不得把 GitHub token 放入 prompt 或 `transientEnv`。 ## 验收 @@ -54,5 +47,5 @@ - 源码合同测试:`node --test internal/agent/agentrun-dispatch.test.mjs`。 - 语法检查:`node --check internal/agent/agentrun-dispatch.mjs && node --check internal/agent/agentrun-dispatch.test.mjs`。 - 合同必须证明 `UNIDESK_SSH_CLIENT_TOKEN` 不出现在 `transientEnv`,GitHub/UniDesk SSH 能力都通过 AgentRun `toolCredentials` SecretRef 装配,且 runner resource bundle 默认暴露 `hwpod`、`unidesk-ssh`、`promptRefs` 和 `skillRefs`。 -- 真实 CLI 验收默认使用短 prompt 走 `backendProfile=deepseek`:首轮 prompt “不调用工具的情况下,你可见的 skill 有哪些?”应能回答 HWLAB bundle skill;同一会话 continuation 应显示 AgentRun 原生 resume 语义且不重复注入 initial prompt;“编译 D601-F103-V2”应触发 `hwpod` HWPOD 路径,若失败则报告正式 blocker,不切换 fallback。 +- 真实 CLI 验收默认使用短 prompt 走 `backendProfile=deepseek`:首轮 prompt “不调用工具的情况下,你可见的 skill 有哪些?”应能回答 HWLAB bundle skill;同一会话 continuation 应显示 AgentRun 原生 resume 语义且不重复注入 initial prompt;“检查 HWPOD 状态”应触发 `hwpod inspect` 或等价 HWPOD node-ops 路径,若失败则报告正式 blocker,不切换 fallback。 - 如果 DeepSeek profile 的 trace 明确失败为 AgentRun `provider-auth-failed`,且上游错误码或消息是 `INSUFFICIENT_BALANCE` / account balance 不足,则该次 CLI 验收改用 `--provider-profile minimax-m3` 继续执行同一短 prompt 组。这个规则只替换 provider profile,不替换 AgentRun 装配标准:仍必须使用同一个 `ResourceBundleRef`、`promptRefs`、`skillRefs`、`toolAliases`、Codex stdio `thread/start` / `thread/resume`,不得拼接历史上下文,不得切回旧 HWLAB prompt/skill 注入方式,也不得用 codex-api、generic shell、gateway shell 或诊断镜像作为替代验收。 diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 34572082..c378393a 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -35,14 +35,14 @@ For M3 hardware proof, the required runtime participants are: - two distinct `hwlab-gateway-simu` identities; - one `hwlab-patch-panel` that owns the route decision. -真实设备目标当前以 HWPOD 作为硬件研发执行逻辑实体。`hwpod` +真实设备目标以 `hwpod` 作为硬件研发执行逻辑实体。`hwpod` 统一封装 target device、workspace、debug probe 和 io probe 四要素; -快速迭代阶段由 `hwpod-spec`、`hwpod-compiler-cli`、`hwpod-node-ops` -和 `hwpod-node` 组成,权威规格见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。 -旧 `device-pod` 文档只作为迁移对照,见 [spec-device-pod.md](spec-device-pod.md)。 +快速阶段由 workspace-local `hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、 +`hwpod-compiler-cli`、cloud-api `/v1/hwpod-node-ops` 和 host/edge +`hwpod-node` 打通闭环,规格见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。 `v0.2` 用户和权限管理规格只保留 `admin` 和 `user` 两类基础角色:code agent -session 归属于创建用户,hwpod 和工具能力由 Cloud API 通过 OpenFGA 按用户、对象和 relation 授权; +session 归属于创建用户,HWPOD 和其他工具能力由 Cloud API 通过 OpenFGA 按用户和工具授权; Kubernetes 只做运行时隔离和资源兜底,不承载最终用户权限。权威口径见 [spec-user-access.md](spec-user-access.md) 和 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 diff --git a/docs/reference/commander-collaboration.md b/docs/reference/commander-collaboration.md index c0064556..7962fe35 100644 --- a/docs/reference/commander-collaboration.md +++ b/docs/reference/commander-collaboration.md @@ -7,7 +7,7 @@ - 指挥官必须亲自掌握关键一手事实,不能只依赖 runner 的二手摘要。 - 必须亲自查看 `http://74.48.78.17:17666/` 默认首屏、`/gate` 或内部诊断页、关键 PR diff、部署 revision、DEV 验收结果和失败证据。 - 专题 issue 评论是长任务的上下文锚点。调查结论、细化方案、阶段进展、卡点、修复边界、验收命令和最终结论必须及时写入对应 GitHub issue 评论;不要只存在对话上下文、临时文件或本地记忆里。上下文压缩、换 agent、跨仓库修复或进入 CI/CD 前,应先读最新 issue 评论,再继续执行。 -- Web/CLI 路径分叉属于优先级高的工具摩擦。浏览器工作台暴露问题后,先用 `hwlab-cli client` 走同一 Cloud Web base URL 复现;Code Agent continuation 优先用 `client agent send --from-trace ` 从 inspect 自动恢复 Web 上下文,device-pod 操作优先验证 `19666` 同源代理,再决定是否修业务本体。 +- Web/CLI 路径分叉属于优先级高的工具摩擦。浏览器工作台暴露问题后,先用 `hwlab-cli client` 走同一 Cloud Web base URL 复现;Code Agent continuation 优先用 `client agent send --from-trace ` 从 inspect 自动恢复 Web 上下文,HWPOD 操作优先验证 `hwpod-cli -> hwpod-compiler-cli -> /v1/hwpod-node-ops -> hwpod-node`,再决定是否修业务本体。 - 一层 Code Queue 直接执行业务实现、修复、部署和验证是允许的基层指挥模式。 - 不得把战略判断、是否偏离用户目标的判断、关键 PR 最终审查、关键部署证据审查、指挥官自我反省和指挥规则更新外派给 runner。 - 不得为了 Gate 生成 Gate、为了报告生成报告、为了审查再派审查、为了管理继续拆管理;Gate、报告、预检和观测脚本只能作为真实上线、真实 E2E 或 blocker 解除的必要最小工具。 diff --git a/docs/reference/device-pod.md b/docs/reference/device-pod.md deleted file mode 100644 index c91b7b9e..00000000 --- a/docs/reference/device-pod.md +++ /dev/null @@ -1,11 +0,0 @@ -# Device Pod 旧链接入口 - -本文仅保留为旧链接入口。HWLAB 当前概念系统只承认 `hwpod`,权威规格是 [spec-hwpod-harness.md](spec-hwpod-harness.md)。[spec-device-pod.md](spec-device-pod.md) 只作为迁移对照,用于识别和清理旧命名、旧 API 和旧文档残留。 - -关键口径: - -- 新开发、授权、CLI、WebUI 和 AgentRun 文档都必须使用 `hwpod`、`hwpod-spec`、`hwpod-cli`、`hwpod-node-ops` 和 `hwpod-node`。 -- `device-pod`、`devicePodId`、`/v1/device-pods`、`device_pods`、`hwlab-device-pod` 和 `device-pod-cli` 这类名字如果仍出现在 source/runtime 中,只表示实现残留或向后兼容 API path,不是当前产品概念。 -- 处理残留时直接删除或改写到 HWPOD 当前合同,不新增负向 gate、legacy mode 或双路径长期文档。 - -需要理解历史映射时只读 [spec-device-pod.md](spec-device-pod.md)。不得从旧文档恢复旧服务规格或旧概念命名。 diff --git a/docs/reference/g14-gitops-cicd.md b/docs/reference/g14-gitops-cicd.md index d5076121..90f03295 100644 --- a/docs/reference/g14-gitops-cicd.md +++ b/docs/reference/g14-gitops-cicd.md @@ -78,7 +78,7 @@ HWLAB 是 monorepo,G14 CI/CD 加速必须按组件输入判断构建和滚动 - `scripts/g14-ci-plan.mjs` 是只读 planner,默认读取当前 workspace 的 `deploy/deploy.json`、`deploy/artifact-catalog.dev.json` 和 `scripts/src/g14-ci-plan-lib.mjs` 内建 component model,输出 `affectedServices`、`reusedServices`、`componentCommitId`、`componentInputHash`、`dockerfileHash`、`baseImageDigest`、`buildArgsHash` 和原因;它不得修改 deploy、catalog 或 GitOps 文件。Tekton `prepare-source` 会先从 `G14-gitops` 注入上一轮发布态 catalog,source 分支里的 catalog 只作为 seed contract。 - 服务清单兼容顺序固定为:显式 `--services`、`deploy.services[]`、`deploy.k3s.serviceMappings[]`、`internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。 - 组件边界固定由 `scripts/src/g14-ci-plan-lib.mjs` 的内建 service-path model 定义;如需新增或调整 `componentPaths`、`sharedPaths`、`runtimeDeps` 或 `buildSystemPaths`,直接修改 planner 库和对应测试,不再额外维护 repo-local commands/forbidden skeleton。 -- `hwpod`/`device-pod-cli` 是 runner 和 runtime 镜像内的稳定工具入口,不是纯本地 `hwlab-cli` 源码工具;`tools/device-pod-cli.mjs`、`tools/device-pod-cli.ts`、`tools/src/device-pod-cli-lib.ts` 和 `skills/device-pod-cli/` 必须作为 shared runtime input 进入 component model。修改这些路径时,G14/v0.2 CI 必须至少触发携带 `/usr/local/bin/hwpod` 的 runtime 服务重新构建或 env-reuse rollout,不能全量复用旧 artifact 后只报告 PipelineRun 成功。 +- `hwpod` 是 runner 内的稳定 HWPOD task 入口,由 `tools/hwpod-cli.ts`、`tools/hwpod-compiler-cli.ts`、`tools/hwpod-ctl.ts`、`tools/hwpod-node.ts` 和 `skills/hwpod-cli/`、`skills/hwpod-ctl/` 组成。修改这些路径时,G14/v0.2 CI 必须至少触发携带 `/usr/local/bin/hwpod` 的 runtime skills/env-reuse 重新装配或 rollout,不能全量复用旧 artifact 后只报告 PipelineRun 成功。 - `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 从 `deploy/artifact-catalog.dev.json` 或 lane catalog 复用已有 sha256 digest。非 env-reuse 服务复用前必须满足 artifact provenance 自证:catalog 有可验证 digest、catalog 的 `sourceCommitId` 在当前 repo 可解析、用该 `sourceCommitId` 的 source tree 重新计算出的 `componentInputHash` 等于 catalog 记录、该 hash 再等于本轮 planner 计算值,且 `dockerfileHash`/`buildArgsHash` 没有不一致。catalog 缺 digest、缺 provenance、source tree 无法解析、catalog hash 与 catalog source tree 不一致、或 catalog hash 与本轮 input 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。 - Artifact catalog 的 per-service provenance 是镜像 digest 的身份证明,不是本轮 planner 状态缓存。reuse 路径只能保留旧 artifact 自身的 `sourceCommitId`、`componentCommitId`、`componentInputHash`、`dockerfileHash`、`baseImage*` 和 `buildArgsHash`;禁止把当前 planner 的 component/build 元数据写入复用的旧 digest,否则下一轮 planner 会把旧镜像误判成已包含新输入,造成 CI/CD false-green。 - `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 默认只预览,只有 G14 Tekton promotion 显式传 `--write` 时,才把生成的 `commitId`、`image`、`imageTag`、`digest`、`publishState` 和 component provenance 字段写进当前 workspace 的 `deploy/artifact-catalog.dev.json`,随后只提交到 `G14-gitops`。`deploy/deploy.json` 是人写的 runtime config 真相源,不得被 promotion、refresh 脚本或人工发布流程回写镜像身份字段。 diff --git a/docs/reference/gateway-outbound-demo.md b/docs/reference/gateway-outbound-demo.md index fad3ad37..a83fe337 100644 --- a/docs/reference/gateway-outbound-demo.md +++ b/docs/reference/gateway-outbound-demo.md @@ -7,7 +7,7 @@ - `hwlab-gateway` 运行在用户 PC 或本地环境,主动访问 `hwlab-cloud-api`;cloud 不需要也不应入站访问 gateway。 - demo 采用普通 HTTP poll/result:gateway 调 `POST /v1/gateway/poll` 拉取命令,执行后调 `POST /v1/gateway/result` 回传 JSON-RPC response。 - `hardware.invoke.shell` 仍是 cloud-api 对外 RPC 方法;有在线 gateway 时通过主动出站链路派发,没有在线 gateway 时保留 `not_connected` 降级返回。 -- 当前命令执行能力只用于受限 demo;真实硬件控制概念以 [spec-hwpod-harness.md](spec-hwpod-harness.md) 和 [spec-user-access.md](spec-user-access.md) 为权威:用户权限由 `cloud-api` 的 `admin/user` 与 OpenFGA relation 判断;设备执行目标收敛到 HWPOD 的 `cloud-api/hwlab-api -> hwpod-node-ops -> hwpod-node`,硬件 trace/evidence/audit 只作为硬件证据链,不作为用户权限模型。 +- 当前命令执行能力只用于受限 demo;正式 HWPOD 控制以 [spec-hwpod-harness.md](spec-hwpod-harness.md) 和 [spec-user-access.md](spec-user-access.md) 为权威:用户权限由 `cloud-api` 的 `admin/user` 与 OpenFGA tool capability 判断;设备执行收敛到 `hwpod-cli -> hwpod-compiler-cli -> cloud-api /v1/hwpod-node-ops -> hwpod-node`。 ## Cloud API 入口 diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md deleted file mode 100644 index 4f01358f..00000000 --- a/docs/reference/spec-device-pod.md +++ /dev/null @@ -1,446 +0,0 @@ -# Device Pod 迁移对照规格 - -本文只保留 HWLAB `v0.2` 既有 `device-pod` 口径作为 HWPOD 迁移对照。当前概念系统只承认 [spec-hwpod-harness.md](spec-hwpod-harness.md) 定义的 `hwpod`、`hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli`、`hwpod-node-ops` 和 `hwpod-node`。本文不得作为当前概念或新开发规格。 - -旧 `device-pod` 是迁移前的逻辑设备能力单元,不是 Kubernetes Pod 名称,也不是当前产品主概念。旧 profile/server authority 路径只能用于识别实现残留和迁移对照;新增业务翻译必须进入 `hwpod-compiler-cli`,不要继续堆在残留 executor 或 `device-host-cli.mjs` 中。 - -当前状态: - -- Source/runtime 中仍存在旧 REST path、DB table、CLI shim、skill path 和 workload/service 命名。它们都是实现残留,不是当前 HWPOD 概念。 -- 旧 API 仍可在过渡期承载已上线用户入口,但任何新文档、测试和实现都必须以 [spec-hwpod-harness.md](spec-hwpod-harness.md) 为目标,把旧命名逐步删除或改写到 HWPOD 当前合同。 -- 本文中出现 `device-pod`、`devicePodId`、`device_pods` 或旧服务名时,只表达迁移对照和残留状态,不表达长期目标。 - -实施跟踪见 [pikasTech/HWLAB#533](https://github.com/pikasTech/HWLAB/issues/533),原 `docs/plan/v02-device-pod-spec-migration.md` 和旧 device-pod MVP 计划全文已迁入该 issue 评论。 - -旧的 `device-pod-cli` 本地 profile 闭环只用于 CLI MVP 和真实硬件最小验证。既有多用户路径曾收敛到: - -```text -browser Cloud Web UI or hwpod/device-pod-cli --> cloud-api AuthPrincipal + OpenFGA relation check --> residual internal executor REST --> gateway transport --> device-host-cli --> Keil / pyOCD / UART / target -``` - -AgentRun runner 和 `hwpod` 的标准设备 API 入口是 `HWLAB_RUNTIME_API_URL=http://hwlab-cloud-api..svc.cluster.local:6667`,不是 `hwlab-cloud-web`。Cloud Web 只服务浏览器同源 UI 代理;`hwpod` 必须携带映射到发起用户的 `HWLAB_API_KEY` 直连 cloud-api,由 cloud-api 按该用户 grant 授权。内部 `:6667` 属于 WHATWG bad port 时,`hwpod` 必须使用 Node `http/https` 原生请求层访问 cloud-api,不能为了规避 bad-port 把设备 API 改走 Cloud Web。 - -## 在系统中的职责划分 - -迁移对照中,旧 `device-pod` 是云端可授权、可审计的逻辑设备能力单元。当前 HWPOD 口径下,这个能力应改写为 `hwpod`,由 `hwpod-spec` 描述、由 `hwpod-compiler-cli` 翻译为 `hwpod-node-ops`,再交给 `hwpod-node` 执行。 - -普通用户、浏览器和 Code Agent session 不直接持有 gateway route、host workspace route、Kubernetes Service 直连能力或 profile 修改权。 - -## 设计目标 - -- 用最少组件把 `device-pod-cli` 从“本地 profile + RPC/gateway 调用”迁到“1:1 REST 请求”。 -- `cloud-api` 是用户身份、用户 API key、OpenFGA relation 和 profile authority 判断入口。 -- 残留 executor 只承接尚未迁走的内部执行请求;它不是当前产品概念,不拥有用户权限、profile authority 或业务翻译权。 -- `device-pod-cli` 只做 selector 解析、cloud-api REST 请求和 JSON 输出;迁移期正式调用模式不读取 `.device-pod/*.json`,不保存、不上传、不修改权威 profile。 -- 残留 runtime 如果仍有单 Deployment/Service,只作为当前实现事实;HWPOD 当前目标不以该名字定义服务。 -- 普通用户和 code agent session 不获得 Kubernetes 用户、Service 直连权限、gateway route 或 host workspace route。 - -## 逻辑模型 - -一个 `device-pod` 由四个设备能力要素组成: - -```text -device-pod -= deviceTarget -+ debugInterface -+ projectWorkspace -+ ioInterface -``` - -- `deviceTarget`:被测设备目标,例如开发板、用户 PCB 或仪器模块。 -- `debugInterface`:下载、复位、chip-id、probe 状态和调试连接能力。 -- `projectWorkspace`:源码、工程、构建工具链和 artifact 边界。 -- `ioInterface`:UART、DI/DO、采样、日志和其他设备 I/O 观测/控制能力。 - -`devicePodId` 是云端和用户界面的稳定身份。实际 k8s Pod 可以重建、滚动或扩容;用户和 code agent 不依赖实际 Pod name。 - -## Profile Authority - -正式接入后,profile 是管理员侧资源: - -```text -admin UI/API --> cloud-api --> device_pods.profile_json + profile_hash --> residual internal execution -``` - -code agent 本地文件只能作为非权威 hint/cache,最多包含: - -```json -{ - "devicePodId": "device-pod-71-freq", - "profileHash": "sha256:...", - "cloudApiUrl": "..." -} -``` - -本地 hint/cache 不得包含以下字段,也不得参与授权或执行路由: - -- `gatewaySessionId` -- `resourceId` -- `capabilityId` -- `hostWorkspaceRoot` -- `hostCli` -- Windows workspace 路径 -- probe UID、串口端口、Keil 路径等硬件路由字段 - -迁移期 profile 必须由 `cloud-api` 从 DB 读取;残留 executor 不接受浏览器、code agent 或 CLI 上传的 profile 作为执行依据。若残留 executor 需要 profile snapshot,应只接受 `cloud-api` 内部服务凭据转发的 snapshot,或通过内部服务凭据向 `cloud-api` 拉取。该凭据不得挂载进 code agent session Pod。 - -当前 profile 和权限边界: - -- profile 创建和修改只走 cloud-api 管理入口,执行前先恢复 `AuthPrincipal` 并按 OpenFGA 检查 admin 或 `profile_editor` relation。 -- 残留 executor 不拥有 profile 修改 API,不保存用户权限,不从内部 service token 恢复用户 actor;它只处理 cloud-api 已授权、已脱敏或已快照的内部执行请求。 -- `hwlab-v02-device-pod-internal` 只是一条服务间调用凭据,不能作为用户 API key、CLI 凭据、runner env、Admin Access 工具能力或授权 source。 -- 用户、CLI 和 Code Agent 只能通过 cloud-api job REST 使用 HWPOD;即使本地 workspace 中存在 hint/cache,也不能改变 gateway route、host workspace、probe UID、UART port 或 OpenFGA relation。 - -## 内部架构 - -迁移对照中的旧路径由 profile registry、job lifecycle、freshness/blocker、bounded output、gateway/device-host adapter 和用户 API key integration 组成。当前 HWPOD 目标把业务翻译上收到 `hwpod-compiler-cli`,把稳定执行下沉到 `hwpod-node-ops` / `hwpod-node`;旧 executor 名称只作为实现残留。 - -当前 HWPOD 目标实现情况见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。旧单服务规格不再作为权威入口。 - -## Profile Shape - -`device_pods.profile_json` 至少表达以下 server-side 字段: - -```json -{ - "schemaVersion": 1, - "devicePodId": "device-pod-71-freq", - "target": { - "id": "target-id" - }, - "projectWorkspace": { - "workspaceRoot": "F:\\Work\\Project", - "projectPath": "FirmWare/MDK-ARM/app.uvprojx", - "targetName": "app", - "hexPath": "FirmWare/MDK-ARM/app/app.hex" - }, - "debugInterface": { - "type": "cmsis-dap", - "probeUid": "...", - "uv4Path": "C:\\Keil_v5\\UV4\\UV4.exe" - }, - "ioInterface": { - "uart": [ - { "id": "uart/1", "port": "COM4", "baudRate": 921600 } - ] - }, - "route": { - "gatewaySessionId": "gws_...", - "resourceId": "res_...", - "capabilityId": "cap_...", - "hostWorkspaceRoot": "F:\\Work\\Project", - "hostCli": "node tools\\device-host-cli.mjs" - } -} -``` - -`profile_json` 不得保存 Git key、云 token、kubeconfig、数据库 URL 或长期 secret。`profile_hash` 由 `cloud-api` 对规范化 profile JSON 计算并在所有响应中返回;用户可见响应只能返回脱敏 profile 摘要和 hash。 - -## 数据表口径 - -旧规格曾推荐 `device_pods` 直接保存权威 profile 和 hash,当前只作为迁移期实现表继续说明,避免额外 profile 微服务: - -```sql -CREATE TABLE IF NOT EXISTS device_pods ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), - profile_json TEXT NOT NULL DEFAULT '{}', - profile_hash TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -``` - -历史版本中的 `profile_ref`、`gateway_ref` 或 `device_pod_json` 可以在迁移时折叠进 `profile_json`。第一阶段不新增 `device_pod_profile_revisions`;需要审计版本、回滚或多环境批准时再引入 profile revision 表。 - -## REST API - -## API 接口说明 - -用户态 API 只经过 `cloud-api` 暴露: - -```text -GET /v1/device-pods -GET /v1/device-pods/{devicePodId}/status -GET /v1/device-pods/{devicePodId}/debug-probe/chip-id -GET /v1/device-pods/{devicePodId}/io-probe/uart/1 -GET /v1/device-pods/{devicePodId}/io-probe/uart/1/tail?maxBytes=12000 -POST /v1/device-pods/{devicePodId}/jobs -GET /v1/device-pods/{devicePodId}/jobs/{jobId} -GET /v1/device-pods/{devicePodId}/jobs/{jobId}/output -POST /v1/device-pods/{devicePodId}/jobs/{jobId}/cancel -``` - -管理员 API 由 `cloud-api` 提供: - -```text -POST /v1/admin/device-pods -PUT /v1/admin/device-pods/{devicePodId} -PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation} -DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation} -``` - -`POST /v1/device-pods/{devicePodId}/jobs` 用 `intent` 表达具体业务,避免把 REST surface 扩张成大量一次性 route: - -```json -{ - "intent": "workspace.build", - "args": { "profile": "debug" }, - "reason": "DEV smoke" -} -``` - -第一阶段 intent 集合: - -- `workspace.ls` -- `workspace.bootsharp` -- `workspace.cat` -- `workspace.rg` -- `workspace.apply-patch` -- `workspace.put` -- `workspace.rm` -- `workspace.rmdir` -- `workspace.keil` -- `workspace.build` -- `debug.status` -- `debug.chip-id` -- `debug.download` -- `debug.reset` -- `io.ports` -- `io.uart.read` -- `io.uart.read-after-launch-flash` -- `io.uart.write` -- `io.uart.jsonrpc` - -`GET /debug-probe/chip-id`、`GET /io-probe/uart/1` 和 `GET /io-probe/uart/1/tail` 是迁移期用户态便捷 REST surface,但不能停留在静态面板或 fake probe;cloud-api 必须在完成 authenticate/grant 后创建对应只读 job,再经当前可用执行链路执行或返回同一套 blocker。 - -所有 job/status/output 和 probe GET 响应必须包含 `devicePodId`、`targetId`、`profileHash`、`traceId`、`operationId`、`status`、`freshness`、`blocker` 和 bounded output metadata。job output 文本默认最大 12000 bytes;超出时必须设置 `truncation.truncated=true`、`truncation.originalBytes`,并避免把完整 executor/gateway 原始输出嵌回 JSON。真实硬件响应不得把 fake、dry-run、SOURCE、LOCAL 或过期缓存标为 `DEV-LIVE`。 - -## 微服务职责 - -| 服务 | 职责 | -| --- | --- | -| `hwlab-cloud-api` | 用户身份、admin/user、用户 API key、OpenFGA relation、profile authority、用户态 REST API、转发到残留执行链路。 | -| 残留 executor | 迁移期内部执行壳;不作为当前 HWPOD 概念,不保存用户权限,不承接新增业务翻译。 | -| `device-pod-cli` | 把 `devicePodId:surface:path operation args` 1:1 转成 cloud-api REST;不保存权威 profile、不读取本地 profile 作为默认 authority、不直连 gateway。 | -| `device-host-cli` | Windows host 侧自包含业务工具,负责 Keil、pyOCD、UART、workspace 文件操作。 | -| `hwlab-gateway` | 只做受控 transport,不理解用户权限和 HWPOD 授权。 | -| `hwlab-cloud-web` | 展示用户可见 HWPOD、admin 管理 profile/relation、显示 job/status/freshness。 | - -## Kubernetes 口径 - -迁移期 runtime 曾使用一个残留 Deployment 和一个 ClusterIP Service: - -```text -hwlab-v02/ -replicas: 1 -manages: many devicePodId -``` - -不为每个 `devicePodId` 创建 Deployment、Service、Ingress、Secret 或 namespace。这样更符合当前规模:运维对象少、GitOps diff 少、问题定位简单,也不会把设备数量直接放大成 k8s 资源数量。 - -只有在满足以下条件时,才考虑拆分为多个 HWPOD node shard 或 per-device workload: - -- 单个服务内 job 队列和 freshness 监控互相影响; -- 不同设备需要不同 host network、USB、Secret 或资源 request; -- 设备数量增长到单实例状态管理明显吃力; -- 强隔离需求超过应用层 OpenFGA relation 和内部服务凭据能覆盖的范围。 - -普通用户和 code agent session Pod 不应直接调用残留 executor Service。当前目标路径是 `code agent -> cloud-api/hwlab-api -> hwpod-node-ops -> hwpod-node`。 - -## 验收标准 - -- `device-pod-cli` 在迁移期正式调用模式下不读取 `.device-pod/.json` 作为权威 profile,只向 cloud-api 提交 `devicePodId`、intent 和 args。 -- 普通用户无授权时不能看到或使用任何 HWPOD;拥有 `viewer` 只能看到摘要,拥有 `operator/job_submitter` 才能提交对应 job,拥有 `profile_editor` 才能修改 profile。 -- code agent 不能通过修改本地文件改变 gateway session、resource、host workspace、probe UID 或 UART port。 -- 残留 executor 不接受无内部服务凭据的 profile snapshot 或 job 请求。 -- 多 `devicePodId` 运行能力只作为迁移期实现事实;HWPOD 目标以 `hwpod` / `hwpod-node` 表达。 -- cloud-api legacy compatibility entry 只能返回 blocked authority payload,不得合成 fake 数据或作为 HWPOD DEV-LIVE 证据。 -- 强副作用 job 必须有 `reason`;迁移期路径也只使用 Web session/cookie 或映射到具体用户的 `HWLAB_API_KEY` 做身份授权。 -- AgentRun runner 访问 hwpod 必须使用 cloud-api 组装的用户 `HWLAB_API_KEY`,该 key 恢复为发起 Code Agent session 的 owner 用户;权限继续由 OpenFGA relation 判定。 -- 撤销 HWPOD relation 必须同时影响该用户通过 Web session、CLI API key 和 AgentRun `hwpod` 的可见性与使用权;revoke API key 后 CLI 和 runner 内旧 key 都必须失效。 - -## CLI 实现口径 - -`tools/device-pod-cli.ts` 是迁移期 CLI shim;HWLAB code-agent runner 内的唯一稳定短入口是 `hwpod`。runner 镜像必须把 `hwpod` 放入 PATH;缺少 `hwpod` 时应判定为 runner image/package 错误并修复镜像或封装,不得改走 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs` 长路径。迁移期 CLI 的默认行为是: - -- `profile list/show` 调用 cloud-api `/v1/device-pods` 和 `/status`,只显示服务端脱敏 profile 摘要和 `profileHash`。 -- AgentRun runner 中只使用装配好的 `HWLAB_RUNTIME_API_URL` 和映射到当前用户的 `HWLAB_API_KEY`,直接访问 `hwlab-cloud-api`;不得手动传 `--api-base-url`,也不得改走 Cloud Web 同源代理。 -- `setup first-admin` 和 `admin device-pod upsert` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile 管理;HWPOD 授权统一使用 `hwlab-cli client access device-pods grant/revoke` 的 Admin Access API。 -- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`。 -- `bootsharp --pod-id ` 和 `:workspace:/ bootsharp` 都转换为 `workspace.bootsharp` job,用于返回 workspace tree、AGENTS.md 摘要和当前路径提示;该入口是上下文恢复和 DS 派单的首个探测动作,不读取本地 profile。 -- workspace 写操作覆盖 `apply-patch`、`put`、`rm`、`rmdir`、`build` 和 `keil add-source/remove-source` 等 Keil 工程维护动作。 -- 源码局部编辑优先使用 `apply-patch`;`put` 只用于明确的整文件写入或新文件创建。 -- UART 业务覆盖 `read`、`write`、`read-after-launch-flash` 和 `jsonrpc`;JSON-RPC 请求必须由 device-host-cli 校验 response id,除非显式传入允许 id mismatch 的业务参数。 -- apply-patch 类失败必须返回可定位的 patch hint,例如缺少 `*** End Patch`、hunk 上下文不匹配或 header 错误;调用方应先重新读取目标文件再重试小 hunk,不应默认绕到整文件覆盖。 -- mutating job 由 cloud-api 侧强制 `reason`;CLI 只转发 `intent`、`args` 和 `reason`。 -- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile,并使用 Admin Access API 管理 relation。 -- DS/device-pod prompt 和 skill 示例必须优先使用 `hwpod`,避免把长路径 wrapper 复制成常态命令;host job 轮询时 job id 必须作为 `status/output/cancel` 的紧随位置参数传入,不能放到 flags 后面或靠 shell 管道解析 JSON。 - -## D601 F103 v0.2 Gateway SOP - -D601 Windows F103 gateway 的稳定命名使用 `gws_D601_F103`。不要因为当前 `devicePodId` 或样例设备名是 `device-pod-71-freq` / `71-FREQ`,把 gateway session 改成旧的 `gws_d601_win_71_freq`;profile route 和 Windows gateway 运行脚本必须使用同一组 F103 命名。 - -当前 HWPOD F103 v2 profile 身份为 `D601-F103-V2`。它的 Windows workspace 固定为 `F:\Work\D601-HWLAB`,Keil 工程为 `projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx`,Keil target 为 `USART`,Keil 可执行文件为 `C:\Keil_v5\UV4\UV4.exe`,UART 为 `COM9`/`115200`。迁移期服务端 profile 字段仍使用 `devicePodId=D601-F103-V2`,不得继续把该 workspace 暴露成 `device-pod-71-freq`。 - -Windows 侧固定入口: - -```text -Task Scheduler: HWLAB-DevicePodGateway-D601-F103 -run script: C:\Users\liang\device-pod-gateway-rust\run-D601-F103.cmd -local status: http://127.0.0.1:7001/status -cloud API: http://74.48.78.17:19667 -``` - -`run-D601-F103.cmd` 中的关键环境变量应保持为: - -```cmd -set "HWLAB_GATEWAY_CLOUD_URL=http://74.48.78.17:19667" -set "HWLAB_ENVIRONMENT=v02" -set "HWLAB_GATEWAY_ID=gtw_D601_F103" -set "HWLAB_GATEWAY_SESSION_ID=gws_D601_F103" -set "HWLAB_GATEWAY_RESOURCE_ID=res_windows_host" -set "HWLAB_GATEWAY_BOX_ID=box_windows_host" -set "HWLAB_GATEWAY_CMD_CAPABILITY_ID=cap_windows_cmd_exec" -set "HWLAB_GATEWAY_CMD_EXEC_ENABLED=1" -set "HWLAB_GATEWAY_MAX_INFLIGHT=4" -set "HWLAB_GATEWAY_CMD_TIMEOUT_MS=120000" -set "HWLAB_GATEWAY_PORT=7001" -``` - -v0.2 `D601-F103-V2` 的服务端 profile route 必须和 gateway 注册保持一致: - -```json -{ - "gatewaySessionId": "gws_D601_F103", - "resourceId": "res_windows_host", - "capabilityId": "cap_windows_cmd_exec", - "hostWorkspaceRoot": "F:\\Work\\D601-HWLAB", - "hostCli": "node tools\\device-host-cli.mjs" -} -``` - -重启 gateway 时使用计划任务,不从临时 shell 直接启动长期进程: - -```cmd -schtasks /End /TN HWLAB-DevicePodGateway-D601-F103 -schtasks /Run /TN HWLAB-DevicePodGateway-D601-F103 -powershell -NoProfile -Command "Invoke-RestMethod http://127.0.0.1:7001/status | ConvertTo-Json -Depth 12" -``` - -通过 UniDesk Windows route 操作时,工作目录应直接定位到 gateway 目录,例如 `D601:win/c/Users/liang/device-pod-gateway-rust`,再读取 `run-D601-F103.cmd` 或查询 `/status`。`/status` 中至少应看到 `gatewaySessionId=gws_D601_F103`、`cloudUrl=http://74.48.78.17:19667`、`session.status=connected`、`outbound.lastPollError=null`。 - -G14 v0.2 迁移期验收在 `G14:/root/hwlab-v02` 执行,先确认 Cloud Web 同源 CLI 能看到当前 hwpod/profile 状态,再用迁移期 `device-pod-cli` shim 创建并轮询 job: - -```bash -bun tools/hwlab-cli/bin/hwlab-cli.ts client device-pods status device-pod-71-freq \ - --base-url http://74.48.78.17:19666 \ - --full - -COOKIE=$(node -pe 'require("./.state/hwlab-cli/session.json").cookie') -bun tools/device-pod-cli.ts device-pod-71-freq:workspace:/ ls \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" \ - --reason "v02 gws_D601_F103 smoke" \ - --timeout-ms 120000 | tee /tmp/hwlab-hwpod-job.json - -JOB=$(node -e 'const fs=require("fs"); const payload=JSON.parse(fs.readFileSync("/tmp/hwlab-hwpod-job.json", "utf8")); console.log(payload.body.job.id)') - -bun tools/device-pod-cli.ts job status --pod-id device-pod-71-freq "$JOB" \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" - -bun tools/device-pod-cli.ts job output --pod-id device-pod-71-freq "$JOB" \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" -``` - -`D601-F103-V2` 的验收命令使用同一条 v0.2 CLI 链路,只替换 `devicePodId` 并从 `bootsharp` 开始: - -```bash -bun tools/hwlab-cli/bin/hwlab-cli.ts client device-pods status D601-F103-V2 \ - --base-url http://74.48.78.17:19666 \ - --full - -COOKIE=$(node -pe 'require("./.state/hwlab-cli/session.json").cookie') -bun tools/device-pod-cli.ts bootsharp --pod-id D601-F103-V2 \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" - -bun tools/device-pod-cli.ts D601-F103-V2:workspace:/ build start \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" \ - --reason "D601-F103-V2 Keil build validation" \ - --timeout-ms 120000 - -bun tools/device-pod-cli.ts D601-F103-V2:debug-probe download start \ - --api-base-url http://74.48.78.17:19667 \ - --cookie "$COOKIE" \ - --reason "D601-F103-V2 Keil download validation" \ - --capture-uart uart/1 \ - --capture-duration-ms 8000 \ - --timeout-ms 120000 -``` - -Code-agent/DS runner 内执行同类验收时,把 `bun tools/device-pod-cli.ts` 换成 `hwpod`;`hwpod` 不在 PATH 时应判定为 runner 镜像或包安装错误并修复该标准入口,不得临时改走长路径 wrapper。UART 串口若被 Windows 侧工具占用,应把 COM busy/access denied 记录为可选串口证据缺口,Keil build/download 是否通过以 job output 中的 build summary、`Programming Done`、`Verify OK` 和 `Application running` 为准。 - -DS 或其他 code-agent runner 由 `hwlab-cli client harness submit` 触发时,控制面应保持短连接:提交后用 `client harness result`、`client harness trace --limit ` 和 `client harness audit --require-bootsharp` 轮询,不把 UniDesk `ssh/tran` 连接长期挂在一次 `wait` 上。`client harness wait` 只用于短窗口观察,CLI 会把超长等待压到低于 UniDesk 透传硬超时的安全窗口,并在 JSON 中返回后续短轮询命令。 - -job 观察优先使用 cloud-api job 入口,避免从 shell 管道里解析整段 JSON: - -```bash -hwpod job status --pod-id D601-F103-V2 --api-base-url --cookie "$COOKIE" -hwpod job output --pod-id D601-F103-V2 --api-base-url --cookie "$COOKIE" -``` - -`build status/output/cancel ` 和 `download status/output/cancel ` 只作为 selector 便捷别名;当 `` 是 `job_devicepod_*` 这类 cloud-api job 时,别名必须映射到 `/jobs/` 或 `/jobs//output`,不得误启动新的 build/download job。Keil host 侧子 job 仍保留 `build status ` 或 `download status ` 语义,用于查看 Windows `device-host-cli` 创建的嵌套 job。 - -搜索下载、烧录或串口证据时,带空格、管道符或较长正则的 `workspace rg` 应使用显式参数,避免本地 shell、selector 和 Windows host 三层参数解析产生歧义: - -```bash -hwpod D601-F103-V2:workspace:/ rg \ - --pattern "Programming Done|Verify OK|Application running|Error" \ - --path projects/01_baseline/captures \ - --api-base-url --cookie "$COOKIE" -``` - -验收通过条件:`client device-pods status` 返回 `status=ok` 且 `blocker=null`;gateway sessions 中 `gws_D601_F103` 为 online;`device-pod-cli` job 进入 `completed`,且 D601 Windows gateway `/status.lastDispatch.operationId` 与该 job 的 `operationId` 一致并有 `lastResultAt`。旧 `gws_d601_win_71_freq` session 只能作为历史 stale 线索,不能作为 v0.2 F103 gateway 的目标命名。 - -## 测试规格 - -## T1 - -阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:普通用户无 grant 时访问 `/v1/device-pods` 不得看到任何 device pod;获得 grant 后能看到对应 devicePodId 和脱敏 profileHash,不能看到 gatewaySessionId、hostWorkspaceRoot 或 probe UID 等敏感路由字段。 - -## T2 - -阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:尝试通过本地 `.device-pod/*.json` 修改 gateway route 或 workspace route,迁移期 cloud-api path 必须忽略该本地文件并继续使用服务端 profile authority。 - -## T3 - -阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:提交一个强副作用 job,例如 download/reset,缺少 reason 时必须被拒绝;补充 reason 后必须经同一条 cloud-api job REST 路径进入 executor,并返回 devicePodId、profileHash、traceId、operationId、freshness、blocker 和 bounded output metadata。 - -## T4 - -阅读 docs/reference/spec-device-pod.md,然后用 cli 手动测试以下内容:对授权 HWPOD/迁移期 profile 运行 `workspace put`、`workspace rm`、`workspace rmdir`、`workspace keil add-source/remove-source` 和 `io-probe jsonrpc` 的 `--dry-run` 与一次真实小闭环。确认请求只经过 cloud-api job REST,输出 intent、reason、traceId 和 bounded output,不读取本地 profile、不直连 gateway。 - -## 规格的实现情况 - -| 规格项 | 状态 | 说明 | -| --- | --- | --- | -| 逻辑 device-pod 模型 | 已实现为规格 | 四要素、profile shape 和 Kubernetes 口径已定义。 | -| profile server authority | 迁移期实现/待收敛 | cloud-api 保存 DB profile 并向用户返回脱敏摘要;残留 executor 不接受用户上传 profile。 | -| 用户 relation + 用户 API key | 已实现/持续约束 | cloud-api 已实现 Admin Access relation、可见性过滤和强副作用 job reason 校验;AgentRun/hwpod 只能使用映射到当前 owner 的用户级 `HWLAB_API_KEY`,权限继续由 OpenFGA relation 判定。 | -| REST/job API | 迁移期实现/待收敛 | cloud-api 已实现 list/status/events/probe/job/output/cancel,并可把已授权 job 转发给残留 executor;后续 HWPOD 收敛应把稳定执行改写到 `hwpod-node-ops` / `hwpod-node`。 | -| G14 device-host 功能吸收 | 部分实现 | v0.2 job intent 已覆盖 workspace put/rm/rmdir、Keil 工程维护和 UART JSON-RPC,保持 cloud-api profile authority、OpenFGA relation 和用户 API key runtime auth。 | -| 禁止 fake 作为 DEV-LIVE | 已实现/持续约束 | 规格和服务 payload 要求显式标记 fake/source。 | - diff --git a/docs/reference/spec-hwpod-harness.md b/docs/reference/spec-hwpod-harness.md index 8dfb26cc..57a899d7 100644 --- a/docs/reference/spec-hwpod-harness.md +++ b/docs/reference/spec-hwpod-harness.md @@ -1,27 +1,12 @@ # HWPOD Harness 规格 -本文是 HWLAB `v0.2` 的 HWPOD Harness 长期规格。概念体系和实施 issue 见 [pikasTech/HWLAB#897](https://github.com/pikasTech/HWLAB/issues/897)。当前概念系统只承认 `hwpod`;旧 Device Pod 规格和实现只作为迁移对照保留在 [spec-device-pod.md](spec-device-pod.md),不能作为当前概念或新开发规格。 +本文是 HWLAB `v0.2` 的 HWPOD Harness 长期规格。概念体系和实施跟踪见 [pikasTech/HWLAB#897](https://github.com/pikasTech/HWLAB/issues/897)。当前实现只打通单线程核心业务闭环,不把鉴权、安全、并发、计量、复杂调度或 Evidence 体系作为前置条件。 -当前阶段只设计核心业务闭环,不把鉴权、安全、并发、计量、复杂调度或完整 Evidence 体系作为前置条件。目标是让 Code Agent 在自己的 workspace 内先获得可观察、可修改、可快速改进的 HWPOD harness 闭环,再逐步把稳定部分平台化。 +本规格不保留旧设备执行路径作为迁移对照;新任务只按 HWPOD 单通道实现和验收。 -## 当前状态 +## 快速迭代阶段 -- 当前产品概念和新文档入口统一为 `hwpod`。`hwlab-device-pod` 不是当前概念系统里的服务名;如果 source/runtime 仍出现该名称,只表示旧实现命名残留。 -- 当前快速迭代入口已经存在 `tools/hwpod-cli.ts`、`tools/hwpod-ctl.ts`、`tools/hwpod-compiler-cli.ts`、`tools/hwpod-node.ts`、`tools/src/hwpod-node-ops-contract.ts` 和对应测试。 -- 当前 cloud-api 仍保留若干旧 REST path、DB table 和 workload/service 命名,例如 `device-pods`、`device_pods` 或 `hwlab-device-pod`。这些是实现残留,不是产品概念;后续清理目标是把稳定入口收敛到 HWPOD 当前合同。 -- 授权当前仍由 Cloud API 的 `AuthPrincipal`、Admin Access 和 OpenFGA 判定;HWPOD 只是受授权的硬件研发执行逻辑实体,不拥有独立用户身份系统。 - -## 残留命名处理 - -- 新增业务翻译、CLI 示例、WebUI 文案、AgentRun prompt、SPEC 和测试都必须写 `hwpod`,不能继续引入 `hwlab-device-pod` 作为当前服务概念。 -- 旧 API path、表名、CLI shim 或 runtime workload 名称只允许作为迁移对照出现。处理它们时直接改到 HWPOD 当前合同,不能增加 legacy mode、feature flag、负向 gate 或双路径长期说明。 -- 如果某段实现暂时仍需要旧名字才能运行,文档必须把它标为“实现命名残留”,并指向本规格作为目标;不得把残留名字写入服务总表或当前权限系统职责划分。 - -## 分阶段口径 - -### 快速迭代阶段 - -快速迭代阶段先把 harness 的业务翻译权放在 Code Agent workspace 内: +快速迭代阶段把业务翻译权放在 Code Agent workspace 内: ```text Code Agent workspace @@ -34,19 +19,19 @@ hwpod-cli / hwpod-ctl -> hwpod-compiler-cli -> hwpod-node-ops plan -> hwlab-api - -> hwpod-node HTTP / injected handler + -> hwpod-node ``` -这一阶段的核心边界: +核心边界: - `hwpod-spec` 存放在 Code Agent workspace 内,是当前 hwpod 的本地声明式定义。 - `hwpod-cli` 是用户和 Code Agent 执行研发动作的入口。 - `hwpod-ctl` 是和 `hwpod-cli` 平级的管理入口,用于初始化、修改、检查 `hwpod-spec`,以及 smoke/node 状态类操作。 -- `hwpod-compiler-cli` 是本地编译器,把高层 intent 和 `hwpod-spec` 编译为 `hwpod-node-ops`。 -- `hwlab-api` 只接收 `hwpod-node-ops` plan,负责转发和返回结果,不承担业务翻译。初版通过 `HWLAB_HWPOD_NODE_OPS_URL` 转发到 node,或在测试/嵌入场景注入 node-ops handler。 -- `hwpod-node` 只维护少量稳定 op handler,不保存完整 `hwpod-spec`,也不解释高层 hwpod intent。初版 `hwpod-node` 可以作为本地 HTTP executor 运行,只保证基础 workspace/cmd ops,debug/io 需要后续绑定真实工具。 +- `hwpod-compiler-cli` 是 workspace-local 编译器,把高层 intent 和 `hwpod-spec` 编译为 `hwpod-node-ops`。 +- `hwlab-api` 只接收 `hwpod-node-ops` plan,负责转发和返回结果,不承担业务翻译。 +- `hwpod-node` 是靠近硬件的薄执行节点,只维护少量稳定 op handler,不保存完整 `hwpod-spec`,也不解释高层 hwpod intent。 -### 云端平台阶段 +## 云端平台阶段 快速闭环跑通后,再逐步迁移为云端平台能力: @@ -130,7 +115,7 @@ spec: | `workspace.rg` | 搜索文件内容 | | `workspace.apply-patch` | 应用源码补丁 | | `debug.build` | 编译工程 | -| `debug.download` | 下载/烧录目标设备 | +| `debug.download` | 下载或烧录目标设备 | | `debug.reset` | 复位目标设备 | | `io.uart.read` | 读取 UART | | `io.uart.write` | 写入 UART | @@ -145,14 +130,9 @@ spec: "planId": "plan_xxx", "hwpodId": "hwpod-local-demo", "nodeId": "node-d601-pc-host", + "intent": "workspace.ls", "ops": [ - { - "opId": "op_001", - "op": "workspace.ls", - "args": { - "path": "." - } - } + { "opId": "op_001", "op": "workspace.ls", "args": { "path": "." } } ] } ``` @@ -187,25 +167,12 @@ bun tools/hwpod-node.ts serve --host 127.0.0.1 --port 19678 `hwpod-cli` 的正常任务路径是:读取 spec、调用 compiler、把 node-ops plan 交给 `hwlab-api`,最后输出 closeout/result。`hwpod-ctl` 的管理路径可以在本地直接修改 spec,也可以生成 smoke plan 交给 `hwlab-api` 验证 node 链路。 -## v0.2 迁移映射 - -| 现有 v0.2 对象 | HWPOD 迁移归属 | 迁移说明 | -| --- | --- | --- | -| `devicePodId` / server profile | workspace-local `hwpod-spec` | 先把 profile 内容迁移成 `.hwlab/hwpod-spec.yaml`,后续再上云 | -| `device-pod-cli` / 旧 `hwpod` alias | `hwpod-cli` | 旧 selector/REST 入口作为迁移参考,新任务入口使用 HWPOD intent -> compiler -> node-ops | -| profile 管理脚本 | `hwpod-ctl` | 变成 workspace-local spec 管理和 smoke 工具 | -| `hwlab-device-pod` 残留 executor 中的 intent -> host argv | `hwpod-compiler-cli` | 高层业务翻译先下放到 workspace-local compiler-cli 快速迭代;残留 executor 命名不得作为当前概念保留 | -| `device-host-cli.mjs` 中的高层业务拼接 | `hwpod-compiler-cli` | 能上收的命令编排和 profile 解释上收到 compiler-cli | -| `device-host-cli.mjs` 中的基础执行能力 | `hwpod-node` | 稳定 op handler 留在 node 侧 | -| `hwlab-gateway` / `devicepod-gateway` | `hwpod-node` 内部 transport/gateway | 不再作为产品主概念 | -| cloud-api device-pod job route | `hwlab-api` node-ops 转发面 | 快速阶段只做 plan submit/result,不做业务翻译 | - ## 验收标准 快速阶段最小验收: 1. Code Agent workspace 内存在 `.hwlab/hwpod-spec.yaml`,并能通过 `hwpod-ctl spec validate`。 2. `hwpod-compiler-cli compile` 能把同一个 spec 和高层 intent 编译为稳定 `hwpod-node-ops-v1` plan。 -3. `hwpod-cli --dry-run` 输出的 plan 不需要云端 spec,也不读取旧 `.device-pod/*.json` profile authority。 +3. `hwpod-cli --dry-run` 输出的 plan 不需要云端 spec,也不读取其他本地旧 profile authority。 4. `hwlab-api` 的 node-ops 入口能接收 plan,返回 JSON result;无可用 node 时必须返回结构化 blocker,而不是静默伪造 DEV-LIVE。 -5. 旧 Device Pod 路径只作为迁移对照;新增业务翻译必须进入 `hwpod-compiler-cli`,不要继续堆在残留 executor 或 `device-host-cli.mjs` 中。 +5. 真实 CLI 验收必须证明 `hwpod-cli -> hwpod-compiler-cli -> hwlab-api /v1/hwpod-node-ops -> hwpod-node` 全链路走通。 diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 82cc5091..ffa72d31 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -1,68 +1,27 @@ # v0.2 用户和权限管理规格 -本文是 HWLAB `v0.2` 用户和权限管理的规格说明。目标是用最少概念支持真实用户使用 code agent session,并让管理员能按用户独立调整 HWPOD/profile、Code Agent session 和工具功能权限,同时避免把用户体系、Kubernetes 租户、设备授权、硬件证据链和审计系统混成一套复杂门禁。 +本文定义 HWLAB `v0.2` 用户、session、API key、Code Agent owner 和工具能力的最小权限口径。当前 HWPOD 快速闭环阶段只设计核心业务功能;不为旧设备路由、旧授权表或旧 REST/job 保留兼容路径。 -本规格与 [spec-hwpod-harness.md](spec-hwpod-harness.md) 配套:用户和权限规格定义谁可以看见、创建和使用 HWPOD;HWPOD 规格定义当前 `hwpod`、`hwpod-spec`、node-ops 和硬件执行边界。[spec-device-pod.md](spec-device-pod.md) 只作为旧 API/table 命名的迁移对照。 - -登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。OpenFGA、Admin Access WebUI 和同路径 CLI 细节见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。本文只定义认证完成后的角色、资源归属、HWPOD capability、tool capability 和 code agent owner 授权;正式用户鉴权只有 Web session 与 CLI/API key 两类。 - -实施跟踪见 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531),原 `docs/plan/v02-multi-user-migration.md` 迁移计划全文已迁入该 issue 评论。 +登录入口、Keycloak OIDC、Web session、CLI API key 和 `AuthPrincipal` 归一见 [spec-v02-auth.md](spec-v02-auth.md)。OpenFGA、Admin Access WebUI 和同路径 CLI 细节见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。HWPOD 概念、`hwpod-spec`、`hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli`、`hwpod-node-ops` 和 `hwpod-node` 见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。 ## 在系统中的职责划分 -用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它消费 [spec-v02-auth.md](spec-v02-auth.md) 产出的 `AuthPrincipal`,负责角色、OpenFGA check/write、用户 API key、hwpod capability、tool capability 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;HWPOD 执行节点只执行受控硬件语义;AgentRun v0.1 只消费 cloud-api 按用户权限注入的 actor/session/hwpod/tool 上下文,不成为 HWLAB 用户权限 authority。 +用户和权限管理不拆独立微服务,权威实现收敛在 `hwlab-cloud-api`:它消费认证模块产出的 `AuthPrincipal`,负责角色、OpenFGA check/write、用户 API key、tool capability 和 Code Agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;AgentRun v0.1 只消费 cloud-api 按用户权限注入的 actor/session/tool 上下文,不成为 HWLAB 用户权限 authority。 -Postgres 是用户、session、业务对象和迁移 ledger 的持久化边界;OpenFGA 是细粒度授权关系与授权判定边界。Kubernetes namespace、ServiceAccount、Service 直连和 gateway route 都不能替代用户权限模型;普通用户不获得 kubeconfig、内部 Service 直连能力、OpenFGA token 或长期 Secret。 - -`v0.2` 本地 bootstrap 管理员账号固定为 `admin`,默认登录密码固定为 `hwlab2026`,只用于空库初始化。Keycloak 接入后的目标 Web 登录以 OIDC 为准,CLI 以 `HWLAB_API_KEY` 为准。 -运行时仍只通过 `hwlab-v02-bootstrap-admin/password-hash` SecretRef 注入本地 bootstrap password hash;Postgres、API 响应、日志、CLI session 和文档不得保存或输出 password hash、session token 原文或 Secret 值。 -如果 live Secret 需要重建或旋转,必须保持目标 OIDC/Web session 与 API key 登录链路可用。 - -## 当前实现状态 - -- 当前 v0.2 runtime 的权限相关组件包含 `hwlab-cloud-api`、`hwlab-cloud-web`、HWPOD 相关实现、OpenFGA、v0.2 Postgres 和 Keycloak 外部 issuer。`hwlab-cloud-api` 是应用层用户身份恢复、OpenFGA check/write、Admin Access 写入、hwpod/profile/job 和 Code Agent owner 校验的收口点。 -- 用户、session、API key、account workspace、HWPOD profile、HWPOD job 和访问摘要保存在 v0.2 Postgres;细粒度 relation 的判定 authority 是 OpenFGA tuple。Postgres 中的摘要或缓存不能在 OpenFGA 不可用时变成独立 allow source。 -- 当前授权管理入口是 Admin Access API、Cloud Web Access 页面和同路径 `hwlab-cli client access ...`。管理员或具备 `access_manager` 的用户通过这些入口调整 role/status、HWPOD relation 和 tool capability。 -- 迁移期 hwpod/profile 的创建和修改仍通过 `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` 和 `profile_editor`/admin 授权完成;这些 path 名称属于实现残留。执行节点只执行 cloud-api 已授权的内部请求,不拥有用户权限判断或 profile 修改权。 -- AgentRun runner 只能收到 cloud-api 按当前 Code Agent session owner 装配的用户级 `HWLAB_API_KEY` 和已授权工具面。`trans_cmd` 是独立 tool capability,表示允许进入受控 UniDesk passthrough 命令面,不授予 Kubernetes Secret、OpenFGA token、残留执行链路内部 token 或任意控制面写权限。 - -## 当前状态收敛规则 - -- 代码、测试和长期文档只能表达当前 Web session/API key/OpenFGA/Admin Access 权限系统。发现绕过当前 `AuthPrincipal -> OpenFGA/Admin Access` 判定的授权入口、共享用户绕过凭据、兼容写分支或只为已移除路径存在的断言时,处理方式是删除或改写为当前合同。 -- 不把历史授权路径迁移成 feature flag、legacy mode、兼容表、负向测试清单或新增门禁。需要证明当前状态时,优先写当前 allow/deny 行为、当前 authority 和当前用户入口验收。 -- issue/PR 评论可以保留排障证据;`docs/reference/` 只保留当前状态、目标状态和稳定判定标准,不保存过程流水账或已移除对象名称清单。 +Postgres 是用户、session、业务对象和迁移 ledger 的持久化边界;OpenFGA 是细粒度授权关系与授权判定边界。Kubernetes namespace、ServiceAccount、Service 直连和 host route 都不能替代用户权限模型;普通用户不获得 kubeconfig、内部 Service 直连能力、OpenFGA token 或长期 Secret。 ## 规格目标 -- 只保留两类角色:`admin` 和 `user`。 -- `code agent session` 直接归属于创建它的用户;普通用户只能查看、继续和取消自己的 session。 -- HWPOD/profile 由 `admin` 或被授予 `profile_editor` 的用户管理;普通用户只有在被授权后才能看到、操作或提交对应 HWPOD job。 -- HWPOD 授权按 `viewer`、`operator`、`profile_editor`、`job_submitter` 等 OpenFGA relation 表达。 -- 工具能力必须独立授权,例如 `hwpod`、`unidesk_ssh`、`trans_cmd` 和 GitHub 写工具;拥有 Code Agent session 不等于拥有这些工具。 -- MVP 不新增产品级 `audit_events` 用户审计表,也不把用户权限依赖到 audit。现有硬件 trace/evidence/audit 字段属于硬件闭环证据,不是多用户权限模型的一部分。 -- 强副作用 HWPOD job 只额外要求业务 `reason`;设备互斥由 executor、gateway 和硬件 host 串行化或返回 blocker,不进入用户权限模型。 -- 普通用户不获得 Kubernetes 用户、kubeconfig、namespace 管理权或直接访问残留执行 Service 的权限;所有用户权限判断在 cloud-api 应用层完成。 -- v0.2 权限数据必须与 `hwlab-dev`/`hwlab-prod` 运行数据隔离。优先在 `hwlab-v02` namespace 内使用独立 Postgres StatefulSet/PVC;若未来显式复用共享 Postgres 实例,也必须使用独立 database 或 schema、独立 Secret 和独立 migration ledger,不得直接复用 `hwlab-dev` 的 pgdata。 - -## 简化边界 - -| 模块 | 不采用的复杂方案 | v0.2 规格方案 | -| --- | --- | --- | -| 用户角色 | `platform_admin`、`device_admin`、`developer`、`viewer` | `admin`、`user` | -| 用户组 | `groups`、`group_members` | 不引入 | -| 项目隔离 | `projects`、`project_members` | 不引入 | -| Code Agent 会话 | `ownerUserId + projectId + sessionId` | `owner_user_id + session id` | -| HWPOD 管理 | 平台管理员和设备管理员分工 | `admin` 统一管理 | -| HWPOD 授权 | 可按 group/project 授权 | 第一版只按具体 `user_id` 授权,relation 由 OpenFGA 表达 | -| 设备权限粒度 | `io.read`、`io.write` 等硬件寄存器级 capability | `viewer`、`operator`、`profile_editor`、`job_submitter` 等产品级 relation | -| Viewer | 单独只读角色 | 不引入 | -| Audit | 独立用户审计表 | 不引入 | -| Lease | 权限和互斥可能混用 | 只作为设备互斥锁 | +- 只保留两类基础角色:`admin` 和 `user`。 +- Code Agent session 直接归属于创建它的用户;普通用户只能查看、继续和取消自己的 session。 +- 工具能力独立授权,例如 `hwpod`、`unidesk_ssh`、`trans_cmd` 和 GitHub 写工具;拥有 Code Agent session 不等于拥有这些工具。 +- 当前 HWPOD 快速阶段不引入用户级 hwpod 授权矩阵;`hwpod-spec` 位于 Code Agent workspace,执行链路按 owner session 和工具能力约束。 +- 普通用户不获得 Kubernetes 用户、kubeconfig、namespace 管理权或直接访问内部 Service 的权限;所有用户权限判断在 cloud-api 应用层完成。 +- MVP 不新增产品级 `audit_events` 用户审计表,也不把用户权限依赖到 audit。 +- v0.2 权限数据必须与 `hwlab-dev`/`hwlab-prod` 运行数据隔离。 ## 表结构 -v0.2 多用户实现复用 cloud-api 和现有 Postgres runtime store,不新增独立用户管理微服务。推荐新增一个 `0002_multi_user_access_v1` 数据库迁移,保持现有 `0001_cloud_core_skeleton.sql` 的硬件 runtime 表不被误用为用户权限表。 - ### `users` 用户身份和角色 source of truth。 @@ -80,10 +39,6 @@ CREATE TABLE IF NOT EXISTS users ( ); ``` -- bootstrap 阶段必须至少有一个 `admin`。 -- `password_hash` 只用于 v0.2 本地 bootstrap 账号;OIDC identity 扩展字段、`api_keys` 表和 API key 规则见 [spec-v02-auth.md](spec-v02-auth.md)。接入 OIDC 后仍保留 `users.id`、`role` 和授权表稳定,不把外部 IdP subject 直接暴露给业务授权。 -- `disabled` 用户不能创建 session、继续 session 或使用 HWPOD。 - ### `user_sessions` 浏览器 server session/cookie 的持久状态。cookie 中只保存不可逆 session token;数据库保存 token hash。 @@ -100,12 +55,13 @@ CREATE TABLE IF NOT EXISTS user_sessions ( ); ``` -- `/auth/session`、OIDC callback、API key 认证和 `/auth/logout` 的最终 authority 是 cloud-api。`/v1/auth/session`、`/v1/users/me`、`/v1/access/status` 和 `/v1/setup/status` 只作为同一 authority 的 REST 状态入口。cloud-web 可以保留同名浏览器路由,但只能作为静态 UI 或代理层。 -- logout 是设置 `revoked_at`,不是仅删除浏览器本地状态。 +### `api_keys` + +CLI 和 runner 的用户 API key 必须映射到 `users.id`。API key 只能收窄用户权限,不能授予超过用户和 OpenFGA tuple 的能力。 ### `agent_sessions` -复用现有 `agent_sessions` 作为 code agent session 归属记录,不再新增同义的 `code_agent_sessions` 表。v0.2 迁移应在现有表上增加 owner 和 chat/session 绑定字段: +复用现有 `agent_sessions` 作为 Code Agent session 归属记录,不新增同义表。 ```sql ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id); @@ -118,40 +74,17 @@ CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id); ``` -- 新建 code agent session 必须写入 `owner_user_id`。 -- 历史 ownerless session 在迁移时可以一次性归属 bootstrap `admin` 或直接标记为 `expired`;迁移完成后不再允许 ownerless 活跃 session。 - -### `device_pods` - -迁移期 hwpod/profile 管理表;字段名仍是实现残留,目标概念以 [spec-hwpod-harness.md](spec-hwpod-harness.md) 为准。profile/spec 必须由 `admin` 或具备 `profile_editor` 的用户通过 cloud-api 管理,不能由 code agent 本地 `.device-pod/` 文件决定。 - -```sql -CREATE TABLE IF NOT EXISTS device_pods ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), - profile_json TEXT NOT NULL DEFAULT '{}', - profile_hash TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -``` - -`profile_json` 中的 gateway route、host workspace、probe UID、串口端口和 host CLI 都是服务端权威字段;普通用户响应只能看到脱敏 profile 摘要和 `profile_hash`。 +新建 Code Agent session 必须写入 `owner_user_id`。历史 ownerless session 在迁移时可以一次性归属 bootstrap `admin` 或标记为 `expired`;迁移完成后不再允许 ownerless 活跃 session。 ## 权限矩阵 | 操作 | `admin` | `user` | | --- | --- | --- | | 管理用户和权限 | 可以;应镜像为 `system:hwlab#admin` 或 `access_manager` | 只有被授予 `access_manager` 才可以 | -| 创建自己的 code agent session | 可以 | 可以 | -| 查看、继续、取消自己的 code agent session | 可以 | 拥有该 session 的 `viewer/operator` 时可以 | -| 查看、取消别人的 code agent session | 可以 | 需要该 session 的显式 relation | -| 创建、更新、删除 HWPOD/profile | 可以 | 需要目标 HWPOD 的 `profile_editor` | -| 给用户授权或撤销 HWPOD/tool | 可以 | 需要 `access_manager` | -| 查看 HWPOD | 可以查看全部 | 需要目标 HWPOD 的 `viewer` 或更高 relation | -| 使用 HWPOD 的 workspace/debug/io 能力 | 可以使用全部 | 需要 `tool:hwpod#can_use` 且目标 HWPOD 具备 `operator/job_submitter` | -| 提交强副作用 HWPOD job | 必须填写 reason | 被授权后仍必须填写 reason | +| 创建自己的 Code Agent session | 可以 | 可以 | +| 查看、继续、取消自己的 Code Agent session | 可以 | 拥有该 session 的 `viewer/operator` 时可以 | +| 查看、取消别人的 Code Agent session | 可以 | 需要该 session 的显式 relation | +| 调用 `hwpod-cli` / `hwpod-ctl` | 可以,但仍受 session owner 和工具边界约束 | 需要 `tool:hwpod#can_use` | | 调用 UniDesk SSH / trans cmd / GitHub 写工具 | 可以,但仍受工具边界约束 | 需要对应 `tool:*#can_use` | ## 请求链路 @@ -159,7 +92,7 @@ CREATE TABLE IF NOT EXISTS device_pods ( cloud-api 每个用户态请求都按同一顺序处理: ```text -authenticate -> actor -> authorize(actor, action, resource) -> reason check for mutating device jobs -> execute +authenticate -> actor -> authorize(actor, action, resource) -> execute ``` ### 登录、API key 和 session 恢复 @@ -180,84 +113,32 @@ cli -> AuthPrincipal(authMethod='api-key') ``` -登录、Keycloak issuer、CLI API key 和 24 小时 Web session 规则见 [spec-v02-auth.md](spec-v02-auth.md)。资源授权模块只消费已经恢复出的 actor/AuthPrincipal。`/auth/session` 使用 cookie 查 `user_sessions`,再查 `users` 得到 `actor`;API key 认证直接从 `api_keys -> users` 得到同一 actor。`/auth/logout` 标记 `user_sessions.revoked_at`。Keycloak access token、refresh token、realm role 和内部 service token 都不能绕过这里的 actor 恢复与资源授权。 - -### admin 创建用户 - -```text -browser admin UI --> cloud-web proxy --> cloud-api POST /v1/admin/users --> authenticate admin --> insert users(role='user') --> return redacted user record -``` - -普通用户请求该接口必须返回 `403`。响应不得返回 `password_hash`、session token 或 Secret 值。 - -### admin 授权 HWPOD - -```text -browser admin Access UI --> cloud-api PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation} --> authenticate admin --> validate users.status='active' --> validate device_pods.status='active' --> write OpenFGA tuple --> return effective permission matrix -``` - -撤销授权走 `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`。管理员只能通过 Admin Access API 写入或删除 OpenFGA relation。 - -需要给用户开通 HWPOD 或工具能力时,统一使用 Admin Access API、Admin Access WebUI 或同路径 `hwlab-cli client access ...`,并按具体 relation 或 `tool:*#can_use` 写入 OpenFGA tuple。 - -### 用户列出 HWPOD - -```text -browser or code agent tool --> cloud-api GET /v1/device-pods --> authenticate actor --> if admin/access manager: list all active device_pods --> if user: check OpenFGA viewer/operator relation for each active device_pod --> return visible HWPOD summaries -``` - -未授权普通用户看到空列表或对单个未授权 HWPOD 收到 `403`;不得回退到 fake default HWPOD。 - -### 用户创建或继续 code agent session +### 用户创建或继续 Code Agent session ```text browser -> cloud-api POST /v1/agent/chat -> authenticate actor -> find or create agent_sessions(owner_user_id=actor.id, conversation_id) --> run code agent turn +-> run Code Agent turn -> persist last_trace_id/thread_id/session_json -> return 202 trace/result polling pointer ``` `GET /v1/agent/chat/result/{traceId}`、`GET /v1/agent/chat/trace/{traceId}` 和 `POST /v1/agent/chat/cancel` 必须通过 `agent_sessions.owner_user_id` 校验 owner;`admin` 可跨用户查看和取消。 -### code agent 使用 HWPOD +### Code Agent 使用 HWPOD ```text code agent turn --> cloud-api device operation route --> authenticate actor from owning session or user API key in runner --> verify agent_sessions.owner_user_id == actor.id --> authorize OpenFGA tool:hwpod and device_pod relation --> require reason for mutating operations --> cloud-api -> HWPOD execution path --> gateway/device-host-cli/hardware path +-> hwpod-cli in workspace +-> hwpod-compiler-cli reads .hwlab/hwpod-spec.yaml +-> hwpod-node-ops plan +-> cloud-api /v1/hwpod-node-ops +-> hwpod-node ``` -code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用残留执行 Service。残留执行服务只信任来自 cloud-api 的内部调用,不做最终用户权限判断。Cloud API 给 AgentRun runner 注入 `hwpod`、UniDesk SSH、`trans_cmd` 或 GitHub 写工具前,必须先按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 检查对应 `tool:*#can_use`。 - -## 内部架构 - -`hwlab-cloud-api` 内部应按 auth/session/API key、authorization、agent session owner、HWPOD relation 和 admin API 模块分层。所有模块共享同一 Postgres runtime store 和 migration ledger,避免拆出早期 `hwlab-user-api` 造成跨服务一致性成本。 - -`user_sessions` 存浏览器 session token hash;`api_keys` 存映射到用户的 CLI/runner API key;`agent_sessions.owner_user_id` 绑定 Code Agent session;迁移期 `device_pods` 存 HWPOD profile authority;OpenFGA tuple 表示用户对 HWPOD、agent session 和工具的细粒度能力。cloud-api 调用残留执行服务使用内部 service token,该 token 不参与用户鉴权、不写入 runner env,也不产生 actor。 +Cloud API 给 AgentRun runner 注入 `hwpod`、UniDesk SSH、`trans_cmd` 或 GitHub 写工具前,必须先按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 检查对应 `tool:*#can_use`。 ## API 接口说明 @@ -265,72 +146,38 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用残留 | --- | --- | | `GET /auth/oidc/login`、`GET /auth/oidc/callback` | Web 登录入口;按 [spec-v02-auth.md](spec-v02-auth.md) 接入 Keycloak 并写入 24 小时 `user_sessions` token hash。 | | `GET /auth/session` | 从 cookie 恢复 actor、role 和 session 状态;API key actor 摘要见 `/v1/users/me`。 | -| `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | REST 状态和兼容入口;不得读取或返回 password hash、session token 原文或 Secret 值。 | +| `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | REST 状态入口;不得读取或返回 password hash、session token 原文或 Secret 值。 | | `GET/POST /v1/api-keys...` | 用户 API key 管理入口;CLI `HWLAB_API_KEY` 规则见 [spec-v02-auth.md](spec-v02-auth.md)。 | -| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session;可选 `devicePod` 或 `devicePods[]` 一次性种下首批服务端权威 profile 并授权给首个 admin;一旦已有用户必须返回 `409 setup_already_completed`。该入口不读取 Kubernetes Secret,不替代正常 admin API。 | +| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session;一旦已有用户必须返回 `409 setup_already_completed`。 | | `POST /auth/logout` | 设置 `revoked_at`,撤销当前 browser session。 | | `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 | -| `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 HWPOD profile authority;URL path 是迁移期实现名。 | -| `GET/PATCH/PUT/DELETE /v1/admin/access...` | admin Access API,读写 OpenFGA 授权、tool capability、role/status 和 effective matrix;见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 | -| `GET /v1/device-pods` 和 HWPOD 操作 API | 按 actor role、OpenFGA relation 和 tool capability 过滤可见/可用 HWPOD。 | +| `GET/PATCH/PUT/DELETE /v1/admin/access...` | admin Access API,读写 OpenFGA 授权、tool capability、role/status 和 effective matrix。 | +| `POST /v1/hwpod-node-ops` | HWPOD node-ops 转发入口;业务翻译必须已由 workspace-local compiler 完成。 | | `POST /v1/agent/chat` 及 result/trace/cancel | 必须校验 `agent_sessions.owner_user_id`;admin 可跨用户查看和取消。 | -`POST /v1/setup/first-admin` 的 HWPOD/profile 初始化只用于空库首次进入系统,不能作为长期 profile 管理入口。每个 seed 必须包含迁移期 `devicePodId` 和 object `profile`;cloud-api 会写入 `device_pods.profile_json/profile_hash`,并通过 Admin Access/OpenFGA relation 授权给首个 admin。响应只能返回脱敏 profile、profileHash 和授权摘要,不得返回 `gatewaySessionId`、`hostWorkspaceRoot`、password 或 session token 原文。 - -## 微服务设计 - -v0.2 不新增独立用户管理微服务。Keycloak 是独立身份提供方,不是 HWLAB 应用层授权服务;OpenFGA 是内部授权 PDP,不对用户暴露独立 API;用户映射、session/API key 消费、OpenFGA check/write、HWPOD relation、tool capability 和 code agent owner 校验全部放在 `hwlab-cloud-api` 内,理由是: - -- 当前权限入口必须和 `/v1/agent/*`、`/v1/device-pods/*`、AgentRun transient env 注入和 runtime store 保持强一致,拆出新的 HWLAB 用户微服务会增加网络、部署、Secret、迁移和一致性成本。 -- cloud-api 已经是 `/v1/agent/*`、迁移期 `/v1/device-pods/*` 和 runtime store 的统一入口,最适合做应用层授权收口。 -- 后续若出现组织、计费、批量用户导入或跨产品用户中心,再把 cloud-api 内的 user/auth 模块抽成 `hwlab-user-api`;抽服务前接口和表结构仍以本文和 [spec-v02-auth.md](spec-v02-auth.md) 为准。 - -各服务职责如下: - -| 服务 | v0.2 职责 | -| --- | --- | -| `hwlab-cloud-web` | Keycloak 登录入口、普通用户工作台、API key 管理入口和 Admin Access 授权 UI;浏览器 `/auth/*` 和 `/v1/admin/access*` 由 cloud-web 代理到 cloud-api。 | -| `hwlab-cloud-api` | 用户映射、Web session/API key 消费、OpenFGA 授权、HWPOD relation、tool capability、code agent owner 校验和对 HWPOD 执行路径的受控转发。 | -| OpenFGA | `hwlab-v02` 内部稳定授权服务,只接受 cloud-api 调用,不向普通用户或公网暴露。 | -| AgentRun v0.1 runner | 执行 code agent session;接收 cloud-api 提供的 owner/session/HWPOD 上下文方便观测,但不作为最终权限 authority。 | -| HWPOD execution path | 执行受控硬件语义;不保存用户权限,不直接面向浏览器或普通用户 session Pod。 | -| `hwlab-edge-proxy` | 公网/FRP 入口和 HTTP 转发;不做业务权限,只转发 cookie/header,不注入伪 actor。 | -| Postgres | v0.2 用户、session、授权、HWPOD/profile 和既有 runtime durable state。 | - -## Kubernetes 落点 - -Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限模型: - -- `v0.2` 使用 `hwlab-v02` namespace,不按用户创建 namespace。 -- 普通用户不直接持有 Kubernetes RBAC、ServiceAccount token 或 kubeconfig。 -- `hwlab-v02` 优先拥有独立 Postgres StatefulSet/PVC,例如 `data-hwlab-v02-postgres-0`;不得把 `hwlab-dev/data-hwlab-g14-postgres-0` 当作 v0.2 权限数据源。 -- code agent worker、session Pod/PVC/Job 必须带稳定 label,例如 `hwlab.pikastech.local/owner-user-id`、`hwlab.pikastech.local/session-id`。 -- 迁移期执行工作负载如果仍承载多 HWPOD,必须带能映射 HWPOD/profile 的稳定 label;普通用户不以该 label 作为授权来源。 -- code agent 到 HWPOD 的访问应收敛到 `code agent -> cloud-api -> HWPOD execution path`,避免普通 session Pod 直接调用残留执行 Service 绕过应用层授权。 -- Keycloak 按 [spec-v02-auth.md](spec-v02-auth.md) 作为独立 `keycloak` namespace 的外部身份源接入;OpenFGA 按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 部署在 `hwlab-v02` namespace 作为内部授权服务;Kubernetes 租户隔离第一轮仍不引入 Dex、oauth2-proxy、Capsule、vCluster、Kyverno 或 service mesh。 - -当前态、差距和迁移步骤已迁入 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531) 评论。 - ## 测试规格 ## T1 -阅读 docs/reference/spec-user-access.md 和 docs/reference/spec-v02-auth.md,然后用 Web session 或 `HWLAB_API_KEY` 访问 `/v1/users/me`,确认响应包含 actor、role 和 active auth method,不包含 password hash、session token 原文、完整 API key 或 Secret 值。空库 bootstrap fallback 可额外验证 `/auth/login`,但不得把它作为目标登录体验。 +阅读 docs/reference/spec-user-access.md,然后用 CLI 手动测试以下内容:创建普通用户,确认 `role=user`、`status=active`,响应不包含 password hash、session token 或 Secret 值。 ## T2 -阅读 docs/reference/spec-user-access.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:用 admin 给普通用户授予某个 HWPOD 的 `viewer` 但不授予 `operator/job_submitter`,确认普通用户只能看到 HWPOD 摘要,提交 job 返回 403;授予 `operator/job_submitter` 后 job 可提交;撤销 relation 后同一用户不能再看到或使用该 HWPOD。 +阅读 docs/reference/spec-user-access.md,然后用普通用户创建 Code Agent session,确认 `agent_sessions.owner_user_id` 写入当前用户;另一个普通用户不能读取或取消该 session。 ## T3 -阅读 docs/reference/spec-user-access.md,然后用 cli 手动测试以下内容:普通用户创建 Code Agent session 后,只能读取、继续和取消自己的 trace/result;另一个普通用户访问该 session 必须失败,admin 可以跨用户查看或取消。 +阅读 docs/reference/spec-user-access.md,然后撤销普通用户的 `tool:hwpod` 能力,创建新的 Code Agent session 并调用 HWPOD,确认 cloud-api 或 runner 装配返回结构化 authorization blocker。 + +## T4 + +阅读 docs/reference/spec-user-access.md,然后检查 source schema、migration 和 live Postgres,确认不存在旧设备授权表或旧 profile authority 表作为授权来源。 ## 规格的实现情况 | 规格项 | 状态 | 说明 | | --- | --- | --- | -| admin/user 两角色模型 | 部分实现 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only 路由。 | -| `users`、`user_sessions`、OpenFGA relation 和 job 表 | 部分实现 | access-control bootstrap 覆盖 users、sessions、迁移期 device_pods、access_tuples 和 jobs;HWPOD 强副作用 job 已接入 reason 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | -| Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 | -| OpenFGA 细粒度授权模型 | 核心已实现/持续约束 | v0.2 enforce runtime 已通过 Admin Access API 和同路径 CLI 管理 HWPOD relation 与 tool capability;后续扩展仍必须按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 保持同一 authority。 | -| 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 | +| admin/user 两角色模型 | 已实现/持续约束 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only route。 | +| `users`、`user_sessions`、`api_keys`、OpenFGA relation | 已实现/持续约束 | 当前授权只表达用户、session 和 tool capability;不保留旧设备授权 source。 | +| Code Agent owner 绑定 | 已实现/持续约束 | session owner、trace/result/cancel 必须按 owner 校验。 | +| HWPOD 工具能力授权 | 目标状态 | `tool:hwpod` 控制 runner 是否注入 HWPOD 能力;具体 HWPOD 执行链路见 `spec-hwpod-harness.md`。 | diff --git a/docs/reference/spec-v02-auth.md b/docs/reference/spec-v02-auth.md index 4a1c67be..a9566958 100644 --- a/docs/reference/spec-v02-auth.md +++ b/docs/reference/spec-v02-auth.md @@ -4,24 +4,16 @@ 基础设施实施跟踪见 [pikasTech/HWLAB#788](https://github.com/pikasTech/HWLAB/issues/788),Keycloak 到 HWLAB 的接入收口见 [pikasTech/HWLAB#814](https://github.com/pikasTech/HWLAB/issues/814)。用户角色、Code Agent session owner、OpenFGA relation 和资源授权矩阵见 [spec-user-access.md](spec-user-access.md);本文只定义“如何登录、如何恢复 actor、如何把请求归一成 actor”。 -## 当前实现状态 - -- Keycloak 已作为独立 `keycloak` namespace 的公网 HTTPS OIDC issuer 运行,HWLAB 侧浏览器 OIDC callback 和默认 Web 登录体验仍按 #814 收口;在该收口完成前,本地 bootstrap/Web session 只承担初始化和受控调试职责。 -- `hwlab-cloud-api` 已作为当前用户态 actor 恢复点:Web session、用户 API key、`/v1/users/me` 和 API key 管理接口都收敛到同一个 `users.id`、`role/status` 和 `AuthPrincipal` 摘要。 -- CLI、同路径验收和 AgentRun runner 的目标用户凭据都是用户级 `HWLAB_API_KEY`。API key 只延续用户身份,不授予额外 hwpod、tool、admin 或 Kubernetes 权限;后续授权仍交给 [spec-user-access.md](spec-user-access.md) 和 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 -- `hwlab-v02-device-pod-internal` 一类内部 service token 只用于 cloud-api 到迁移期残留执行链路的服务间调用,不能恢复用户 actor,不能进入 CLI、Web、Code Agent runner env、API key 管理或 Admin Access 授权决策。 -- Keycloak realm role、Keycloak admin、OpenFGA token、Kubernetes ServiceAccount 和残留执行链路内部 token 都不是 HWLAB 用户权限来源。当前用户权限只从 `users.role/status`、Code Agent session owner、用户 API key 和 OpenFGA relation 组合得到。 - ## 设计目标 - Web 用户通过 Keycloak OIDC 登录或注册,HWLAB 不再把本地账号密码表单作为目标登录体验。 - Web 使用 `hwlab-cloud-api` 发行的 httpOnly `hwlab_session`,每个 session token 最长 24 小时;第一版不做复杂 refresh token 管理,但必须可撤销、可重新登录轮换。 - CLI 是纯 CLI 体验,不打开浏览器、不跳转 Web、不做 device-code flow,也不直接消费 Keycloak access token;标准凭据是环境变量 `HWLAB_API_KEY`。 -- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id`;内部执行 service token 只允许 cloud-api 调用受控执行链路,不能恢复用户 actor。 +- AgentRun runner 内的 `hwpod` 也必须使用同一类用户 API key 认证,映射到发起 Code Agent session 的 `users.id`;不存在可恢复用户 actor 的共享系统 key。 - 每个用户在首次登录后自动拥有一个默认 API key;用户也可以在 Web 中创建、查看、失效或重新生成 API key。 - API key 长效有效,除非用户或管理员手动 revoke/regenerate;它不跟 Web session 的 24 小时过期绑定。 - Keycloak 只做身份认证和账号注册;HWLAB 的 `users.role`、`users.status`、OpenFGA relation 和 Code Agent owner 仍是应用层授权 source of truth。 -- hwpod 权限只由 `users.role/status`、Code Agent session owner 和 OpenFGA relation 控制;runner 只接收当前 owner 的用户 API key。 +- HWPOD 工具能力只由 `users.role/status`、Code Agent session owner 和 OpenFGA tool capability 控制;不存在“对所有硬件目标授权”的共享 runner key。 ## 系统边界 @@ -80,7 +72,7 @@ browser - 公网管理入口是 `https://auth.74-48-78-17.nip.io/admin/master/console/`,`/admin/` 可以重定向到该原生 console;不要在 HWLAB Cloud Web 中重做 Keycloak 管理 UI。 - 公网 issuer 是 `https://auth.74-48-78-17.nip.io/realms/hwlab`,discovery 与 JWKS 必须全部返回 HTTPS URL。 - Keycloak management 端口 `9000` 不作为公网入口暴露;健康和管理探测留在集群内或受控透传内完成。 -- `hwlab` realm 必须 enabled,并在小范围测试阶段允许 self-registration;新注册用户在 HWLAB 应用层只能默认成为普通 `user`,不自动拥有 HWPOD relation。 +- `hwlab` realm 必须 enabled,并在小范围测试阶段允许 self-registration;新注册用户在 HWLAB 应用层只能默认成为普通 `user`,不自动拥有 HWPOD 工具能力。 - `hwlab-cloud-web` client 的 redirect URI 指向 `https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`,web origin 指向 `https://hwlab.74-48-78-17.nip.io`,Direct Access Grants 必须关闭,避免把 Keycloak password grant 变成第三种 CLI 鉴权方式。 管理员凭据边界: @@ -100,9 +92,9 @@ browser 短期小范围测试允许 Keycloak 开启自助注册,并且不要求邮箱或手机校验。该策略只有在以下条件同时满足时成立: - 新注册用户默认只映射为 HWLAB `user` 角色,不自动成为 `admin`。 -- 新用户没有 HWPOD relation,也不能看到或使用任何 HWPOD,直到 `admin` 在 HWLAB 中授权。 +- 新用户没有 HWPOD 工具能力,也不能通过 Code Agent 使用 HWPOD,直到 `admin` 在 HWLAB 中授权。 - Keycloak 账号状态必须能被管理员禁用;HWLAB `users.status='disabled'` 也必须能独立阻断 session 和 API key。 -- 如果公网注册出现垃圾账号或撞库迹象,第一优先级是关闭 Keycloak self-registration 或增加邀请码/管理员审核;不要把防滥用逻辑塞进 HWPOD 授权。 +- 如果公网注册出现垃圾账号或撞库迹象,第一优先级是关闭 Keycloak self-registration 或增加邀请码/管理员审核;不要把防滥用逻辑塞进 HWPOD 工具授权。 邮箱和手机字段可以作为 profile 信息保存,但第一版不要求验证。后续如果进入更大范围公网使用,应至少补充邮箱验证、注册限流或邀请制中的一种。 @@ -155,7 +147,7 @@ type AuthPrincipal = { 1. `Authorization: Bearer hwl_live_...` 或 `HWLAB_API_KEY` 映射出的 header 是用户 API key,适用于 CLI 和 AgentRun runner 内 `hwpod`。 2. `hwlab_session` cookie 是 Web session。 -本地 `/auth/login` 只用于空库 bootstrap 和 legacy/debug;它发行的 session 也必须走同一个 `hwlab_session` cookie,不再通过 `Authorization: Bearer ` 恢复用户。残留执行链路内部服务 key 只能在 cloud-api 到内部执行壳的受控链路内使用,不出现在用户 API、CLI、AgentRun runner 或浏览器文档中。 +本地 `/auth/login` 只用于空库 bootstrap 和 legacy/debug;它发行的 session 也必须走同一个 `hwlab_session` cookie,不再通过 `Authorization: Bearer ` 恢复用户。用户 API、CLI、AgentRun runner 或浏览器文档中不得出现跨用户共享系统 key。 ## Web 登录流程 @@ -198,7 +190,7 @@ bun tools/hwlab-cli/bin/hwlab-cli.ts client auth whoami - CLI 不跳转浏览器,不依赖 Web cookie,不要求 username/password 交互。 - `hwlab-cli` 默认从 `HWLAB_API_KEY` 读取 key,并发送 `Authorization: Bearer `。 -- AgentRun runner 的 transient env 只允许注入映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;可以使用该用户默认 key,也可以使用同一 `api_keys` 表中为该用户创建的 runner 专用 key,但绝不能使用跨用户共享的内部执行系统 key 或 Keycloak token。 +- AgentRun runner 的 transient env 只允许注入映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;可以使用该用户默认 key,也可以使用同一 `api_keys` 表中为该用户创建的 runner 专用 key,但绝不能使用跨用户共享系统 key 或 Keycloak token。 - `client auth status` 必须显示 endpoint、是否检测到 `HWLAB_API_KEY`、key prefix 和用户摘要;不得输出完整 API key。 - `client auth whoami` 或 `client request GET /v1/users/me` 必须能用 API key 返回与 Web 同一用户的 `AuthPrincipal` 摘要。 - `client auth login --username ...` 和本地 cookie profile 属于 legacy 兼容入口;目标体验不再把它作为一等 CLI 登录。 @@ -254,10 +246,10 @@ API key 行为: 登录和认证只回答“请求是谁”。资源授权仍由 [spec-user-access.md](spec-user-access.md) 定义: -- `admin` 可以管理用户、HWPOD profile/relation,并跨用户查看或取消 Code Agent session。 -- `user` 只能访问自己的 Code Agent session 和被授权的 HWPOD。 -- Web session、CLI API key 和 AgentRun runner 内 `hwpod` API key 得到同一个 `users.id` 时,应看到相同 OpenFGA HWPOD relation 和账号 workspace。 -- Keycloak realm role、group 或 claim 不直接决定 HWLAB HWPOD 权限;最多作为创建/绑定用户时的输入线索。 +- `admin` 可以管理用户、工具 capability,并跨用户查看或取消 Code Agent session。 +- `user` 只能访问自己的 Code Agent session 和被授权的工具能力。 +- Web session、CLI API key 和 AgentRun runner 内 `hwpod` API key 得到同一个 `users.id` 时,应看到相同 OpenFGA tool capability 和账号 workspace。 +- Keycloak realm role、group 或 claim 不直接决定 HWLAB 工具权限;最多作为创建/绑定用户时的输入线索。 ## 测试规格 @@ -275,11 +267,11 @@ API key 行为: ## T4 -阅读 docs/reference/spec-v02-auth.md,然后 revoke 或 regenerate API key,再用旧 `HWLAB_API_KEY` 请求 `/v1/users/me` 或迁移期 `/v1/device-pods`,必须返回 `401 api_key_invalid`;同一用户重新登录 Web session 不应恢复旧 key。 +阅读 docs/reference/spec-v02-auth.md,然后 revoke 或 regenerate API key,再用旧 `HWLAB_API_KEY` 请求 `/v1/users/me` 或 `/v1/hwpod-node-ops`,必须返回 `401 api_key_invalid` 或等价鉴权 blocker;同一用户重新登录 Web session 不应恢复旧 key。 ## T5 -阅读 docs/reference/spec-v02-auth.md,然后分别使用 Web session 和同一用户的 CLI API key 访问迁移期 `/v1/device-pods`、创建 Code Agent session 和读取自己的 trace/result,确认授权结果一致;另一个普通用户的 API key 不能读取该 session,admin 可以跨用户查看。 +阅读 docs/reference/spec-v02-auth.md,然后分别使用 Web session 和同一用户的 CLI API key 创建 Code Agent session、提交 HWPOD node-ops smoke 和读取自己的 trace/result,确认授权结果一致;另一个普通用户的 API key 不能读取该 session,admin 可以跨用户查看。 ## T6 @@ -290,13 +282,13 @@ API key 行为: | 规格项 | 状态 | 说明 | | --- | --- | --- | | Keycloak 独立 namespace 与公网 HTTPS issuer | 部署已完成 | `keycloak` namespace、Caddy/FRP HTTPS、`hwlab` issuer、admin console 和 bootstrap Job 已形成部署基线;后续只按本文件继续硬化。 | -| Keycloak 自助注册且不强制邮箱/手机验证 | Keycloak 侧已完成 | 只适合小范围测试;HWLAB 应用层仍必须默认 `user`、无 HWPOD relation。 | +| Keycloak 自助注册且不强制邮箱/手机验证 | Keycloak 侧已完成 | 只适合小范围测试;HWLAB 应用层仍必须默认 `user`、无 HWPOD 工具能力。 | | Web OIDC login/callback | 待 HWLAB 接入收口 | Keycloak client、Cloud API/Web rollout 和浏览器 callback 验收见 #814;redirect URI 必须使用 `https://hwlab.74-48-78-17.nip.io/auth/oidc/callback`。 | | Web session 24 小时轮换 | 待 HWLAB 接入收口 | 当前目标是 callback 成功后由 `hwlab-cloud-api` 发行 24 小时 `hwlab_session`;验收见 #814。 | -| CLI/AgentRun `HWLAB_API_KEY` 一等登录 | 已实现/持续约束 | 当前 Cloud API 支持用户 API key 恢复 actor,CLI 从 env 读取并走 `/v1/users/me`;AgentRun runner 只能接收映射到当前 owner 的用户 key。 | -| API key 一次性显示和 revoke/regenerate | 已实现/持续约束 | 创建或 regenerate 时返回一次性完整 key;列表、默认状态和 CLI 默认输出只返回 metadata、prefix 和脱敏 actor;revoke 后旧 key 必须立即失效。 | -| `AuthPrincipal` 归一 | 部分实现/持续约束 | 用户 API key 和 Web session 已归一到 `AuthPrincipal`;浏览器 OIDC callback 仍按 #814 收口;内部 service token 不作为正式用户 auth method。 | -| `admin/user` 与 HWPOD relation 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、OpenFGA relation 和 Code Agent owner 绑定;资源授权继续按 spec-user-access 收敛。 | +| CLI/AgentRun `HWLAB_API_KEY` 一等登录 | 待 HWLAB 接入收口 | 目标是统一 env API key 映射到用户,无浏览器跳转,无 Keycloak token,无跨用户共享系统 key;验收见 #814。 | +| API key 一次性显示和 revoke/regenerate | 待 HWLAB 接入收口 | 目标状态只在创建或 regenerate 时显示完整 key;列表和已存在默认 key 只返回 metadata。 | +| `AuthPrincipal` 归一 | 待 HWLAB 接入收口 | 后续实现必须把 Web session、CLI API key 和 AgentRun `hwpod` API key 都归一成同一用户 actor;legacy/internal key 不作为正式用户方法。 | +| `admin/user` 与 tool capability 授权 | 部分实现 | 现有 cloud-api 已有本地用户、session、OpenFGA tool capability 和 Code Agent owner 绑定;资源授权继续按 spec-user-access 收敛。 | ## Keycloak 部署纪律 diff --git a/docs/reference/spec-v02-cicd.md b/docs/reference/spec-v02-cicd.md index 18e13e54..5f06cfc1 100644 --- a/docs/reference/spec-v02-cicd.md +++ b/docs/reference/spec-v02-cicd.md @@ -103,7 +103,7 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。` - Tekton/Argo 的 rendered runtime desired state。 - image digest、publish state、reuse evidence 或 CI 生成的 rollout metadata。 -如果权限、schema 或 runtime desired state 发生迁移,必须分别核对 source schema/代码、`v0.2-gitops` rendered YAML 和 live runtime。source branch 只保留当前目标 schema 和实现,`v0.2-gitops` 与 live ConfigMap 体现发布后的 rendered state,fixed workspace 下 ignored `runtime-v02/postgres.yaml` 即使存在也不能代表真相。 +如果权限、schema 或 runtime desired state 发生迁移,必须分别核对 source schema/代码、`v0.2-gitops` rendered YAML 和 live runtime。source branch 应只保留当前 migration/ensure 逻辑,`v0.2-gitops` 与 live ConfigMap 应体现发布后的 rendered state,fixed workspace 下 ignored `runtime-v02/postgres.yaml` 即使存在也不能代表真相。 `v0.2-gitops` branch 必须包含: @@ -126,9 +126,9 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。` 7. affected service 通过 BuildKit 发布到 G14 本地 registry;reused service 复用 catalog digest。 所有 selected service 的 build TaskRun 都只依赖 `plan-artifacts`,不按 service 串行排队,也不设置 8 并发或其他 Pipeline 级限流。 实际并发由 Tekton controller、G14 节点资源、PVC I/O、BuildKit sidecar 和本地 registry 承载能力决定。 - `hwlab-cloud-web` 和 HWPOD residual executor serviceId 必须使用 `env-reuse-git-mirror-checkout`。 + `hwlab-cloud-web` 必须使用 `env-reuse-git-mirror-checkout`。 只有 package/runtime/env 输入变化时才构建 `-env` 镜像。 - 纯前端源码、HWPOD/迁移期执行源码或 boot code 变化只更新三变量和 GitOps desired state, + 纯前端源码、HWPOD workspace 工具或 boot code 变化只更新三变量和 GitOps desired state, 不再重新构建业务镜像。 8. promotion 刷新 `deploy/artifact-catalog.v02.json`,render `deploy/gitops/g14/runtime-v02/**`,只在本 PipelineRun 的 source commit 仍是当前 `origin/v0.2` head 时推送到 `devops-infra` mirror/relay 的 `v0.2-gitops`;若 source branch 已推进,本轮输出 superseded/no-op,写出 `runtime-ready-required=false`,不得回写旧 GitOps revision。 9. `hwlab-g14-v02` 从本地 mirror/relay 的 `v0.2-gitops:deploy/gitops/g14/runtime-v02` 同步到 `hwlab-v02`。 @@ -160,9 +160,9 @@ mirror 的 HTTP upload-pack 必须允许按精确 commit SHA 拉取已存在对 v0.2 最小校验的目标是拦截高确定性低级错误,不恢复旧重型门禁。`hwlab-cloud-web` 源码、模板或浏览器 bundle 输入发生变化时,CI 必须在镜像发布和 GitOps promotion 前执行 Cloud Web source check。该 check 至少包含实际 bundle 输入集合的 TypeScript 语义检查、自动发现的单元测试、bundle build 和 dist freshness 校验。 -语法检查和 Bun build 不能证明浏览器运行路径安全。`node --check` 只解析语法,`Bun.build()` 只转译和打包,二者都可能放过 `isRequestTraceEvent is not defined` 这类未绑定标识符;因此 Cloud Web check 必须用实际参与 bundle 的 `app.ts`、迁移期 `app-device-pod.ts`、`app-conversation.ts`、`app-trace.ts` 和 `app-helpers.ts` 生成同一入口并运行 TypeScript semantic check,例如 `tsc --noEmit` 或等价 Bun/TS checker。该检查失败时不得继续发布 `hwlab-cloud-web` 镜像。 +语法检查和 Bun build 不能证明浏览器运行路径安全。`node --check` 只解析语法,`Bun.build()` 只转译和打包,二者都可能放过 `isRequestTraceEvent is not defined` 这类未绑定标识符;因此 Cloud Web check 必须覆盖实际 Vite/React 入口和 `web/hwlab-cloud-web/src/**` bundle 输入,并运行 TypeScript semantic check,例如 `tsc --noEmit` 或等价 Bun/TS checker。该检查失败时不得继续发布 `hwlab-cloud-web` 镜像。 -Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-web/**/*.test.ts`,不能只依赖手写文件清单。新增 `app-trace`、markdown、auth、status 或 HWPOD 前端纯逻辑测试后,应天然进入 `bun run --cwd web/hwlab-cloud-web check`。trace 渲染核心路径必须有不依赖浏览器、Playwright、公网或真实 provider 的轻量单测,直接构造 `runnerTrace.events` 并调用 trace row/render helper,确保请求事件、setup 事件、tool command summary、assistant markdown 和 completion row 不会因未定义 helper 或数据形态漂移在浏览器运行时崩溃。 +Cloud Web 单元测试必须自动发现并执行 repo-owned `web/hwlab-cloud-web/**/*.test.ts`,不能只依赖手写文件清单。新增 trace、markdown、auth、status 或 HWPOD node-ops 前端纯逻辑测试后,应天然进入 `bun run --cwd web/hwlab-cloud-web check`。trace 渲染核心路径必须有不依赖浏览器、Playwright、公网或真实 provider 的轻量单测,直接构造 `runnerTrace.events` 并调用 trace row/render helper,确保请求事件、setup 事件、tool command summary、assistant markdown 和 completion row 不会因未定义 helper 或数据形态漂移在浏览器运行时崩溃。 默认 v0.2 CI 不启动 Playwright、布局 smoke、移动端截图、旧 quick prompt 检查、旧 M3 evidence 检查或历史 DEV/D601 browser gate。这些检查只能作为显式人工诊断或专项验收命令存在,不能重新进入最小 CI/CD 关键路径。新增测试也必须只表达当前 v0.2 目标行为;发现旧 UI/旧路由/旧门禁断言阻碍当前目标时,删除旧断言而不是维护兼容分支。 @@ -191,7 +191,7 @@ G14 host、worktree、k3s 控制面或 pod 内的验证命令必须按短连接 | 剪裁 fast-path 探针 | 约 50s | `prepare-source` 约 11s;`gitops-promote` 约 7s;`runtime-ready` 约 17s | runtime 有实际变化时的合理预算。 | | P1 no-op runtime skip | 约 37-40s | 固定阶段预算见下文;`runtime-ready` 跳过 | source-only 且 runtime identity-only 变化时的目标预算;当前代表性实测为 38s。 | -当前预算判定:source-only、所有 service 都复用 artifact、GitOps runtime 只发生 source identity 变化时,总耗时应接近 40s;超过 50s 需要先查是否误触发 `runtime-ready`、是否发生 GitHub 直连、是否恢复了 `npm ci` 或无效 preflight。真正需要 rollout 的 code-only 变更允许约 50s,因为 `runtime-ready` 必须等待 Argo 与 workload 收敛。涉及 BuildKit publish、env image rebuild、registry push 或真实 runtime 滚动时,不适用 40s 预算,应按 affected service 的 build 耗时单独测量。混合变更必须按 service 作用域裁剪:只改 `package.json` 的 `scripts`、旧门禁入口、短连接 CLI、CLI 测试、非 runtime 文档或 HWPOD host asset 时,不得触发无关 runtime service 全量 build;若同一变更确实同时改了 `internal/cloud/**`、HWPOD/迁移期执行 code 和 skill bundle,则只允许对应 service build/rollout,其他 service 必须复用 catalog digest。 +当前预算判定:source-only、所有 service 都复用 artifact、GitOps runtime 只发生 source identity 变化时,总耗时应接近 40s;超过 50s 需要先查是否误触发 `runtime-ready`、是否发生 GitHub 直连、是否恢复了 `npm ci` 或无效 preflight。真正需要 rollout 的 code-only 变更允许约 50s,因为 `runtime-ready` 必须等待 Argo 与 workload 收敛。涉及 BuildKit publish、env image rebuild、registry push 或真实 runtime 滚动时,不适用 40s 预算,应按 affected service 的 build 耗时单独测量。混合变更必须按 service 作用域裁剪:只改 `package.json` 的 `scripts`、旧门禁入口、短连接 CLI、CLI 测试、非 runtime 文档或 HWPOD workspace 工具时,不得触发无关 runtime service 全量 build;若同一变更确实同时改了 `internal/cloud/**`、HWPOD node-ops server code 和 skill bundle,则只允许对应 service build/rollout,其他 service 必须复用 catalog digest。 真实 rebuild 场景必须按全并行 fan-out 判定性能。 `plan-artifacts` 之后所有 affected service build task 应同时进入 Tekton 调度队列,`collect-artifacts` 只做 fan-in 等待全部 build task 写入 service report。 @@ -212,7 +212,7 @@ G14 host、worktree、k3s 控制面或 pod 内的验证命令必须按短连接 | `gitops-promote` no-op | 约 7-9s | 未输出 `skipped-runtime-unchanged`,或写入了 `v0.2-gitops`,说明 runtime 比对未命中。 | | `runtime-ready` | no-op 应跳过;真实 rollout 约 15-20s | no-op 场景出现 TaskRun 即为 P1 退化;真实 rollout 超时则按 Argo/workload 排障。 | -真实 rollout 若超过约 20s,先区分是 workload 本身启动慢,还是 `runtime-ready` 观察集合过大。正常日志应带 `observedCount`,且该值应接近本轮 `rolloutServices` 数量加上明确连带依赖;如果 `workloadCount` 很大但 `observedCount` 缺失,或 FRP、Postgres、HWPOD/迁移期执行服务在无关 cloud-web/cloud-api 变更中重启,通常说明 render 把复用服务的 Pod template 绑定到了全局 source commit。修复方向是收窄 `runtime-ready` 观察集合,并把复用服务 template identity 改成 artifact commit、boot commit、config hash 或 migration hash,而不是加大 timeout 或恢复全 namespace 等待。 +真实 rollout 若超过约 20s,先区分是 workload 本身启动慢,还是 `runtime-ready` 观察集合过大。正常日志应带 `observedCount`,且该值应接近本轮 `rolloutServices` 数量加上明确连带依赖;如果 `workloadCount` 很大但 `observedCount` 缺失,或 FRP、Postgres 等无关服务在 cloud-web/cloud-api 变更中重启,通常说明 render 把复用服务的 Pod template 绑定到了全局 source commit。修复方向是收窄 `runtime-ready` 观察集合,并把复用服务 template identity 改成 artifact commit、boot commit、config hash 或 migration hash,而不是加大 timeout 或恢复全 namespace 等待。 ## 性能优化原理 @@ -261,7 +261,7 @@ CI/CD 拓扑只表达依赖:`prepare-source` 输出 source/catalog,轻量检 正确做法是保持 source tree 只读、每个 service 独立 workdir 和 report、每个 service 独立 image repo。 先按容量节点定位真实瓶颈,再决定是否增加资源或修 sidecar/registry/mirror。 -planner 必须按 service component model 裁剪 build。`tools/` 下的短连接 CLI 源码不是 runtime service 输入;`skills/` 只有 agent runtime 或 skills bundle 真正消费的子目录才影响对应 runtime;HWPOD host CLI asset 属于 skills bundle 或 host 侧分发输入,不得让 cloud-api、agent-worker、gateway、edge-proxy 等服务重建。CI 读取 artifact catalog 时支持 repo 相对路径和绝对路径,便于用真实 catalog 做本地热探测;catalog 缺失才允许 fail closed 到 rebuild,不得把“诊断命令没读到 catalog”误判为生产路径必须全量构建。 +planner 必须按 service component model 裁剪 build。`tools/` 下的短连接 CLI 源码不是 runtime service 输入;`skills/` 只有 agent runtime 或 skills bundle 真正消费的子目录才影响对应 runtime;HWPOD workspace tools、`hwpod-cli`、`hwpod-ctl` 和 `hwpod-compiler-cli` 属于 Code Agent workspace/skills 分发输入,不得让 cloud-api、gateway、edge-proxy 等服务重建。CI 读取 artifact catalog 时支持 repo 相对路径和绝对路径,便于用真实 catalog 做本地热探测;catalog 缺失才允许 fail closed 到 rebuild,不得把“诊断命令没读到 catalog”误判为生产路径必须全量构建。 P1 no-op runtime skip 只跳过无实际 runtime 变化的等待。planner 输出 `buildServices=[]` 且 `rolloutServices=[]` 后,`gitops-promote` 会在写入前对旧 `runtime-v02` 与新 render 结果做归一化比较:忽略 source commit、artifact source commit、boot commit 和等价 commit env/annotation 这类 identity-only 字段。如果归一化结果相同,promotion 输出 `skipped-runtime-unchanged`,把 Tekton result `runtime-ready-required=false` 写出,并跳过 GitOps commit、push、Argo hard refresh 和 `runtime-ready`。如果 workload spec、image digest、env image、SecretRef、Service、Ingress、FRP、ConfigMap 或 rollout service 有实际变化,必须保持 `runtime-ready-required=true` 并等待 runtime 收敛。 @@ -616,7 +616,7 @@ GitOps branch 已更新、source branch render 通过、PipelineRun 名称存在 | Argo v02 Application | 已实现 | `hwlab-g14-v02` 指向 v02 GitOps path 和 namespace。 | | FRP `19666/19667` 入口 | 已实现 | 由 `hwlab-v02-frpc` 与 master frps allowlist 共同提供。 | | SecretRef 独立与 provider 验收 | 已实现/持续约束 | SecretRef 已独立;验收必须做真实短连接聊天。 | -| env 容器复用三变量启动 | 已实现/持续约束 | HWPOD/迁移期执行 fast lane 已由 CI/CD 自动推导 `HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT`、`HWLAB_BOOT_SH`;code-only rollout 复用 env image digest,只更新代码身份。 | +| env 容器复用三变量启动 | 已实现/持续约束 | env-reuse fast lane 已由 CI/CD 自动推导 `HWLAB_BOOT_REPO`、`HWLAB_BOOT_COMMIT`、`HWLAB_BOOT_SH`;code-only rollout 复用 env image digest,只更新代码身份。 | | `devops-infra` git mirror/relay 加速 | 已实现/持续约束 | source/catalog/runtime checkout 读路径和 GitOps promotion 写路径均使用独立基础设施集群 mirror/relay;Argo source 指向本地 mirror,GitHub flush 由 UniDesk CLI 手动触发,不设置 CronJob。runtime namespace 不持有 GitHub deploy key,registry 保持 G14 `hwlab-ci/hwlab-registry`。 | | `hwlab-cli` 不进 CI/CD service matrix | 已实现/持续约束 | CLI 是固定 repo 短连接 client,不发布镜像、不生成 artifact、不创建 `build-hwlab-cli` TaskRun;相关旧入口出现时直接删除。 | | CI/CD fast lane 性能预算 | 已实现/持续约束 | env-reuse no-op 目标约 40s;真实 runtime rollout 目标约 50s;不得恢复 `prepare-source` 依赖安装、GitHub 关键路径写入或 no-op runtime 等待。 | diff --git a/docs/reference/spec-v02-documentation-governance.md b/docs/reference/spec-v02-documentation-governance.md index e711fd7b..b7b1ebbb 100644 --- a/docs/reference/spec-v02-documentation-governance.md +++ b/docs/reference/spec-v02-documentation-governance.md @@ -59,7 +59,7 @@ | `docs/schema-drift-map.md` | 人写摘要归档到 #532;机器 source of truth 仍是 `protocol/schema-drift-map.json`。 | | `docs/plan/hwlab-v02-namespace-cicd.md` | 全文迁入 [pikasTech/HWLAB#530](https://github.com/pikasTech/HWLAB/issues/530) 评论;长期规格见 [spec-v02-cicd.md](spec-v02-cicd.md)。 | | `docs/plan/v02-multi-user-migration.md` | 全文迁入 [pikasTech/HWLAB#531](https://github.com/pikasTech/HWLAB/issues/531) 评论;长期规格见 [spec-user-access.md](spec-user-access.md)。 | -| `docs/plan/v02-device-pod-spec-migration.md` 和旧 device-pod MVP plan | 全文迁入 [pikasTech/HWLAB#533](https://github.com/pikasTech/HWLAB/issues/533) 评论;长期规格见 [spec-device-pod.md](spec-device-pod.md)。 | +| 旧设备执行规格和旧 MVP plan | 旧路线只保留在对应 issue 历史评论;当前长期规格见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。 | ## 验收标准 diff --git a/docs/reference/spec-v02-hwlab-agent-skills.md b/docs/reference/spec-v02-hwlab-agent-skills.md index 482139a8..f593ef97 100644 --- a/docs/reference/spec-v02-hwlab-agent-skills.md +++ b/docs/reference/spec-v02-hwlab-agent-skills.md @@ -36,7 +36,7 @@ Code Agent 第一版 skill 来源分为两类:预装 skill 读取镜像内只 - HWLAB 自有发现路径:`internal/cloud/codex-stdio-session-helpers.ts` 的 `discoverSkillsForStdio()` 读取 `HWLAB_CODE_AGENT_SKILLS_DIRS`,扫描每个目录下直接或一级子目录中的 `SKILL.md`,并把 `Current skills discovery facts` 注入 Codex prompt。 - Codex 原生发现路径:Codex 会从工作区 `.agents/skills` 等标准位置加载 skill;因此 `hwlab-cloud-api` 必须准备 `/workspace/hwlab/.agents/skills` symlink 聚合目录,让 Codex 原生机制也能看到 `/app/skills` 与 `/data/user-skills` 中的 skill。 - 两条发现路径都必须保留来源信息;同名 skill 不能因为 HWLAB 侧去重而消失。 -- AgentRun 装配发现路径:HWLAB v0.2 通过 AgentRun `ResourceBundleRef.skillRefs` 把 repo 内 `skills/device-pod-cli/SKILL.md` 和 `skills/hwlab-agent-runtime/SKILL.md` 装配到 runner 工作区 `.agents/skills`。`GET /v1/skills` 必须额外返回 `agentRunAssembly` 摘要,包含 `skillRefs`、`promptRefs`、`toolAliases`、资源 bundle repo/branch/commit 和 `.agents/skills` 运行时装配目录;Cloud Web skills 面板必须展示该摘要,不能只展示 `/app/skills` 与 `/data/user-skills` 的静态列表。 +- AgentRun 装配发现路径:HWLAB v0.2 通过 AgentRun `ResourceBundleRef.skillRefs` 把 repo 内 `skills/hwpod-cli/SKILL.md`、`skills/hwpod-ctl/SKILL.md` 和 `skills/hwlab-agent-runtime/SKILL.md` 装配到 runner 工作区 `.agents/skills`。`GET /v1/skills` 必须额外返回 `agentRunAssembly` 摘要,包含 `skillRefs`、`promptRefs`、`toolAliases`、资源 bundle repo/branch/commit 和 `.agents/skills` 运行时装配目录;Cloud Web skills 面板必须展示该摘要,不能只展示 `/app/skills` 与 `/data/user-skills` 的静态列表。 - MiniMax-M3 runner 引导必须说明工具调用 JSON、BusyBox/GNU 工具差异和 GitHub CLI 读 issue/PR 的低噪声用法,降低无效 tool-call arguments 与命令兼容性摩擦。 ## API 接口说明 diff --git a/docs/reference/spec-v02-hwlab-cli.md b/docs/reference/spec-v02-hwlab-cli.md index f0691cc1..798b6c17 100644 --- a/docs/reference/spec-v02-hwlab-cli.md +++ b/docs/reference/spec-v02-hwlab-cli.md @@ -8,17 +8,10 @@ 登录鉴权目标见 [spec-v02-auth.md](spec-v02-auth.md):CLI 必须是一等纯 CLI 体验,默认从环境变量 `HWLAB_API_KEY` 读取用户 API key,并发送 `Authorization: Bearer hwl_live_...`。`client auth login --username ...`、本地 cookie session 和 profile cookie 只属于当前实现的 legacy 兼容入口;后续目标验收不得要求 CLI 打开浏览器、跳转 Web 或输入 Keycloak 密码。 -正式复现和验收必须通过运行时装配解析 endpoint,而不是在命令里手动传 URL。标准环境是 `HWLAB_RUNTIME_NAMESPACE=hwlab-v02`、`HWLAB_RUNTIME_LANE=v02`、`HWLAB_RUNTIME_ENDPOINT_LOCKED=1` 和 `HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME=1`;CLI 输出必须包含 `runtimeEndpoint.source=runtime-namespace`、`runtimeEndpoint.explicitOverride=false` 和解析出的 `baseUrl`。`--base-url`、`--api-base-url`、`HWLAB_CLIENT_BASE_URL` 或等价显式 URL 只允许在本地 debug 且未设置 endpoint locked 时使用;issue 复现、最终验收、Web 等价 CLI、AgentRun runner、迁移期 `device-pod-cli` shim 和 `hwpod` 都不得靠人工判断 17666/19666/19667。 +正式复现和验收必须通过运行时装配解析 endpoint,而不是在命令里手动传 URL。标准环境是 `HWLAB_RUNTIME_NAMESPACE=hwlab-v02`、`HWLAB_RUNTIME_LANE=v02`、`HWLAB_RUNTIME_ENDPOINT_LOCKED=1` 和 `HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME=1`;CLI 输出必须包含 `runtimeEndpoint.source=runtime-namespace`、`runtimeEndpoint.explicitOverride=false` 和解析出的 `baseUrl`。`--base-url`、`--api-base-url`、`HWLAB_CLIENT_BASE_URL` 或等价显式 URL 只允许在本地 debug 且未设置 endpoint locked 时使用;issue 复现、最终验收、Web 等价 CLI、AgentRun runner 和 `hwpod` 都不得靠人工判断 17666/19666/19667。 当前阶段的 Web 等价 CLI 验收默认使用 `admin` 的默认账号 workspace:不要为了避免污染而临时创建测试账号、切换 profile、指定临时 `projectId` 或隔离 workspace。需要清理上下文时直接通过 `client workbench restore/status/reset --confirm` 作用于 admin 默认 workspace,并在 issue 评论记录 reset、traceId、workspace revision 和恢复结果。只有用户明确要求多账号/多 profile 隔离验证,或目标功能本身就是账号隔离/profile 行为时,才使用 `--profile`、新增账号或非默认 `projectId`。 -## 当前实现状态 - -- `hwlab-cli client auth status|whoami` 当前以 `HWLAB_API_KEY` 和 `/v1/users/me` 恢复用户 actor;默认输出只显示 endpoint、key prefix 和脱敏用户摘要,不保存或打印完整 API key。 -- `client access ...` 当前是 Admin Access WebUI 的非视觉同路径入口,覆盖 summary、users、HWPOD/profile relation grant/revoke、tool grant/revoke 和 check;它只打 Cloud Web 同源 path,不直连 OpenFGA 或手动传 OpenFGA token。 -- `hwpod` 在 AgentRun runner 中使用 `HWLAB_RUNTIME_API_URL` 直达 cloud-api,并携带当前 owner 的用户级 `HWLAB_API_KEY`。内部执行凭据不属于 CLI auth 状态,也不得通过 CLI profile、state file 或 runner env 暴露。 -- CLI 变更是 source-only 短连接工具变更,不创建常驻 Service、镜像、Job template 或 GitOps 对象;测试和验收只表达当前 Cloud Web/API/OpenFGA 权限行为,不保留已移除路径的断言。 - ## Code Agent session 手动化 Code Agent session 是显式资源,不再由普通 `client agent send`、Workbench composer、`--from-trace` 或账号 workspace 自动创建、滚动或替换。账号 workspace 只能记录当前显式选中的 session、最近 trace 和展示状态;它不是隐式 session factory。 @@ -34,19 +27,19 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb ## 在系统中的职责划分 -- 提供 WEB 等价的非视觉业务入口:登录鉴权、显式 Code Agent session 管理、HWPOD 看板、Admin Access 授权管理、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。 +- 提供 WEB 等价的非视觉业务入口:登录鉴权、显式 Code Agent session 管理、HWPOD node-ops 状态、Admin Access 授权管理、Code Agent 对话、trace/result 轮询、logout 和工作台 live summary。 - 只走 Cloud Web 同源 API surface;正式运行时由 `HWLAB_RUNTIME_*` 装配出当前 lane 的 Web/API endpoint,失败时必须 fail closed,不能静默退回 legacy DEV 入口。 - Web/CLI 路径一致性优先于继续 Web 修复。Cloud Web 暴露 Code Agent、AgentRun、continuation、steer、trace/result 或 provider 问题后,必须先能用 runtime namespace/lane 装配出的 `bun tools/hwlab-cli/bin/hwlab-cli.ts client agent send/result/trace/inspect/steer ...` 对同一 Cloud Web origin、同一 `/v1/agent/chat*`、同一 `conversationId/sessionId/threadId/retryOf` 复现或解释,再继续修 Web 状态机。Cloud API 只用于显式 admin/setup/gateway 诊断,不得替代 WEB 同源路径验收。 - 从 Web trace 回放 Code Agent 问题时,优先用 `client agent inspect --trace-id ` 读取 Cloud Web 的 `/v1/agent/chat/inspect`,输出 trace 所属 `conversationId/sessionId/threadId`、session 状态和 `retryOf` 建议;`client agent send --from-trace ` 只能作为显式复现该 trace 所属 session 的入口,不能自动创建、滚动或替换 session。inspect 缺失或 session 已 failed/stale 时,CLI 必须返回结构化 blocker 和显式新建 session 建议。 -- 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、残留执行 Service、gateway RPC 或本地 fixture;需要鉴权的请求优先使用 `HWLAB_API_KEY` 生成的 `Authorization: Bearer hwl_live_...`,legacy/debug 才使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它使用同一 runtime endpoint resolver 定位 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。显式 API URL 只作为 unlocked local debug 入口。 +- 默认业务子命令不直连 Postgres、Kubernetes Service、Secret、内部执行 Service、gateway RPC 或本地 fixture;需要鉴权的请求优先使用 `HWLAB_API_KEY` 生成的 `Authorization: Bearer hwl_live_...`,legacy/debug 才使用 `/auth/*` 返回的 cookie 或显式 `--cookie`。唯一例外是 `client gateway` 诊断族:它使用同一 runtime endpoint resolver 定位 Cloud API,用于短连接观测 gateway session、单次 shell invoke 和 transport 压测;该入口只验证底层传输稳定性,不替代 Web 用户流程授权,也不发布镜像或常驻服务。显式 API URL 只作为 unlocked local debug 入口。 - Pod 内透传执行不放进 `hwlab-cli`;需要进入正在工作的 Code Agent/Cloud API pod 时,`hwlab-cli` 只查询并输出 UniDesk 标准 route,实际透传由 UniDesk `bun scripts/cli.ts ssh 'G14:k3s:hwlab-v02:pod::' ...` 完成。`pod:` 是 route 语法,`/` 只用于 pod 内文件系统路径。 - `client runtime routes` 必须按当前运行 profile/lane 的数据生成 UniDesk `pod:` route;实现不得硬编码 `dev`、`v0.2`、`v0.3`、namespace 或 catalog path。新增版本只允许通过 `deploy.json.lanes[profile]` 声明 namespace、artifact catalog 和 service overrides,不为每个版本新增代码分支。 - 运行时不做内部证明型校验、旧健康诊断或重断言;CI/CD 只保留能证明代码可构建、语法正确和最小冒烟可用的校验。功能正确性通过 `hwlab-cli client` 短连接真实业务 E2E 暴露和修复。 - 专用子命令覆盖高频用户工作台;`client request METHOD /path` 覆盖 WEB 同源代理允许的其他非视觉 API。`client request` 只接受以 `/` 开头的 Cloud Web 相对路径,禁止绝对 URL,避免绕过 Cloud Web 直接打内部服务。 - `client access ...` 是 Admin Access 页面的同路径 CLI,不直连 OpenFGA,不手动传 OpenFGA token,不把 `19667` Cloud API 当作 Web 等价验收路径。所有授权读写都必须输出 runtimeEndpoint、route、actor、mode、decision 和 effective matrix 摘要。 -- `client gateway pressure` 是 HWPOD/gateway 高频故障的真实业务传输压测入口;必须覆盖 small stdout、大 stdout、长单行 stdout、stderr flood、结构化 timeout 和超出 gateway inflight 上限的并发请求。所有场景必须返回 JSON、HTTP/route/traceId/requestId、字节数、truncated 标记、sha256 和 bounded preview;失败必须明确是 `http_*`、`stdout_not_truncated`、`stderr_not_truncated`、`timeout_not_observed`、`structured_gateway_busy` 等可定位原因,禁止无输出、长时间黑洞或只靠 shell pipe 截断。 +- `client gateway pressure` 是 gateway/transport 高频故障的真实业务传输压测入口;必须覆盖 small stdout、大 stdout、长单行 stdout、stderr flood、结构化 timeout 和超出 gateway inflight 上限的并发请求。所有场景必须返回 JSON、HTTP/route/traceId/requestId、字节数、truncated 标记、sha256 和 bounded preview;失败必须明确是 `http_*`、`stdout_not_truncated`、`stderr_not_truncated`、`timeout_not_observed`、`structured_gateway_busy` 等可定位原因,禁止无输出、长时间黑洞或只靠 shell pipe 截断。 - 输出默认是 JSON;任何失败都要有 `ok:false`、`action`、`status`、HTTP 状态、route 和可定位错误,不允许无 stdout 成功。可能返回大对象的 `client` 子命令默认返回紧凑摘要,避免高频排障输出爆炸;需要完整响应体时显式加 `--full`。 -- `hwpod` 是 AgentRun runner 中的设备 API 标准短入口,必须自动使用装配的 `HWLAB_RUNTIME_API_URL` 直达 `hwlab-cloud-api`,并使用映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;迁移期 `device-pod-cli` 只作为 shim,不是当前概念入口。设备 API 不能把 Cloud Web 同源代理当作通道,也不能手动传 URL 或 session token。`job output` 默认也必须返回紧凑 JSON:保留 job/status/blocker/freshness/text/evidence 摘要,省略嵌套 gateway dispatch 和长命令;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 +- `hwpod` 在 AgentRun runner 中是设备 API 标准短入口,必须自动使用装配的 `HWLAB_RUNTIME_API_URL` 直达 `hwlab-cloud-api`,并使用映射到当前 Code Agent session owner 的 `HWLAB_API_KEY`;不能把 Cloud Web 同源代理当作设备 API 通道,也不能手动传 URL 或 session token。HWPOD CLI 默认必须返回紧凑 JSON:保留 action/status/blocker/nodeOps/trace 摘要,省略长输出;需要完整 payload 时显式加 `--full`。Code Agent 和人工不得用 `| head`、`grep` 或 shell 管道作为默认输出压缩方式,避免 stdout pipe、子进程信号转发或长输出造成 commandExecution 黑洞。 - Code Agent 交互必须默认暴露 `traceId`、`resultUrl`、终态和 assistant 回复文本摘要;不能要求用户先拉全量 trace 再手工查找回复。 - CLI 本地登录态必须支持 `--profile NAME` 隔离,同一 base URL 下不同 profile 写入 `.state/hwlab-cli/profiles//.json`。切换到其他账号再切回原账号时,`client workbench restore/status` 必须从服务端账号 workspace 恢复之前的 `workspaceId`、`conversationId`、`sessionId`、`threadId`、`activeTraceId` 和 revision,而不是只依赖本地文件。 - `client workbench restore/status/watch/reset` 是账号 workspace 的非视觉入口:`restore/status` 对应 `GET /v1/workbench/workspace`,`watch` 对应 `/events?afterRevision=`,`reset --confirm` 对应服务端 reset。输出必须显示 workspace revision、selected conversation/session、active trace 和本地 state file,且不得保存 password、session token 原文以外的 Secret 值。 @@ -80,20 +73,16 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb | `hwlab-cli client auth session` | `GET /auth/session` | Web session debug。 | | `hwlab-cli client auth profiles` | 本地状态读取 | 列出同一 base URL 下的本地 profile state,用于账号切换可见性。 | | `hwlab-cli client auth logout` | `POST /auth/logout` | 撤销 server session 并清理本地 cookie。 | -| `hwlab-cli client access summary` | `GET /v1/admin/access/summary` | Admin Access 总览,显示 OpenFGA mode/readiness/store/model、用户/tool/HWPOD 数量和 mismatch 摘要。 | +| `hwlab-cli client access summary` | `GET /v1/admin/access/summary` | Admin Access 总览,显示 OpenFGA mode/readiness/store/model、用户/tool 数量和 mismatch 摘要。 | | `hwlab-cli client access users list` | `GET /v1/admin/access/users` | 列出用户、role/status、Keycloak 绑定摘要和 effective capability 摘要。 | -| `hwlab-cli client access users inspect USER` | `GET /v1/admin/access/users/{userId}` | 查看单个用户的 HWPOD/profile、agent session 和 tool 权限矩阵。 | +| `hwlab-cli client access users inspect USER` | `GET /v1/admin/access/users/{userId}` | 查看单个用户的 agent session 和 tool 权限矩阵。 | | `hwlab-cli client access users set-role USER --role admin|user` | `PATCH /v1/admin/access/users/{userId}` | 更新用户 role/status,并同步 OpenFGA admin tuple。 | -| `hwlab-cli client access device-pods grant/revoke USER POD --relation REL` | `PUT/DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` | 迁移期 API path;授予或撤销 HWPOD/profile 的 `viewer/operator/profile_editor/job_submitter` 等 relation。 | | `hwlab-cli client access tools grant/revoke USER TOOL` | `PUT/DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 授予或撤销 `hwpod`、`unidesk_ssh`、`trans_cmd`、GitHub 写工具等 capability。 | | `hwlab-cli client access check --user USER --relation REL --object OBJECT` | `POST /v1/admin/access/check` | 管理员调试单次 authorization check,输出 decision 和 redacted actor/object。 | | `hwlab-cli client provider-profiles list` | `GET /v1/admin/provider-profiles` | 管理页的非视觉状态入口;输出 actor、profile、SecretRef、resourceVersion、hash 后缀和最近验证结果,不输出 API Key 或 Secret data。 | | `hwlab-cli client provider-profiles set-key PROFILE --key-stdin` | `PUT /v1/admin/provider-profiles/{profile}/credential` | 从 stdin 写入 provider API Key,Cloud API 鉴权后委托 AgentRun;默认只输出 resourceVersion/hash 后缀和 failureKind。 | | `hwlab-cli client provider-profiles validate PROFILE --wait` | `POST /v1/admin/provider-profiles/{profile}/validate` + `GET /validations/{id}` | 触发 provider canary 并短连接轮询,输出 validationId、runId、commandId、jobName、traceId、status、failureKind 和 redacted bridge 摘要。 | -| `hwlab-cli client device-pods list` | `GET /v1/device-pods` | 迁移期 HWPOD 列表 API path;CLI 当前命名残留,目标概念是 `hwpod`。 | -| `hwlab-cli client device-pods status POD` | `GET /v1/device-pods/{pod}/status` | 迁移期 HWPOD status API path;CLI 当前命名残留,目标概念是 `hwpod`。 | -| `hwlab-cli client device-pods events POD` | `GET /v1/device-pods/{pod}/events` | 对应纯文本事件流。 | -| `hwlab-cli client device-pods probe POD` | `/debug-probe/chip-id`、`/io-probe/uart/1`、`/tail` | 对应 Target/Debug/IO 摘要。 | +| `hwlab-cli client request POST /v1/hwpod-node-ops` | `POST /v1/hwpod-node-ops` | HWPOD node-ops 同路径 smoke;Code Agent 侧正式业务入口仍是 `hwpod`,由 `hwpod-compiler-cli` 生成 node-ops。 | | `hwlab-cli client runtime routes` | `GET /v1/live-builds` | 查询当前工作面 pod,并输出 UniDesk 标准 `pod:` route;不执行透传、不调用 kubectl、不内嵌 UniDesk。 | | `hwlab-cli client gateway sessions` | `GET Cloud API /v1/gateway/sessions` | 显式 Cloud API 诊断入口,观察 gateway online/stale、inflight 和 capability;默认不带 Web cookie。 | | `hwlab-cli client gateway invoke` | `POST Cloud API /v1/rpc/hardware.invoke.shell` | 显式 Cloud API 诊断入口,执行一次 bounded shell dispatch 并返回结构化 dispatch 摘要。 | @@ -108,7 +97,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb | `hwlab-cli client harness submit` | `POST /v1/agent/chat` | G14 harness-ops 的短连接提交入口,默认 provider profile 为 `deepseek`,返回 trace/result URL;`harness-ops` 和 `harness-opt` 是同义别名。 | | `hwlab-cli client harness wait/result/trace` | `GET /v1/agent/chat/result/{trace}`、`GET /trace/{trace}` | 轮询或读取一次 Code Agent 结果和 trace;单次 wait 最长 60 秒。 | | `hwlab-cli client harness audit` | `GET /v1/agent/chat/trace/{trace}` 或本地 trace file | 只输出工具摩擦信号,辅助发现应补的主 CLI/HWPOD 操作;不是运行时 gate。 | -| `hwlab-cli client workbench summary` | `/health/live`、`/v1`、`/v1/live-builds`、`/v1/device-pods*` | 汇总 Cloud Workbench 非视觉功能面。 | +| `hwlab-cli client workbench summary` | `/health/live`、`/v1`、`/v1/live-builds`、`/v1/hwpod-node-ops` | 汇总 Cloud Workbench 非视觉功能面。 | | `hwlab-cli client workbench restore/status/watch/reset` | `GET/PATCH /v1/workbench/workspace*` | 恢复、观察或重置账号级共享 workspace,支持 Web/CLI 和多 profile 共享同一账号状态。 | | `hwlab-cli client rpc METHOD [--full]` | `POST /json-rpc` | 像 Web `callRpc` 一样自动生成 `id`、`traceId` 和 `meta`,覆盖 `system.health`、`cloud.adapter.describe` 等 JSON-RPC 非视觉能力。 | | `hwlab-cli client request METHOD /path [--full]` | Cloud Web 同源相对路径 | 覆盖 `/v1/access/status`、`/v1/setup/status`、`/v1/diagnostics/gate`、`/v1/m3/status`、`/v1/m3/io` 等低频或新增 WEB API;`/json-rpc` 优先使用 `client rpc`。 | @@ -124,7 +113,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb ## T2 -阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:在 runtime endpoint locked 环境下运行 `client auth session`、迁移期 `client device-pods list`、`client device-pods status device-pod-71-freq` 和 `client workbench summary --pod-id device-pod-71-freq`,确认全部由 runtime namespace 解析到 Cloud Web 同源 API,未登录时返回认证 blocker,登录后返回真实 HWPOD/profile payload,不读取本地 fixture,也不需要手动传 URL。 +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:在 runtime endpoint locked 环境下运行 `client auth session`、`client request POST /v1/hwpod-node-ops --body '{"contractVersion":"hwpod-node-ops-v1","plan":{"steps":[{"op":"node.health"}]}}'` 和 `client workbench summary`,确认全部由 runtime namespace 解析到 Cloud Web 同源 API,未登录时返回认证 blocker,登录后返回真实 HWPOD node-ops payload,不读取本地 fixture,也不需要手动传 URL。 ## T3 @@ -164,7 +153,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb ## T6 -阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:在 runtime endpoint locked 环境下运行 `hwpod job output --pod-id D601-F103-V2 `,确认 `hwpod` 自动定位当前 lane 的 Cloud API,默认输出包含 `body.compacted=true`、状态、job 摘要和 bounded text,且不包含嵌套 `dispatch.command`;再加 `--full` 确认完整 payload 可按需展开。通过 `client harness submit` 让 Code Agent 执行同一 `hwpod job output`,确认 trace 中 commandExecution 可以完成,不需要 `| head`,也不需要手动传 URL。 +阅读 docs/reference/spec-v02-hwlab-cli.md,然后在 `G14:/root/hwlab-v02` 用 cli 手动测试以下内容:在 runtime endpoint locked 环境下让 Code Agent 执行 `hwpod inspect` 或等价只读 HWPOD 命令,确认 `hwpod` 自动定位当前 lane 的 Cloud API,先调用 `hwpod-compiler-cli` 生成 `hwpod-node-ops-v1` plan,再由 Cloud API 转发到 `hwpod-node` 执行;trace 中 commandExecution 必须完成,默认输出为紧凑 JSON,不需要 `| head`,也不需要手动传 URL。 ## T7 @@ -172,7 +161,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb ## T8 -阅读 docs/reference/spec-v02-hwlab-cli.md 和 docs/reference/spec-v02-openfga-authorization.md,然后在 runtime endpoint locked 环境下运行 `client access summary`、`client access users list`、`client access users inspect `、`client access device-pods grant/revoke ...`、`client access tools grant/revoke ...` 和 `client access check ...`。确认所有命令都走 Cloud Web 同源 `19666` path,输出 JSON、route、actor、OpenFGA mode/decision 和 effective matrix,不输出 OpenFGA token、完整 API key 或 Secret 值。 +阅读 docs/reference/spec-v02-hwlab-cli.md 和 docs/reference/spec-v02-openfga-authorization.md,然后在 runtime endpoint locked 环境下运行 `client access summary`、`client access users list`、`client access users inspect `、`client access tools grant/revoke ...` 和 `client access check ...`。确认所有命令都走 Cloud Web 同源 `19666` path,输出 JSON、route、actor、OpenFGA mode/decision 和 effective matrix,不输出 OpenFGA token、完整 API key 或 Secret 值。 ## T9 @@ -184,7 +173,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb | --- | --- | --- | | 固定 repo 短连接 client | 目标状态 | `hwlab-cli` 在 `G14:/root/hwlab-v02` 或当前 v0.2 worktree 直接用 Bun 运行,不作为 runtime service。 | | WEB 等价 API client | 目标状态 | `client` 子命令覆盖 Cloud Web 非视觉业务面。 | -| Admin Access 同路径 CLI | 已实现/持续约束 | `client access ...` 覆盖 OpenFGA summary、user matrix、grant/revoke、tool capability 和 check,必须走 Cloud Web 同源 path。 | +| Admin Access 同路径 CLI | 目标状态 | `client access ...` 覆盖 OpenFGA summary、user matrix、grant/revoke、tool capability 和 check,必须走 Cloud Web 同源 path。 | | Provider profile 管理同路径 CLI | 目标状态 | `client provider-profiles ...` 覆盖管理页状态、API Key 写入和 canary 验证,必须走 Cloud Web 同源 path 并委托 AgentRun。 | | 显式 Code Agent session 管理 | 目标状态 | `client agent session create|select|status|list` 是 `send` 前置;普通 `send` 不自动创建或滚动 session。 | | WEB composer 状态机等价 | 目标状态 | `client agent composer status|submit` 复用 Web composer policy,但必须显示 sessionRequired/sessionUsable,不能自动创建或滚动 session。 | @@ -192,8 +181,8 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb | 通用同源 API request | 目标状态 | `client request` 用于追平低频和新增 WEB API,禁止绝对 URL。 | | G14 harness-ops 短连接能力 | 目标状态 | `client harness` / `client harness-ops` / `client harness-opt` 覆盖 submit/result/trace/wait/audit,只作为业务 API client。 | | Gateway transport 压测 | 已实现 | `client gateway pressure` 只作为显式短连接诊断入口,覆盖大输出、timeout 和并发超容量的结构化返回。 | -| HWPOD job output 紧凑输出 | 已实现 | `hwpod job output` 默认省略嵌套 dispatch,`--full` 才展开完整 payload,防止 Code Agent 通过 shell pipe 压输出。 | -| 用户 API key 登录 | 已实现/持续约束 | `HWLAB_API_KEY` 是 CLI 一等登录入口;完整 key 不写入默认输出或本地 state。 | +| HWPOD CLI 紧凑输出 | 已实现 | `hwpod` 默认输出紧凑 JSON,包含 action/status/compilerInvocation/route/result 摘要,防止 Code Agent 通过 shell pipe 压输出。 | +| 用户 API key 登录 | 目标状态 | `HWLAB_API_KEY` 是 CLI 一等登录入口;完整 key 不写入默认输出或本地 state。 | | 本地 cookie session | Legacy | `.state/hwlab-cli/session.json` 只作为现有 cookie/session 兼容,不再作为目标一等 CLI 登录体验。 | | 账号 profile 与共享 workspace | 已实现 | `--profile` 隔离本地登录态,`client workbench` 通过服务端 `account_workspaces` 恢复同账号共享 workspace。 | | 镜像/Service/Job template | 已废弃 | 相关 deploy、GitOps、artifact 和 Tekton 口径必须删除。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index f50640eb..c114ee87 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -1,28 +1,22 @@ # v0.2 hwlab-cloud-api 服务规格 `hwlab-cloud-api` 是 `v0.2` 应用层核心服务,运行在 `hwlab-v02` namespace,内部端口 `6667`,公网经 `hwlab-edge-proxy` 和 FRP 暴露为 `http://74.48.78.17:19667`。 + Provider API Key 管理属于 Cloud API 的 authenticated admin surface:前端只调用 `/v1/admin/provider-profiles*`,Cloud API 使用 HWLAB `AuthPrincipal` 和授权模型判定后,把 provider profile 状态查询、API Key 写入和 canary 验证委托给 AgentRun 后端。Cloud API 不直接写 AgentRun Secret,不把 API Key 原文写入日志或响应;详细合同见 [spec-v02-provider-management.md](spec-v02-provider-management.md)。 ## 在系统中的职责划分 -- 承担 runtime health、DB readiness、登录鉴权、`AuthPrincipal`、OpenFGA 授权 check/write、用户/session/API key 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、HWPOD/profile/job authority 和 live build inventory。 -- 是 `hwlab-cloud-web`、Code Agent session、HWPOD 用户态操作、Admin Access API、AgentRun 工具注入和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问内部执行壳、OpenFGA Service 或 HWPOD node。 +- 承担 runtime health、DB readiness、登录鉴权、`AuthPrincipal`、OpenFGA 授权 check/write、用户/session/API key 权限、Code Agent 对话、trace/result 轮询、gateway outbound registry、M3 IO 控制、HWPOD node-ops 转发和 live build inventory。 +- 是 `hwlab-cloud-web`、Code Agent session、HWPOD CLI、Admin Access API、AgentRun 工具注入和 gateway outbound poll 的唯一应用层收口点;普通用户不直接访问 OpenFGA Service 或 host 侧 node 资源。 - 读取 `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;用户 API key 存在 Postgres `api_keys`,HWPOD 用户态授权只能从该表恢复到用户 actor。文档和日志只允许记录 SecretRef 名称、key、字节数或哈希指纹,不记录值。 - 读取 OpenFGA URL、auth token、store/model 指针和 mode 时只能通过 env/SecretRef/Postgres runtime config;`/health/live` 和 `/v1/admin/access/summary` 只输出 readiness、mode、storeId/modelId 摘要和 degraded reason,不输出 token、Postgres URL 或 tuple secret。 -## 当前实现状态 - -- 当前 v0.2 runtime 的应用层收口由 `hwlab-cloud-api` 承担,相关稳定服务包含 Cloud Web、HWPOD 相关实现、OpenFGA、v0.2 Postgres、FRP 入口和 Keycloak 外部 issuer。普通用户入口最终都必须恢复成 `AuthPrincipal` 后再执行授权。 -- Cloud API 当前已经承载本地 bootstrap/Web session、用户 API key、API key create/revoke/regenerate、`/v1/users/me`、Admin Access API、OpenFGA check/write、迁移期 HWPOD/profile/job authority、Code Agent owner binding 和 AgentRun transient env 装配。Keycloak 浏览器 OIDC callback 仍按 [spec-v02-auth.md](spec-v02-auth.md) 的 #814 收口。 -- OpenFGA token、残留执行链路内部 token、provider key、GitHub token 和 UniDesk SSH token 都是服务或工具凭据,不是用户 actor。Cloud API 可以消费其中一部分完成内部调用,但不得把它们写入用户 API 响应、CLI 默认输出、runner `HWLAB_API_KEY` 或 Admin Access 授权矩阵。 -- OpenFGA 不可达、store/model 未就绪或写入失败时,权限写操作必须结构化失败;不得用本地 role/status、access 摘要或 runtime cache 替代 OpenFGA 放行。 - ## 内部架构 - `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。 -- `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、HWPOD/profile authority、gateway poll/result 和 Code Agent chat。 +- `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、HWPOD node-ops、gateway poll/result 和 Code Agent chat。 - `internal/cloud/openfga-authorization.ts` 或等价模块负责 OpenFGA client、store/model bootstrap、`enforce` 策略、check/write 和 structured authorization decision;最终规格见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 -- `internal/cloud/access-control.ts` 负责 `/auth/*`、OIDC callback、Web session、用户 API key、admin/user、HWPOD profile/relation、HWPOD job lifecycle 和 Code Agent owner binding;登录与鉴权 authority 见 [spec-v02-auth.md](spec-v02-auth.md)。当前本地 `/auth/login` 只作为 bootstrap fallback。 +- `internal/cloud/access-control.ts` 负责 `/auth/*`、OIDC callback、Web session、用户 API key、admin/user、tool capability 和 Code Agent owner binding;登录与鉴权 authority 见 [spec-v02-auth.md](spec-v02-auth.md)。当前本地 `/auth/login` 只作为 bootstrap fallback。 - `internal/cloud/access-control.ts` 也是账号 workspace authority:`account_workspaces` 记录同一账号的 Workbench 当前 workspace、selected conversation/session、active trace、provider profile 和 revision。 - Code Agent session 生命周期必须显式化。Cloud API 目标入口为 `POST /v1/agent/sessions` 创建 session、`GET/PATCH /v1/agent/sessions*` 查询/选择/标记状态;`POST /v1/agent/chat` 只接受显式传入或账号 workspace 中已显式选中的 usable session。没有 session 时返回 `session_required`,session failed/stale/canceled 时返回 `session_not_usable`,不得自动创建、滚动或替换 session。 - Code Agent session record 是 provider profile authority。`POST /v1/agent/chat` 带显式 session 且请求未显式给出 provider profile 时,Cloud API 必须继承该 session 的 `providerProfile` 并映射为 AgentRun `backendProfile`;请求显式覆盖 provider profile 时,覆盖必须进入 trace/result 可见字段。账号 workspace 的 provider profile 只能在 selected session 与目标 session 完全一致时作为 fallback,不得让旧 workspace 覆盖显式 session。 @@ -45,14 +39,14 @@ Provider API Key 管理属于 Cloud API 的 authenticated admin surface:前端 | --- | --- | | `GET /health`、`GET /health/live` | 返回 service identity、environment、revision、DB/runtime/Code Agent readiness 和 blocker。 | | `GET /live` | 轻量 live 标记。 | -| `GET /v1` | REST adapter 索引、RPC 方法、runtime readiness 和 HWPOD/M3 能力摘要。 | +| `GET /v1` | REST adapter 索引、RPC 方法、runtime readiness、HWPOD node-ops 和 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...` | 迁移期 HWPOD API path;经 cloud-api 鉴权后读取服务端 profile/OpenFGA relation/job authority;probe GET 会创建只读 job 并经当前可用执行链路执行或返回同源 blocker,不会回退到 fake 数据。 | +| `POST /v1/hwpod-node-ops` | 接收 `hwpod-node-ops-v1` plan,经 cloud-api 鉴权和工具能力校验后转发到 `hwpod-node` 执行,返回 node/action/result/blocker 摘要;不回退到 fake 数据或旧内部 executor。 | | `GET /auth/oidc/login`、`GET /auth/oidc/callback`、`GET /auth/session`、`POST /auth/logout` | Keycloak OIDC、Web session 24 小时轮换和 logout 入口,最终规格见 [spec-v02-auth.md](spec-v02-auth.md)。 | | `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | v0.2 用户/session/setup 的 REST 状态和兼容入口;响应不得暴露 password hash、session token 原文或 Secret 值。 | | `GET/POST /v1/api-keys...` | 用户 API key 管理入口;CLI 和 AgentRun runner 内 `hwpod` 都使用 `HWLAB_API_KEY`,映射到用户后再按权限表授权。 | -| `POST /v1/admin/users`、`POST/PUT /v1/admin/device-pods` | `admin` 管理用户和 HWPOD/profile 的入口;`device-pods` path 是迁移期实现名。 | +| `POST /v1/admin/users` | `admin` 管理用户 role/status 的入口。 | | `GET/PATCH/PUT/DELETE /v1/admin/access...` | Admin Access API;唯一正式授权管理入口,读写 OpenFGA 细粒度授权、tool capability、role/status 和 effective matrix。 | | `POST /v1/admin/access/check` | 管理员调试授权 check;返回 mode、decision、object/relation 和 redacted actor,不返回 OpenFGA token。 | | `GET/PUT/POST /v1/admin/provider-profiles...` | Provider API Key 管理入口;Cloud API 鉴权和审计后委托 AgentRun 后端,返回脱敏 profile 状态、SecretRef 摘要和 canary 结果,不返回 Secret value。 | @@ -61,7 +55,6 @@ Provider API Key 管理属于 Cloud API 的 authenticated admin surface:前端 | `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/internal/device-pod/gateway-dispatch` | 迁移期内部残留 path;仅接受内部服务凭据,用于把已授权 job dispatch 到 gateway poll/result;普通用户和 Code Agent 不可调用,后续应收敛到 HWPOD node-ops。 | | `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`GET /v1/agent/chat/result/{traceId}`、`GET /v1/agent/chat/trace/{traceId}`、`POST /v1/agent/chat/cancel` | Code Agent 短连接提交、运行中 steer、轮询、trace 和取消;普通 chat 必须绑定显式 usable session。 | ### `/v1/live-builds` 语义 @@ -70,7 +63,7 @@ Provider API Key 管理属于 Cloud API 的 authenticated admin surface:前端 需要验证 Cloud Web 时必须读取 `services[]` 中 `serviceId=hwlab-cloud-web` 的行,并区分 `build.createdAt`、`image.tag/digest`、`commit.id`、`revision` 和 `build.liveMetadataMatch`。`/health/live` 只能证明当前服务 health/revision;Cloud Web 顶部 build chip 和详情弹窗展示的构建时间、env image 和实际 runtime commit 以 `/v1/live-builds` 的对应 service row 为准。 -登录鉴权 API 的最终规格见 [spec-v02-auth.md](spec-v02-auth.md);用户、权限、HWPOD 管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md) 和 [spec-hwpod-harness.md](spec-hwpod-harness.md),旧 path/table 对照见 [spec-device-pod.md](spec-device-pod.md)。 +登录鉴权 API 的最终规格见 [spec-v02-auth.md](spec-v02-auth.md);用户、权限和工具能力管理 API 的最终规格见 [spec-user-access.md](spec-user-access.md)。HWPOD 概念体系见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。 OpenFGA、Access API、工具能力和 shadow/enforce 策略的最终规格见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 ## 测试规格 @@ -89,11 +82,11 @@ OpenFGA、Access API、工具能力和 shadow/enforce 策略的最终规格见 [ ## T2.2 -阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一个显式 `conversationId/sessionId/threadId` 中先完成一轮“看看 HWPOD 可用性?”这类 Code Agent 请求,再发送“总结我们刚才的对话内容”;第二轮 AgentRun command 必须只包含本轮原始 message/prompt、同一个标准 threadId 和必要运行元数据,不得包含 `conversationContext/messages` 或历史 prompt 拼接。assistant reply 必须通过 Codex stdio 原生 `thread/resume` 记住第一轮,不能回答“这是本会话第一条消息”或等价的新会话结论;若 `thread/resume` 失败,应以 `thread-resume-failed` 终止并标记 session failed/stale,继续前必须显式创建新 session。 +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:在同一个显式 `conversationId/sessionId/threadId` 中先完成一轮“检查 HWPOD node 状态”这类 Code Agent 请求,再发送“总结我们刚才的对话内容”;第二轮 AgentRun command 必须只包含本轮原始 message/prompt、同一个标准 threadId 和必要运行元数据,不得包含 `conversationContext/messages` 或历史 prompt 拼接。assistant reply 必须通过 Codex stdio 原生 `thread/resume` 记住第一轮,不能回答“这是本会话第一条消息”或等价的新会话结论;若 `thread/resume` 失败,应以 `thread-resume-failed` 终止并标记 session failed/stale,继续前必须显式创建新 session。 ## T3 -阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问迁移期 `/v1/device-pods` 必须返回认证错误;登录后访问 HWPOD list/status 时必须显示当前 authority contract 和 `fake=false` 来源,不得出现 fake fallback。 +阅读 docs/reference/spec-v02-hwlab-cloud-api.md,然后用 cli 手动测试以下内容:未登录访问 `POST /v1/hwpod-node-ops` 必须返回认证错误;登录后提交 `node.health` 只读 plan 时必须显示 `contractVersion=hwpod-node-ops-v1`、目标 node/action 摘要和真实 blocker/result,不得出现 fake fallback。 ## T4 @@ -116,9 +109,9 @@ OpenFGA、Access API、工具能力和 shadow/enforce 策略的最终规格见 [ | 显式 Code Agent session 生命周期 | 目标状态 | `/v1/agent/sessions*` 管理 session create/select/status;`/v1/agent/chat` 无 session 时返回 `session_required`,失败 session 不自动滚动。 | | Postgres durable runtime | 已实现 | 通过 v02 独立 DB SecretRef 和 migration ledger 判定。 | | gateway outbound poll/result | 已实现 | 支持 gateway 主动轮询和 `hardware.invoke.shell` 分发。 | -| HWPOD/profile/job authority | 迁移期实现/持续收敛 | profile/relation/list/status/job 持久化在 cloud-api;用户态 probe GET 已收敛为只读 job;内部 gateway dispatch route 仍是残留 path,后续应收敛到 HWPOD node-ops。 | -| v0.2 登录鉴权与 admin/user 权限模型 | 部分实现/持续约束 | 本地 bootstrap/Web session、用户 API key、`AuthPrincipal`、admin user/HWPOD relation API 和 Code Agent owner binding 已接入;Keycloak OIDC 浏览器 callback 仍按 spec-v02-auth 收口。 | -| OpenFGA 细粒度授权与 Admin Access API | 已实现/持续约束 | OpenFGA client/bootstrap、`enforce`、`/v1/admin/access*`、tool capability check 和 runner env 过滤已成为当前权限 authority;后续扩展不得新增第二条授权写路径。 | +| HWPOD node-ops 转发 | 已实现 | `POST /v1/hwpod-node-ops` 接收 `hwpod-compiler-cli` 生成的 plan,经 cloud-api 转发到 `hwpod-node`;无在线 node 时返回结构化 blocker。 | | Provider API Key 管理委托 | 目标状态 | 需要实现 `/v1/admin/provider-profiles*`,由 HWLAB 鉴权后委托 AgentRun 后端管理 profile Secret/配置和 canary。 | +| v0.2 登录鉴权与 admin/user 权限模型 | 部分实现 | 当前 `/auth/*` 是本地账号密码 bootstrap fallback 和 Web session;Keycloak OIDC、24 小时 Web session、CLI API key 和 `AuthPrincipal` 归一仍需按 spec-v02-auth 收敛。admin user/tool capability API 和 Code Agent owner binding 已接入;生产 bootstrap 依赖 SecretRef。 | +| OpenFGA 细粒度授权与 Admin Access API | 目标状态 | 需要实现 OpenFGA client/bootstrap、`enforce`、`/v1/admin/access*`、tool capability check 和 runner env 过滤。 | | 账号共享 workspace authority | 已实现 | `account_workspaces` 持久化同账号 Web/CLI 共享 workspace,支持 revision 冲突可见性、账号隔离、最近 active trace 记录和 reset;同一用户不同 session/run 允许并发,互斥只在 session/thread/run 层处理。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-web.md b/docs/reference/spec-v02-hwlab-cloud-web.md index de1f06e0..735bdba7 100644 --- a/docs/reference/spec-v02-hwlab-cloud-web.md +++ b/docs/reference/spec-v02-hwlab-cloud-web.md @@ -1,19 +1,20 @@ # v0.2 hwlab-cloud-web 服务规格 `hwlab-cloud-web` 是 `v0.2` 浏览器工作台,运行在 `hwlab-v02` namespace,内部端口 `8080`,公网经 FRP 暴露为 `http://74.48.78.17:19666/`。 + Provider API Key 配置入口也归属 Cloud Web:左侧顶级导航必须提供“管理”页面,具体路由、状态展示、API Key 写入表单、验证结果展示和脱敏规则见 [spec-v02-provider-management.md](spec-v02-provider-management.md)。Cloud Web 不直接调用 AgentRun,不保存完整 API Key,也不把 AgentRun token 暴露给浏览器。 ## 在系统中的职责划分 -- 向用户提供 Cloud Workbench、Code Agent 对话、live status、HWPOD 右侧面板、trace 展示和帮助内容。 -- 只消费 `hwlab-cloud-api`,不直接访问 Postgres、gateway、残留执行 Service、FRP、Kubernetes 或 provider Secret。 +- 向用户提供 Cloud Workbench、Code Agent 对话、live status、HWPOD node-ops 右侧面板、trace 展示和帮助内容。 +- 只消费 `hwlab-cloud-api`,不直接访问 Postgres、gateway、host/node 资源、FRP、Kubernetes 或 provider Secret。 - 为浏览器提供同源代理,避免前端直接跨域调用内部 ClusterIP。 - Web 登录按 [spec-v02-auth.md](spec-v02-auth.md) 走 Keycloak OIDC;未登录用户进入 Keycloak 登录/注册,callback 后由 cloud-api 发行 24 小时 `hwlab_session`。 - Cloud Web 提供 API key 管理入口,让用户查看默认 API key、创建新 key、revoke 或 regenerate;浏览器日常请求仍使用 Web session,不要求用户手动输入 API key。 -- Cloud Web 提供 admin-only Access 页面,让管理员按用户管理 role/status、HWPOD relation、Code Agent session 可见性和工具 capability;页面只调用 cloud-api `/v1/admin/access*` 同源 API,不直接访问 OpenFGA、Postgres、Kubernetes 或 Keycloak admin API。 +- Cloud Web 提供 admin-only Access 页面,让管理员按用户管理 role/status、Code Agent session 可见性和工具 capability;页面只调用 cloud-api `/v1/admin/access*` 同源 API,不直接访问 OpenFGA、Postgres、Kubernetes 或 Keycloak admin API。 - Cloud Web 提供 admin-only Provider 管理页面,让管理员通过 cloud-api `/v1/admin/provider-profiles*` 同源 API 配置 AgentRun provider API Key;页面不得直接访问 AgentRun、Kubernetes Secret、Moon Bridge 或 provider upstream。 -- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result、HWPOD list/status 和 HWPOD job 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。 -- Cloud Web 只承担浏览器 UI 和 `hwlab-cli client` 的同源代理。AgentRun runner 内的 `hwpod` 不走 Cloud Web;runner 使用映射到发起用户的 `HWLAB_API_KEY` 直连 Cloud API,Cloud Web 不保留 HWPOD lease 路由。 +- Cloud Web 与 `hwlab-cli client` 必须共享同一组非视觉业务 API。浏览器遇到的 Code Agent continuation、trace/result 和 HWPOD node-ops 问题,必须能通过 `hwlab-cli client` 走同一 `19666` Cloud Web path 复现;不能让 CLI 长期绕到 `19667` Cloud API 后把 Web 路径缺口误判为业务已通过。 +- Cloud Web 只承担浏览器 UI 和 `hwlab-cli client` 的同源代理。AgentRun runner 内的 `hwpod` 不走 Cloud Web;runner 使用映射到发起用户的 `HWLAB_API_KEY` 直连 Cloud API,Cloud Web 不保留 HWPOD 运行路由。 - 浏览器启动后必须从 `GET /v1/workbench/workspace` hydrate 账号 workspace;同一个账号在多个浏览器标签页、多个浏览器或 CLI profile 中应看到同一个 `workspaceId`、selected conversation/session/thread、provider profile 和 active trace。浏览器 localStorage 只能作为短期缓存,并必须绑定 actor,不能作为 workspace authority。 - Code Agent session 管理必须全部手动化。Workbench 可以从账号 workspace 恢复“已显式选中”的 session,但不能在普通发送、页面刷新、trace replay、失败恢复或 provider resume 失败时自动创建、滚动或替换 session。没有已选 session 时,composer 必须展示“新建 session/选择 session”的显式动作;session failed/stale/canceled 后必须保留失败证据,继续前由用户显式新建或选择另一个 session。 - 显式 session 的 `providerProfile` 优先于账号 workspace provider profile。Workbench 可以展示 workspace 默认 provider,但对已选 session 发送 turn 时必须使用该 session 的 provider profile;用户想切换 provider 时,应显式创建或选择对应 provider 的 session,不能把旧 workspace provider 静默套到当前 session 上。 @@ -55,7 +56,7 @@ Provider API Key 配置入口也归属 Cloud Web:左侧顶级导航必须提 历史与收敛(蒸馏自 #775 / #777 / #791 / #795 / #797 / #798 / #802 的过程): -- 迁移前 `app-device-pod.ts:fetchJson` 走 inactivity-timeout(`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`)。React 迁移后 #777 把 `client.ts` 加了 `ActivityRef` 抽象,但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来,于是 Web UI 退化成 total-timeout,#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。 +- React 收敛前的旧单页 `fetchJson` 走 inactivity-timeout(`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`)。React 收敛后 #777 把 `client.ts` 加了 `ActivityRef` 抽象,但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来,于是 Web UI 退化成 total-timeout,#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。 - #795 PR #797 修了一版但留了 `max(4x, +60s)` 兜底 cap;PR #798 删 hard-cap 改成 `for(;;)`,但前端 fail 状态机没改(fail 消息只入本地 state,cloud-api 不知情)+ hydrate path 没有 re-attach 主动订阅。 - #802 收口:用 `subscribeToTrace` 一个入口统一 submit + hydrate;`onActivity` 在每次成功 poll 上调让 inactivity 永远不 fire;fail 消息也 `persistConversation`。 @@ -76,11 +77,11 @@ Provider API Key 配置入口也归属 Cloud Web:左侧顶级导航必须提 历史与收敛(蒸馏自 #775 / #777 / #791 / #795 的过程): -- 迁移前 `app-device-pod.ts:fetchJson` 走的是 inactivity-timeout(`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`),`waitForAgentMessageResult` 也按 `idleMs >= CODE_AGENT_TIMEOUT_MS` 走。React 迁移后 #777 把 `client.ts` 加了 `ActivityRef` 抽象,但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来,于是 Web UI 退化成 total-timeout,#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。 +- React 收敛前的旧单页 `fetchJson` 走的是 inactivity-timeout(`scheduleTimeout` 每秒重算 `remainingMs = timeoutMs - (now - lastActivityAt)`),`waitForAgentMessageResult` 也按 `idleMs >= CODE_AGENT_TIMEOUT_MS` 走。React 收敛后 #777 把 `client.ts` 加了 `ActivityRef` 抽象,但 `workbench.ts` / `runner-trace.ts` 没把 ref 串起来,于是 Web UI 退化成 total-timeout,#795 出现"bootshar 9.7s 跑完但 Web 37s 处 timeout"的回归。 - #795 PR #797 修了一版,但留了 `max(totalTimeoutMs * 4, totalTimeoutMs + 60s)` 兜底 cap。用户反馈"硬上限等于又引入 #795 同一类回归",第二轮把 `for(;;)` + 删 `attempt > TRACE_HARD_CAP_ATTEMPTS` + 删 `startedAt` 累计计时 + per-poll 传 `totalTimeoutMs`(不缩小),实现"完全无 total / 轮询上限"。 - Round 10 起 commit / spec / 测试同步固化为本节。 -- `web/hwlab-cloud-web/app.ts` 是浏览器端主入口,和迁移期 `app-device-pod.ts`、`app-conversation.ts`、`app-trace.ts`、`app-helpers.ts` 共同组成实际 bundle 输入集合,组织 Workbench 状态、Code Agent 会话缓存、trace 轮询和 HWPOD 面板。 +- `web/hwlab-cloud-web/src/App.tsx` 是浏览器端主入口,和 `src/components/**`、`src/state/**`、`src/services/**`、`src/types/**` 共同组成实际 bundle 输入集合,组织 Workbench 状态、Code Agent 会话缓存、trace 轮询和 HWPOD node-ops 面板。 - `internal/dev-entrypoint/http.mjs` 提供静态服务、health 和 HTTP proxy 基础能力。 - `internal/dev-entrypoint/cloud-web-routes.mjs` 定义可代理到 cloud-api 的同源 API route 和认证边界。 - `web/hwlab-cloud-web/auth.ts` 管理工作台登录态、Keycloak redirect/callback 状态和 API key 管理 UI 调用;真正的登录鉴权和用户权限 authority 仍应收敛到 cloud-api。 @@ -95,12 +96,12 @@ Provider API Key 配置入口也归属 Cloud Web:左侧顶级导航必须提 | `GET /help` | 返回可用 route 摘要。 | | `GET /auth/oidc/login`、`GET /auth/oidc/callback`、`GET /auth/session`、`POST /auth/logout` | 同源代理到 cloud-api 的 Keycloak/Web session 入口;登录鉴权最终规格见 [spec-v02-auth.md](spec-v02-auth.md)。 | | `GET/POST /v1/api-keys...` | 同源代理到 cloud-api 的 API key 管理入口;短期测试允许当前用户重复查看默认 key 明文。 | -| `GET/PATCH/PUT/DELETE /v1/admin/access...` | 同源代理到 cloud-api 的 Admin Access API;用于 Access 页面读取 summary/user matrix、授予/撤销 HWPOD relation、tool capability 和 role/status。 | +| `GET/PATCH/PUT/DELETE /v1/admin/access...` | 同源代理到 cloud-api 的 Admin Access API;用于 Access 页面读取 summary/user matrix、授予/撤销 tool capability 和 role/status。 | | `GET /v1`、`GET /v1/...` | 同源代理到 `hwlab-cloud-api`;公开的 Code Agent result/trace 轮询按 route policy 处理。 | | `GET/PATCH /v1/workbench/workspace...` | 同源代理到 cloud-api 的账号 workspace authority,用于 Web/CLI 共享工作区和 revision 冲突保护。 | | `POST/GET/PATCH /v1/agent/sessions...` | 同源代理到 cloud-api 的显式 Code Agent session 生命周期入口;Web 不在普通 send 中隐式创建 session。 | | `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口;steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 | -| `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的迁移期 HWPOD job/操作 path;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 | +| `POST /v1/hwpod-node-ops` | 受控同源代理到 cloud-api 的 HWPOD node-ops 入口;用于 Web/CLI 同路径只读 smoke,Code Agent 正式业务入口仍是 runner 内 `hwpod` 直达 Cloud API。 | | `POST /v1/web-performance` | 浏览器 RUM 上报入口;只允许低基数性能事件和数值,Cloud API 聚合后进入 Prometheus,详见 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md)。 | | `GET /v1/web-performance/summary` | 性能监控顶级页读取的同源摘要接口;返回低基数 WebUI 体感性能 JSON,包含样本数、route p95、Web Vitals、long task 和错误/超时问题队列,不返回 Prometheus 原始文本或高基数 trace/session/conversation/thread/user 标识。 | | `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 | @@ -115,7 +116,7 @@ Browser/layout/live smoke 属于显式专项诊断,不进入默认 Cloud Web c WebUI 性能监控 issue 的 live closeout 不能只检查页面可见或 sidecar 存活;必须用 `19666` Web 入口触发真实浏览器 RUM 上报,再按 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md) 查询 `hwlab_webui_*` Prometheus 指标。LCP、Navigation Timing、业务 API timing、Long Task、CLS/INP/FID 近似只表达用户感知性能趋势,不替代 trace/result/inspect 的高基数排障证据。 -Cloud Web 顶级性能页是观测面,不是业务工作台。访问 `/performance` / `#/performance` 时不得初始化 Workbench store 的 workspace hydrate 或 live refresh,也不得触发 HWPOD events、agent conversations、live-builds、system.health 等 workspace-only 请求。性能页只允许主动请求 `GET /v1/web-performance/summary` 和静态资源;如果需要调查 workspace 或 HWPOD 性能,必须从 workspace 原入口触发样本,而不是让性能页自己制造业务流量。 +Cloud Web 顶级性能页是观测面,不是业务工作台。访问 `/performance` / `#/performance` 时不得初始化 Workbench store 的 workspace hydrate 或 live refresh,也不得触发 HWPOD node-ops、agent conversations、live-builds、system.health 等 workspace-only 请求。性能页只允许主动请求 `GET /v1/web-performance/summary` 和静态资源;如果需要调查 workspace 或 HWPOD 性能,必须从 workspace 原入口触发样本,而不是让性能页自己制造业务流量。 Live smoke 登录前必须等待前端 auth bootstrap 结束(`body[data-auth-state]` 不再是 `checking`,且 login submit 已可见/可用)再填表;登录后必须断言 URL query 不含 `username` 或 `password`,防止原生 form submit 泄漏凭据并伪装成 layout 超时。 @@ -153,11 +154,11 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, ## T3 -阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench HWPOD 面板,确认 status/freshness/blocker 显示来自迁移期 `/v1/device-pods` path,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。 +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:打开 Workbench HWPOD node-ops 面板,确认 status/freshness/blocker 显示来自 `/v1/hwpod-node-ops`,未登录或未授权时必须显示认证/授权 blocker,不得把 fixture 或 blocked fallback 写成真实硬件 DEV-LIVE。 ## T3.1 -阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:通过 `19666` Cloud Web 同源 path 对当前允许的 HWPOD job/操作 POST 做只读或 dry-run 级验证,确认与 `19667` Cloud API 的 route policy 对齐;如果 Cloud API 返回业务级 4xx,Cloud Web 也应透传业务错误,不应在 Web 层直接 404。 +阅读 docs/reference/spec-v02-hwlab-cloud-web.md,然后用 cli 手动测试以下内容:通过 `19666` Cloud Web 同源 path 对 `/v1/hwpod-node-ops` 提交只读 `node.health` plan,确认与 `19667` Cloud API 的 route policy 对齐;如果 Cloud API 返回业务级 4xx,Cloud Web 也应透传业务错误,不应在 Web 层直接 404。 ## T4 @@ -169,7 +170,7 @@ Cloud Web check 通过后仍需执行 bundle build 和 dist freshness 校验, ## T6 -阅读 docs/reference/spec-v02-hwlab-cloud-web.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:登录 admin 后打开 Cloud Web Access 页面,确认 ActivityRail 显示 Access 入口,页面加载 `/v1/admin/access/summary` 和用户权限矩阵;普通用户访问同一路由必须显示 authorization blocker。通过页面授予/撤销一次 HWPOD relation 后,`hwlab-cli client access users inspect ` 必须看到同一 effective matrix。 +阅读 docs/reference/spec-v02-hwlab-cloud-web.md 和 docs/reference/spec-v02-openfga-authorization.md,然后用 cli 手动测试以下内容:登录 admin 后打开 Cloud Web Access 页面,确认 ActivityRail 显示 Access 入口,页面加载 `/v1/admin/access/summary` 和用户权限矩阵;普通用户访问同一路由必须显示 authorization blocker。通过页面授予/撤销一次 `tool:hwpod` capability 后,`hwlab-cli client access users inspect ` 必须看到同一 effective matrix。 ## Session state 持久化与 eviction reset @@ -204,11 +205,12 @@ runner pod 或 runner Job 丢失但 PVC 仍存在时,下一轮必须继续使 | 规格项 | 状态 | 说明 | | --- | --- | --- | | Workbench 首屏 | 已实现 | 当前页面直接进入工作台,不是 landing page。 | -| cloud-api 同源代理 | 已实现 | 受 route policy 控制;HWPOD job POST 必须与 Cloud API route policy 对齐。 | +| cloud-api 同源代理 | 已实现 | 受 route policy 控制;HWPOD node-ops POST 必须与 Cloud API route policy 对齐。 | | Code Agent UI/trace/result | 已实现 | 支持 provider profile、timeout、trace 轮询和取消。 | | Code Agent 无锁 composer | 已实现 | Web/CLI 共享 composer policy;运行中输入框保持可编辑并自动走 steer。 | | 账号 workspace hydrate/sync | 已实现 | 启动读取 `account_workspaces`,Code Agent 请求携带 workspace revision,终态再同步 workspace。 | -| HWPOD 面板 | 未完全实现 | 当前仍有迁移期 path/payload 残留;目标只展示 HWPOD 当前概念和真实授权 payload。 | +| HWPOD node-ops 面板 | 已实现 | 当前消费 `/v1/hwpod-node-ops` 的只读健康 plan,展示 node/action/result/blocker 摘要。 | | Admin Access 授权页面 | 目标状态 | 新增 admin-only ActivityRail 页面,管理 OpenFGA relation/tool capability/role status,并与 CLI `client access` 共用同一路径。 | +| Provider API Key 管理页面 | 目标状态 | 新增 admin-only 管理页面,走 `/v1/admin/provider-profiles*` 同源 API 并委托 AgentRun。 | | 完整多用户 admin/user UI | 未完全实现 | 登录态存在;Keycloak/Web session/API key 需按 spec-v02-auth 收敛,权限 authority 仍需按 spec-user-access 收敛到 cloud-api。 | diff --git a/docs/reference/spec-v02-hwlab-gateway.md b/docs/reference/spec-v02-hwlab-gateway.md index 39a5bd59..79b61738 100644 --- a/docs/reference/spec-v02-hwlab-gateway.md +++ b/docs/reference/spec-v02-hwlab-gateway.md @@ -44,7 +44,7 @@ ## T4 -阅读 docs/reference/spec-v02-hwlab-gateway.md,然后通过 v02 cloud-api/device-pod 触发一次 gateway shell dispatch,确认 JSON-RPC response meta、dispatch audit 和 evidence 的 `environment` 都是 `v02`。 +阅读 docs/reference/spec-v02-hwlab-gateway.md,然后通过 v02 cloud-api 的 gateway 诊断入口触发一次 bounded shell dispatch,确认 JSON-RPC response meta、dispatch audit 和 evidence 的 `environment` 都是 `v02`。 ## 规格的实现情况 diff --git a/docs/reference/spec-v02-observability-monitoring.md b/docs/reference/spec-v02-observability-monitoring.md index c69b26b4..c1f2cda1 100644 --- a/docs/reference/spec-v02-observability-monitoring.md +++ b/docs/reference/spec-v02-observability-monitoring.md @@ -34,7 +34,7 @@ Cloud Web 用户感知性能必须进入同一套 Prometheus 查询面。浏览 Cloud Web 顶级性能页通过同源 `GET /v1/web-performance/summary` 读取低基数 JSON 摘要,用于展示用户可感知的 WebUI 样本数、慢 API route p95、Web Vitals、long task 和错误/超时问题队列。该接口不得返回 Prometheus 原始文本、Secret、prompt/assistant 正文或 trace/session/conversation/thread/user 等高基数标识;CLI 同路径验收使用 `hwlab-cli client request GET /v1/web-performance/summary`,不直连 Prometheus 公网地址。 -观测面不得污染被观测对象。Cloud Web 性能页自身(`/performance` / `#/performance`)不得启动 workspace hydrate、live refresh、HWPOD events、agent conversations 等业务请求,也不得把性能页自身的 LCP、Navigation Timing、Long Task 或 `/v1/web-performance*` 请求写入 WebUI 业务性能样本。Cloud API 在 `/v1/web-performance` 入库时必须丢弃来自性能页或观测 API 的样本,作为旧 bundle、缓存客户端或探针误上报的兜底。 +观测面不得污染被观测对象。Cloud Web 性能页自身(`/performance` / `#/performance`)不得启动 workspace hydrate、live refresh、HWPOD node-ops、agent conversations 等业务请求,也不得把性能页自身的 LCP、Navigation Timing、Long Task 或 `/v1/web-performance*` 请求写入 WebUI 业务性能样本。Cloud API 在 `/v1/web-performance` 入库时必须丢弃来自性能页或观测 API 的样本,作为旧 bundle、缓存客户端或探针误上报的兜底。 ## API 接口说明 @@ -47,7 +47,6 @@ Cloud Web 顶级性能页通过同源 `GET /v1/web-performance/summary` 读取 | `hwlab-cloud-api` | HTTP request count/latency、DB readiness/query latency、Code Agent submit/result/trace latency、AgentRun dispatch status、provider profile terminal status | 核心业务 authority,优先接入。 | | `hwlab-cloud-web` | HTTP request count/latency、static asset/proxy latency、upstream timeout/error count | 只记录同源代理与静态服务指标。 | | `hwlab-edge-proxy` | HTTP proxy request count/latency、upstream status、timeout/error count | 证明公网 edge 到 cloud-api 的性能。 | -| HWPOD residual executor serviceId | executor request count/latency、job terminal status、gateway dispatch latency | serviceId 可暂时保留旧实现命名;不记录 device output text,不作为当前产品概念。 | | `hwlab-agent-skills` | health/list/upload/tree/file request count/latency、error count | 技能包服务指标。 | | `hwlab-deepseek-proxy` | bridge request count/latency、upstream status、model/readiness probe result | 不记录 prompt、response 或 upstream token。 | @@ -109,7 +108,7 @@ HWLAB v0.2 可声明 `PrometheusRule`,但规则只表达当前 v0.2 目标行 - Code Agent submit/result terminal latency。 - AgentRun dispatch failure rate by provider profile。 - edge-proxy upstream 5xx/timeout rate。 -- HWPOD job failure rate;如果 metric label 仍出现残留 executor serviceId,只作为实现残留维度。 +- HWPOD node-ops failure/blocker rate。 规则命名和 label 必须能定位 lane、namespace、service 和 operation;不要把单个 trace/run/job 写入规则。 @@ -164,7 +163,7 @@ HWLAB v0.2 可声明 `PrometheusRule`,但规则只表达当前 v0.2 目标行 | --- | --- | --- | | 应用侧监控接入边界 | 已实现 | HWLAB 只负责 metrics sidecar、ServiceMonitor/PrometheusRule 和受控查询;共享 Prometheus control-plane 不在 `hwlab-v02`。 | | G14 共享监控控制面 | 已实现 | 由 UniDesk/G14 基础设施规格定义,默认位于 `devops-infra`。 | -| v0.2 服务 `/metrics` | 已实现/持续收敛 | 第一阶段接入 `hwlab-cloud-api`、`hwlab-cloud-web`、`hwlab-edge-proxy`、HWPOD residual executor serviceId、`hwlab-agent-skills`、`hwlab-deepseek-proxy`;残留 serviceId 不作为当前产品概念。 | +| v0.2 服务 `/metrics` | 已实现 | 第一阶段接入 `hwlab-cloud-api`、`hwlab-cloud-web`、`hwlab-edge-proxy`、`hwlab-agent-skills`、`hwlab-deepseek-proxy`。 | | ServiceMonitor / PrometheusRule | 已实现 | 已进入 v0.2 GitOps desired state;规则用于观测,不作为发布旧门禁。 | | 受控查询与验收 | 已实现 | 通过 UniDesk `hwlab g14 observability status|query` 和集群内边界检查验证 target discovered、health probe 可用和公网不暴露。 | | Cloud Web 用户感知性能 | 已实现 | 浏览器 RUM 经 `/v1/web-performance` 聚合为 `hwlab_webui_*` 指标,由 `hwlab-cloud-api` sidecar 附加导出,关闭 issue 前必须用 `19666` Web 入口触发并在 Prometheus 中看到样本。 | diff --git a/docs/reference/spec-v02-openfga-authorization.md b/docs/reference/spec-v02-openfga-authorization.md index 987b02ea..d4c84529 100644 --- a/docs/reference/spec-v02-openfga-authorization.md +++ b/docs/reference/spec-v02-openfga-authorization.md @@ -1,10 +1,8 @@ # v0.2 OpenFGA 授权与 Admin Access 管理规格 -本文是 HWLAB `v0.2` 细粒度资源授权、OpenFGA 接入、管理员 Access WebUI 和同路径 CLI 的长期规格。Keycloak 只回答“用户是谁”;OpenFGA 回答“该用户能不能对该对象做该动作”;`hwlab-cloud-api` 仍是唯一应用层 enforcement point 和授权写入口。 +本文是 HWLAB `v0.2` 细粒度资源授权、OpenFGA 接入、管理员 Access WebUI 和同路径 CLI 的长期规格。Keycloak 只回答“用户是谁”;OpenFGA 回答“该用户能不能对该对象做该动作”;`hwlab-cloud-api` 是唯一应用层 enforcement point 和授权写入口。 -本规格补充 [spec-v02-auth.md](spec-v02-auth.md) 和 [spec-user-access.md](spec-user-access.md):前者定义 Keycloak/Web session/API key 到 `AuthPrincipal` 的认证归一;后者定义用户、session owner、HWPOD 和工具能力的业务权限口径;本文定义 OpenFGA 如何在 `hwlab-v02` namespace 内以 Kubernetes 原生方式落地,并如何被 Cloud Web 与 `hwlab-cli client` 管理和验证。 - -实施跟踪 issue 必须记录 spec、GitOps、cloud-api、Cloud Web、CLI、测试、PR/CI/CD 和原入口验收进展。过程记录写 issue 评论,本文只保留稳定目标和验收口径。 +当前 HWPOD 快速闭环阶段只管理用户、Code Agent session 和工具能力,不为旧设备对象保留 OpenFGA type、relation、API 或 CLI。 ## 在系统中的职责划分 @@ -16,31 +14,7 @@ | HWLAB v0.2 Postgres | 业务对象、用户、session、API key、access_tuples 摘要、OpenFGA store/model 指针和迁移 ledger 的 durable source。 | | `hwlab-cloud-web` | Admin Access 页面和同源 API proxy;不直接调用 OpenFGA。 | | `hwlab-cli client` | Web 等价非视觉授权管理入口;通过 `19666` Cloud Web 同源 path 调 cloud-api,不直连 OpenFGA。 | -| AgentRun v0.1 runner | 只消费 cloud-api 根据用户权限注入的短期工具环境;工具凭据必须按 tool capability 独立授权。 | - -OpenFGA 是 HWLAB 应用层授权基础设施,不是 Kubernetes RBAC、ServiceAccount、NetworkPolicy 或 Keycloak realm role 的替代。普通用户不获得 kubeconfig、内部 Service 直连、OpenFGA token 或 Keycloak admin 权限。 - -## 当前实现状态 - -- 当前 v0.2 desired state 和 runtime 都包含 `hwlab-openfga` ClusterIP 服务、Postgres backend、migration/bootstrap 过程和 `hwlab-v02-openfga` SecretRef。`hwlab-cloud-api` 在 `/health/live` 和 Admin Access summary 中只公开 mode、readiness、store/model 摘要和 degraded reason。 -- `enforce` 是当前正式授权口径。Admin Access 写入 OpenFGA 失败时必须 fail closed;本地 role/status、effective matrix 或 access 摘要只能作为 domain state 和可见性材料,不能在 OpenFGA 不可用时单独放行。 -- 当前授权写入口只有 cloud-api Admin Access API;Cloud Web Access 和 `hwlab-cli client access ...` 都调用同一路 API。浏览器、CLI、AgentRun runner 和普通用户都不直连 OpenFGA,也不持有 OpenFGA token。 -- `tool:trans_cmd#can_use` 的当前含义是允许在 agent/tool registry 中暴露受控 UniDesk passthrough 命令能力;它不改变 UniDesk route/operation 边界,不授予 Kubernetes Secret 读取、裸控制面写入、残留执行链路内部 token 或主 server SSH key。 -- hwpod/profile 修改当前仍由迁移期 `POST/PUT /v1/admin/device-pods` path 和 `profile_editor`/admin 权限控制;OpenFGA 管 relation,cloud-api 管 profile authority,执行节点只执行内部受控 job。该 API path 名称是实现残留,不是当前概念命名。 - -## Kubernetes 和 GitOps 落点 - -OpenFGA 固定部署在 `hwlab-v02` namespace,作为稳定外部服务进入 v0.2 GitOps desired state: - -- `deploy/gitops/g14/runtime-v02/openfga.yaml` 声明 `Deployment`、`Service`、migration `Job` 或 init/migrate step、SecretRef 和 health/readiness。 -- `deploy/gitops/g14/runtime-v02/kustomization.yaml` 必须引用 `openfga.yaml`;render 脚本如果有 service inventory 或 artifact catalog,也必须加入 OpenFGA 的外部服务记录。 -- OpenFGA Service 只允许 ClusterIP,默认 DNS 为 `hwlab-openfga.hwlab-v02.svc.cluster.local`;不得配置 FRP、公网 Ingress 或 Cloud Web 直连。 -- 数据存储使用 Postgres backend;禁止 production/stable runtime 使用 memory backend。可复用 `hwlab-v02-postgres` 实例,但必须使用独立 database/schema/role 和独立 SecretRef,避免和 cloud-api migration ledger 混写。 -- 首次部署和版本升级必须执行 OpenFGA migrate;migration 失败时 cloud-api OpenFGA readiness 必须 degraded,不能进入 enforce。 -- OpenFGA 鉴权使用 preshared key 或等价内部服务 token SecretRef;Secret 值不得出现在 ConfigMap、日志、issue、trace 或 CLI 默认输出。 -- Readiness 使用 OpenFGA health endpoint;cloud-api `/health/live` 汇总 OpenFGA endpoint、store/model status、mode 和 degraded reason,但不输出 token。 - -OpenFGA 官方 Helm chart 可以作为 YAML 来源,但 `hwlab-v02` desired state 仍以 repo 内 GitOps render 后的 YAML 为准。任何 OpenFGA 镜像、SecretRef、Postgres URL 或 migration 变更都属于运行面/权限高风险变更,必须走 PR、CI/CD 和原入口验收。 +| AgentRun v0.1 runner | 只消费 cloud-api 根据用户权限注入的短期工具环境;不持有跨用户共享 key、GitHub token 或 UniDesk SSH token。 | ## 授权模型 @@ -50,7 +24,6 @@ OpenFGA 官方 Helm chart 可以作为 YAML 来源,但 `hwlab-v02` desired sta | --- | --- | --- | | 用户 | `user:usr_123` | HWLAB `users.id`,不是 Keycloak `sub`。 | | 系统 | `system:hwlab` | 平台级 admin、access 管理和全局工具授权。 | -| HWPOD | `device_pod:device-pod-71-freq` | 服务端 HWPOD/profile authority 对象;`device_pod` 是迁移期 OpenFGA object type 名称,不是当前产品概念。 | | Code Agent session | `agent_session:ags_123` | 会话 owner/collaborator/viewer 判定。 | | 工具 | `tool:hwpod`、`tool:unidesk_ssh`、`tool:github_pr`、`tool:trans_cmd` | AgentRun runner 可注入或可调用的功能能力。 | @@ -67,18 +40,8 @@ type system define admin: [user] define access_manager: admin define can_manage_users: admin - define can_manage_device_pods: admin define can_manage_tools: admin -type device_pod - relations - define owner: [user] - define viewer: [user] or owner or admin from system - define operator: [user] or owner or admin from system - define profile_editor: [user] or owner or admin from system - define job_submitter: [user] or operator - define admin: admin from system - type agent_session relations define owner: [user] @@ -97,11 +60,9 @@ type tool | 功能 | Check | | --- | --- | | 管理用户和权限 | `user: access_manager system:hwlab` 或 `admin system:hwlab` | -| 查看 HWPOD | `viewer device_pod:` | -| 提交 HWPOD job / 使用 `hwpod` | `operator` 或 `job_submitter device_pod:`,并要求 `can_use tool:hwpod` | -| 修改 HWPOD profile | `profile_editor device_pod:` 或 `admin system:hwlab` | | 查看自己的 Code Agent session | `viewer agent_session:` | | 继续/取消 Code Agent session | `operator agent_session:` | +| 使用 HWPOD 工具 | `can_use tool:hwpod` | | 注入 UniDesk SSH 透传能力 | `can_use tool:unidesk_ssh` | | 允许 trans 透传 cmd | `can_use tool:trans_cmd`,且仍受 UniDesk route/operation 边界限制 | | GitHub PR/issue 写操作工具 | `can_use tool:github_pr` 或更细工具对象 | @@ -113,29 +74,15 @@ type tool `hwlab-cloud-api` 是唯一 policy enforcement point。所有用户态请求按以下顺序处理: ```text -authenticate -> AuthPrincipal -> load domain object -> openfga check -> reason check for mutating device jobs -> execute +authenticate -> AuthPrincipal -> load domain object -> openfga check -> execute ``` -配置项: - -| 配置 | 说明 | -| --- | --- | -| `HWLAB_OPENFGA_MODE=enforce` | v0.2 正式运行面固定以 OpenFGA 为准;OpenFGA 不可达或写入失败时 fail closed 并返回结构化 blocker。 | -| `HWLAB_OPENFGA_API_URL` | 集群内 OpenFGA Service URL。 | -| `HWLAB_OPENFGA_AUTHN_TOKEN` 或 SecretRef | cloud-api 调 OpenFGA 的内部 token。 | -| `HWLAB_OPENFGA_STORE_ID` / `MODEL_ID` | 可由 env 注入,也可由 cloud-api bootstrap 后写入 Postgres runtime config。 | -| `HWLAB_OPENFGA_TIMEOUT_MS` | 单次 check/write 超时;超时必须结构化返回。 | - -模式语义: - -- `enforce` 是唯一正式运行模式:HWPOD、agent session 和工具能力以 OpenFGA check 为准。OpenFGA 不可达、store/model 未就绪或 check 超时时,高风险写操作 fail closed;低风险只读可以返回 degraded blocker,不得静默放行。 -- `off` / `shadow` 不作为 v0.2 runtime 目标路径;文档、测试或 render 如再次把它们作为业务 allow source,应优先删除而不是兼容。 +`enforce` 是唯一正式运行模式。OpenFGA 不可达、store/model 未就绪或 check 超时时,高风险写操作 fail closed;低风险只读可以返回 degraded blocker,不得静默放行。 tuple 写入必须由 cloud-api admin API 统一完成,并和 Postgres domain state 保持事务级或可恢复一致: - 用户创建、禁用、角色提升/降级时同步写 `users` 和 `system:hwlab` tuples。 -- HWPOD/profile 创建/更新/删除时同步迁移期 `device_pods` 表和相关 tuples;删除对象时清理 tuple 或标记不可用。 -- Code Agent session 创建时写 `agent_session:#owner@user:`;取消/归档不删除 owner tuple,便于审计和 trace 回放。 +- Code Agent session 创建时写 `agent_session:#owner@user:`;取消/归档不删除 owner tuple,便于 trace 回放。 - API key `scopes_json` 只能作为用户权限的收窄条件,不能授予超过 OpenFGA 的能力。 ## Admin Access API @@ -144,36 +91,19 @@ Cloud Web 和 CLI 只能通过 cloud-api 的 admin API 管理授权。第一版 | 接口 | 说明 | | --- | --- | -| `GET /v1/admin/access/summary` | 返回 mode、OpenFGA readiness、storeId/modelId、user/tool/HWPOD 数量和最近 mismatch 摘要。 | +| `GET /v1/admin/access/summary` | 返回 mode、OpenFGA readiness、storeId/modelId、user/tool 数量和最近 mismatch 摘要。 | | `GET /v1/admin/access/users` | 列出用户、role/status、Keycloak 绑定摘要、API key 数量和 effective capability 摘要。 | -| `GET /v1/admin/access/users/{userId}` | 返回单个用户的 HWPOD、agent session 和 tool 权限矩阵。 | +| `GET /v1/admin/access/users/{userId}` | 返回单个用户的 agent session 和 tool 权限矩阵。 | | `PATCH /v1/admin/access/users/{userId}` | 更新用户 `role/status`,并同步 OpenFGA admin tuple。 | -| `PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` | 授予 `viewer/operator/profile_editor/job_submitter` 等 relation。 | -| `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}` | 撤销指定 HWPOD relation;URL path 仍是迁移期实现名。 | | `PUT /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 授予工具能力,例如 `hwpod`、`unidesk_ssh`、`github_pr`、`trans_cmd`。 | | `DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | 撤销工具能力。 | | `POST /v1/admin/access/check` | 管理员调试单次 authorization check;响应必须标明 actor/object/relation/mode,但不得泄漏 token。 | 所有 write API 必须要求当前 actor 具备 `access_manager system:hwlab` 或 admin tuple;普通 `user` 不可调用。响应必须包含结构化 `authorization` 字段:`mode`、`allowed`、`decisionSource`、`storeId`、`modelId`、`relation`、`object` 和 redacted actor。 -Cloud Web 必须把 Admin Access 读写 API 作为同源代理路径转发给 cloud-api,包括 `POST /v1/admin/access/check`、`PATCH /v1/admin/access/users/{userId}`、HWPOD relation 的 `PUT/DELETE` 和 tool capability 的 `PUT/DELETE`。Cloud Web 不保存授权副本,也不直接访问 OpenFGA。 - ## Admin Access WebUI -Cloud Web 新增 ActivityRail 顶层入口 `Access`,只对具备 access manager/admin 权限的用户显示。它不是 Keycloak 管理后台,也不放在 Settings 子页里。 - -页面布局: - -- 左栏:用户列表、搜索、role/status 筛选、Keycloak 绑定摘要。 -- 中栏:选中用户的权限矩阵,按 `HWPOD`、`Agent Sessions`、`Tools`、`Platform` 分组;权限用 checkbox/toggle 表达,危险工具用显式确认。 -- 右栏:effective permission 预览、最近变更、OpenFGA mode/readiness、写入 blocker 和保存结果。 - -交互规则: - -- 初始加载先调用 `GET /v1/admin/access/summary` 和用户列表;OpenFGA degraded 时页面必须显示 blocker。 -- 修改权限时先在本地形成 pending diff;保存后逐项调用对应 PATCH/PUT/DELETE API,并刷新 effective matrix。 -- 页面不暴露 OpenFGA tuple 原文作为主要操作面;需要排障时只在详情中显示 redacted object/relation。 -- 普通用户或未登录用户访问 Access route 时显示授权 blocker 或跳登录,不渲染空管理表。 +Cloud Web 的 Access 页面只管理用户 role/status、Code Agent session 可见性和工具 capability。页面不暴露 OpenFGA tuple 原文作为主要操作面;需要排障时只在详情中显示 redacted object/relation。 ## 同路径 CLI @@ -185,74 +115,44 @@ Cloud Web 新增 ActivityRail 顶层入口 `Access`,只对具备 access manage | `client access users list` | `GET /v1/admin/access/users` | | `client access users inspect USER` | `GET /v1/admin/access/users/{userId}` | | `client access users set-role USER --role admin|user` | `PATCH /v1/admin/access/users/{userId}` | -| `client access device-pods grant USER POD --relation viewer|operator|profile_editor|job_submitter` | `PUT /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`;迁移期 path,业务对象是 HWPOD | -| `client access device-pods revoke USER POD --relation ...` | `DELETE /v1/admin/access/users/{userId}/device-pods/{devicePodId}/{relation}`;迁移期 path,业务对象是 HWPOD | | `client access tools grant USER TOOL` | `PUT /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | | `client access tools revoke USER TOOL` | `DELETE /v1/admin/access/users/{userId}/tools/{toolId}/can-use` | | `client access check --user USER --relation REL --object OBJECT` | `POST /v1/admin/access/check` | CLI 输出必须是 JSON,包含 `runtimeEndpoint`、HTTP route、actor 摘要、mode、decision 和变更后的 effective matrix 摘要。它不能直接调用 OpenFGA API,不能手动传 OpenFGA token,不能把 `--base-url 19667` 作为 WebUI 等价验收路径。 -权限变更的真实入口验收必须以 Cloud Web 同源 origin 为准。最小闭环是:`client access summary` 确认 `openfga.mode=enforce` 且 ready;对一个普通用户执行某个 HWPOD relation 的 `check false -> grant -> check true -> revoke -> check false`;对至少一个工具能力执行同样闭环,`trans_cmd` 必须覆盖;最后 `users inspect` 确认测试 tuple 已撤回。验收报告必须记录 `baseUrl`、method/path、actor、relation/object、HTTP status、OpenFGA decision 和最终 effective matrix 摘要。 - ## AgentRun 工具能力边界 Cloud API 在创建 AgentRun command/runner 时必须按 OpenFGA 决策装配 transient env 和工具说明: - 用户没有 `can_use tool:hwpod` 时,不注入 `HWLAB_API_KEY` 给 `hwpod`,也不在 prompt/tools 中声明 HWPOD 操作可用。 -- 用户没有目标 HWPOD 的 `operator`/`job_submitter` 时,即使拥有 `tool:hwpod`,具体 HWPOD job 也必须被 cloud-api 拒绝。 - 用户没有 `can_use tool:unidesk_ssh` 时,不注入 UniDesk SSH client token、workspace route 或相关 alias。 -- `tool:trans_cmd` 只代表允许通过受控 UniDesk route 调用透传命令;它不绕过 UniDesk CLI 的 route/operation 安全边界,不允许 pod 内任意 Secret 读取或 Kubernetes 写操作。 +- `tool:trans_cmd` 只代表允许通过受控 UniDesk route 调用透传命令;它不绕过 UniDesk CLI 的 route/operation 安全边界。 - GitHub issue/PR 写入能力必须单独由工具对象授权;拥有 Code Agent session 不等于拥有 GitHub 写权限。 -## 授权残留处理 - -- 授权残留指任何让用户或工具能力绕过当前 `AuthPrincipal -> OpenFGA/Admin Access` 判定的代码、文档、测试、render 或 runtime 路径,也包括共享用户绕过凭据、兼容写分支和只为已移除路径存在的断言。 -- 处理方式是删除或改写为当前合同,不迁移为 legacy mode、兼容表、feature flag、负向 gate 或长期分叉。快速测试应断言当前 Web session/API key/OpenFGA/Admin Access 的 allow/deny 行为,而不是保留被移除路径的名字。 -- 运行面排查可以临时扫描 source、GitOps 和 live schema 证明当前 authority 收敛;长期 SPEC 只保留当前 authority、当前入口和判定规则,不维护已移除对象清单。 - ## 测试规格 ## T1 -阅读 docs/reference/spec-v02-openfga-authorization.md,然后检查 `deploy/gitops/g14/runtime-v02` 和渲染结果,确认 OpenFGA 以 ClusterIP-only 服务部署在 `hwlab-v02`,使用 Postgres backend、migration job/step 和 SecretRef;`kustomization.yaml` 引用 OpenFGA 资源,且没有 FRP/Ingress 公网暴露。 +阅读 docs/reference/spec-v02-openfga-authorization.md,然后访问 `GET /health/live` 和 `GET /v1/admin/access/summary`,确认响应显示 `openfga.mode`、readiness、storeId/modelId 摘要和 degraded reason;响应不得包含 OpenFGA token、Postgres URL 或 Secret 值。 ## T2 -阅读 docs/reference/spec-v02-openfga-authorization.md,然后访问 `GET /health/live` 和 `GET /v1/admin/access/summary`,确认响应显示 `openfga.mode`、readiness、storeId/modelId 摘要和 degraded reason;响应不得包含 OpenFGA token、Postgres URL 或 Secret 值。 +阅读 docs/reference/spec-v02-openfga-authorization.md,然后撤销普通用户的 `tool:hwpod` 或 `tool:unidesk_ssh`,创建新的 Code Agent session 并检查 trace/runner env 摘要,确认对应工具 alias/env 未注入;尝试调用时返回结构化 authorization blocker。 ## T3 -阅读 docs/reference/spec-v02-openfga-authorization.md,然后用 admin 通过 CLI 或 WebUI 给普通用户授予某个 HWPOD 的 `viewer` 但不授予 `operator`,确认该用户可以看到 HWPOD 摘要,但提交 HWPOD job 返回 403;再授予 `operator/job_submitter` 后 job 可提交。 +阅读 docs/reference/spec-v02-openfga-authorization.md,然后从浏览器打开 Access 页面,确认只有 admin/access manager 可见 ActivityRail 入口;普通用户访问 route 显示授权 blocker。 ## T4 -阅读 docs/reference/spec-v02-openfga-authorization.md,然后撤销普通用户的 `tool:hwpod` 或 `tool:unidesk_ssh`,创建新的 Code Agent session 并检查 trace/runner env 摘要,确认对应工具 alias/env 未注入;尝试调用时返回结构化 authorization blocker。 - -## T5 - -阅读 docs/reference/spec-v02-openfga-authorization.md,然后在 `enforce` 运行面授予和撤销普通用户的 HWPOD relation,确认迁移期 `/v1/device-pods`、status、job 和 Access summary 都按 OpenFGA 结果执行;OpenFGA 写失败时 Admin Access API 必须返回结构化 5xx blocker,不能更新本地 access matrix。 - -## T6 - -阅读 docs/reference/spec-v02-openfga-authorization.md,然后从浏览器打开 Access 页面,确认只有 admin/access manager 可见 ActivityRail 入口;普通用户访问 route 显示授权 blocker。保存一次权限变更后,CLI `client access users inspect USER` 必须看到同一 effective matrix。 - -## T7 - 阅读 docs/reference/spec-v02-openfga-authorization.md,然后在 runtime endpoint locked 环境运行 `hwlab-cli client access summary/users/check/grant/revoke`,确认全部走 Cloud Web 同源 `19666` path,输出 JSON、route、actor、mode、decision 和 effective matrix;不手动传 OpenFGA URL/token,不直连 `19667` 作为最终 Web 等价验收。 -## T8 - -阅读 docs/reference/spec-v02-openfga-authorization.md 和 docs/reference/spec-user-access.md,然后检查 source schema、`v0.2-gitops` rendered ConfigMap 和 live Postgres:确认当前授权状态能由 `AuthPrincipal`、users/session/API key、HWPOD profile、Admin Access API 和 OpenFGA tuple 完整表达。检查中发现其他授权 source 时,必须删除或改写到当前合同,不新增兼容分支或负向 gate。 - ## 规格的实现情况 | 规格项 | 状态 | 说明 | | --- | --- | --- | | OpenFGA 作为 v0.2 内部授权服务 | 已实现/持续约束 | v0.2 runtime 以 `enforce` 模式运行,summary 暴露 redacted store/model 和 readiness;OpenFGA 不向公网、浏览器或 CLI 暴露。 | | Cloud API OpenFGA client/bootstrap/check/write | 已实现 | Admin Access API 可 check/write tuple,响应包含 structured decision 和 redacted OpenFGA 状态。 | -| 细粒度 HWPOD / session / tool 授权 | 核心已实现/持续扩展 | HWPOD relation 和 tool capability 统一写 OpenFGA tuple;后续 session/tool 扩展必须继续保持同一 authority。 | -| Admin Access WebUI | 部分实现/持续约束 | Cloud Web Access 页面使用同一 Admin Access API;浏览器交互深测可作为专项验收,但不得新增第二条授权路径。 | -| 同路径 CLI | 已实现 | `client access ...` 走 Cloud Web 同源 path,已覆盖 summary、users、check、HWPOD relation grant/revoke 和 tool grant/revoke。 | -| AgentRun 工具注入按用户权限过滤 | 部分实现/持续约束 | `hwpod`、`unidesk_ssh`、`trans_cmd`、GitHub 写工具必须独立授权;runner 中的 `HWLAB_API_KEY` 必须映射到当前 Code Agent session owner。 | - +| session / tool 授权 | 核心已实现/持续扩展 | Code Agent session 和 tool capability 是当前授权来源。 | +| 同路径 CLI | 已实现 | `client access ...` 走 Cloud Web 同源 path,覆盖 summary、users、check 和 tool grant/revoke。 | diff --git a/docs/reference/spec-v02-provider-management.md b/docs/reference/spec-v02-provider-management.md index 8134072c..386f0e53 100644 --- a/docs/reference/spec-v02-provider-management.md +++ b/docs/reference/spec-v02-provider-management.md @@ -85,7 +85,7 @@ HWLAB Cloud API 必须调用 AgentRun 后端的 provider profile 管理 API, } ``` -AgentRun 对该调用不做用户鉴权,但可以校验调用来源、tenant、profile allowlist 和 request schema。HWLAB 必须把 AgentRun 返回的 failureKind 原样保留到审计和前端响应中,不能把 AgentRun provider/Secret 错误改写成 HWLAB auth、device-pod 或 Cloud Web 故障。 +AgentRun 对该调用不做用户鉴权,但可以校验调用来源、tenant、profile allowlist 和 request schema。HWLAB 必须把 AgentRun 返回的 failureKind 原样保留到审计和前端响应中,不能把 AgentRun provider/Secret 错误改写成 HWLAB auth、HWPOD node-ops 或 Cloud Web 故障。 ## DeepSeek v0.2 通道规则 diff --git a/docs/reference/spec-v02-services.md b/docs/reference/spec-v02-services.md index ef7c32a0..2f2f6fc1 100644 --- a/docs/reference/spec-v02-services.md +++ b/docs/reference/spec-v02-services.md @@ -1,74 +1,55 @@ # v0.2 微服务总体规格 -本文是 HWLAB `v0.2` 微服务总体规格和服务规格索引。它定义组件取舍、依赖方向、语言迁移边界、裁撤口径和单服务 spec 入口;单个服务的 API、测试规格和实现状态以对应 `docs/reference/spec-*.md` 为准。 +本文是 HWLAB `v0.2` 服务矩阵、稳定外部服务和 HWPOD 快速闭环边界的总览。单项服务 API、测试规格和实现状态以对应 `docs/reference/spec-*.md` 为准。 `docs/reference/spec-*.md` 是微服务、稳定外部服务、短连接 CLI 和系统能力的权威出处;代码开发和测试代码编写必须先对齐对应 spec,再修改实现或测试。 -细节权威出处: +## 职责划分 -- 用户、权限、session 归属和 hwpod relation/capability:见 [spec-user-access.md](spec-user-access.md)。 -- OpenFGA 细粒度授权、Admin Access 管理页和同路径 CLI:见 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md)。 -- HWPOD 当前概念、workspace-local spec、compiler、node-ops 和 node 边界:见 [spec-hwpod-harness.md](spec-hwpod-harness.md)。 -- Device Pod 迁移对照和残留命名识别:见 [spec-device-pod.md](spec-device-pod.md)。 -- `v0.2` branch、namespace、GitOps、FRP、SecretRef 和发布验收:见 [spec-v02-cicd.md](spec-v02-cicd.md)。 -- Code Agent provider 真实聊天验收:见 [code-agent-chat-readiness.md](code-agent-chat-readiness.md)。 -- G14 GitOps、Tekton、Argo CD、registry 和外部稳定中间件边界:见 [g14-gitops-cicd.md](g14-gitops-cicd.md)。 -- 保留服务、稳定外部服务和短连接 CLI 的单项 spec:见本文“服务总表”。 +`hwlab-v02` 是独立 runtime namespace,公网只暴露 `19666/19667`。浏览器进入 `hwlab-cloud-web`,应用 API 收敛到 `hwlab-cloud-api`,执行调度接入 AgentRun v0.1,HWPOD 快速闭环由 Code Agent workspace 内的 `hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli` 和 host/edge 侧 `hwpod-node` 完成。 -## 在系统中的职责划分 +- `hwlab-cloud-api` 是 v0.2 应用层 authority:用户身份、`admin/user`、Code Agent session owner、OpenFGA relation、工具能力、HWPOD node-ops 转发和 trace/result 都在这里收口。 +- `hwlab-cloud-web` 是用户入口和同源 API proxy,不拥有业务 authority。 +- `hwlab-edge-proxy` 是公网 API/FRP 边界,不做业务权限。 +- `hwlab-gateway` 只保留为底层 transport 诊断和历史硬件通道,不作为 HWPOD 产品主概念。 +- `hwlab-agent-skills` 是 skills bundle 的 health/list 可见服务,不承载每次 Code Agent 对话的运行时依赖。 +- `hwpod-node` 不部署为 G14 runtime Service;它运行在 PC host、AI 网关、调试器边缘设备或实验室控制盒上,通过 `hwpod-node-ops` 接收基础操作。 +- `hwpod-cli`、`hwpod-ctl` 和 `hwpod-compiler-cli` 是 Code Agent workspace 内短连接工具,不进入 runtime service inventory、artifact catalog 或 GitOps desired state。 +- `hwlab-agent-mgr`、`hwlab-agent-worker`、repo-owned codex-stdio supervisor、router、tunnel、模拟器和旧硬件执行服务不在 v0.2 runtime service matrix 中,不再生成 Deployment、Job template、Service、artifact 或 GitOps desired state。 -`hwlab-v02` 是独立 runtime namespace,公网只暴露 `19666/19667`。浏览器进入 `hwlab-cloud-web`,API、agent、device 和 gateway 请求收敛到 `hwlab-cloud-api`,内部硬件与 agent 能力由专门服务承接,稳定外部服务只提供数据库、模型桥、provider 通道和 FRP 入口。 +## 主要链路 -- `hwlab-cloud-api` 是 v0.2 应用层 authority:用户身份、`admin/user`、Code Agent session owner、OpenFGA relation、hwpod/profile authority 和用户态 REST 都在这里判定。 -- OpenFGA 是 `hwlab-v02` 内部稳定授权服务,只作为 cloud-api 的 PDP/relationship store;Keycloak、Cloud Web、CLI、AgentRun runner 和普通用户都不能直接调用 OpenFGA。 -- `hwlab-cloud-web` 只作为用户入口和 API proxy,不拥有业务 authority;CLI 可以旁路 UI,但不能旁路 `cloud-api` 的授权。 -- 当前设备研发概念是 HWPOD:用户态请求必须先经 `cloud-api` 完成身份和 OpenFGA/Admin Access 判定,再按 HWPOD 目标进入 `hwpod-node-ops` / `hwpod-node`。source/runtime 中仍存在的旧 executor workload 或 API path 只属于实现命名残留,不是当前服务概念。 -- Code Agent session 归属、鉴权、trace 和用户态 API 收敛在 `hwlab-cloud-api`;执行调度接入 AgentRun v0.1 共享基础设施。`hwlab-agent-mgr`、`hwlab-agent-worker` 和 repo-owned codex-stdio supervisor 不是 v0.2 runtime service matrix,不再生成 Deployment、Job template、Service、artifact 或 GitOps desired state。 -- `hwlab-gateway` 是 transport,不理解用户权限、不保存 profile authority;用户端已经验证稳定,v0.2 第一阶段先不改造它。 -- Code Agent provider 通道分为 `codex-api` loopback forwarder 和 `deepseek` bridge/Moon Bridge;自研 bridge/forwarder 属于 HWLAB 常驻服务,Moon Bridge 和 hyueapi/DeepSeek upstream 是稳定外部依赖。 -- `hwlab-router`、`hwlab-tunnel-client`、`hwlab-gateway-simu`、`hwlab-box-simu`、`hwlab-patch-panel` 在 v0.2 裁撤;不再为这些裁撤对象保留单独规格文档。 -- `hwlab-cli` 是固定 repo 内短连接业务 client,不是镜像、常驻服务或 Job template;一次性脚本、render/publish/smoke helper、vendored 前端库和稳定外部服务不纳入 Bun + TypeScript 常驻服务迁移范围,短连接 CLI 自身按 [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) 使用 Bun + TypeScript。 - -## 内部架构 - -v0.2 的主要请求链路按以下方向收敛: +浏览器和 API: ```text browser -> hwlab-cloud-web -> hwlab-edge-proxy -> hwlab-cloud-api --> Postgres +-> Postgres / OpenFGA / AgentRun / provider bridge ``` +HWPOD 快速闭环: + ```text -cloud-web or hwpod-cli or code agent tool --> hwlab-cloud-api --> hwpod-node-ops +Code Agent workspace +-> hwpod-cli or hwpod-ctl +-> hwpod-compiler-cli +-> hwlab-cloud-api /v1/hwpod-node-ops -> hwpod-node --> Keil / pyOCD / UART / target +-> target device / workspace / debug probe / io probe ``` +Code Agent provider: + ```text hwlab-cloud-api --> OpenFGA --> Postgres +-> AgentRun v0.1 +-> codex-api profile or deepseek profile +-> upstream provider ``` -```text -hwlab-cloud-api --> codex-api profile --> hwlab-codex-api-responses-forwarder --> hyueapi upstream -``` - -```text -hwlab-cloud-api --> deepseek profile --> hwlab-deepseek-responses-bridge --> Moon Bridge --> DeepSeek upstream -``` +CI/CD: ```text origin/v0.2 @@ -79,18 +60,15 @@ origin/v0.2 -> 19666 / 19667 ``` -这些链路只表达总体依赖方向。接口、鉴权、SecretRef、health、job、profile 和 provider 行为以对应规格文档为准。 - ## API 接口说明 | 接口类别 | 入口 | 权威规格 | | --- | --- | --- | | 浏览器工作台 | `http://74.48.78.17:19666/` | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md) | | API/live 公网入口 | `http://74.48.78.17:19667/health/live` 和同源 API | [spec-v02-hwlab-edge-proxy.md](spec-v02-hwlab-edge-proxy.md)、[spec-v02-hwlab-cloud-api.md](spec-v02-hwlab-cloud-api.md) | -| 用户、session、授权 | `/auth/*`、`/v1/admin/*`、`/v1/agent/chat*` | [spec-user-access.md](spec-user-access.md)、[spec-v02-hwlab-cloud-api.md](spec-v02-hwlab-cloud-api.md) | -| OpenFGA 授权管理 | `/v1/admin/access*`、`hwlab-cli client access ...`、Cloud Web Access 页面 | [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) | -| HWPOD | `hwpod-cli`、`hwpod-ctl`、`hwpod-node-ops`、迁移期 `/v1/device-pods*` API | [spec-hwpod-harness.md](spec-hwpod-harness.md)、[spec-device-pod.md](spec-device-pod.md) | -| Gateway transport | `cloud-api /v1/gateway/poll`、`/v1/gateway/result`、gateway `/status` | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md) | +| 用户、session、授权 | `/auth/*`、`/v1/admin/*`、`/v1/agent/chat*` | [spec-user-access.md](spec-user-access.md)、[spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) | +| HWPOD 快速闭环 | `/v1/hwpod-node-ops`、`hwpod-cli`、`hwpod-ctl` | [spec-hwpod-harness.md](spec-hwpod-harness.md) | +| Gateway transport 诊断 | `cloud-api /v1/gateway/poll`、`/v1/gateway/result`、gateway `/status` | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md) | | Code Agent provider | `deepseek` 和 `codex-api` provider profile | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md)、[spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) | | Code Agent AgentRun 调度 | `hwlab-cloud-api` 会话 owner/auth/trace -> AgentRun v0.1 dispatch | [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md)、[spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) | | 短连接 CLI | `G14:/root/hwlab-v02` 内直接运行 `hwlab-cli client ...` | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) | @@ -98,82 +76,32 @@ origin/v0.2 | 公网 FRP | master `frps` + `hwlab-v02-frpc` TCP `19666/19667` | [spec-v02-frpc.md](spec-v02-frpc.md) | | CI/CD 控制 | render、Tekton、GitOps、Argo、runtime health | [spec-v02-cicd.md](spec-v02-cicd.md) | -服务级 HTTP、CLI、Job 或 TCP 接口以各服务 spec 为准。用户态入口只走 `19666/19667`,内部服务只通过 ClusterIP 或 Job 模板调用,稳定外部服务只暴露最小必要协议,不向普通用户开放 Kubernetes、Secret、Service 直连或 provider 凭据。 - ## 服务总表 -| 对象 | 类型 | v0.2 处理 | Bun + TS | 细节出处 | -| --- | --- | --- | --- | --- | -| `hwlab-cloud-api` | HWLAB 自研常驻服务 | 保留并核心化 | 是,P0 | [spec-v02-hwlab-cloud-api.md](spec-v02-hwlab-cloud-api.md)、[spec-user-access.md](spec-user-access.md)、[spec-hwpod-harness.md](spec-hwpod-harness.md) | -| `hwlab-cloud-web` runtime wrapper | HWLAB 自研常驻 web/proxy wrapper | 保留 | 是,P0 | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md)、[cloud-workbench.md](cloud-workbench.md) | -| `hwlab-edge-proxy` | HWLAB 自研常驻服务 | 保留 | 是,P0 | [spec-v02-hwlab-edge-proxy.md](spec-v02-hwlab-edge-proxy.md) | -| HWPOD harness / node-ops / node | HWLAB 硬件研发执行能力 | 当前概念保留并增强;旧 executor 命名作为残留清理 | 视入口而定 | [spec-hwpod-harness.md](spec-hwpod-harness.md)、[spec-device-pod.md](spec-device-pod.md) | -| `hwlab-codex-api-responses-forwarder` | HWLAB 自研常驻 sidecar | 保留 | 是,P1 | [spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) | -| `hwlab-deepseek-responses-bridge` / `hwlab-deepseek-proxy` | HWLAB 自研 bridge + Moon Bridge 外部依赖 | 保留 | 是,P1 for bridge | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md) | -| AgentRun v0.1 runner | 共享 Agent 执行基础设施 | 作为外部基础设施接入,不进 HWLAB service/artifact matrix | 否 | [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md) | -| `hwlab-agent-skills` wrapper | HWLAB 自研 bundle/health wrapper | 保留 | 仅常驻 wrapper 需要,P2 | [spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) | -| `hwlab-gateway` | HWLAB 自研用户端/硬件 transport | 保留 | 暂不迁 | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md)、[gateway-outbound-demo.md](gateway-outbound-demo.md) | -| v0.2 Observability Monitoring | 应用侧监控声明 | 保留为业务接入能力,不进 runtime service inventory | 否 | [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md) | -| `hwlab-router` | HWLAB 自研路由占位服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | -| `hwlab-tunnel-client` | HWLAB 自研 tunnel 状态占位服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | -| `hwlab-gateway-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | -| `hwlab-box-simu` | HWLAB 自研模拟服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | -| `hwlab-patch-panel` | HWLAB 自研接线盘服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec | -| `hwlab-cli` | 固定 repo 短连接 client | 保留为 WEB 等价非视觉业务入口,不进 runtime service inventory | 是,CLI 自身 | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) | -| `hwpod-cli` / `hwpod-ctl` / `hwpod-compiler-cli` | CLI 工具 | 保留并作为当前 HWPOD 入口 | 否 | [spec-hwpod-harness.md](spec-hwpod-harness.md) | -| `device-pod-cli` | CLI shim/迁移残留 | 仅作过渡,不作为当前概念入口 | 否 | [spec-device-pod.md](spec-device-pod.md) | -| render/publish/smoke scripts | 一次性脚本 | 保留现状 | 否 | [spec-v02-cicd.md](spec-v02-cicd.md)、[g14-gitops-cicd.md](g14-gitops-cicd.md) | -| browser-side Cloud Web JS | HWLAB 自研前端浏览器代码 | 保留并 TS 化 | 是,P0 | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md)、[cloud-workbench.md](cloud-workbench.md) | -| Moon Bridge | 外部稳定服务 | 保留 | 否 | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md) | -| `frpc` / `frps` | 外部稳定服务 | 保留 | 否 | [spec-v02-frpc.md](spec-v02-frpc.md) | -| Postgres | 外部稳定服务 | 保留 | 否 | [spec-v02-postgres.md](spec-v02-postgres.md) | -| OpenFGA | 外部稳定服务 / 内部授权 PDP | 新增并保留,ClusterIP-only | 否 | [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) | -| Argo CD / Tekton / BuildKit / registry | 外部稳定服务 | 保留 | 否 | [spec-v02-cicd.md](spec-v02-cicd.md)、[g14-gitops-cicd.md](g14-gitops-cicd.md) | -| Codex CLI | 外部工具/runtime | 保留 | 否 | [code-agent-chat-readiness.md](code-agent-chat-readiness.md) | -| Keil / pyOCD / UART 工具 | 外部或主机侧工具 | 保留 | 否 | [spec-device-pod.md](spec-device-pod.md) | +| 对象 | 类型 | v0.2 处理 | 细节出处 | +| --- | --- | --- | --- | +| `hwlab-cloud-api` | HWLAB 自研常驻服务 | 保留并核心化 | [spec-v02-hwlab-cloud-api.md](spec-v02-hwlab-cloud-api.md)、[spec-user-access.md](spec-user-access.md)、[spec-hwpod-harness.md](spec-hwpod-harness.md) | +| `hwlab-cloud-web` | HWLAB 自研常驻 web/proxy wrapper | 保留 | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md)、[cloud-workbench.md](cloud-workbench.md) | +| `hwlab-edge-proxy` | HWLAB 自研常驻服务 | 保留 | [spec-v02-hwlab-edge-proxy.md](spec-v02-hwlab-edge-proxy.md) | +| `hwlab-gateway` | HWLAB 自研 transport | 保留为底层 transport 诊断和兼容硬件通道,不作为 HWPOD 产品主概念 | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md)、[gateway-outbound-demo.md](gateway-outbound-demo.md) | +| `hwlab-agent-skills` | HWLAB 自研 bundle/health wrapper | 保留 | [spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) | +| AgentRun v0.1 runner | 共享 Agent 执行基础设施 | 外部基础设施接入,不进 HWLAB service/artifact matrix | [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md) | +| HWPOD workspace tools | 短连接 CLI/skill | `hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli` 放在 Code Agent workspace 内快速迭代 | [spec-hwpod-harness.md](spec-hwpod-harness.md) | +| `hwpod-node` | host/edge executor | 运行在 PC host、AI 网关或调试器边缘设备;不进入 G14 runtime service inventory | [spec-hwpod-harness.md](spec-hwpod-harness.md) | +| v0.2 Observability Monitoring | 应用侧监控声明 | 保留为业务接入能力,不进 runtime service inventory | [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md) | +| `hwlab-codex-api-responses-forwarder` | HWLAB 自研 sidecar | 保留 | [spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) | +| `hwlab-deepseek-responses-bridge` / `hwlab-deepseek-proxy` | HWLAB 自研 bridge + Moon Bridge 外部依赖 | 保留 | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md) | +| Moon Bridge | 外部稳定服务 | 保留 | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md) | +| `frpc` / `frps` | 外部稳定服务 | 保留 | [spec-v02-frpc.md](spec-v02-frpc.md) | +| Postgres | 外部稳定服务 | 保留 | [spec-v02-postgres.md](spec-v02-postgres.md) | +| OpenFGA | 外部稳定服务 / 内部授权 PDP | 新增并保留,ClusterIP-only | [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) | +| Argo CD / Tekton / BuildKit / registry | 外部稳定服务 | 保留 | [spec-v02-cicd.md](spec-v02-cicd.md)、[g14-gitops-cicd.md](g14-gitops-cicd.md) | +| Codex CLI | 外部工具/runtime | 保留 | [code-agent-chat-readiness.md](code-agent-chat-readiness.md) | +| Keil / pyOCD / UART 工具 | 外部或主机侧工具 | 由 HWPOD spec 和 node-ops 绑定使用 | [spec-hwpod-harness.md](spec-hwpod-harness.md) | -## 语言迁移边界 +## 裁撤集合 -v0.2 服务语言统一约束 HWLAB 仓库内自研、会以 Deployment、sidecar 或长期进程运行的 JavaScript 服务代码,同时约束 HWLAB 自研 Cloud Web 浏览器端代码。稳定外部服务、外部镜像、第三方二进制、CLI、一次性脚本、测试脚本、GitOps/render/publish helper 和 vendored 代码不纳入迁移范围。 - -迁移目标: - -- 自研常驻服务入口使用 `Bun + TypeScript`。 -- 自研前端浏览器代码使用 TypeScript 并进入前端 build/typecheck 链路;低频 UI 分支不能只依赖浏览器运行时发现语法错误。 -- Bun 负责运行 `.ts`;发布前必须通过 TypeScript 类型检查,不能只依赖 Bun 运行时转译。 -- 镜像构建和 CI 原语校验必须包含 `tsc --noEmit` 或等价 `bun run typecheck`。 -- 每个 `.ts` 文件不超过 2000 行;超过必须先拆模块,再迁移或继续开发。 -- 一个服务迁移完成后,不长期保留 `.mjs` 和 `.ts` 双入口兼容;runtime command、health check、artifact inventory 和 GitOps render 必须收敛到单一入口。 - -第一阶段迁移集合: - -```text -hwlab-cloud-api -hwlab-cloud-web runtime wrapper -hwlab-edge-proxy -HWPOD harness / node-ops / node -hwlab-codex-api-responses-forwarder -hwlab-deepseek-responses-bridge -browser-side Cloud Web JS -``` - -后续迁移集合: - -```text -hwlab-agent-skills wrapper -``` - -暂不迁移集合: - -```text -hwlab-gateway -hwpod-cli / hwpod-ctl / hwpod-compiler-cli -device-pod-cli residual shim -scripts and tools -stable external services -``` - -裁撤集合: +以下对象不再保留单服务 spec、GitOps desired state、artifact catalog、Tekton build task 或长期测试入口: ```text hwlab-router @@ -183,38 +111,31 @@ hwlab-box-simu hwlab-patch-panel hwlab-agent-mgr hwlab-agent-worker +旧硬件执行服务和旧本地设备 CLI ``` -裁撤集合不再新增单服务 spec。若历史文档仍提到这些服务作为 v0.2 必需依赖,应删除旧口径或交叉引用本文。 +发现旧测试、旧预检、旧脚本或旧文档再次把这些对象作为 v0.2 目标行为时,直接删除旧断言并按本文服务矩阵收敛。 ## 测试规格 ## T1 -阅读 docs/reference/spec-v02-services.md,然后用 cli 手动测试以下内容:列出 `docs/reference/spec-*.md`,确认 AGENTS.md 的“规格”部分索引了全部 spec,且没有 `hwlab-router`、`hwlab-tunnel-client`、`hwlab-gateway-simu`、`hwlab-box-simu` 或 `hwlab-patch-panel` 的单服务 spec 入口。 +阅读 docs/reference/spec-v02-services.md,然后用 CLI 手动测试以下内容:列出 `deploy/deploy.json`、`deploy/deploy.schema.json`、`deploy/artifact-catalog.dev.json` 和 `deploy/gitops/g14/runtime-v02`,确认 runtime service set 只包含 `hwlab-cloud-api`、`hwlab-cloud-web`、`hwlab-gateway`、`hwlab-edge-proxy` 和 `hwlab-agent-skills`。 ## T2 -阅读 docs/reference/spec-v02-services.md,然后用 cli 手动测试以下内容:从 `deploy/gitops/g14/runtime-v02` 读取 Deployment、StatefulSet、Service 和 Job 模板,确认每个保留服务和稳定外部服务都有对应 spec;确认 `hwlab-router`、`hwlab-tunnel-client`、`hwlab-gateway-simu`、`hwlab-box-simu` 和 `hwlab-patch-panel` 不再出现在 v0.2 runtime desired state 或 v0.2 artifact catalog 中。 +阅读 docs/reference/spec-v02-services.md,然后用 CLI 手动测试以下内容:确认 `hwpod-cli`、`hwpod-ctl`、`hwpod-compiler-cli` 和 `hwpod-node` 不被渲染为 G14 Deployment、Service、Tekton build task 或 artifact service;它们属于 workspace/host 侧 HWPOD harness。 ## T3 -阅读 docs/reference/spec-v02-services.md,然后用 cli 手动测试以下内容:检查自研常驻服务和 browser-side Cloud Web JS 是否被列入 Bun + TypeScript 迁移集合,确认 CLI、一次性脚本、vendored 代码和稳定外部服务不被误纳入迁移范围。 - -## T4 - -阅读 docs/reference/spec-v02-services.md,然后用 cli 手动测试以下内容:逐个检查 `docs/reference/spec-*.md` 是否包含“在系统中的职责划分”“内部架构”“API 接口说明”“测试规格”“规格的实现情况”五个部分。 +阅读 docs/reference/spec-v02-services.md,然后用 CLI 手动测试以下内容:检查 `docs/reference/spec-*.md` 与 AGENTS.md 规格索引,确认裁撤集合没有单服务 spec 入口。 ## 规格的实现情况 | 规格项 | 状态 | 说明 | | --- | --- | --- | -| v0.2 总体依赖方向 | 已实现/持续收敛 | 本文定义浏览器、API、HWPOD、provider 和 CI/CD 链路;旧 executor 命名只作为残留清理对象。 | +| v0.2 总体依赖方向 | 已实现/持续约束 | 本文定义浏览器、API、HWPOD、provider 和 CI/CD 链路。 | | 保留服务均有 spec | 已实现 | 本文服务总表列出当前保留服务和 spec 文件。 | | 稳定外部服务纳入 spec | 已实现 | Postgres、Codex API forwarder/hyueapi、DeepSeek/Moon Bridge、FRP 已独立成文或交叉引用权威规格。 | -| OpenFGA 授权服务纳入 spec | 目标状态 | 需要按 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 完成 GitOps、cloud-api、WebUI 和 CLI。 | -| HWPOD 当前概念收敛 | 目标状态 | 新开发和长期文档只使用 HWPOD 概念;旧 `device-pod` / `hwlab-device-pod` 命名从服务总表、权限职责和新测试中移除。 | -| `simu`、接线盘、router、tunnel-client 裁撤 | 已实现 | 本文记录裁撤口径;v0.2 render、artifact catalog、Tekton build service set 和 cloud-api 运行时 env 不再包含裁撤对象。 | -| Bun + TypeScript 迁移边界 | 已实现 | 本文区分第一阶段、后续、暂不迁移和裁撤集合。 | -| spec 作为开发和测试权威 | 已实现 | AGENTS.md 规格区提供顶级索引。 | - +| HWPOD 快速闭环边界 | 已实现/持续约束 | workspace-local tools、API node-ops 转发和 host/edge node 边界见 `spec-hwpod-harness.md`。 | +| 裁撤集合不进 runtime | 已实现/持续约束 | render、artifact catalog、Tekton build service set 和 cloud-api runtime env 不再包含裁撤对象。 | diff --git a/internal/agent/agentrun-dispatch.mjs b/internal/agent/agentrun-dispatch.mjs index 7c22b8cd..59d015df 100644 --- a/internal/agent/agentrun-dispatch.mjs +++ b/internal/agent/agentrun-dispatch.mjs @@ -7,14 +7,17 @@ export const DEFAULT_HWLAB_AGENTRUN_BRANCH = "v0.2"; export const DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE = "deepseek"; export const DEFAULT_UNIDESK_MAIN_SERVER_ENV = "UNIDESK_MAIN_SERVER_IP"; export const DEFAULT_HWLAB_AGENTRUN_TOOL_ALIASES = Object.freeze([ - { name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }, + { name: "hwpod", path: "tools/hwpod-cli.ts", kind: "bun-script" }, + { name: "hwpod-ctl", path: "tools/hwpod-ctl.ts", kind: "bun-script" }, + { name: "hwpod-compiler", path: "tools/hwpod-compiler-cli.ts", kind: "bun-script" }, { name: "unidesk-ssh", path: "tools/unidesk-ssh.mjs", kind: "bun-script" } ]); export const DEFAULT_HWLAB_AGENTRUN_PROMPT_REFS = Object.freeze([ { name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true } ]); export const DEFAULT_HWLAB_AGENTRUN_SKILL_REFS = Object.freeze([ - { name: "device-pod-cli", path: "skills/device-pod-cli/SKILL.md", required: true, aggregateAs: "device-pod-cli" }, + { name: "hwpod-cli", path: "skills/hwpod-cli/SKILL.md", required: true, aggregateAs: "hwpod-cli" }, + { name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" }, { name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" } ]); diff --git a/internal/agent/agentrun-dispatch.test.mjs b/internal/agent/agentrun-dispatch.test.mjs index 085befc5..4f9cf41d 100644 --- a/internal/agent/agentrun-dispatch.test.mjs +++ b/internal/agent/agentrun-dispatch.test.mjs @@ -26,18 +26,23 @@ test("HWLAB AgentRun assembly grants GitHub and UniDesk SSH through toolCredenti assert.equal(assembly.runPayload.backendProfile, "deepseek"); assert.equal(assembly.runPayload.workspaceRef.branch, "v0.2"); assert.equal(assembly.runPayload.resourceBundleRef.commitId, commitId); - assert.deepEqual(assembly.runPayload.resourceBundleRef.toolAliases.map((item) => item.name), ["hwpod", "unidesk-ssh"]); - assert.equal(assembly.runPayload.resourceBundleRef.toolAliases[0].path, "tools/device-pod-cli.mjs"); + assert.deepEqual(assembly.runPayload.resourceBundleRef.toolAliases.map((item) => item.name), ["hwpod", "hwpod-ctl", "hwpod-compiler", "unidesk-ssh"]); + assert.equal(assembly.runPayload.resourceBundleRef.toolAliases[0].path, "tools/hwpod-cli.ts"); + assert.equal(assembly.runPayload.resourceBundleRef.toolAliases[1].path, "tools/hwpod-ctl.ts"); + assert.equal(assembly.runPayload.resourceBundleRef.toolAliases[2].path, "tools/hwpod-compiler-cli.ts"); assert.deepEqual(assembly.runPayload.resourceBundleRef.promptRefs, [ { name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true } ]); assert.deepEqual(assembly.runPayload.resourceBundleRef.skillRefs, [ - { name: "device-pod-cli", path: "skills/device-pod-cli/SKILL.md", required: true, aggregateAs: "device-pod-cli" }, + { name: "hwpod-cli", path: "skills/hwpod-cli/SKILL.md", required: true, aggregateAs: "hwpod-cli" }, + { name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" }, { name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" } ]); assert.deepEqual(assembly.boundaries.promptRefs, ["hwlab-v02-runtime"]); - assert.deepEqual(assembly.boundaries.skillRefs, ["device-pod-cli", "hwlab-agent-runtime"]); + assert.deepEqual(assembly.boundaries.skillRefs, ["hwpod-cli", "hwpod-ctl", "hwlab-agent-runtime"]); assert.equal(assembly.boundaries.toolAliases.includes("hwpod"), true); + assert.equal(assembly.boundaries.toolAliases.includes("hwpod-ctl"), true); + assert.equal(assembly.boundaries.toolAliases.includes("hwpod-compiler"), true); assert.equal(assembly.boundaries.toolAliases.includes("unidesk-ssh"), true); assert.equal(assembly.commandPayload.payload.traceId, "trc_hwlab_agentrun_001"); assert.equal(assembly.commandPayload.payload.threadId, "thread_001"); diff --git a/internal/agent/prompts/hwlab-v02-runtime.md b/internal/agent/prompts/hwlab-v02-runtime.md index 4128ca01..66ad85b9 100644 --- a/internal/agent/prompts/hwlab-v02-runtime.md +++ b/internal/agent/prompts/hwlab-v02-runtime.md @@ -5,15 +5,14 @@ You are running inside the HWLAB v0.2 Code Agent runtime assembled by AgentRun. Use the repo-local resource bundle as the source of runtime rules: - Required skills are mounted from `ResourceBundleRef.skillRefs` into `.agents/skills` for this run. -- The expected HWLAB skills are `device-pod-cli` and `hwlab-agent-runtime`. -- Use `hwpod` from PATH for Device Pod work, including D601-F103-V2 build, status, job polling, output inspection, debug-probe, and UART operations. -- Use `unidesk-ssh` only for UniDesk passthrough tasks that are not covered by Device Pod APIs. +- The expected HWLAB skills are `hwpod-cli`, `hwpod-ctl`, and `hwlab-agent-runtime`. +- Use `hwpod` from PATH for HWPOD work. It is the standard `hwpod-cli` entry and must read `.hwlab/hwpod-spec.yaml`, call `hwpod-compiler-cli`, submit `/v1/hwpod-node-ops`, and wait for `hwpod-node` results. +- Use `unidesk-ssh` only for UniDesk passthrough tasks that are not covered by HWPOD APIs. Do not use fallback execution paths: - Do not rely on Codex default system skills as a substitute for HWLAB bundle skills. - Do not read `/app/skills`, host skill directories, ConfigMaps, or user-provided long prompts as a substitute for `ResourceBundleRef.skillRefs`. -- Do not call generic gateway shell, old diagnostic images, local `.device-pod/*.json`, or direct Windows host commands when `hwpod` can reach the formal Device Pod path. - Do not manually concatenate prior user/assistant messages. Conversation continuity must come from Codex stdio `thread/resume` only. When the user asks what skills are visible, mention the HWLAB bundle skills by name and manifest path before any generic model/system skill list. @@ -25,8 +24,8 @@ MiniMax-M3 tool-call guidance: - Prefer `rg` for repository search. If `rg` is unavailable, use `find ... -name` plus plain `grep`; BusyBox `grep` may not support GNU flags such as `--include`. - Treat optional tool probes as non-terminal checks: use `command -v rg >/dev/null 2>&1 || true` or a separate short command, then choose the available path. Do not let an optional missing tool mark the whole verification as failed. - The AgentRun runner does not guarantee `python3` or `jq`. Do not use them unless a separate `command -v python3` or `command -v jq` probe succeeds. Prefer `node -e` only for short one-line JSON checks; otherwise use multiple `grep -F` checks against saved JSON. -- For `/v1/skills` verification, prefer this low-quoting sequence: `curl -fsS http://74.48.78.17:19666/v1/skills -o /tmp/skills.json`, then separate `grep -F` commands for the expected `commitId`, `device-pod-cli`, `hwlab-agent-runtime`, `hwpod`, and `unidesk-ssh` strings. +- For `/v1/skills` verification, prefer this low-quoting sequence: `curl -fsS http://74.48.78.17:19666/v1/skills -o /tmp/skills.json`, then separate `grep -F` commands for the expected `commitId`, `hwpod-cli`, `hwpod-ctl`, `hwlab-agent-runtime`, `hwpod`, and `unidesk-ssh` strings. - For GitHub issue/PR reads, prefer `gh issue view --repo owner/name --json title,body,state,comments` or `gh pr view ... --json ...`; avoid plain `gh issue view` output that can be polluted by Projects Classic GraphQL warnings. - If a command fails because the command arguments were malformed, report the malformed command and retry once with a shorter single-purpose command. -When the user asks to compile or operate `D601-F103-V2`, start from the Device Pod path with `hwpod bootsharp --pod-id D601-F103-V2`, then use `hwpod` job/status/output commands as needed. If the Device Pod path returns a named blocker, report that blocker and do not switch to fallback paths. +When the user asks to compile or operate hardware such as `D601-F103-V2`, start from the HWPOD path: validate `.hwlab/hwpod-spec.yaml` with `hwpod-ctl`, run `hwpod inspect`, then use `hwpod build` / `hwpod download` / `hwpod reset` as needed. If HWPOD returns a named blocker, report and fix that blocker in the HWPOD path. diff --git a/internal/agent/runtime-prompt.test.mjs b/internal/agent/runtime-prompt.test.mjs index 0ae2a285..6dffd671 100644 --- a/internal/agent/runtime-prompt.test.mjs +++ b/internal/agent/runtime-prompt.test.mjs @@ -13,6 +13,7 @@ test("HWLAB v0.2 runtime prompt constrains MiniMax-M3 tool calls", async () => { assert.match(prompt, /command -v python3/u); assert.match(prompt, /multiple `grep -F` checks/u); assert.match(prompt, /curl -fsS http:\/\/74\.48\.78\.17:19666\/v1\/skills -o \/tmp\/skills\.json/u); - assert.match(prompt, /device-pod-cli/u); + assert.match(prompt, /hwpod-cli/u); + assert.match(prompt, /hwpod-ctl/u); assert.match(prompt, /unidesk-ssh/u); }); diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index d9090e15..eb2c31fb 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -32,13 +32,12 @@ test("Postgres workspace update query preserves parameter numbering", async () = return { rows: [{ id: "wsp_pg_param_guard", owner_user_id: "usr_pg_param_guard", - project_id: "prj_device_pod_workbench", + project_id: "prj_hwpod_workbench", name: "Default Workbench", status: "active", is_default: true, selected_conversation_id: null, selected_agent_session_id: null, - selected_device_pod_id: null, active_trace_id: null, provider_profile: null, workspace_json: "{}", @@ -65,12 +64,12 @@ test("Postgres workspace update query preserves parameter numbering", async () = const update = calls.find((call) => call.sql.startsWith("UPDATE account_workspaces")); assert.ok(update); - assert.match(update.sql, /created_at=\$16, updated_at=\$17/u); - assert.match(update.sql, /\$18::text = 'admin'/u); - assert.equal(update.params.length, 18); + assert.match(update.sql, /created_at=\$15, updated_at=\$16/u); + assert.match(update.sql, /\$17::text = 'admin'/u); + assert.equal(update.params.length, 17); + assert.equal(update.params[14], "2026-06-01T00:00:00.000Z"); assert.equal(update.params[15], "2026-06-01T00:00:00.000Z"); - assert.equal(update.params[16], "2026-06-01T00:00:00.000Z"); - assert.equal(update.params[17], "user"); + assert.equal(update.params[16], "user"); }); test("cloud api /auth/oidc/login returns 302 to Keycloak when issuer and client are configured", async () => { @@ -287,10 +286,10 @@ test("workbench workspace permits a new turn after AgentRun active trace reaches const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-terminal", password: "alice-pass" }, adminLogin.cookie); assert.equal(alice.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-terminal", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue655_shared", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue655_shared", threadId: "thread-issue-655", sessionStatus: "active", @@ -349,7 +348,7 @@ test("workbench workspace permits a new turn after AgentRun active trace reaches await waitForCondition(() => agentRunCalls.some((call) => call.method === "POST" && call.path === "/api/v1/runs")); assert.equal(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result"), false); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.body.workspace.activeTraceId, "trc_issue655_after_done"); assert.equal(restored.body.workspace.workspace.sessionStatus, "running"); } finally { @@ -393,9 +392,9 @@ test("workbench workspace permits continuation when stale active trace has idle try { const { port } = server.address(); const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", adminLogin.cookie); const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_idle", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_stale_active_idle", threadId: "thread-stale-active-idle", status: "active", @@ -431,7 +430,7 @@ test("workbench workspace permits continuation when stale active trace has idle assert.equal(next.body.conversationId, "cnv_stale_active_idle"); assert.equal(next.body.sessionId, "ses_stale_active_idle"); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", adminLogin.cookie); assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_idle_next"); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); @@ -503,10 +502,10 @@ test("workbench workspace status clears completed AgentRun active trace on read" const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-status", password: "alice-pass" }, adminLogin.cookie); assert.equal(alice.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-status", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_status", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue664_status", threadId: "thread-issue-664", sessionStatus: "active", @@ -550,7 +549,7 @@ test("workbench workspace status clears completed AgentRun active trace on read" assert.equal(update.status, 200); assert.equal(update.body.workspace.activeTraceId, "trc_issue664_status_done"); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.activeTraceId, null); assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_status_done"); @@ -633,11 +632,11 @@ test("workbench workspace terminal status sync preserves a newer selected conver const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue808", password: "alice-pass" }, adminLogin.cookie); assert.equal(alice.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue808", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const oldConversation = await putJson(port, "/v1/agent/conversations/cnv_issue808_old", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue808_old", threadId: "thread-issue-808-old", sessionStatus: "running", @@ -646,7 +645,7 @@ test("workbench workspace terminal status sync preserves a newer selected conver }, aliceLogin.cookie); assert.equal(oldConversation.status, 200); const newConversation = await putJson(port, "/v1/agent/conversations/cnv_issue808_new", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue808_new", threadId: "thread-issue-808-new", sessionStatus: "idle", @@ -691,7 +690,7 @@ test("workbench workspace terminal status sync preserves a newer selected conver assert.equal(oldActive.body.workspace.activeTraceId, "trc_issue808_old_done"); const selectedNew = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", conversationId: "cnv_issue808_new", sessionId: "ses_issue808_new", updatedByClient: "test-suite" @@ -700,7 +699,7 @@ test("workbench workspace terminal status sync preserves a newer selected conver assert.equal(selectedNew.body.workspace.selectedConversationId, "cnv_issue808_new"); assert.equal(selectedNew.body.workspace.selectedAgentSessionId, "ses_issue808_new"); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.activeTraceId, null); assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue808_new"); @@ -713,7 +712,7 @@ test("workbench workspace terminal status sync preserves a newer selected conver assert.equal(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_issue808_old/commands/cmd_issue808_old/result"), false); const selectedOld = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", conversationId: "cnv_issue808_old", sessionId: "ses_issue808_old", updatedByClient: "test-suite" @@ -721,7 +720,7 @@ test("workbench workspace terminal status sync preserves a newer selected conver assert.equal(selectedOld.status, 200); assert.equal(selectedOld.body.workspace.selectedConversationId, "cnv_issue808_old"); - const repaired = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const repaired = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(repaired.status, 200); assert.equal(repaired.body.workspace.activeTraceId, null); assert.equal(repaired.body.workspace.selectedConversationId, "cnv_issue808_old"); @@ -771,14 +770,14 @@ test("manual Code Agent session select restores running active trace", async () const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-issue810", password: "alice-pass" }, adminLogin.cookie); assert.equal(alice.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-issue810", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); await accessController.recordAgentSessionOwner({ ownerUserId: alice.body.user.id, ownerRole: "user", sessionId: "ses_issue810_running", - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", agentId: "hwlab-code-agent", status: "running", conversationId: "cnv_issue810_running", @@ -804,7 +803,7 @@ test("manual Code Agent session select restores running active trace", async () ownerUserId: alice.body.user.id, ownerRole: "user", sessionId: "ses_issue810_idle", - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", agentId: "hwlab-code-agent", status: "idle", conversationId: "cnv_issue810_idle", @@ -820,7 +819,7 @@ test("manual Code Agent session select restores running active trace", async () }); const selectedIdle = await postJson(port, "/v1/agent/sessions/ses_issue810_idle/select", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", workspaceId: workspace.body.workspace.workspaceId, updatedByClient: "test-suite" }, aliceLogin.cookie); @@ -830,7 +829,7 @@ test("manual Code Agent session select restores running active trace", async () assert.equal(selectedIdle.body.workspace.activeTraceId, null); const selectedRunning = await postJson(port, "/v1/agent/sessions/ses_issue810_running/select", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", workspaceId: workspace.body.workspace.workspaceId, updatedByClient: "test-suite" }, aliceLogin.cookie); @@ -847,7 +846,7 @@ test("manual Code Agent session select restores running active trace", async () assert.equal(selectedRunning.body.workspace.workspace.sessionStatus, "running"); assert.equal(selectedRunning.body.workspace.workspace.providerProfile, "minimax-m3"); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue810_running"); assert.equal(restored.body.workspace.selectedAgentSessionId, "ses_issue810_running"); @@ -923,10 +922,10 @@ test("workbench workspace status repairs terminal selected conversation after ac const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-repair", password: "alice-pass" }, adminLogin.cookie); assert.equal(alice.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-repair", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_repair", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue664_repair", threadId: "thread-issue-664-repair", sessionStatus: "running", @@ -972,7 +971,7 @@ test("workbench workspace status repairs terminal selected conversation after ac assert.equal(update.body.workspace.selectedConversation.status, "running"); assert.equal(update.body.workspace.selectedConversation.messages.length, 0); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.activeTraceId, null); assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_repair_done"); @@ -989,379 +988,6 @@ test("workbench workspace status repairs terminal selected conversation after ac } }); -test("cloud api access control grants visible device pods and requires device-pod executor", async () => { - let directGatewayDispatches = 0; - const gatewayRegistry = { - isOnline: () => true, - enqueue: async () => { - directGatewayDispatches += 1; - throw new Error("cloud-api must not bypass hwlab-device-pod executor"); - }, - describe: () => ({ sessions: [] }) - }; - const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - gatewayRegistry, - openFgaAuthorizer, - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - accessController, - gatewayRegistry, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - assert.equal(adminLogin.status, 200); - assert.equal(adminLogin.body.actor.role, "admin"); - const adminCookie = adminLogin.cookie; - - const userCreate = await postJson(port, "/v1/admin/users", { - username: "alice", - password: "alice-pass", - displayName: "Alice" - }, adminCookie); - assert.equal(userCreate.status, 201); - assert.equal(userCreate.body.user.username, "alice"); - assert.equal(JSON.stringify(userCreate.body).includes("alice-pass"), false); - const bobCreate = await postJson(port, "/v1/admin/users", { - username: "bob", - password: "bob-pass", - displayName: "Bob" - }, adminCookie); - assert.equal(bobCreate.status, 201); - - const podCreate = await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - name: "71-FREQ", - profile: { - schemaVersion: 1, - devicePodId: "device-pod-local-spoof", - target: { id: "target-71-freq" }, - projectWorkspace: { projectPath: "FirmWare/MDK-ARM/app.uvprojx", targetName: "app" }, - route: { - gatewaySessionId: "gws_missing", - resourceId: "res_windows_host", - capabilityId: "cap_device_host_cli", - hostCli: "node tools/device-host-cli.mjs" - } - } - }, adminCookie); - assert.equal(podCreate.status, 201); - assert.equal(podCreate.body.devicePod.devicePodId, "device-pod-71-freq"); - assert.notEqual(podCreate.body.devicePod.profileHash, "sha256:a-local-profile-hash"); - assert.equal(podCreate.body.devicePod.profile.route.gatewaySessionId, "redacted"); - assert.equal(JSON.stringify(podCreate.body).includes("gws_missing"), false); - - const secretProfile = await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-secret", - profile: { - schemaVersion: 1, - target: { id: "target-secret" }, - route: { - gatewaySessionId: "gws_secret", - cloudToken: "should-not-be-stored" - } - } - }, adminCookie); - assert.equal(secretProfile.status, 400); - assert.equal(secretProfile.body.error.code, "device_pod_profile_secret_forbidden"); - assert.equal(JSON.stringify(secretProfile.body).includes("should-not-be-stored"), false); - - const emptyLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - assert.equal(emptyLogin.status, 200); - const aliceCookie = emptyLogin.cookie; - const emptyList = await getJson(port, "/v1/device-pods", aliceCookie); - assert.equal(emptyList.status, 200); - assert.deepEqual(emptyList.body.devicePods, []); - - await grantDevicePodAccess(port, adminCookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator", "job_submitter"]); - await grantDevicePodAccess(port, adminCookie, bobCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator", "job_submitter"]); - - const visible = await getJson(port, "/v1/device-pods", aliceCookie); - assert.equal(visible.status, 200); - assert.equal(visible.body.contractVersion, "device-pod-authority-v1"); - assert.equal(visible.body.source.kind, "CLOUD_API_PROFILE_AUTHORITY"); - assert.equal(visible.body.source.fake, false); - assert.equal(visible.body.devicePods.length, 1); - assert.equal(visible.body.devicePods[0].devicePodId, "device-pod-71-freq"); - assert.match(visible.body.devicePods[0].profileHash, /^sha256:/u); - - const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", aliceCookie); - assert.equal(status.status, 200); - assert.equal(status.body.devicePodId, "device-pod-71-freq"); - assert.equal(status.body.targetId, "target-71-freq"); - assert.equal(status.body.profileHash, visible.body.devicePods[0].profileHash); - assert.match(status.body.traceId, /^trc_devicepod_/u); - assert.match(status.body.operationId, /^op_devicepod_/u); - assert.equal(status.body.status, "ok"); - assert.equal(status.body.freshness.stale, false); - assert.equal(status.body.blocker, null); - assert.equal(status.body.truncation.truncated, false); - assert.equal(status.body.output.summary, "device-pod status ok"); - - const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "workspace.ls", - args: { path: "." } - }, aliceCookie); - assert.equal(job.status, 409); - assert.equal(job.body.status, "blocked"); - assert.equal(job.body.blocker.code, "device_pod_executor_unavailable"); - assert.equal(job.body.blocker.summary, "HWLAB_DEVICE_POD_URL is not configured; cloud-api will not bypass hwlab-device-pod executor"); - assert.equal(job.body.devicePodId, "device-pod-71-freq"); - assert.equal(job.body.profileHash, visible.body.devicePods[0].profileHash); - assert.equal(directGatewayDispatches, 0); - - const mutatingWithReason = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "debug.reset", - reason: "reset smoke" - }, aliceCookie); - assert.equal(mutatingWithReason.status, 409); - assert.equal(mutatingWithReason.body.status, "blocked"); - assert.equal(mutatingWithReason.body.devicePodId, "device-pod-71-freq"); - assert.equal(mutatingWithReason.body.profileHash, visible.body.devicePods[0].profileHash); - assert.equal(mutatingWithReason.body.blocker.code, "device_pod_executor_unavailable"); - assert.equal(mutatingWithReason.body.freshness.stale, true); - const blockedJob = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithReason.body.job.id}`, aliceCookie); - assert.equal(blockedJob.status, 200); - assert.equal(blockedJob.body.job.id, mutatingWithReason.body.job.id); - assert.equal(blockedJob.body.status, "blocked"); - assert.equal(blockedJob.body.blocker.code, "device_pod_executor_unavailable"); - const blockedOutput = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithReason.body.job.id}/output`, aliceCookie); - assert.equal(blockedOutput.status, 200); - assert.equal(blockedOutput.body.truncation.truncated, false); - assert.match(blockedOutput.body.output.error, /HWLAB_DEVICE_POD_URL is not configured/u); - - const mutatingWithoutReason = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "debug.reset" - }, aliceCookie); - assert.equal(mutatingWithoutReason.status, 400); - assert.equal(mutatingWithoutReason.body.error.code, "device_job_reason_required"); - assert.equal(mutatingWithoutReason.body.status, "blocked"); - assert.equal(mutatingWithoutReason.body.devicePodId, "device-pod-71-freq"); - assert.equal(mutatingWithoutReason.body.profileHash, visible.body.devicePods[0].profileHash); - assert.equal(mutatingWithoutReason.body.blocker.code, "device_job_reason_required"); - assert.equal(mutatingWithoutReason.body.freshness.stale, true); - - const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" }); - assert.equal(bobLogin.status, 200); - const bobListWithGrant = await getJson(port, "/v1/device-pods", bobLogin.cookie); - assert.equal(bobListWithGrant.status, 200); - assert.equal(bobListWithGrant.body.devicePods.length, 1); - - const privatePodCreate = await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-private", - profile: { schemaVersion: 1, target: { id: "target-private" }, route: { gatewaySessionId: "gws_private" } } - }, adminCookie); - assert.equal(privatePodCreate.status, 201); - const bobPrivateStatus = await getJson(port, "/v1/device-pods/device-pod-private/status", bobLogin.cookie); - assert.equal(bobPrivateStatus.status, 403); - assert.equal(bobPrivateStatus.body.error.code, "device_pod_forbidden"); - const bobMissingStatus = await getJson(port, "/v1/device-pods/device-pod-missing/status", bobLogin.cookie); - assert.equal(bobMissingStatus.status, 404); - assert.equal(bobMissingStatus.body.error.code, "device_pod_not_found"); - - const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceCookie); - assert.equal(events.status, 200); - assert.equal(events.body.events[0].blocker.code, "device_pod_executor_unavailable"); - - const unsupported = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "debug.erase-all", - args: {} - }, aliceCookie); - assert.equal(unsupported.status, 400); - assert.equal(unsupported.body.error.code, "unsupported_device_job_intent"); - - await revokeDevicePodAccess(port, adminCookie, bobCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator", "job_submitter"]); - - const afterRevoke = await getJson(port, "/v1/device-pods", bobLogin.cookie); - assert.equal(afterRevoke.status, 200); - assert.deepEqual(afterRevoke.body.devicePods, []); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api admin access API grants, checks, and revokes device-pod and tool permissions", async () => { - const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); - const accessController = createAccessController({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - openFgaAuthorizer, - now: () => "2026-06-04T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - accessController, - now: () => "2026-06-04T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const setup = await postJson(port, "/v1/setup/first-admin", { username: "access-admin", password: "admin-pass" }); - assert.equal(setup.status, 201); - const adminCookie = setup.cookie; - - const alice = await postJson(port, "/v1/admin/users", { username: "access-alice", password: "alice-pass", displayName: "Access Alice" }, adminCookie); - assert.equal(alice.status, 201); - const userId = alice.body.user.id; - - const pod = await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-access-api", - name: "Access API Pod", - profile: { schemaVersion: 1, target: { id: "target-access-api" }, route: { gatewaySessionId: "gws_access_api" } } - }, adminCookie); - assert.equal(pod.status, 201); - - const summary = await getJson(port, "/v1/admin/access/summary", adminCookie); - assert.equal(summary.status, 200); - assert.equal(summary.body.contractVersion, "admin-access-v1"); - assert.equal(summary.body.openfga.mode, "enforce"); - assert.equal(summary.body.counts.users, 2); - assert.equal(summary.body.counts.devicePods, 1); - assert.deepEqual(summary.body.supported.devicePodRelations, ["viewer", "operator", "profile_editor", "job_submitter"]); - - const users = await getJson(port, "/v1/admin/access/users", adminCookie); - assert.equal(users.status, 200); - assert.ok(users.body.users.some((item) => item.user.id === userId)); - - const grantViewer = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-access-api/viewer`, {}, adminCookie); - assert.equal(grantViewer.status, 201); - const matrixAfterViewer = grantViewer.body.access.devicePods.find((item) => item.devicePod.devicePodId === "device-pod-access-api"); - assert.equal(matrixAfterViewer.relations.viewer, true); - assert.equal(matrixAfterViewer.relations.operator, false); - - const grantTool = await putJson(port, `/v1/admin/access/users/${userId}/tools/hwpod/can-use`, {}, adminCookie); - assert.equal(grantTool.status, 201); - assert.equal(grantTool.body.access.tools.hwpod, true); - - const checkViewer = await postJson(port, "/v1/admin/access/check", { - userId, - relation: "viewer", - object: "device_pod:device-pod-access-api" - }, adminCookie); - assert.equal(checkViewer.status, 200); - assert.equal(checkViewer.body.authorization.allowed, true); - assert.equal(checkViewer.body.authorization.mode, "enforce"); - - const aliceLogin = await postJson(port, "/auth/login", { username: "access-alice", password: "alice-pass" }); - assert.equal(aliceLogin.status, 200); - const visible = await getJson(port, "/v1/device-pods", aliceLogin.cookie); - assert.equal(visible.status, 200); - assert.deepEqual(visible.body.devicePods.map((item) => item.devicePodId), ["device-pod-access-api"]); - - const revokeTool = await deleteJson(port, `/v1/admin/access/users/${userId}/tools/hwpod/can-use`, {}, adminCookie); - assert.equal(revokeTool.status, 200); - assert.equal(revokeTool.body.access.tools.hwpod, false); - - const revokeViewer = await deleteJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-access-api/viewer`, {}, adminCookie); - assert.equal(revokeViewer.status, 200); - const matrixAfterRevoke = revokeViewer.body.access.devicePods.find((item) => item.devicePod.devicePodId === "device-pod-access-api"); - assert.equal(matrixAfterRevoke.relations.viewer, false); - - const hidden = await getJson(port, "/v1/device-pods", aliceLogin.cookie); - assert.equal(hidden.status, 200); - assert.deepEqual(hidden.body.devicePods, []); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api enforce mode uses OpenFGA relations for device-pod visibility and jobs", async () => { - const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); - const accessController = createAccessController({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - openFgaAuthorizer, - now: () => "2026-06-04T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" - }, - accessController, - now: () => "2026-06-04T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - assert.equal(adminLogin.status, 200); - const adminCookie = adminLogin.cookie; - - const alice = await postJson(port, "/v1/admin/users", { username: "enforce-alice", password: "alice-pass" }, adminCookie); - assert.equal(alice.status, 201); - const userId = alice.body.user.id; - - const pod = await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-enforce", - profile: { schemaVersion: 1, target: { id: "target-enforce" }, route: { gatewaySessionId: "gws_enforce" } } - }, adminCookie); - assert.equal(pod.status, 201); - - const viewer = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-enforce/viewer`, {}, adminCookie); - assert.equal(viewer.status, 201); - assert.equal(openFgaAuthorizer.tuples.has(`user:${userId}#viewer@device_pod:device-pod-enforce`), true); - - const profileCheck = await postJson(port, "/v1/admin/access/check", { - userId, - relation: "profile_editor", - object: "device_pod:device-pod-enforce" - }, adminCookie); - assert.equal(profileCheck.status, 200); - assert.equal(profileCheck.body.authorization.allowed, false); - assert.equal(profileCheck.body.authorization.mode, "enforce"); - - const aliceLogin = await postJson(port, "/auth/login", { username: "enforce-alice", password: "alice-pass" }); - assert.equal(aliceLogin.status, 200); - const visible = await getJson(port, "/v1/device-pods", aliceLogin.cookie); - assert.equal(visible.status, 200); - assert.deepEqual(visible.body.devicePods.map((item) => item.devicePodId), ["device-pod-enforce"]); - - const deniedJob = await postJson(port, "/v1/device-pods/device-pod-enforce/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie); - assert.equal(deniedJob.status, 403); - assert.equal(deniedJob.body.error.code, "device_pod_authorization_denied"); - assert.match(deniedJob.body.blocker.summary, /relation operator/u); - - const operator = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-enforce/operator`, {}, adminCookie); - assert.equal(operator.status, 201); - const allowedJob = await postJson(port, "/v1/device-pods/device-pod-enforce/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie); - assert.equal(allowedJob.status, 409); - assert.equal(allowedJob.body.blocker.code, "device_pod_executor_unavailable"); - - const profileGrant = await putJson(port, `/v1/admin/access/users/${userId}/device-pods/device-pod-enforce/profile_editor`, {}, adminCookie); - assert.equal(profileGrant.status, 201); - const profileAllowed = await postJson(port, "/v1/admin/access/check", { - userId, - relation: "profile_editor", - object: "device_pod:device-pod-enforce" - }, adminCookie); - assert.equal(profileAllowed.status, 200); - assert.equal(profileAllowed.body.authorization.allowed, true); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - test("cloud api stores and restores Code Agent conversations by authenticated account", async () => { const server = createCloudApiServer({ env: { @@ -1386,7 +1012,7 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" }); const stored = await putJson(port, "/v1/agent/conversations/cnv_account_sync", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_account_sync", threadId: "thread-account-sync", lastTraceId: "trc_account_sync", @@ -1403,14 +1029,14 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac assert.equal(stored.body.conversation.status, "idle"); assert.equal(stored.body.conversation.messages.length, 2); - const aliceList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const aliceList = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(aliceList.status, 200); assert.equal(aliceList.body.count, 1); assert.equal(aliceList.body.defaultConversation.conversationId, "cnv_account_sync"); assert.equal(aliceList.body.defaultConversation.messages[1].text, "在"); assert.equal(aliceList.body.defaultConversation.valuesRedacted, true); - const bobList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", bobLogin.cookie); + const bobList = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", bobLogin.cookie); assert.equal(bobList.status, 200); assert.deepEqual(bobList.body.conversations, []); @@ -1418,10 +1044,10 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac assert.equal(bobDirect.status, 404); assert.equal(bobDirect.body.error.code, "agent_conversation_not_found"); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", conversationId: "cnv_account_sync", updatedByClient: "test-suite" }, aliceLogin.cookie); @@ -1429,7 +1055,7 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac assert.equal(selected.body.workspace.selectedConversationId, "cnv_account_sync"); const deleted = await deleteJson(port, "/v1/agent/conversations/cnv_account_sync", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", workspaceId: workspace.body.workspace.workspaceId, updatedByClient: "test-suite" }, aliceLogin.cookie); @@ -1438,7 +1064,7 @@ test("cloud api stores and restores Code Agent conversations by authenticated ac assert.equal(deleted.body.archivedCount, 1); assert.equal(deleted.body.workspace.selectedConversationId, null); - const afterDeleteList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const afterDeleteList = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(afterDeleteList.status, 200); assert.equal(afterDeleteList.body.count, 0); @@ -1479,7 +1105,7 @@ test("cloud api exposes terminal conversation status when stored session status const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue834", password: "alice-pass" }); const stored = await putJson(port, "/v1/agent/conversations/cnv_issue834_final", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue834_final", threadId: "thread-issue-834", sessionStatus: "running", @@ -1501,7 +1127,7 @@ test("cloud api exposes terminal conversation status when stored session status assert.equal(direct.body.conversation.lastTraceId, "trc_issue834_final"); assert.equal(direct.body.conversation.messages[1].text, "最终回复已经生成。"); - const list = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const list = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(list.status, 200); const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue834_final"); assert.ok(listed, "expected stale-running conversation to be listed"); @@ -1557,7 +1183,7 @@ test("cloud api repairs persisted final response fallback from terminal result", }); const stored = await putJson(port, "/v1/agent/conversations/cnv_issue834_fallback", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue834_fallback", threadId: "thread-issue-834-fallback", sessionStatus: "completed", @@ -1571,7 +1197,7 @@ test("cloud api repairs persisted final response fallback from terminal result", assert.equal(stored.body.conversation.messages[1].text, fallbackText); assert.equal(stored.body.conversation.status, "completed"); - const list = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const list = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(list.status, 200); const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue834_fallback"); assert.ok(listed, "expected fallback conversation to be listed"); @@ -1588,10 +1214,10 @@ test("cloud api repairs persisted final response fallback from terminal result", assert.equal(direct.body.conversation.messages[1].text, finalText); assert.equal(direct.body.conversation.messages[1].status, "idle"); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", conversationId: "cnv_issue834_fallback", sessionId: "ses_issue834_fallback", updatedByClient: "test-suite" @@ -1646,7 +1272,7 @@ test("cloud api repairs conversation snapshot when persisted result advances las }); const stored = await putJson(port, "/v1/agent/conversations/cnv_issue842_snapshot", { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId: "ses_issue842_snapshot", threadId: "thread-issue-842-snapshot", sessionStatus: "idle", @@ -1660,7 +1286,7 @@ test("cloud api repairs conversation snapshot when persisted result advances las assert.equal(stored.body.conversation.lastTraceId, "trc_issue842_persisted_final"); assert.equal(stored.body.conversation.messages.at(-1).text, "上一轮回复"); - const list = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const list = await getJson(port, "/v1/agent/conversations?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(list.status, 200); const listed = list.body.conversations.find((conversation) => conversation.conversationId === "cnv_issue842_snapshot"); assert.ok(listed, "expected conversation to be listed"); @@ -1674,10 +1300,10 @@ test("cloud api repairs conversation snapshot when persisted result advances las assert.equal(direct.body.conversation.messages.at(-1).traceId, "trc_issue842_persisted_final"); assert.equal(direct.body.conversation.messages.at(-1).text, finalText); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", conversationId: "cnv_issue842_snapshot", sessionId: "ses_issue842_snapshot", updatedByClient: "test-suite" @@ -1722,15 +1348,15 @@ test("cloud api terminal workspace sync preserves saved messages and stores runn const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue853-fast-fail", password: "alice-pass" }, adminLogin.cookie); assert.equal(aliceCreate.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue853-fast-fail", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); const savedMessages = [ - { id: "msg_issue853_user", role: "user", title: "用户", text: "请执行 hwpod profile list", status: "sent", conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" }, + { id: "msg_issue853_user", role: "user", title: "用户", text: "请执行 hwpod inspect", status: "sent", conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" }, { id: "msg_issue853_pending", role: "agent", title: "Code Agent 处理中", text: "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。", status: "running", traceId, conversationId, sessionId, threadId, createdAt: "2026-06-04T10:24:59.000Z" } ]; const stored = await putJson(port, `/v1/agent/conversations/${conversationId}`, { - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", sessionId, threadId, sessionStatus: "active", @@ -1742,7 +1368,7 @@ test("cloud api terminal workspace sync preserves saved messages and stores runn await accessController.recordAgentSessionOwner({ ownerUserId: aliceCreate.body.user.id, sessionId, - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", agentId: "hwlab-code-agent", status: "failed", conversationId, @@ -1761,7 +1387,7 @@ test("cloud api terminal workspace sync preserves saved messages and stores runn }); const preserved = await accessController.getAgentSession(sessionId); assert.equal(preserved.session.messages.length, 2); - assert.equal(preserved.session.messages[0].text, "请执行 hwpod profile list"); + assert.equal(preserved.session.messages[0].text, "请执行 hwpod inspect"); codeAgentChatResults.set(traceId, { status: "failed", @@ -1793,13 +1419,13 @@ test("cloud api terminal workspace sync preserves saved messages and stores runn assert.equal(selected.status, 200); assert.equal(selected.body.workspace.activeTraceId, traceId); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.activeTraceId, null); const messages = restored.body.workspace.selectedConversation.messages; assert.equal(messages.length, 2); assert.equal(messages[0].role, "user"); - assert.equal(messages[0].text, "请执行 hwpod profile list"); + assert.equal(messages[0].text, "请执行 hwpod inspect"); assert.equal(messages[1].role, "agent"); assert.equal(messages[1].status, "failed"); assert.equal(messages[1].text, "HyueAPI 403 INSUFFICIENT_BALANCE"); @@ -1808,7 +1434,7 @@ test("cloud api terminal workspace sync preserves saved messages and stores runn assert.equal(messages[1].runnerTrace.eventsCompacted, true); assert.equal(messages[1].runnerTrace.fullTraceLoaded, false); assert.equal(restored.body.workspace.selectedConversation.messageCount, 2); - assert.equal(restored.body.workspace.selectedConversation.firstUserMessagePreview, "请执行 hwpod profile list"); + assert.equal(restored.body.workspace.selectedConversation.firstUserMessagePreview, "请执行 hwpod inspect"); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -1822,7 +1448,7 @@ test("cloud api terminal workspace sync rebuilds missing user message from saved const conversationId = "cnv_issue853_preview_only"; const sessionId = "ses_issue853_preview_only"; const threadId = "thread-issue-853-preview-only"; - const userText = "请执行 hwpod profile list,并在最终回复里原样包含 HWLAB-853-FINAL-preview-only"; + const userText = "请执行 hwpod inspect,并在最终回复里原样包含 HWLAB-853-FINAL-preview-only"; const env = { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", @@ -1842,13 +1468,13 @@ test("cloud api terminal workspace sync rebuilds missing user message from saved const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice-issue853-preview-only", password: "alice-pass" }, adminLogin.cookie); assert.equal(aliceCreate.status, 201); const aliceLogin = await postJson(port, "/auth/login", { username: "alice-issue853-preview-only", password: "alice-pass" }); - const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(workspace.status, 200); await accessController.recordAgentSessionOwner({ ownerUserId: aliceCreate.body.user.id, sessionId, - projectId: "prj_device_pod_workbench", + projectId: "prj_hwpod_workbench", agentId: "hwlab-code-agent", status: "failed", conversationId, @@ -1898,7 +1524,7 @@ test("cloud api terminal workspace sync rebuilds missing user message from saved }, aliceLogin.cookie); assert.equal(selected.status, 200); - const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie); + const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_hwpod_workbench", aliceLogin.cookie); assert.equal(restored.status, 200); assert.equal(restored.body.workspace.activeTraceId, null); const messages = restored.body.workspace.selectedConversation.messages; @@ -1921,14 +1547,14 @@ test("cloud api terminal workspace sync rebuilds missing user message from saved }); test("access controller preserves user message when owner evidence updates with agent-only result", async () => { - const projectId = "prj_device_pod_workbench"; + const projectId = "prj_hwpod_workbench"; const ownerUserId = "usr_issue853_owner_merge"; const conversationId = "cnv_issue853_owner_merge"; const sessionId = "ses_issue853_owner_merge"; const traceId = "trc_issue853_owner_merge"; const threadId = "thread-issue-853-owner-merge"; const nowValue = "2026-06-04T10:45:00.000Z"; - const userText = "请执行 hwpod profile list,并在最终回复里原样包含 HWLAB-853-FINAL-owner-merge"; + const userText = "请执行 hwpod inspect,并在最终回复里原样包含 HWLAB-853-FINAL-owner-merge"; const accessController = createAccessController({ now: () => nowValue }); await accessController.recordAgentSessionOwner({ @@ -2077,26 +1703,7 @@ test("access controller preserves AgentRun runner mapping across conversation sn assert.equal(restored.session.agentRun.reuseEligible, true); }); -test("cloud api protects device-pod routes when access control is required", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/device-pods`); - assert.equal(response.status, 401); - const payload = await response.json(); - assert.equal(payload.error.code, "auth_required"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api accepts user API key Bearer auth for authorized device access", async () => { - const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); +test("cloud api accepts user API keys as Bearer auth for the same account", async () => { const accessController = createAccessController({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", @@ -2104,7 +1711,6 @@ test("cloud api accepts user API key Bearer auth for authorized device access", HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" }, - openFgaAuthorizer, now: () => "2026-05-28T00:00:00.000Z" }); const server = createCloudApiServer({ @@ -2122,539 +1728,19 @@ test("cloud api accepts user API key Bearer auth for authorized device access", try { const { port } = server.address(); const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } - }, adminLogin.cookie); - const userApiKey = (await postJson(port, "/v1/api-keys", { name: "Device access test" }, adminLogin.cookie)).body.key.displaySecret; - assert.match(userApiKey, /^hwl_live_/u); + assert.equal(adminLogin.status, 200); - const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", null, { - authorization: `Bearer ${userApiKey}` - }); - assert.equal(status.status, 200); - assert.equal(status.body.devicePodId, "device-pod-71-freq"); - assert.equal(status.body.actor.id, "usr_v02_admin"); - assert.equal(status.body.actor.role, "admin"); - assert.equal(JSON.stringify(status.body).includes(userApiKey), false); + const createdKey = await postJson(port, "/v1/api-keys", { name: "Code Agent runner" }, adminLogin.cookie); + assert.equal(createdKey.status, 201); + const apiKey = createdKey.body.key.displaySecret; + assert.ok(apiKey.startsWith("hwl_live_")); - const adminWithUserApiKey = await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-user-api-key-admin", - profile: { schemaVersion: 1, target: { id: "target-blocked" }, route: { gatewaySessionId: "gws_unused" } } - }, null, { - authorization: `Bearer ${userApiKey}` - }); - assert.equal(adminWithUserApiKey.status, 201); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api dispatches authorized device jobs to the internal device-pod executor", async () => { - const executorRequests = []; - const executor = createServer(async (request, response) => { - const body = await requestJson(request); - executorRequests.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], internalToken: request.headers["x-hwlab-internal-token"], body }); - if (request.method === "POST" && body.args?.path === "src") { - response.writeHead(202, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: true, - status: "running", - contractVersion: "device-pod-executor-v1", - devicePodId: "device-pod-71-freq", - traceId: body.traceId, - operationId: body.operationId, - job: { id: body.jobId, devicePodId: body.devicePodId, status: "running", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId }, - blocker: null - })); - return; - } - if (request.method === "GET" && request.url.endsWith("/output")) { - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: true, - status: "completed", - contractVersion: "device-pod-executor-v1", - devicePodId: "device-pod-71-freq", - traceId: "trc_executor_refresh", - operationId: "op_executor_refresh", - job: { id: request.url.split("/").at(-2), devicePodId: "device-pod-71-freq", status: "completed", intent: "workspace.ls" }, - output: { - executor: { status: "completed" }, - output: { dispatch: { exitCode: 0, stdoutBytes: 15 } }, - text: "executor output", - bytes: 15, - truncation: { maxBytes: 12000, truncated: false } - } - })); - return; - } - if (request.method === "POST" && body.intent === "workspace.evidence" && body.args?.kind === "verify") { - const text = JSON.stringify({ - ok: true, - action: "workspace.build.verify", - data: { - buildJob: { jobId: "keil-build-latest", status: "completed", success: true }, - artifacts: [{ kind: "hex", exists: true, size: 42, headMagicB64: "Og==" }], - missing: [] - } - }); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: true, - status: "completed", - contractVersion: "device-pod-executor-v1", - devicePodId: "device-pod-71-freq", - traceId: body.traceId, - operationId: body.operationId, - job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId }, - output: { text, bytes: Buffer.byteLength(text, "utf8"), truncation: { maxBytes: 12000, truncated: false } } - })); - return; - } - response.writeHead(409, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: false, - status: "blocked", - contractVersion: "device-pod-executor-v1", - devicePodId: "device-pod-71-freq", - traceId: body.traceId, - operationId: body.operationId, - job: { id: body.jobId, devicePodId: body.devicePodId, status: "blocked", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId }, - blocker: { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary: "executor test blocker" }, - output: { text: "executor output", bytes: 15, truncation: { maxBytes: 12000, truncated: false } } - })); - }); - await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); - const executorPort = executor.address().port; - const accessEnv = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }; - const accessController = createAccessController({ - env: accessEnv, - openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: accessEnv, - accessController, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } - }, adminLogin.cookie); - await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator"]); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - - const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie); - assert.equal(job.status, 409); - assert.equal(job.body.blocker.code, "gateway_dispatch_unavailable"); - assert.equal(job.body.job.status, "blocked"); - assert.equal(executorRequests.length, 1); - assert.equal(executorRequests[0].internalService, "hwlab-cloud-api"); - assert.equal(executorRequests[0].internalToken, INTERNAL_TOKEN); - assert.equal(executorRequests[0].body.profileHash, job.body.profileHash); - assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id); - assert.equal(executorRequests[0].body.intent, "workspace.ls"); - - const stored = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${job.body.job.id}`, aliceLogin.cookie); - assert.equal(stored.status, 200); - assert.equal(stored.body.job.id, job.body.job.id); - assert.equal(stored.body.blocker.code, "gateway_dispatch_unavailable"); - - const runningJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "src" } }, aliceLogin.cookie); - assert.equal(runningJob.status, 202); - assert.equal(runningJob.body.status, "running"); - const output = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${runningJob.body.job.id}/output`, aliceLogin.cookie); - assert.equal(output.status, 200); - assert.equal(output.body.status, "completed"); - assert.equal(output.body.blocker, null); - assert.equal(output.body.freshness.stale, false); - assert.equal(output.body.output.text, "executor output"); - assert.deepEqual(output.body.output.output.dispatch, { exitCode: 0, stdoutBytes: 15 }); - assert.equal(output.body.truncation.truncated, false); - assert.ok(executorRequests.some((item) => item.method === "GET" && item.url.endsWith(`/jobs/${runningJob.body.job.id}/output`))); - - const verifyJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.evidence", args: { kind: "verify", expected: "axf,hex", headBytes: 16 } }, aliceLogin.cookie); - assert.equal(verifyJob.status, 200); - assert.equal(verifyJob.body.status, "completed"); - assert.equal(verifyJob.body.job.intent, "workspace.evidence"); - assert.equal(verifyJob.body.outputUrl.endsWith("/output"), true); - const verify = JSON.parse(verifyJob.body.output.text); - assert.equal(verify.action, "workspace.build.verify"); - assert.equal(verify.data.buildJob.jobId, "keil-build-latest"); - assert.equal(verify.data.artifacts[0].headMagicB64, "Og=="); - assert.equal(verifyJob.body.truncation.truncated, false); - assert.equal(executorRequests.some((item) => item.method === "GET" && item.url.endsWith(`/jobs/${verifyJob.body.job.id}/output`)), false); - - const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceLogin.cookie); - const completedEvent = events.body.events.find((event) => event.refs.jobId === runningJob.body.job.id); - assert.equal(completedEvent.status, "completed"); - assert.equal(completedEvent.blocker, null); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api waits longer for synchronous device-pod build verify output (HWLAB #821)", async () => { - const executor = createServer(async (request, response) => { - const body = await requestJson(request); - await new Promise((resolve) => setTimeout(resolve, 1500)); - const text = JSON.stringify({ ok: true, action: "workspace.build.verify", data: { buildJob: { jobId: "keil-build-slow" }, artifacts: [], missing: [] } }); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: true, - status: "completed", - contractVersion: "device-pod-executor-v1", - devicePodId: "device-pod-71-freq", - traceId: body.traceId, - operationId: body.operationId, - job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId }, - output: { text, bytes: Buffer.byteLength(text, "utf8"), truncation: { maxBytes: 12000, truncated: false } } - })); - }); - await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); - const executorPort = executor.address().port; - const accessEnv = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }; - const accessController = createAccessController({ - env: accessEnv, - openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: accessEnv, - accessController, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const userCreate = await postJson(port, "/v1/admin/users", { username: "slow-alice", password: "alice-pass" }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } - }, adminLogin.cookie); - await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator"]); - const aliceLogin = await postJson(port, "/auth/login", { username: "slow-alice", password: "alice-pass" }); - - const verifyJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.evidence", args: { kind: "verify", expected: "axf,hex" } }, aliceLogin.cookie); - assert.equal(verifyJob.status, 200); - assert.equal(verifyJob.body.status, "completed"); - const verify = JSON.parse(verifyJob.body.output.text); - assert.equal(verify.action, "workspace.build.verify"); - assert.equal(verify.data.buildJob.jobId, "keil-build-slow"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api bounds device-pod job output payloads", async () => { - const longText = "x".repeat(65000); - const executor = createServer(async (request, response) => { - const body = await requestJson(request); - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: true, - status: "completed", - contractVersion: "device-pod-executor-v1", - devicePodId: "device-pod-71-freq", - traceId: body.traceId, - operationId: body.operationId, - job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent }, - output: { text: longText, nested: { kept: true } }, - text: longText - })); - }); - await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); - const executorPort = executor.address().port; - const accessEnv = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }; - const accessController = createAccessController({ - env: accessEnv, - openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: accessEnv, - accessController, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } - }, adminLogin.cookie); - await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer", "operator"]); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - - const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie); - assert.equal(job.status, 200); - const output = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${job.body.job.id}/output`, aliceLogin.cookie); - assert.equal(output.status, 200); - assert.equal(output.body.bytes, 64000); - assert.equal(output.body.text.length, 64000); - assert.equal(output.body.output.text.length, 64000); - assert.equal(output.body.truncation.truncated, true); - assert.equal(output.body.truncation.originalBytes, 65000); - assert.equal(output.body.output.omitted.reason, "device_job_output_truncated"); - assert.equal(JSON.stringify(output.body).includes(longText), false); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("DEVICE_JOB_INTENTS accepts workspace.evidence and debug.evidence for v0.2 #773", () => { - const fs = require("node:fs"); - const path = require("node:path"); - const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8"); - assert.match(src, /"workspace\.evidence"/u, "workspace.evidence must be added to DEVICE_JOB_INTENTS"); - assert.match(src, /"debug\.evidence"/u, "debug.evidence must be added to DEVICE_JOB_INTENTS"); -}); - -test("_deviceJobRequiresReason helper considers sub-action for workspace.build / debug.download", () => { - const fs = require("node:fs"); - const path = require("node:path"); - const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8"); - assert.match(src, /function _deviceJobRequiresReason\(intent, args, reason\)/u, "_deviceJobRequiresReason must accept reason"); - assert.match(src, /DEVICE_JOB_READ_ONLY_SUB_ACTIONS\.has\(action\)/u, "must consult DEVICE_JOB_READ_ONLY_SUB_ACTIONS"); - assert.match(src, /!DEVICE_JOB_ACTIONABLE_INTENTS\.has\(intent\)\s*\)\s*return\s*true/u, "non-actionable mutating intent must always require reason"); -}); - -test("executorOutputPayload extracts text from evidence and summary fields", () => { - const fs = require("node:fs"); - const path = require("node:path"); - const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8"); - const m = src.match(/function executorOutputPayload[\s\S]+?\n\}/u); - assert.ok(m, "executorOutputPayload not found"); - const body = m[0]; - assert.match(body, /evidence[\s\S]*?\.text/u, "must check evidence.text"); - assert.match(body, /evidence[\s\S]*?\.logTail/u, "must check evidence.logTail"); - assert.match(body, /evidence[\s\S]*?\.summary/u, "must check evidence.summary"); - assert.match(body, /output\.summary/u, "must check output.summary"); - assert.match(body, /nestedOutput\.summary/u, "must check nestedOutput.summary"); -}); - -test("cloud api routes device-pod probe GET requests through executor jobs", async () => { - const executorRequests = []; - const executor = createServer(async (request, response) => { - const body = await requestJson(request); - executorRequests.push({ method: request.method, url: request.url, body }); - const text = body.intent === "debug.chip-id" ? "chip-id: 0x12345678" : "uart tail output"; - response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); - response.end(JSON.stringify({ - accepted: true, - status: "completed", - contractVersion: "device-pod-executor-v1", - devicePodId: body.devicePodId, - traceId: body.traceId, - operationId: body.operationId, - job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent }, - output: { text }, - text - })); - }); - await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve)); - const executorPort = executor.address().port; - const accessEnv = { - HWLAB_ACCESS_CONTROL_REQUIRED: "1", - HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", - HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass", - HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN, - HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}` - }; - const accessController = createAccessController({ - env: accessEnv, - openFgaAuthorizer: createFakeOpenFgaAuthorizer({ mode: "enforce" }), - now: () => "2026-05-28T00:00:00.000Z" - }); - const server = createCloudApiServer({ - env: accessEnv, - accessController, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); - const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie); - await postJson(port, "/v1/admin/device-pods", { - devicePodId: "device-pod-71-freq", - profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } } - }, adminLogin.cookie); - await grantDevicePodAccess(port, adminLogin.cookie, userCreate.body.user.id, "device-pod-71-freq", ["viewer"]); - const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); - - const chip = await getJson(port, "/v1/device-pods/device-pod-71-freq/debug-probe/chip-id", aliceLogin.cookie); - assert.equal(chip.status, 200); - assert.equal(chip.body.interface, "debug-probe"); - assert.equal(chip.body.intent, "debug.chip-id"); - assert.equal(chip.body.job.intent, "debug.chip-id"); - assert.equal(chip.body.output.text, "chip-id: 0x12345678"); - assert.equal(chip.body.source.fake, false); - - const uart = await getJson(port, "/v1/device-pods/device-pod-71-freq/io-probe/uart/1/tail?durationMs=250&maxBytes=20", aliceLogin.cookie); - assert.equal(uart.status, 200); - assert.equal(uart.body.interface, "io-probe"); - assert.equal(uart.body.intent, "io.uart.read"); - assert.equal(uart.body.output.text, "uart tail output"); - assert.equal(uart.body.truncation.maxBytes, 20); - - assert.equal(executorRequests.length, 2); - assert.equal(executorRequests[0].method, "POST"); - assert.equal(executorRequests[0].body.intent, "debug.chip-id"); - assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id); - assert.equal(executorRequests[1].body.intent, "io.uart.read"); - assert.deepEqual(executorRequests[1].body.args, { uartId: "uart/1", durationMs: 250, maxBytes: 20 }); - - const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceLogin.cookie); - assert.equal(events.status, 200); - assert.ok(events.body.events.some((event) => event.intent === "debug.chip-id")); - assert.ok(events.body.events.some((event) => event.intent === "io.uart.read")); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api internal device-pod gateway dispatch is service-only and fail-closed without online gateway", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN }, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const external = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", { params: {} }); - assert.equal(external.status, 403); - assert.equal(external.body.error.code, "device_pod_internal_authority_required"); - - const headerOnly = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", { - id: "req_devicepod_dispatch_test", - params: { - gatewaySessionId: "gws_missing", - resourceId: "res_devicepod_test", - capabilityId: "cap_device_host_cli", - operationId: "op_devicepod_dispatch_test", - traceId: "trc_devicepod_dispatch_test", - input: { command: "node tools/device-host-cli.mjs health" } - } - }, null, { "x-hwlab-internal-service": "hwlab-device-pod" }); - assert.equal(headerOnly.status, 403); - assert.equal(headerOnly.body.error.code, "device_pod_internal_authority_required"); - - const blocked = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", { - id: "req_devicepod_dispatch_test", - params: { - gatewaySessionId: "gws_missing", - resourceId: "res_devicepod_test", - capabilityId: "cap_device_host_cli", - operationId: "op_devicepod_dispatch_test", - traceId: "trc_devicepod_dispatch_test", - input: { command: "node tools/device-host-cli.mjs health" } - } - }, null, devicePodInternalHeaders()); - assert.equal(blocked.status, 409); - assert.equal(blocked.body.blocker.code, "gateway_dispatch_unavailable"); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - -test("cloud api internal device-pod gateway dispatch uses gateway poll result", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_ENVIRONMENT: "v02", HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN }, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const poll = await postJson(port, "/v1/gateway/poll", { - serviceId: "hwlab-gateway", - gatewayId: "gtw_devicepod_test", - gatewaySessionId: "gws_devicepod_test", - resourceId: "res_devicepod_test", - capabilities: [{ capabilityId: "cap_device_host_cli", resourceId: "res_devicepod_test" }] - }); - assert.equal(poll.status, 200); - - const dispatchPromise = postJson(port, "/v1/internal/device-pod/gateway-dispatch", { - id: "req_devicepod_dispatch_test", - params: { - gatewaySessionId: "gws_devicepod_test", - resourceId: "res_devicepod_test", - capabilityId: "cap_device_host_cli", - operationId: "op_devicepod_dispatch_test", - traceId: "trc_devicepod_dispatch_test", - input: { command: "node tools/device-host-cli.mjs health", timeoutMs: 1000 } - } - }, null, devicePodInternalHeaders()); - - const queued = await postJson(port, "/v1/gateway/poll", { gatewayId: "gtw_devicepod_test", gatewaySessionId: "gws_devicepod_test" }); - assert.equal(queued.status, 200); - assert.equal(queued.body.type, "request"); - assert.equal(queued.body.request.meta.environment, "v02"); - assert.equal(queued.body.request.method, "hardware.invoke.shell"); - assert.equal(queued.body.request.params.input.command, "node tools/device-host-cli.mjs health"); - - const result = await postJson(port, "/v1/gateway/result", { - gatewayId: "gtw_devicepod_test", - gatewaySessionId: "gws_devicepod_test", - response: { - jsonrpc: "2.0", - id: queued.body.request.id, - result: { - accepted: true, - status: "succeeded", - shellExecuted: true, - dispatchStatus: "succeeded", - stdout: "host cli ok", - exitCode: 0, - gatewaySessionId: "gws_devicepod_test" - } - } - }); - assert.equal(result.status, 200); - - const dispatch = await dispatchPromise; - assert.equal(dispatch.status, 200); - assert.equal(dispatch.body.meta.environment, "v02"); - assert.equal(dispatch.body.result.status, "completed"); - assert.equal(dispatch.body.result.dispatch.stdout, "host cli ok"); + const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); + assert.equal(me.status, 200); + assert.equal(me.body.authMethod, "api-key"); + assert.equal(me.body.actor.id, "usr_v02_admin"); + assert.equal(me.body.actor.role, "admin"); + assert.equal(JSON.stringify(me.body).includes(apiKey), false); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } @@ -2699,10 +1785,8 @@ test("cloud api exposes v1 access status routes and returns structured REST erro }); test("cloud api first-admin setup opens access when bootstrap secret is absent", async () => { - const openFgaAuthorizer = createFakeOpenFgaAuthorizer({ mode: "enforce" }); const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - accessController: createAccessController({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, openFgaAuthorizer, now: () => "2026-05-28T00:00:00.000Z" }), now: () => "2026-05-28T00:00:00.000Z" }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -2718,23 +1802,7 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent", const setup = await postJson(port, "/v1/setup/first-admin", { username: "admin", password: "admin-pass", - displayName: "Initial Admin", - devicePod: { - devicePodId: "device-pod-71-freq", - name: "71-FREQ", - profile: { - schemaVersion: 1, - devicePodId: "device-pod-seed-spoof", - target: { id: "target-71-freq" }, - route: { - gatewaySessionId: "gws_first_admin_seed", - resourceId: "res_windows_host", - capabilityId: "cap_device_host_cli", - hostWorkspaceRoot: "F:\\Work\\Project", - hostCli: "node tools/device-host-cli.mjs" - } - } - } + displayName: "Initial Admin" }); assert.equal(setup.status, 201); assert.equal(setup.body.created, true); @@ -2742,16 +1810,7 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent", assert.equal(setup.body.actor.role, "admin"); assert.equal(setup.body.actor.username, "admin"); assert.equal(setup.body.setupRequired, false); - assert.deepEqual(setup.body.devicePodBootstrap, { requested: 1, initialized: 1 }); - assert.equal(setup.body.devicePodsInitialized[0].devicePod.devicePodId, "device-pod-71-freq"); - assert.equal(setup.body.devicePodsInitialized[0].access.length, 4); - assert.equal(setup.body.devicePodsInitialized[0].access.every((item) => item.ok !== false), true); - assert.match(setup.body.devicePodsInitialized[0].devicePod.profileHash, /^sha256:/u); - assert.equal(setup.body.devicePodsInitialized[0].devicePod.profile.route.gatewaySessionId, "redacted"); - assert.equal(JSON.stringify(setup.body).includes("device-pod-seed-spoof"), false); assert.equal(JSON.stringify(setup.body).includes("admin-pass"), false); - assert.equal(JSON.stringify(setup.body).includes("gws_first_admin_seed"), false); - assert.equal(JSON.stringify(setup.body).includes("F:\\Work\\Project"), false); assert.equal(typeof setup.cookie, "string"); const session = await getJson(port, "/v1/users/me", setup.cookie); @@ -2766,20 +1825,6 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent", assert.equal(second.status, 409); assert.equal(second.body.error.code, "setup_already_completed"); - const pods = await getJson(port, "/v1/device-pods", setup.cookie); - assert.equal(pods.status, 200); - assert.equal(pods.body.devicePods.length, 1); - assert.equal(pods.body.devicePods[0].devicePodId, "device-pod-71-freq"); - assert.match(pods.body.devicePods[0].profileHash, /^sha256:/u); - - const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { - intent: "workspace.ls", - args: { path: "." } - }, setup.cookie); - assert.equal(job.status, 409); - assert.equal(job.body.devicePodId, "device-pod-71-freq"); - assert.equal(job.body.blocker.code, "device_pod_executor_unavailable"); - const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); assert.equal(login.status, 200); assert.equal(login.body.actor.role, "admin"); @@ -2844,69 +1889,6 @@ test("cloud api bootstrap password synchronizes existing admin", async () => { } }); -test("cloud api first-admin setup validates device-pod seed before creating admin", async () => { - const server = createCloudApiServer({ - env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, - now: () => "2026-05-28T00:00:00.000Z" - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const invalid = await postJson(port, "/v1/setup/first-admin", { - username: "admin", - password: "admin-pass", - devicePod: { devicePodId: "device-pod-71-freq" } - }); - assert.equal(invalid.status, 400); - assert.equal(invalid.body.error.code, "invalid_params"); - - const before = await getJson(port, "/v1/setup/status"); - assert.equal(before.status, 200); - assert.equal(before.body.setupRequired, true); - - const ambiguous = await postJson(port, "/v1/setup/first-admin", { - username: "admin", - password: "admin-pass", - devicePod: { devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1 } }, - devicePods: [{ devicePodId: "device-pod-alt", profile: { schemaVersion: 1 } }] - }); - assert.equal(ambiguous.status, 400); - assert.equal(ambiguous.body.error.code, "invalid_params"); - - const secretSeed = await postJson(port, "/v1/setup/first-admin", { - username: "admin", - password: "admin-pass", - devicePod: { - devicePodId: "device-pod-71-freq", - profile: { - schemaVersion: 1, - target: { id: "target-71-freq" }, - route: { gatewaySessionId: "gws_seed" }, - databaseUrl: "postgres://user:pass@example.invalid/hwlab" - } - } - }); - assert.equal(secretSeed.status, 400); - assert.equal(secretSeed.body.error.code, "device_pod_profile_secret_forbidden"); - assert.equal(JSON.stringify(secretSeed.body).includes("postgres://"), false); - - const afterSecretSeed = await getJson(port, "/v1/setup/status"); - assert.equal(afterSecretSeed.status, 200); - assert.equal(afterSecretSeed.body.setupRequired, true); - - const setup = await postJson(port, "/v1/setup/first-admin", { - username: "admin", - password: "admin-pass" - }); - assert.equal(setup.status, 201); - assert.equal(setup.body.actor.role, "admin"); - assert.deepEqual(setup.body.devicePodBootstrap, { requested: 0, initialized: 0 }); - } finally { - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); - } -}); - async function postJson(port, path, body, cookie = null, extraHeaders = {}) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { method: "POST", @@ -2924,10 +1906,6 @@ async function postJson(port, path, body, cookie = null, extraHeaders = {}) { }; } -function devicePodInternalHeaders(extra = {}) { - return { ...extra, "x-hwlab-internal-service": "hwlab-device-pod", "x-hwlab-internal-token": INTERNAL_TOKEN }; -} - async function putJson(port, path, body, cookie = null, extraHeaders = {}) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { method: "PUT", @@ -2992,20 +1970,6 @@ async function deleteJson(port, path, body = {}, cookie = null, extraHeaders = { }; } -async function grantDevicePodAccess(port, cookie, userId, devicePodId, relations) { - for (const relation of relations) { - const response = await putJson(port, `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, {}, cookie); - assert.equal(response.status, 201, `grant ${relation} should use Admin Access API`); - } -} - -async function revokeDevicePodAccess(port, cookie, userId, devicePodId, relations) { - for (const relation of relations) { - const response = await deleteJson(port, `/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, {}, cookie); - assert.equal(response.status, 200, `revoke ${relation} should use Admin Access API`); - } -} - function createFakeOpenFgaAuthorizer({ mode = "enforce" } = {}) { const tuples = new Set(); function key({ userId, relation, object }) { return `user:${userId}#${relation}@${object}`; } @@ -3018,7 +1982,7 @@ function createFakeOpenFgaAuthorizer({ mode = "enforce" } = {}) { async check({ actor, userId, relation, object }) { const subject = actor?.id ?? userId; const allowed = tuples.has(key({ userId: subject, relation, object })) || tuples.has(key({ userId: subject, relation: "admin", object: "system:hwlab" })); - return { contractVersion: "openfga-authorization-v1", mode, user: `user:${subject}`, relation, object, allowed, decisionSource: allowed && !tuples.has(key({ userId: subject, relation, object })) ? "fake-openfga-admin" : "fake-openfga", degraded: false }; + return { contractVersion: "openfga-authorization-v1", mode, user: `user:${subject}`, relation, object, allowed, decisionSource: "fake-openfga", degraded: false }; }, async writeTuple({ userId, relation, object }) { tuples.add(key({ userId, relation, object })); diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 0b040776..7cc5167f 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -8,11 +8,9 @@ import { } from "./code-agent-agentrun-adapter.ts"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts"; import { - DEVICE_POD_RELATIONS, HWLAB_TOOL_IDS, createOpenFgaAuthorizer, - openFgaObject, - parseOpenFgaObject + openFgaObject } from "./openfga-authorization.ts"; import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts"; @@ -36,30 +34,6 @@ const API_KEY_DEFAULT_NAME = "Default API key"; const AUTH_METHOD_API_KEY = "api-key"; const AUTH_METHOD_WEB_SESSION = "web-session"; const AUTH_METHOD_LEGACY_LOCAL_SESSION = "legacy-local-session"; -const DEVICE_JOB_CONTRACT_VERSION = "device-pod-job-v1"; -const DEVICE_JOB_INTENTS = new Set([ - "workspace.bootsharp", - "workspace.ls", - "workspace.cat", - "workspace.rg", - "workspace.apply-patch", - "workspace.put", - "workspace.rm", - "workspace.rmdir", - "workspace.keil", - "workspace.build", - "debug.status", - "debug.chip-id", - "debug.download", - "debug.reset", - "workspace.evidence", - "debug.evidence", - "io.ports", - "io.uart.read", - "io.uart.read-after-launch-flash", - "io.uart.write", - "io.uart.jsonrpc" -]); const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ `CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, @@ -141,7 +115,6 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ is_default BOOLEAN NOT NULL DEFAULT true, selected_conversation_id TEXT, selected_agent_session_id TEXT, - selected_device_pod_id TEXT, active_trace_id TEXT, provider_profile TEXT, workspace_json TEXT NOT NULL DEFAULT '{}', @@ -153,31 +126,6 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_account_workspaces_default ON account_workspaces(owner_user_id, project_id) WHERE is_default = TRUE AND status = 'active'`, `CREATE INDEX IF NOT EXISTS idx_account_workspaces_owner ON account_workspaces(owner_user_id, project_id, updated_at DESC)`, - `CREATE TABLE IF NOT EXISTS device_pods ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), - profile_json TEXT NOT NULL DEFAULT '{}', - profile_hash TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS device_pod_jobs ( - id TEXT PRIMARY KEY, - device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, - owner_user_id TEXT NOT NULL REFERENCES users(id), - status TEXT NOT NULL, - intent TEXT NOT NULL, - args_json TEXT NOT NULL DEFAULT '{}', - reason TEXT NOT NULL DEFAULT '', - trace_id TEXT NOT NULL, - operation_id TEXT NOT NULL, - output_json TEXT NOT NULL DEFAULT '{}', - blocker_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - completed_at TEXT - )`, `CREATE TABLE IF NOT EXISTS access_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -195,34 +143,6 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ `CREATE INDEX IF NOT EXISTS idx_access_tuples_object ON access_tuples(object, relation)` ]); -const MUTATING_INTENTS = new Set([ - "workspace.apply-patch", - "workspace.build", - "workspace.put", - "workspace.rm", - "workspace.rmdir", - "workspace.keil", - "debug.download", - "debug.reset", - "io.uart.read-after-launch-flash", - "io.uart.write", - "io.uart.jsonrpc" -]); -const DEVICE_JOB_READ_ONLY_SUB_ACTIONS = new Set([ - "status", - "output", - "wait", - "cancel", - "evidence" -]); -const DEVICE_JOB_ACTIONABLE_INTENTS = new Set([ - "workspace.build", - "debug.download" -]); -const DEVICE_JOB_OUTPUT_MAX_BYTES = 64000; -const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu; -const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u; - export function createAccessController(options = {}) { return new AccessController({ ...options, @@ -238,18 +158,13 @@ function accessStoreForRuntime(runtimeStore, options = {}) { } class AccessController { - constructor({ store, env = process.env, gatewayRegistry = null, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, devicePodExecutorUrl = env.HWLAB_DEVICE_POD_URL, devicePodExecutorTimeoutMs = 1200, devicePodSyncTimeoutMs = env.HWLAB_DEVICE_POD_SYNC_TIMEOUT_MS ?? 120000, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null } = {}) { + constructor({ store, env = process.env, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null } = {}) { this.store = store; this.env = env; - this.gatewayRegistry = gatewayRegistry; this.fetchImpl = fetchImpl; this.traceStore = traceStore; this.codeAgentChatResults = codeAgentChatResults; this.codeAgentEnv = env; - this.devicePodExecutorUrl = normalizeBaseUrl(devicePodExecutorUrl); - this.devicePodInternalToken = textOr(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); - this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200; - this.devicePodSyncTimeoutMs = Number.parseInt(String(devicePodSyncTimeoutMs), 10) || 120000; this.now = now; this.required = required; this.openFga = openFgaAuthorizer ?? createOpenFgaAuthorizer({ env, fetchImpl, now, configStore: store }); @@ -402,7 +317,6 @@ class AccessController { const body = await jsonBody(request); const password = requiredText(body.password, "password"); - const devicePodSeeds = firstAdminDevicePodSeeds(body); const now = this.now(); const user = await this.store.createFirstAdmin?.({ id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", @@ -414,19 +328,6 @@ class AccessController { if (!user) { return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409)); } - const adminAccess = await this.syncUserAdminTuple(user, user); - const devicePodsInitialized = []; - for (const seed of devicePodSeeds) { - const pod = await this.store.upsertDevicePod({ ...seed, now }); - const access = []; - for (const relation of ["viewer", "operator", "profile_editor", "job_submitter"]) { - access.push(await this.grantDevicePodRelation({ userId: user.id, devicePodId: pod.id, relation, admin: user })); - } - devicePodsInitialized.push({ - devicePod: publicDevicePod(pod, { includeAdmin: true }), - access - }); - } const token = randomBytes(32).toString("base64url"); const session = await this.store.createSession({ userId: user.id, @@ -442,12 +343,6 @@ class AccessController { actor: publicActor(user), session: publicSession({ ...session, user }), setupRequired: false, - adminAccess, - devicePodBootstrap: { - requested: devicePodSeeds.length, - initialized: devicePodsInitialized.length - }, - devicePodsInitialized, contractVersion: "user-access-v1" }); } catch (error) { @@ -518,25 +413,6 @@ class AccessController { return sendJson(response, 201, { created: true, user: redactedUser(user) }); } - if ((request.method === "POST" && url.pathname === "/v1/admin/device-pods") || (request.method === "PUT" && url.pathname.startsWith("/v1/admin/device-pods/"))) { - const body = await jsonBody(request); - const devicePodId = request.method === "PUT" - ? decodeURIComponent(url.pathname.split("/").filter(Boolean)[3] ?? "") - : textOr(body.devicePodId ?? body.id, ""); - const profile = assertDevicePodProfileSafe(normalizeObject(body.profile ?? body.profileJson), "profile"); - const pod = await this.store.upsertDevicePod({ - id: requiredText(devicePodId, "devicePodId"), - name: textOr(body.name, devicePodId), - status: body.status === "disabled" ? "disabled" : "active", - profile, - now: this.now() - }); - return sendJson(response, request.method === "POST" ? 201 : 200, { - upserted: true, - devicePod: publicDevicePod(pod, { includeAdmin: true }) - }); - } - sendJson(response, 404, errorPayload("not_found", "Admin route is not implemented", 404)); } catch (error) { sendAccessError(response, error); @@ -580,20 +456,6 @@ class AccessController { await this.syncUserAdminTuple(updated, auth.actor); return sendJson(response, 200, { updated: true, user: redactedUser(updated), access: await this.userAccessMatrixPayload(updated, auth.actor) }); } - const devicePodRelationMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)\/device-pods\/([^/]+)\/([^/]+)$/u); - if (devicePodRelationMatch && (request.method === "PUT" || request.method === "DELETE")) { - const userId = decodeURIComponent(devicePodRelationMatch[1]); - const devicePodId = decodeURIComponent(devicePodRelationMatch[2]); - const relation = decodeURIComponent(devicePodRelationMatch[3]); - if (!DEVICE_POD_RELATIONS.includes(relation)) return sendJson(response, 400, errorPayload("invalid_device_pod_relation", "Device pod relation is not supported", 400)); - await this.requireGrantTargets({ devicePodId, userId }); - const result = request.method === "PUT" - ? await this.grantDevicePodRelation({ userId, devicePodId, relation, admin: auth.actor }) - : await this.revokeDevicePodRelation({ userId, devicePodId, relation }); - if (result.ok === false) return sendJson(response, result.status ?? 503, accessWriteErrorPayload(result)); - const user = await this.store.getUserById(userId); - return sendJson(response, request.method === "PUT" ? 201 : 200, { ok: result.ok !== false, changed: true, result, access: user ? await this.userAccessMatrixPayload(user, auth.actor) : null }); - } const toolMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)\/tools\/([^/]+)\/can-use$/u); if (toolMatch && (request.method === "PUT" || request.method === "DELETE")) { const userId = decodeURIComponent(toolMatch[1]); @@ -622,27 +484,22 @@ class AccessController { } async adminAccessSummary(actor) { - const [users, pods, tuples, openfga] = await Promise.all([ + const [users, tuples, openfga] = await Promise.all([ this.store.listUsers?.() ?? [], - this.store.listDevicePods?.() ?? [], this.store.listAccessTuples?.() ?? [], this.openFga.describe() ]); - const toolTupleCount = tuples.filter((tuple) => parseOpenFgaObject(tuple.object)?.type === "tool").length; - const devicePodTupleCount = tuples.filter((tuple) => parseOpenFgaObject(tuple.object)?.type === "device_pod").length; + const toolTupleCount = tuples.filter((tuple) => tuple.object === openFgaObject("tool", "hwpod") || tuple.object.startsWith("tool:")).length; return { contractVersion: "admin-access-v1", actor: publicActor(actor), openfga, counts: { users: users.length, - devicePods: pods.length, tuples: tuples.length, - toolTuples: toolTupleCount, - devicePodTuples: devicePodTupleCount + toolTuples: toolTupleCount }, supported: { - devicePodRelations: DEVICE_POD_RELATIONS, toolIds: HWLAB_TOOL_IDS } }; @@ -653,23 +510,17 @@ class AccessController { return { user: redactedUser(user), tupleCount: tuples.length, - tools: toolsFromTuples(tuples), - devicePodCount: devicePodMatrixFromTuples(tuples).length + tools: toolsFromTuples(tuples) }; } async userAccessMatrixPayload(user, actor) { - const [tuples, pods, openfga] = await Promise.all([ + const [tuples, openfga] = await Promise.all([ this.store.listAccessTuples?.({ userId: user.id }) ?? [], - this.store.listDevicePods?.() ?? [], this.openFga.describe() ]); - const devicePods = pods.map((pod) => { - const relations = Object.fromEntries(DEVICE_POD_RELATIONS.map((relation) => [relation, tuples.some((tuple) => tuple.object === openFgaObject("device_pod", pod.id) && tuple.relation === relation)])); - return { devicePod: publicDevicePod(pod, { includeAdmin: true }), relations }; - }); const tools = Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")])); - return { contractVersion: "admin-access-v1", actor: publicActor(actor), user: redactedUser(user), openfga, devicePods, tools, tuples: tuples.map(publicAccessTuple) }; + return { contractVersion: "admin-access-v1", actor: publicActor(actor), user: redactedUser(user), openfga, tools, tuples: tuples.map(publicAccessTuple) }; } async syncUserAdminTuple(user, admin) { @@ -685,16 +536,6 @@ class AccessController { for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user); } - async grantDevicePodRelation({ userId, devicePodId, relation, admin }) { - const object = openFgaObject("device_pod", devicePodId); - return this.grantAccessTuple({ userId, relation, object, admin }); - } - - async revokeDevicePodRelation({ userId, devicePodId, relation }) { - const object = openFgaObject("device_pod", devicePodId); - return this.revokeAccessTuple({ userId, relation, object }); - } - async grantAccessTuple({ userId, relation, object, admin }) { const result = await this.openFga.writeTuple({ userId, relation, object }); if (result.ok !== false) await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() }); @@ -707,71 +548,6 @@ class AccessController { return result; } - async listVisibleDevicePods(actor) { - const pods = await this.store.listDevicePods?.() ?? []; - const visible = []; - for (const pod of pods.filter((item) => item.status === "active")) { - const authorization = await this.openFga.check({ actor, relation: "viewer", object: openFgaObject("device_pod", pod.id) }); - if (authorization.allowed) visible.push(pod); - } - return visible; - } - - async getVisibleDevicePod(actor, id) { - return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null; - } - - async authorizeDevicePodOperation({ actor, pod, relation }) { - const authorization = await this.openFga.check({ actor, relation, object: openFgaObject("device_pod", pod.id) }); - return authorization; - } - - async handleDevicePodRoute(request, response, url) { - try { - await this.ensureBootstrap(); - const auth = await this.authenticate(request, { required: true }); - if (!auth.ok) return sendJson(response, auth.status, auth); - - if (request.method === "GET" && url.pathname === "/v1/device-pods") { - const pods = await this.listVisibleDevicePods(auth.actor); - return sendJson(response, 200, { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-authority-v1", - status: "ok", - source: authoritySource(), - actor: publicActor(auth.actor), - devicePods: pods.map((pod) => publicDevicePod(pod)), - selectedDevicePodId: pods[0]?.id ?? null, - observedAt: this.now() - }); - } - - const parsed = parseDevicePodPath(url.pathname); - if (!parsed) return sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404)); - const pod = await this.getVisibleDevicePod(auth.actor, parsed.devicePodId); - if (!pod) { - const existing = await this.store.getDevicePod(parsed.devicePodId); - if (existing?.status === "active") { - return sendJson(response, 403, errorPayload("device_pod_forbidden", `Device Pod ${parsed.devicePodId} is not authorized for the current actor`, 403)); - } - return sendJson(response, 404, errorPayload("device_pod_not_found", `Device Pod ${parsed.devicePodId} was not found`, 404)); - } - - if (request.method === "GET" && parsed.route === "status") return sendJson(response, 200, this.devicePodStatus(pod, auth.actor)); - if (request.method === "GET" && parsed.route === "events") return sendJson(response, 200, await this.devicePodEvents(pod, url.searchParams)); - if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "debug-probe", intent: "debug.chip-id" }); - if (request.method === "GET" && parsed.route === "io-probe/uart/1") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.ports", args: { uartId: "uart/1" } }); - if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.uart.read", args: uartTailArgs(url.searchParams, "uart/1"), outputMaxBytes: boundedOutputMaxBytes(url.searchParams) }); - if (request.method === "POST" && parsed.route === "jobs") return this.createDevicePodJob(request, response, pod, auth.actor); - if (request.method === "GET" && parsed.route.startsWith("jobs/")) return this.getDevicePodJob(response, pod, parsed.route, auth.actor); - if (request.method === "POST" && parsed.route.startsWith("jobs/") && parsed.route.endsWith("/cancel")) return this.cancelDevicePodJob(response, pod, parsed.route, auth.actor); - - sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404)); - } catch (error) { - sendAccessError(response, error); - } - } - async authenticate(request, { required = this.required } = {}) { await this.ensureBootstrap(); const apiKeySecret = apiKeyFromRequest(request); @@ -1140,7 +916,7 @@ class AccessController { const session = await this.recordAgentSessionOwner({ ownerUserId: auth.actor.id, sessionId, - projectId: textOr(body.projectId, "prj_device_pod_workbench"), + projectId: textOr(body.projectId, "prj_hwpod_workbench"), agentId: textOr(body.agentId, "hwlab-code-agent"), status: textOr(body.status ?? body.sessionStatus, "active"), conversationId, @@ -1160,7 +936,7 @@ class AccessController { return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); } const body = await jsonBody(request); - const projectId = textOr(body.projectId, "prj_device_pod_workbench"); + const projectId = textOr(body.projectId, "prj_hwpod_workbench"); const visible = await this.visibleConversationForActor(auth.actor, conversationId, projectId, { includeArchived: true }); if (!visible) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); const archived = await this.store.archiveAgentConversation?.({ @@ -1212,7 +988,7 @@ class AccessController { await this.ensureBootstrap(); const auth = await this.authenticate(request, { required: true }); if (!auth.ok) return sendJson(response, auth.status, auth); - const projectId = textOr(url.searchParams.get("projectId"), "prj_device_pod_workbench"); + const projectId = textOr(url.searchParams.get("projectId"), "prj_hwpod_workbench"); let workspace = await this.store.getOrCreateDefaultWorkspace?.({ ownerUserId: auth.actor.id, projectId, @@ -1247,10 +1023,6 @@ class AccessController { const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId); if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403)); } - const selectedDevicePodId = textOr(body.selectedDevicePodId ?? body.devicePodId, current.selectedDevicePodId ?? ""); - if (selectedDevicePodId && !(await this.store.getVisibleDevicePod(auth.actor, selectedDevicePodId))) { - return sendJson(response, 403, errorPayload("workspace_device_pod_forbidden", "Selected device pod is not visible to the current actor", 403)); - } const workspace = await this.store.updateWorkspace?.({ workspaceId, ownerUserId: auth.actor.id, @@ -1260,7 +1032,6 @@ class AccessController { status: body.status === "archived" ? "archived" : "active", selectedConversationId, selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId) || current.selectedAgentSessionId, - selectedDevicePodId, activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null, providerProfile: textOr(body.providerProfile, current.providerProfile ?? ""), patch: normalizeWorkspacePatch(body, auth.actor), @@ -1329,7 +1100,6 @@ class AccessController { actorRole: auth.actor.role, selectedConversationId: null, selectedAgentSessionId: null, - selectedDevicePodId: null, activeTraceId: null, providerProfile: null, patch: { resetAt: this.now(), resetBy: publicActor(auth.actor), messages: [] }, @@ -1418,7 +1188,6 @@ class AccessController { actorRole: actor?.role ?? "user", selectedConversationId, selectedAgentSessionId, - selectedDevicePodId: workspace.selectedDevicePodId, activeTraceId: null, providerProfile: workspace.providerProfile, patch: { @@ -1467,10 +1236,7 @@ class AccessController { const conversation = workspace?.selectedConversationId ? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId) : null; - const selectedDevicePod = workspace?.selectedDevicePodId - ? await this.store.getVisibleDevicePod(actor, workspace.selectedDevicePodId) - : null; - return publicWorkbenchWorkspace(workspace, { conversation, selectedDevicePod }); + return publicWorkbenchWorkspace(workspace, { conversation }); } async syncTerminalWorkbenchConversation({ workspace, actor, result, activeTraceId, selectedConversationId, selectedAgentSessionId, now }) { @@ -1586,7 +1352,7 @@ class AccessController { const repaired = await this.recordAgentSessionOwner({ ownerUserId: actor.id, sessionId, - projectId: textOr(conversation.projectId ?? projectId, "prj_device_pod_workbench"), + projectId: textOr(conversation.projectId ?? projectId, "prj_hwpod_workbench"), agentId: conversation.agentId ?? "hwlab-code-agent", status: sessionStatus, conversationId, @@ -1637,392 +1403,6 @@ class AccessController { return conversationsFromAgentSessions(sessions).find((item) => item.conversationId !== excludedConversationId) ?? null; } - async requireGrantTargets({ devicePodId, userId }) { - const pod = await this.store.getDevicePod(devicePodId); - if (!pod || pod.status !== "active") { - throw Object.assign(new Error(`Device Pod ${devicePodId} does not exist or is disabled`), { statusCode: 404, code: "device_pod_not_found" }); - } - const user = await this.store.getUserById(userId); - if (!user || user.status !== "active") { - throw Object.assign(new Error(`User ${userId} does not exist or is disabled`), { statusCode: 404, code: "user_not_found" }); - } - } - - devicePodStatus(pod, actor) { - const route = pod.profile.route ?? {}; - const gatewaySessionId = textOr(route.gatewaySessionId, ""); - const gatewayOnline = gatewaySessionId && this.gatewayRegistry?.isOnline?.(gatewaySessionId) === true; - const blocker = gatewayOnline ? null : gatewayDispatchBlocker(gatewaySessionId ? "gateway session is not connected" : "profile route.gatewaySessionId is missing"); - const observedAt = this.now(); - const status = blocker ? "blocked" : "ok"; - const output = boundedOutput({ text: blocker?.summary ?? "device-pod status ok", summary: blocker?.summary ?? "device-pod status ok" }); - const freshnessPayload = freshness(observedAt, blocker); - return { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-authority-v1", - status, - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - profileHash: pod.profileHash, - traceId: `trc_devicepod_${randomUUID()}`, - operationId: `op_devicepod_${randomUUID()}`, - observedAt, - source: authoritySource(), - actor: publicActor(actor), - devicePod: publicDevicePod(pod), - blocker, - freshness: freshnessPayload, - output: output.output, - text: output.text, - bytes: output.bytes, - truncation: output.truncation, - summary: { - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - status, - freshness: freshnessPayload, - profileHash: pod.profileHash, - blocker - } - }; - } - - async devicePodEvents(pod, searchParams) { - const limit = Math.min(Math.max(Number.parseInt(searchParams.get("limit") ?? "80", 10) || 80, 1), 1000); - const jobs = await this.store.listDevicePodJobs(pod.id, limit); - const events = jobs.map((job) => ({ - eventId: `evt_${job.id}`, - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - ts: job.updatedAt, - level: job.status === "blocked" || job.status === "failed" ? "warn" : "info", - scope: "job", - intent: job.intent, - status: job.status, - summary: normalizeBlocker(job.blocker)?.summary ?? `job ${job.id} ${job.status}`, - blocker: normalizeBlocker(job.blocker), - refs: jobRefs(job) - })); - return { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: "device-pod-authority-v1", - status: "ok", - observedAt: this.now(), - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - source: authoritySource(), - events, - lines: events.map(formatEventLine), - truncation: { limit, returned: events.length, truncated: jobs.length >= limit } - }; - } - - async createDevicePodProbeJob(response, pod, actor, { interfaceName, intent, args = {}, outputMaxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES }) { - const result = await this.createReadOnlyDevicePodJob({ pod, actor, intent, args }); - const payload = this.jobOutputPayload(result.job, pod, { maxBytes: outputMaxBytes }); - return sendJson(response, result.httpStatus, { - ...payload, - interface: interfaceName, - intent, - source: authoritySource() - }); - } - - async createReadOnlyDevicePodJob({ pod, actor, intent, args = {} }) { - if (!DEVICE_JOB_INTENTS.has(intent) || MUTATING_INTENTS.has(intent)) { - throw Object.assign(new Error(`Device probe intent ${intent} is not supported as a read-only probe`), { statusCode: 400, code: "unsupported_device_probe_intent" }); - } - const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation: "viewer" }); - if (!authorization.allowed) { - return { - job: await this.createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args, reason: "", authorization }), - httpStatus: 403 - }; - } - const traceId = `trc_devicepod_${randomUUID()}`; - const operationId = `op_devicepod_${randomUUID()}`; - const now = this.now(); - const job = await this.store.createDevicePodJob({ - id: `job_devicepod_${randomUUID()}`, - devicePodId: pod.id, - ownerUserId: actor.id, - status: "running", - intent, - args: normalizeObject(args), - reason: "", - traceId, - operationId, - output: { text: "" }, - blocker: null, - now, - completedAt: null - }); - if (this.devicePodExecutorUrl) return this.dispatchDevicePodExecutorJob({ job, pod, actor }); - return this.blockDevicePodJobWithoutExecutor({ job, pod }); - } - - async createDevicePodJob(request, response, pod, actor) { - const body = await jsonBody(request); - const intent = requiredText(body.intent, "intent"); - if (!DEVICE_JOB_INTENTS.has(intent)) { - return sendJson(response, 400, errorPayload("unsupported_device_job_intent", `Device job intent ${intent} is not supported`, 400)); - } - const reason = textOr(body.reason, ""); - if (_deviceJobRequiresReason(intent, normalizeObject(body.args), reason)) { - return this.rejectDevicePodJob(response, pod, actor, { - httpStatus: 400, - intent, - args: normalizeObject(body.args), - blocker: deviceJobBlocked("device_job_reason_required", `Device job intent ${intent} requires reason`, false) - }); - } - const args = normalizeObject(body.args); - const relation = MUTATING_INTENTS.has(intent) ? "job_submitter" : "operator"; - const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation }); - if (!authorization.allowed) { - return this.rejectDevicePodJob(response, pod, actor, { - httpStatus: 403, - intent, - args, - reason, - blocker: authorizationBlocker("device_pod_authorization_denied", `Device job intent ${intent} is not authorized for relation ${relation}`, authorization) - }); - } - const traceId = `trc_devicepod_${randomUUID()}`; - const operationId = `op_devicepod_${randomUUID()}`; - const now = this.now(); - const job = await this.store.createDevicePodJob({ - id: `job_devicepod_${randomUUID()}`, - devicePodId: pod.id, - ownerUserId: actor.id, - status: "running", - intent, - args, - reason, - traceId, - operationId, - output: {}, - blocker: null, - now, - completedAt: null - }); - const dispatched = this.devicePodExecutorUrl - ? await this.dispatchDevicePodExecutorJob({ job, pod, actor }) - : await this.blockDevicePodJobWithoutExecutor({ job, pod }); - const payload = shouldReturnDevicePodCreateOutput(dispatched.job) - ? this.jobOutputPayload(dispatched.job, pod) - : this.jobPayload(dispatched.job, pod); - sendJson(response, dispatched.httpStatus, payload); - } - - async rejectDevicePodJob(response, pod, actor, { httpStatus, intent, reason = "", args = {}, blocker }) { - const now = this.now(); - const job = await this.store.createDevicePodJob({ - id: `job_devicepod_${randomUUID()}`, - devicePodId: pod.id, - ownerUserId: actor.id, - status: "blocked", - intent, - args, - reason, - traceId: `trc_devicepod_${randomUUID()}`, - operationId: `op_devicepod_${randomUUID()}`, - output: { error: blocker.summary }, - blocker, - now, - completedAt: now - }); - return sendJson(response, httpStatus, { - ...this.jobPayload(job, pod), - error: { code: blocker.code, message: blocker.summary } - }); - } - - async createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args = {}, reason = "", authorization }) { - const now = this.now(); - const blocker = authorizationBlocker("device_pod_authorization_denied", `Device Pod ${pod.id} is not authorized for the current actor`, authorization); - return this.store.createDevicePodJob({ - id: `job_devicepod_${randomUUID()}`, - devicePodId: pod.id, - ownerUserId: actor.id, - status: "blocked", - intent, - args, - reason, - traceId: `trc_devicepod_${randomUUID()}`, - operationId: `op_devicepod_${randomUUID()}`, - output: { error: blocker.summary }, - blocker, - now, - completedAt: now - }); - } - - async dispatchDevicePodExecutorJob({ job, pod, actor }) { - const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs`; - try { - const response = await fetchJsonWithTimeout(this.fetchImpl, target, { - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) - }, - body: stableJson({ - jobId: job.id, - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - profileHash: pod.profileHash, - profile: pod.profile, - ownerUserId: actor.id, - intent: job.intent, - args: job.args, - reason: job.reason, - traceId: job.traceId, - operationId: job.operationId - }) - }, devicePodExecutorCreateTimeoutMs(this, job)); - const updated = await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); - const status = updated.status; - return { job: updated ?? job, httpStatus: response.status >= 400 ? response.status : status === "running" ? 202 : 200 }; - } catch (error) { - const blocker = devicePodExecutorBlocker(error?.message ?? "device-pod executor request failed"); - const updated = await this.store.updateDevicePodJob(pod.id, job.id, { - status: "blocked", - output: { error: blocker.summary }, - blocker, - completedAt: this.now(), - updatedAt: this.now() - }); - return { job: updated ?? job, httpStatus: 409 }; - } - } - - async getDevicePodJob(response, pod, route, actor) { - const parts = route.split("/"); - const jobId = decodeURIComponent(parts[1] ?? ""); - let job = await this.store.getDevicePodJob(pod.id, jobId); - if (!job) return sendJson(response, 404, errorPayload("device_job_not_found", `Device job ${jobId} was not found`, 404)); - if (job.ownerUserId !== actor.id && actor.role !== "admin") return sendJson(response, 403, errorPayload("device_job_owner_required", "Only the owner or admin can inspect the job", 403)); - if (this.devicePodExecutorUrl && !terminalJobStatus(job.status)) { - job = await this.refreshDevicePodExecutorJob({ job, pod, output: parts[2] === "output" }); - } - if (parts[2] === "output") return sendJson(response, 200, this.jobOutputPayload(job, pod)); - return sendJson(response, 200, this.jobPayload(job, pod)); - } - - async cancelDevicePodJob(response, pod, route, actor) { - const jobId = decodeURIComponent(route.split("/")[1] ?? ""); - const job = await this.store.getDevicePodJob(pod.id, jobId); - if (!job) return sendJson(response, 404, errorPayload("device_job_not_found", `Device job ${jobId} was not found`, 404)); - if (job.ownerUserId !== actor.id && actor.role !== "admin") return sendJson(response, 403, errorPayload("device_job_owner_required", "Only the owner or admin can cancel the job", 403)); - if (this.devicePodExecutorUrl && !terminalJobStatus(job.status)) { - const canceledByExecutor = await this.cancelDevicePodExecutorJob({ job, pod }); - return sendJson(response, 200, this.jobPayload(canceledByExecutor, pod)); - } - const canceled = await this.store.updateDevicePodJob(pod.id, jobId, { - status: terminalJobStatus(job.status) ? job.status : "canceled", - blocker: terminalJobStatus(job.status) ? job.blocker : { code: "device_job_canceled", summary: "Device job was canceled by user" }, - completedAt: terminalJobStatus(job.status) ? job.completedAt : this.now(), - updatedAt: this.now() - }); - return sendJson(response, 200, this.jobPayload(canceled, pod)); - } - - async refreshDevicePodExecutorJob({ job, pod, output = false }) { - const suffix = output ? "/output" : ""; - const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}${suffix}`; - try { - const response = await fetchJsonWithTimeout(this.fetchImpl, target, { - method: "GET", - headers: { accept: "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) } - }, this.devicePodExecutorTimeoutMs); - return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); - } catch (error) { - return await this.blockDevicePodJobOnExecutorError({ job, pod, error }); - } - } - - async cancelDevicePodExecutorJob({ job, pod }) { - const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/cancel`; - try { - const response = await fetchJsonWithTimeout(this.fetchImpl, target, { - method: "POST", - headers: { accept: "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) } - }, this.devicePodExecutorTimeoutMs); - return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); - } catch (error) { - return await this.blockDevicePodJobOnExecutorError({ job, pod, error }); - } - } - - async updateDevicePodJobFromExecutorResponse({ job, pod, response }) { - const status = normalizeDeviceJobStatus(response.body?.status ?? response.body?.job?.status, response.status); - const blocker = response.body?.blocker ?? (status === "completed" || status === "running" || status === "queued" ? null : devicePodExecutorBlocker(`device-pod executor returned HTTP ${response.status}`)); - const updated = await this.store.updateDevicePodJob(pod.id, job.id, { - status, - output: executorOutputPayload(response.body, response.status), - blocker, - completedAt: terminalJobStatus(status) ? job.completedAt ?? this.now() : null, - updatedAt: this.now() - }); - return updated ?? job; - } - - async blockDevicePodJobOnExecutorError({ job, pod, error }) { - const blocker = devicePodExecutorBlocker(error?.message ?? "device-pod executor request failed"); - const updated = await this.store.updateDevicePodJob(pod.id, job.id, { - status: "blocked", - output: { error: blocker.summary }, - blocker, - completedAt: this.now(), - updatedAt: this.now() - }); - return updated ?? job; - } - - async blockDevicePodJobWithoutExecutor({ job, pod }) { - const blocker = devicePodExecutorBlocker("HWLAB_DEVICE_POD_URL is not configured; cloud-api will not bypass hwlab-device-pod executor"); - const updated = await this.store.updateDevicePodJob(pod.id, job.id, { - status: "blocked", - output: { error: blocker.summary }, - blocker, - completedAt: this.now(), - updatedAt: this.now() - }); - return { job: updated ?? job, httpStatus: 409 }; - } - - jobPayload(job, pod) { - const blocker = normalizeBlocker(job.blocker); - return { - serviceId: CLOUD_API_SERVICE_ID, - contractVersion: DEVICE_JOB_CONTRACT_VERSION, - accepted: !["blocked", "failed"].includes(job.status), - status: job.status, - devicePodId: pod.id, - targetId: targetIdFromProfile(pod.profile), - profileHash: pod.profileHash, - traceId: job.traceId, - operationId: job.operationId, - job: publicJob(job), - blocker, - freshness: freshness(job.updatedAt, blocker), - outputUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/output`, - cancelUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/cancel` - }; - } - - jobOutputPayload(job, pod, { maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES } = {}) { - const bounded = boundedOutput(job.output, maxBytes); - return { - ...this.jobPayload(job, pod), - output: bounded.output, - text: bounded.text, - bytes: bounded.bytes, - truncation: bounded.truncation - }; - } } class MemoryAccessStore { @@ -2032,8 +1412,6 @@ class MemoryAccessStore { this.sessions = new Map(); this.apiKeys = new Map(); this.oidcStates = new Map(); - this.devicePods = new Map(); - this.jobs = new Map(); this.agentSessions = new Map(); this.workspaces = new Map(); this.accessConfig = new Map(); @@ -2109,16 +1487,6 @@ class MemoryAccessStore { const key = await this.createApiKey({ userId: input.userId, name: API_KEY_DEFAULT_NAME, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: [], now: input.now }); return { key, secret, created: true }; } - async upsertDevicePod(input) { - const existing = this.devicePods.get(input.id); - const profile = devicePodProfileForAuthority(input.id, normalizeObject(input.profile)); - const now = input.now ?? this.now(); - const pod = { id: input.id, name: input.name ?? existing?.name ?? input.id, status: input.status ?? existing?.status ?? "active", profile, profileHash: profileHash(profile), createdAt: existing?.createdAt ?? now, updatedAt: now }; - this.devicePods.set(pod.id, pod); - return pod; - } - async getDevicePod(id) { return this.devicePods.get(id) ?? null; } - async listDevicePods() { return [...this.devicePods.values()].sort((a, b) => String(a.id).localeCompare(String(b.id))); } async getAccessConfig(key) { return this.accessConfig.get(textOr(key, "")) ?? ""; } async setAccessConfig(input = {}) { this.accessConfig.set(textOr(input.key, ""), textOr(input.value, "")); return { key: input.key, value: input.value, updatedAt: input.updatedAt ?? this.now() }; } async upsertAccessTuple(input = {}) { @@ -2133,10 +1501,6 @@ class MemoryAccessStore { .filter((tuple) => !input.object || tuple.object === input.object) .sort((a, b) => `${a.object}:${a.relation}`.localeCompare(`${b.object}:${b.relation}`)); } - async createDevicePodJob(input) { const job = normalizeJob(input); this.jobs.set(job.id, job); return job; } - 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; @@ -2182,7 +1546,7 @@ class MemoryAccessStore { } async getOrCreateDefaultWorkspace(input = {}) { const ownerUserId = textOr(input.ownerUserId, ""); - const projectId = textOr(input.projectId, "prj_device_pod_workbench"); + const projectId = textOr(input.projectId, "prj_hwpod_workbench"); const existing = [...this.workspaces.values()].find((workspace) => workspace.ownerUserId === ownerUserId && workspace.projectId === projectId && workspace.isDefault === true && workspace.status === "active") ?? null; if (existing) return existing; const now = input.now ?? this.now(); @@ -2272,18 +1636,11 @@ class PostgresAccessStore extends MemoryAccessStore { async findUserByKeycloakSubject(issuer, sub) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at FROM users WHERE keycloak_issuer = $1 AND keycloak_sub = $2 LIMIT 1", [issuer, sub]); return pgUserWithOIDC(result.rows?.[0]); } async createUserOidc(input) { await this.ensureSchema(); const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, keycloak_issuer = EXCLUDED.keycloak_issuer, keycloak_sub = EXCLUDED.keycloak_sub, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [id, input.username, input.displayName ?? input.username, input.role ?? "user", input.status ?? "active", null, "keycloak", input.keycloakIssuer, input.keycloakSub, input.email ?? null, now, now, now]); return pgUserWithOIDC(result.rows?.[0]); } async updateUserOidcLogin(input) { await this.ensureSchema(); const result = await this.query("UPDATE users SET email = COALESCE($2, email), last_login_at = $3, updated_at = $3 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [input.userId, input.email ?? null, input.lastLoginAt]); return pgUserWithOIDC(result.rows?.[0]); } - async upsertDevicePod(input) { await this.ensureSchema(); const pod = await super.upsertDevicePod(input); const result = await this.query("INSERT INTO device_pods (id, name, status, profile_json, profile_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, profile_json = EXCLUDED.profile_json, profile_hash = EXCLUDED.profile_hash, updated_at = EXCLUDED.updated_at RETURNING *", [pod.id, pod.name, pod.status, stableJson(pod.profile), pod.profileHash, pod.createdAt, pod.updatedAt]); return pgDevicePod(result.rows?.[0]); } - async getDevicePod(id) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods WHERE id = $1 LIMIT 1", [id]); return pgDevicePod(result.rows?.[0]); } - async listDevicePods() { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods ORDER BY id", []); return result.rows.map(pgDevicePod); } async getAccessConfig(key) { await this.ensureSchema(); const result = await this.query("SELECT value FROM access_config WHERE key = $1 LIMIT 1", [key]); return textOr(result.rows?.[0]?.value, ""); } async setAccessConfig(input = {}) { await this.ensureSchema(); await this.query("INSERT INTO access_config (key, value, updated_at) VALUES ($1,$2,$3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at", [input.key, input.value, input.updatedAt ?? this.now()]); return { key: input.key, value: input.value, updatedAt: input.updatedAt ?? this.now() }; } async upsertAccessTuple(input = {}) { await this.ensureSchema(); const tuple = { userId: input.userId, relation: input.relation, object: input.object, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() }; await this.query("INSERT INTO access_tuples (user_id, relation, object, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (user_id, relation, object) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [tuple.userId, tuple.relation, tuple.object, tuple.createdByAdminId, tuple.createdAt]); return tuple; } async deleteAccessTuple(input = {}) { await this.ensureSchema(); await this.query("DELETE FROM access_tuples WHERE user_id = $1 AND relation = $2 AND object = $3", [input.userId, input.relation, input.object]); } async listAccessTuples(input = {}) { await this.ensureSchema(); const params = []; const clauses = []; if (input.userId) { params.push(input.userId); clauses.push(`user_id = $${params.length}`); } if (input.object) { params.push(input.object); clauses.push(`object = $${params.length}`); } const sql = `SELECT user_id, relation, object, created_by_admin_id, created_at FROM access_tuples ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY object, relation, user_id`; const result = await this.query(sql, params); return result.rows.map(pgAccessTuple); } - async createDevicePodJob(input) { await this.ensureSchema(); const job = normalizeJob(input); await this.query("INSERT INTO device_pod_jobs (id, device_pod_id, owner_user_id, status, intent, args_json, reason, trace_id, operation_id, output_json, blocker_json, created_at, updated_at, completed_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", jobParams(job)); return job; } - 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(); @@ -2346,13 +1703,13 @@ class PostgresAccessStore extends MemoryAccessStore { async getOrCreateDefaultWorkspace(input = {}) { await this.ensureSchema(); const ownerUserId = textOr(input.ownerUserId, ""); - const projectId = textOr(input.projectId, "prj_device_pod_workbench"); + const projectId = textOr(input.projectId, "prj_hwpod_workbench"); const found = await this.query("SELECT * FROM account_workspaces WHERE owner_user_id = $1 AND project_id = $2 AND is_default = TRUE AND status = 'active' ORDER BY updated_at DESC LIMIT 1", [ownerUserId, projectId]); const existing = pgWorkspace(found.rows?.[0]); if (existing) return existing; const now = input.now ?? this.now(); const workspace = normalizeWorkspaceRecord({ id: defaultWorkspaceId(ownerUserId, projectId), ownerUserId, projectId, now }, null, now, { create: true }); - const result = await this.query("INSERT INTO account_workspaces (id, owner_user_id, project_id, name, status, is_default, selected_conversation_id, selected_agent_session_id, selected_device_pod_id, active_trace_id, provider_profile, workspace_json, revision, updated_by_session_id, updated_by_client, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT (id) DO UPDATE SET updated_at = account_workspaces.updated_at RETURNING *", workspaceParams(workspace)); + const result = await this.query("INSERT INTO account_workspaces (id, owner_user_id, project_id, name, status, is_default, selected_conversation_id, selected_agent_session_id, active_trace_id, provider_profile, workspace_json, revision, updated_by_session_id, updated_by_client, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (id) DO UPDATE SET updated_at = account_workspaces.updated_at RETURNING *", workspaceParams(workspace)); return pgWorkspace(result.rows?.[0]) ?? workspace; } async getWorkspaceForUser(input = {}) { @@ -2374,80 +1731,18 @@ class PostgresAccessStore extends MemoryAccessStore { if (!current) return null; const now = input.now ?? this.now(); const workspace = normalizeWorkspaceRecord(input, current, now, { replaceJson: input.replaceJson === true }); - const result = await this.query("UPDATE account_workspaces SET project_id=$3, name=$4, status=$5, is_default=$6, selected_conversation_id=$7, selected_agent_session_id=$8, selected_device_pod_id=$9, active_trace_id=$10, provider_profile=$11, workspace_json=$12, revision=$13, updated_by_session_id=$14, updated_by_client=$15, created_at=$16, updated_at=$17 WHERE id=$1 AND ($18::text = 'admin' OR owner_user_id=$2) RETURNING *", [...workspaceParams(workspace), textOr(input.actorRole, "user")]); + const result = await this.query("UPDATE account_workspaces SET project_id=$3, name=$4, status=$5, is_default=$6, selected_conversation_id=$7, selected_agent_session_id=$8, active_trace_id=$9, provider_profile=$10, workspace_json=$11, revision=$12, updated_by_session_id=$13, updated_by_client=$14, created_at=$15, updated_at=$16 WHERE id=$1 AND ($17::text = 'admin' OR owner_user_id=$2) RETURNING *", [...workspaceParams(workspace), textOr(input.actorRole, "user")]); return pgWorkspace(result.rows?.[0]); } } -function parseDevicePodPath(pathname) { - const prefix = "/v1/device-pods/"; - if (!pathname.startsWith(prefix)) return null; - const [devicePodId, ...rest] = pathname.slice(prefix.length).split("/").map((part) => decodeURIComponent(part)).filter(Boolean); - return devicePodId ? { devicePodId, route: rest.join("/") } : null; -} - +function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; } +function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; } +function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } async function jsonBody(request) { const text = await readBody(request); try { return text ? JSON.parse(text) : {}; } catch (error) { throw Object.assign(new Error("Invalid JSON body"), { statusCode: 400, code: "parse_error", reason: error.message }); } } - -function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; } -function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; } -function firstString(...values) { return values.find((value) => typeof value === "string") ?? ""; } -function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } -function assertDevicePodProfileSafe(profile, field = "profile") { - const findings = []; - collectDevicePodProfileSecretFindings(profile, field, findings); - if (findings.length > 0) { - const paths = findings.slice(0, 3).join(", "); - throw Object.assign(new Error(`${field} contains forbidden secret material at ${paths}`), { statusCode: 400, code: "device_pod_profile_secret_forbidden" }); - } - return profile; -} -function devicePodProfileForAuthority(devicePodId, profile) { return { ...assertDevicePodProfileSafe(profile, "profile"), devicePodId }; } -function collectDevicePodProfileSecretFindings(value, path, findings) { - if (findings.length >= 5) return; - if (Array.isArray(value)) { - value.forEach((item, index) => collectDevicePodProfileSecretFindings(item, `${path}[${index}]`, findings)); - return; - } - if (value && typeof value === "object") { - for (const [key, child] of Object.entries(value)) { - const childPath = `${path}.${key}`; - if (DEVICE_POD_PROFILE_SECRET_KEY_PATTERN.test(key)) findings.push(childPath); - collectDevicePodProfileSecretFindings(child, childPath, findings); - if (findings.length >= 5) return; - } - return; - } - if (typeof value === "string" && DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN.test(value)) findings.push(path); -} -function firstAdminDevicePodSeeds(body = {}) { - const hasSingle = Object.hasOwn(body, "devicePod"); - const hasMany = Object.hasOwn(body, "devicePods"); - if (hasSingle && hasMany) throw Object.assign(new Error("Use devicePod or devicePods, not both"), { statusCode: 400, code: "invalid_params" }); - if (!hasSingle && !hasMany) return []; - const values = hasMany ? body.devicePods : [body.devicePod]; - if (!Array.isArray(values)) throw Object.assign(new Error("devicePods must be an array"), { statusCode: 400, code: "invalid_params" }); - return values.map((item, index) => firstAdminDevicePodSeed(item, hasSingle ? "devicePod" : `devicePods[${index}]`)); -} -function firstAdminDevicePodSeed(value, field) { - if (!value || typeof value !== "object" || Array.isArray(value)) throw Object.assign(new Error(`${field} must be an object`), { statusCode: 400, code: "invalid_params" }); - const id = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`); - const profile = normalizeObject(value.profile ?? value.profileJson); - if (Object.keys(profile).length === 0) throw Object.assign(new Error(`${field}.profile is required`), { statusCode: 400, code: "invalid_params" }); - assertDevicePodProfileSafe(profile, `${field}.profile`); - return { - id, - name: textOr(value.name, id), - status: value.status === "disabled" ? "disabled" : "active", - profile - }; -} -function normalizeBaseUrl(value) { const text = textOr(value, "").replace(/\/+$/u, ""); return /^https?:\/\//u.test(text) ? text : ""; } -function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), DEVICE_JOB_OUTPUT_MAX_BYTES); } -function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : fallback, 1), 60000); } -function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function safeTraceIdLocal(value) { const text = textOr(value, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function safeWorkspaceId(value) { return /^wsp_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } @@ -2457,7 +1752,6 @@ function apiKeyFromRequest(request) { const token = String(auth).replace(/^Bearer\s+/iu, "").trim(); return isApiKeySecret(token) ? token : ""; } -function devicePodInternalHeaders(serviceId, token) { return token ? { "x-hwlab-internal-service": serviceId, "x-hwlab-internal-token": token } : { "x-hwlab-internal-service": serviceId }; } function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; } function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); } function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); } @@ -2481,62 +1775,10 @@ function hashApiKey(secret) { function isApiKeySecret(value) { return textOr(value, "").startsWith(API_KEY_PREFIX); } function stableJson(value) { return JSON.stringify(sortJson(value)); } function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; } -function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } function accessTupleKey(tuple = {}) { return `${tuple.userId}\u0000${tuple.relation}\u0000${tuple.object}`; } -function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, OpenFGA relation, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } -function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; } +function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin" }; } + function normalizeToolId(value) { return textOr(value, "").replace(/-/gu, "_"); } -function _deviceJobRequiresReason(intent, args, reason) { - if (!MUTATING_INTENTS.has(intent)) return false; - if (reason) return false; - if (!DEVICE_JOB_ACTIONABLE_INTENTS.has(intent)) return true; - const action = typeof args?.action === "string" ? args.action.trim().toLowerCase() : ""; - if (action && DEVICE_JOB_READ_ONLY_SUB_ACTIONS.has(action)) return false; - return true; -} -function deviceJobBlocked(code, summary, retryable) { return { code, layer: "device-pod", retryable, summary, userMessage: summary }; } -function authorizationBlocker(code, summary, authorization) { return { code, layer: "authorization", retryable: authorization?.degradedReason ? true : false, summary, userMessage: summary, authorization }; } -function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; } -function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; } -function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(status, ""); return ["queued", "running", "completed", "failed", "blocked", "canceled"].includes(text) ? text : httpStatus >= 400 ? "blocked" : "running"; } -function executorOutputPayload(body, httpStatus) { - const output = normalizeObject(body?.output); - const nestedOutput = normalizeObject(output.output); - const text = firstString( - body?.text, - output.text, - nestedOutput.text, - output.summary, - nestedOutput.summary, - normalizeObject(nestedOutput.evidence).text, - normalizeObject(output.evidence).text, - normalizeObject(nestedOutput.evidence).logTail, - normalizeObject(output.evidence).logTail, - normalizeObject(nestedOutput.evidence).summary, - normalizeObject(output.evidence).summary - ); - return { - executor: body ?? {}, - output: Object.keys(nestedOutput).length > 0 ? nestedOutput : output, - text, - httpStatus - }; -} -function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) { - const source = normalizeObject(output); - const text = typeof source.text === "string" ? source.text : stableJson(source); - const buffer = Buffer.from(text, "utf8"); - const clipped = buffer.length > maxBytes; - const boundedText = clipped ? buffer.subarray(0, maxBytes).toString("utf8") : text; - const bounded = { ...source, text: boundedText }; - if (clipped) { - delete bounded.executor; - delete bounded.output; - bounded.omitted = { reason: "device_job_output_truncated", originalBytes: buffer.length }; - } - return { output: bounded, text: boundedText, bytes: Math.min(buffer.length, maxBytes), truncation: { maxBytes, truncated: clipped, originalBytes: buffer.length } }; -} -function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "cloud-api" }; } function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; } function publicAuthMethod(method) { return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION; @@ -2558,31 +1800,9 @@ function publicApiKey(key, { includeSecret = false } = {}) { } function redactedUser(user) { return publicActor(user); } function publicSession(session) { return { id: session.id, userId: session.userId, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, revoked: Boolean(session.revokedAt) }; } -function publicDevicePod(pod, { includeAdmin = false } = {}) { return { devicePodId: pod.id, name: pod.name, status: pod.status, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, profile: redactedProfile(pod.profile), createdAt: pod.createdAt, updatedAt: pod.updatedAt, blocker: null, ...(includeAdmin ? { admin: { profileStored: true, routeStored: Boolean(pod.profile.route) } } : {}) }; } function publicAccessTuple(tuple) { return tuple ? { userId: tuple.userId, relation: tuple.relation, object: tuple.object, createdByAdminId: tuple.createdByAdminId, createdAt: tuple.createdAt } : null; } function toolsFromTuples(tuples = []) { return Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")])); } function defaultDeniedToolCapabilities() { return { contractVersion: "admin-access-v1", actor: null, tools: Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, { allowed: false, authorization: { contractVersion: "openfga-authorization-v1", mode: "enforce", allowed: false, decisionSource: "missing-owner", degraded: false } }])), valuesRedacted: true }; } -function devicePodMatrixFromTuples(tuples = []) { - const byPod = new Map(); - for (const tuple of tuples) { - const parsed = parseOpenFgaObject(tuple.object); - if (parsed?.type !== "device_pod") continue; - const current = byPod.get(parsed.id) ?? Object.fromEntries(DEVICE_POD_RELATIONS.map((relation) => [relation, false])); - if (DEVICE_POD_RELATIONS.includes(tuple.relation)) current[tuple.relation] = true; - byPod.set(parsed.id, current); - } - return [...byPod.entries()].map(([devicePodId, relations]) => ({ devicePodId, relations })).sort((a, b) => a.devicePodId.localeCompare(b.devicePodId)); -} -function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaVersion ?? null, target: { id: profile.target?.id ?? null }, projectWorkspace: { projectPath: profile.projectWorkspace?.projectPath ?? null, targetName: profile.projectWorkspace?.targetName ?? null, hexPath: profile.projectWorkspace?.hexPath ?? null }, debugInterface: { type: profile.debugInterface?.type ?? null }, ioInterface: { uartCount: Array.isArray(profile.ioInterface?.uart) ? profile.ioInterface.uart.length : 0 }, route: { configured: Boolean(profile.route?.gatewaySessionId), gatewaySessionId: "redacted", resourceId: profile.route?.resourceId ? "redacted" : null, capabilityId: profile.route?.capabilityId ? "redacted" : null } }; } -function targetIdFromProfile(profile = {}) { return profile.target?.id ?? profile.targetId ?? null; } -function terminalJobStatus(status) { return ["completed", "failed", "blocked", "canceled"].includes(status); } -function shouldReturnDevicePodCreateOutput(job) { return terminalJobStatus(job?.status) && job?.intent === "workspace.evidence" && textOr(normalizeObject(job.args).kind, "") === "verify"; } -function shouldSynchronouslyWaitForDevicePodJob(job) { return job?.intent === "workspace.evidence" && textOr(normalizeObject(job.args).kind, "") === "verify"; } -function devicePodExecutorCreateTimeoutMs(controller, job) { return shouldSynchronouslyWaitForDevicePodJob(job) ? controller.devicePodSyncTimeoutMs : controller.devicePodExecutorTimeoutMs; } -function publicJob(job) { return { id: job.id, devicePodId: job.devicePodId, ownerUserId: job.ownerUserId, status: job.status, intent: job.intent, reason: job.reason, traceId: job.traceId, operationId: job.operationId, createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt }; } -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: mergeAgentSessionOwnerEvidence(input.session, existing?.session), updatedAt: now }; } function mergeAgentSessionOwnerEvidence(nextValue, existingValue) { const existing = normalizeObject(existingValue); @@ -2660,7 +1880,6 @@ function normalizeWorkspacePatch(body = {}, actor = null) { ...workspace, selectedConversationId: textOr(body.selectedConversationId ?? body.conversationId ?? workspace.selectedConversationId, workspace.selectedConversationId ?? null), selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId ?? workspace.selectedAgentSessionId) || workspace.selectedAgentSessionId, - selectedDevicePodId: textOr(body.selectedDevicePodId ?? body.devicePodId ?? workspace.selectedDevicePodId, workspace.selectedDevicePodId ?? null), activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId ?? workspace.activeTraceId) || null, providerProfile: textOr(body.providerProfile ?? workspace.providerProfile, workspace.providerProfile ?? null), messages: Array.isArray(body.messages) ? body.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : Array.isArray(workspace.messages) ? workspace.messages : undefined, @@ -2675,13 +1894,12 @@ function normalizeWorkspaceRecord(input = {}, existing = null, now = new Date(). return { id: textOr(input.workspaceId ?? input.id, existing?.id ?? (create ? defaultWorkspaceId(input.ownerUserId, input.projectId) : "")), ownerUserId: textOr(input.ownerUserId, existing?.ownerUserId ?? ""), - projectId: textOr(input.projectId, existing?.projectId ?? "prj_device_pod_workbench"), + projectId: textOr(input.projectId, existing?.projectId ?? "prj_hwpod_workbench"), name: textOr(input.name, existing?.name ?? "Default Workbench"), status: input.status === "archived" ? "archived" : existing?.status ?? "active", isDefault: input.isDefault === false ? false : existing?.isDefault ?? true, selectedConversationId: nullableText(input.selectedConversationId, existing?.selectedConversationId), selectedAgentSessionId: nullableText(input.selectedAgentSessionId, existing?.selectedAgentSessionId), - selectedDevicePodId: nullableText(input.selectedDevicePodId, existing?.selectedDevicePodId), activeTraceId: nullableText(input.activeTraceId, existing?.activeTraceId), providerProfile: nullableText(input.providerProfile, existing?.providerProfile), workspace: workspaceJson, @@ -2697,7 +1915,7 @@ function nullableText(value, fallback = null) { const text = textOr(value, ""); return text || fallback || null; } -function publicWorkbenchWorkspace(workspace, { conversation = null, selectedDevicePod = null } = {}) { +function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) { if (!workspace) return null; return { workspaceId: workspace.id, @@ -2709,11 +1927,9 @@ function publicWorkbenchWorkspace(workspace, { conversation = null, selectedDevi revision: workspace.revision, selectedConversationId: workspace.selectedConversationId, selectedAgentSessionId: workspace.selectedAgentSessionId, - selectedDevicePodId: workspace.selectedDevicePodId, activeTraceId: workspace.activeTraceId, providerProfile: workspace.providerProfile, selectedConversation: conversation, - selectedDevicePod: selectedDevicePod ? publicDevicePod(selectedDevicePod) : null, workspace: redactedWorkspaceJson(workspace.workspace), updatedBySessionId: workspace.updatedBySessionId, updatedByClient: workspace.updatedByClient, @@ -2728,7 +1944,6 @@ function redactedWorkspaceJson(value = {}) { return pruneEmpty({ selectedConversationId: textOr(workspace.selectedConversationId, ""), selectedAgentSessionId: textOr(workspace.selectedAgentSessionId, ""), - selectedDevicePodId: textOr(workspace.selectedDevicePodId, ""), activeTraceId: textOr(workspace.activeTraceId, ""), previousActiveTraceId: textOr(workspace.previousActiveTraceId, ""), providerProfile: textOr(workspace.providerProfile, ""), @@ -2751,7 +1966,7 @@ function redactedWorkspaceJson(value = {}) { }); } function workspaceParams(workspace) { - return [workspace.id, workspace.ownerUserId, workspace.projectId, workspace.name, workspace.status, workspace.isDefault, workspace.selectedConversationId, workspace.selectedAgentSessionId, workspace.selectedDevicePodId, workspace.activeTraceId, workspace.providerProfile, stableJson(workspace.workspace ?? {}), workspace.revision, workspace.updatedBySessionId, workspace.updatedByClient, workspace.createdAt, workspace.updatedAt]; + return [workspace.id, workspace.ownerUserId, workspace.projectId, workspace.name, workspace.status, workspace.isDefault, workspace.selectedConversationId, workspace.selectedAgentSessionId, workspace.activeTraceId, workspace.providerProfile, stableJson(workspace.workspace ?? {}), workspace.revision, workspace.updatedBySessionId, workspace.updatedByClient, workspace.createdAt, workspace.updatedAt]; } function normalizeConversationSnapshot(body = {}, actor = null) { const snapshot = normalizeObject(body.snapshot ?? body); @@ -3168,23 +2383,8 @@ function pgUser(row) { return row ? { id: row.id, username: row.username, displa function pgUserWithOIDC(row) { const user = pgUser(row); if (!user) return null; return { ...user, authProvider: textOr(row.auth_provider, "local"), keycloakIssuer: textOr(row.keycloak_issuer, "") || null, keycloakSub: textOr(row.keycloak_sub, "") || null, email: textOr(row.email, "") || null, lastLoginAt: textOr(row.last_login_at, "") || null }; } function pgApiKey(row) { if (!row) return null; return { id: row.id, userId: row.user_id, name: textOr(row.name, API_KEY_DEFAULT_NAME), keyPrefix: row.key_prefix, keyHash: row.key_hash ?? null, displaySecret: row.display_secret ?? null, scopes: parseJson(row.scopes_json, []), status: textOr(row.status, "active"), createdAt: row.created_at, lastUsedAt: row.last_used_at ?? null, revokedAt: row.revoked_at ?? null }; } 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: normalizeBlocker(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 pgWorkspace(row) { return row ? { id: row.id, ownerUserId: row.owner_user_id, projectId: row.project_id, name: row.name, status: row.status, isDefault: row.is_default !== false, selectedConversationId: row.selected_conversation_id, selectedAgentSessionId: row.selected_agent_session_id, selectedDevicePodId: row.selected_device_pod_id, activeTraceId: row.active_trace_id, providerProfile: row.provider_profile, workspace: parseJson(row.workspace_json, {}), revision: Number(row.revision ?? 1), updatedBySessionId: row.updated_by_session_id, updatedByClient: row.updated_by_client, createdAt: row.created_at, updatedAt: row.updated_at } : null; } +function pgWorkspace(row) { return row ? { id: row.id, ownerUserId: row.owner_user_id, projectId: row.project_id, name: row.name, status: row.status, isDefault: row.is_default !== false, selectedConversationId: row.selected_conversation_id, selectedAgentSessionId: row.selected_agent_session_id, activeTraceId: row.active_trace_id, providerProfile: row.provider_profile, workspace: parseJson(row.workspace_json, {}), revision: Number(row.revision ?? 1), updatedBySessionId: row.updated_by_session_id, updatedByClient: row.updated_by_client, createdAt: row.created_at, updatedAt: row.updated_at } : null; } function pgAccessTuple(row) { return row ? { userId: row.user_id, relation: row.relation, object: row.object, createdByAdminId: row.created_by_admin_id, createdAt: row.created_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; } } function normalizeBlocker(value) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null; } - -async function fetchJsonWithTimeout(fetchImpl, url, options, timeoutMs) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetchImpl(url, { ...options, signal: controller.signal }); - const text = await response.text(); - return { status: response.status, body: parseJson(text, {}) }; - } finally { - clearTimeout(timeout); - } -} diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index c90f0854..f828ee4d 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -32,14 +32,17 @@ const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); const HWLAB_RESOURCE_TOOL_ALIASES = Object.freeze([ - Object.freeze({ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }), + Object.freeze({ name: "hwpod", path: "tools/hwpod-cli.ts", kind: "bun-script" }), + Object.freeze({ name: "hwpod-ctl", path: "tools/hwpod-ctl.ts", kind: "bun-script" }), + Object.freeze({ name: "hwpod-compiler", path: "tools/hwpod-compiler-cli.ts", kind: "bun-script" }), Object.freeze({ name: "unidesk-ssh", path: "tools/unidesk-ssh.mjs", kind: "bun-script" }) ]); const HWLAB_RESOURCE_PROMPT_REFS = Object.freeze([ Object.freeze({ name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true }) ]); const HWLAB_RESOURCE_SKILL_REFS = Object.freeze([ - Object.freeze({ name: "device-pod-cli", path: "skills/device-pod-cli/SKILL.md", required: true, aggregateAs: "device-pod-cli" }), + Object.freeze({ name: "hwpod-cli", path: "skills/hwpod-cli/SKILL.md", required: true, aggregateAs: "hwpod-cli" }), + Object.freeze({ name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" }), Object.freeze({ name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" }) ]); @@ -897,7 +900,9 @@ async function resolveToolCapabilities({ params = {}, options = {} } = {}) { function filteredResourceToolAliases(toolCapabilities = null) { return HWLAB_RESOURCE_TOOL_ALIASES.filter((alias) => { - if (alias.name === "hwpod") return toolCapabilityAllowed(toolCapabilities, "hwpod"); + if (alias.name === "hwpod" || alias.name === "hwpod-ctl" || alias.name === "hwpod-compiler") { + return toolCapabilityAllowed(toolCapabilities, "hwpod"); + } if (alias.name === "unidesk-ssh") return toolCapabilityAllowed(toolCapabilities, "unidesk_ssh") || toolCapabilityAllowed(toolCapabilities, "trans_cmd"); return true; }); @@ -1076,7 +1081,7 @@ function agentRunFailureAttribution({ code, message, canceled = false } = {}) { return { category: "provider_invalid_tool_call", retryable: true, - userMessage: "AgentRun/provider 返回了无效 tool-call arguments JSON;这不是 HWLAB device-pod 或 Cloud API 端点失败,请查看 providerTrace.failureKind 和 runnerTrace 定位上游 tool_call_id。", + userMessage: "AgentRun/provider 返回了无效 tool-call arguments JSON;这不是 HWPOD 或 Cloud API 端点失败,请查看 providerTrace.failureKind 和 runnerTrace 定位上游 tool_call_id。", summary: message || "AgentRun/provider returned invalid tool-call arguments JSON." }; } diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index 2e5d2fc1..d2116c87 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -213,21 +213,21 @@ test("code agent session registry stores bounded non-sensitive conversation fact assert.match(facts.summary, /workspace=\/workspace\/hwlab/u); }); -test("Codex child env carries only user API key needed by Code Agent tools", () => { +test("Codex child env carries only HWPOD runtime API key needed by Code Agent tools", () => { const child = childProcessEnv({ PATH: "/usr/bin", CODEX_HOME: "/tmp/codex-home", OPENAI_API_KEY: "test-openai-key-material", - HWLAB_DEVICE_POD_API_URL: "http://127.0.0.1:6667", HWLAB_CLOUD_API_URL: "http://127.0.0.1:6667", + HWLAB_RUNTIME_API_URL: "http://127.0.0.1:6667", HWLAB_API_KEY: "hwl_live_user-default-key", HWLAB_SESSION_COOKIE: "hwlab_session=browser-cookie", HWLAB_SESSION_TOKEN: "browser-session-token", HWLAB_BEARER_TOKEN: "browser-bearer-token" }); - assert.equal(child.HWLAB_DEVICE_POD_API_URL, "http://127.0.0.1:6667"); assert.equal(child.HWLAB_CLOUD_API_URL, "http://127.0.0.1:6667"); + assert.equal(child.HWLAB_RUNTIME_API_URL, "http://127.0.0.1:6667"); assert.equal(child.HWLAB_API_KEY, "hwl_live_user-default-key"); assert.equal(child.OPENAI_API_KEY, undefined); assert.equal(child.HWLAB_SESSION_COOKIE, undefined); @@ -867,9 +867,9 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw const turn = calls.find((call) => call.method === "turn/start"); assert.ok(turn, "Codex stdio turn/start should be called"); assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-tran\.mjs/u); - assert.match(turn.args.prompt, /canonical skill location is \/app\/skills\/device-pod-cli\/SKILL\.md/u); - assert.match(turn.args.prompt, /hwpod is the only standard runner entry/u); - assert.doesNotMatch(turn.args.prompt, /node \/app\/skills\/device-pod-cli\/scripts\/device-pod-cli\.mjs/u); + assert.match(turn.args.prompt, /\/app\/skills\/hwpod-cli\/SKILL\.md/u); + assert.match(turn.args.prompt, /\/app\/skills\/hwpod-ctl\/SKILL\.md/u); + assert.match(turn.args.prompt, /hwpod is the standard HWPOD task runner entry/u); assert.match(turn.args.prompt, /Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod/u); assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u); assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u); @@ -1076,8 +1076,8 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn const turn = calls.find((call) => call.method === "turn/start"); assert.ok(turn, "Codex stdio turn/start should be called"); assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-tran\.mjs/u); - assert.match(turn.args.prompt, /device-pod-cli/u); - assert.match(turn.args.prompt, /\/app\/skills\/device-pod-cli\/SKILL\.md/u); + assert.match(turn.args.prompt, /\/app\/skills\/hwpod-cli\/SKILL\.md/u); + assert.match(turn.args.prompt, /\/app\/skills\/hwpod-ctl\/SKILL\.md/u); assert.match(turn.args.prompt, /hwpod must auto-locate from that assembled environment/u); assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u); assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u); @@ -2138,7 +2138,7 @@ test("Codex app-server no-progress diagnosis identifies tool-result ack stalls", completeAfterMs: null, commandExecution: { id: "cmd_stdio_tool_ack_stall", - command: ["hwpod", "D601-F103-V2:workspace:/", "ls"], + command: ["hwpod", "workspace", "ls", "."], exitCode: 0, durationMs: 123, aggregatedOutput: "workspace listing completed\n" diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts index c9eabe4e..8b50e292 100644 --- a/internal/cloud/codex-stdio-session-helpers.ts +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -1655,7 +1655,6 @@ export function childProcessEnv(env = process.env) { CODEX_HOME: resolveCodexHome(env), CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "hwlab_code_agent", UNIDESK_SKILLS_PATH: "/app/skills", - ...(env.HWLAB_DEVICE_POD_API_URL ? { HWLAB_DEVICE_POD_API_URL: env.HWLAB_DEVICE_POD_API_URL } : {}), ...(env.HWLAB_CLOUD_API_URL ? { HWLAB_CLOUD_API_URL: env.HWLAB_CLOUD_API_URL } : {}), ...(env.HWLAB_RUNTIME_API_URL ? { HWLAB_RUNTIME_API_URL: env.HWLAB_RUNTIME_API_URL } : {}), ...(env.HWLAB_RUNTIME_WEB_URL ? { HWLAB_RUNTIME_WEB_URL: env.HWLAB_RUNTIME_WEB_URL } : {}), diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index f86e5813..f25f11d9 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -204,9 +204,9 @@ export const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "Use the provided workspace and repo-owned Codex stdio session only.", "Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.", "For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.", - "For any request that mentions device-pod, device-pod-cli, device-host-cli, a device-pod profile, device-pod-71-freq, device-pod-71-00075-11, D601-F103-V2, or a registered device-pod Keil build/download/UART/debug-probe operation, first use the HWLAB internal skill named device-pod-cli. The canonical skill location is /app/skills/device-pod-cli/SKILL.md; read that manifest and run hwpod for the operation. hwpod is the only standard runner entry; if it is absent, report a runner image/package error instead of invoking a long-path launcher. After selecting the target pod or resuming context, run bootsharp --pod-id before edits, build, or download.", + "For any request that mentions HWPOD, hwpod, hwpod-spec, hwpod-cli, hwpod-ctl, D601-F103-V2, or a registered hardware build/download/UART/debug-probe operation, first use the HWLAB internal skills named hwpod-cli and hwpod-ctl. The canonical skill locations are /app/skills/hwpod-cli/SKILL.md and /app/skills/hwpod-ctl/SKILL.md; read those manifests as needed. hwpod is the standard HWPOD task runner entry, and hwpod-ctl is the standard workspace-local spec management entry; if either required command is absent, report a runner image/package error instead of invoking a long-path launcher.", "Do not pass --api-base-url, --api-url, --cloud-api-url, --base-url, or session tokens to hwpod in HWLAB runners. WEB and AgentRun assemble the current runtime endpoint and credential; hwpod must auto-locate from that assembled environment.", - "When a matching device-pod profile exists, do not bypass device-pod-cli with direct hwlab-gateway-tran.mjs, Windows Keil skills, serial-monitor skills, keil-cli.py, or ad hoc path discovery. Only drop to tran or Windows-side skills when the device-pod-cli skill explicitly says to bootstrap, install, or repair the lower layer.", + "When a matching workspace-local hwpod-spec exists, keep hardware work on hwpod-cli and hwpod-ctl. Only drop to tran or Windows-side skills when the hwpod-cli or hwpod-ctl skill explicitly says to bootstrap, install, or repair the lower layer.", "For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work [options] -- . The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.", "For Windows cmd, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- . For PowerShell, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps --