diff --git a/AGENTS.md b/AGENTS.md index 162375bd..b6fd97ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,12 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - HWLAB #7、用户反馈、长期看板和指挥简报的 GitHub issue 正文写入必须走 UniDesk CLI:`cd /root/unidesk && bun scripts/cli.ts gh ...`;禁止直接用原生 `gh issue edit/create/comment` 写这些 issue。事故和工具补强需求见 [pikasTech/unidesk#142](https://github.com/pikasTech/unidesk/issues/142)。 - 在 UniDesk CLI 局部替换、写前备份和写后 hash 验证能力完成前,不要对 #7 做无 guard 的整篇 body replace;必须先保留 before body、确认维护纪律 heading 仍存在,再写入。 +## P0 DEV CD Promotion 顺序 + +- `ci-publish` 只证明镜像已经为 publish report 的 `artifactPublish.sourceCommitId` 构建并推送;它不会改变 rollout 目标。禁止在 `ci-publish` 成功后直接把 `dev-cd-apply` 的旧 desired-state 输出当作新版本上线成功。 +- 正式 DEV CD 前必须用刚成功的 publish report 刷新三份 repo desired-state:`node scripts/refresh-artifact-catalog.mjs --target-ref --publish-report `,再执行 `node scripts/deploy-desired-state-plan.mjs --promotion-commit --check`,确认 `deploy/deploy.json`、`deploy/artifact-catalog.dev.json` 和 `deploy/k8s/base/workloads.yaml` 全部指向同一个已发布 commit。 +- 只有刷新后的 desired-state 已提交/推送,并且 CD 报告与 `16666/16667` live health 都观测到该短 commit,才能声明 DEV 上线完成;如果 CD report 里 `commitId` 仍是旧值,必须先修 desired-state,不要重复跑 CD。长期细节见 [docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)。 + ## 工作区 - G14 分支固定 source workspace 是 G14 节点上的 `/root/hwlab`,固定使用 `G14` 分支和 `origin git@github.com:pikasTech/HWLAB.git`。在 G14 上进行代码、文档、GitOps render、Tekton/poller/Argo CD 修复或 CI/CD 验证前,必须先确认 `pwd` 为 `/root/hwlab` 且 `git status --short --branch` 为 `G14...origin/G14`;不满足时先停止并修正 workspace。 diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index b5976383..852aaf50 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -5,12 +5,12 @@ "profile": "dev", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", - "commitId": "76e8d90", + "commitId": "d42c77d", "artifactState": "published", "publish": { "ciPublished": true, "registryVerified": true, - "provenance": "ci-artifact:ci-publish-20260525T063152-1592bc", + "provenance": "ci-artifact:ci-publish-20260525T165205-a0002d", "note": "Digest fields were copied from a successful DEV artifact publish report for this source commit." }, "healthContract": { @@ -66,10 +66,10 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:0f14ba1c1501951e14cbb41f0a74820499bb2bd1747837e7e35cd3053e990b08", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:332d4bbbd25a03335601621b8ddf3c809d8b2675e5ac540ee17593a51d5cbafd", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -79,15 +79,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-cloud-web", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:57500374e28a4458fb5ed8387ea2fe65516e9f2f1af7f1521967b92d70484d4d", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:a015bd5da578a5a6ff7898072b0679ad1227cef67ff176189f12201d1a375981", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -97,15 +97,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-agent-mgr", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:a248ec5d26ac071d628950618c839694781d06029a781960f8f435ea8e6ef0a0", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:11643219ff087a7723d6b1ce68c9e5d3fde862941e4504d96997504fdf8fb319", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -115,15 +115,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-agent-worker", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:6053b678c33b49f58bcb165233c40eb8dca58b2bf3dc64a43ca29011ae453616", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:e799939b24c0eb3d0f713f79351ebcba37cd163efbc166c23326fd327bd3fef0", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -133,15 +133,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-gateway", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:418097ada5bc0d9425f26746955d7cbe99c6647179a93bbea6ac742cad8a5cc1", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:fc9cba8b4505186aeabf9ba9d3fc5743293163660dc51f30f7a0f44fbaf655ee", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -151,15 +151,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-gateway-simu", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:b2817f594a73b5d6ac89b525f2121f35aeade9e09f16391a5ccd1666051466bf", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:d64bb559815b81fd07ccfea011cee92f7bfb2626162a644c98879391d74967cd", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -169,15 +169,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-box-simu", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:26807f576efcdcc0cb9ff5d4137ef499a90cc5a808a7155f859b81b8ffaa9b57", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:068c2ce4be56701482facdcc2e58a8d68fd5e79a8e2b3367c9f21b5d569cb296", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -187,15 +187,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-patch-panel", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:67bbd6a9a9b0db027fb07f52379532988a525b69125f4676050e8f45f859074d", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:0a3a103eab19f8d7140d5de6add6ab993dbb8e1fc18de70e95496b5f97d88f68", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -205,15 +205,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-router", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-router:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:dc316be8b9e6d2d87ab4e4b96d2dc039704bd0aa44bbf7c3ac55ddad4c913c4c", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-router:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:39b863967408c232681d48e77cbfa3ea02f0dcd3fa5368ee703b46ee93ac5c6b", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -223,15 +223,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-tunnel-client", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:91fad9cbaa41ce82e1d1f73976a72ead5eff9ccef19672ff699cd65d6515431b", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:c8c57a95b4b12e1aed3ce1345feab4da6689124c74c57814df58820afce031c9", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -241,15 +241,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-edge-proxy", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:f19695d500b8ccc6d2b3fabcde3c8d93b27c544c3811067916b80dfe688f11c7", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:8fa6af5d0cf2a8cb76b173386d48c2ceb5b969bc15c5b5afa3b1a4be0275256c", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -259,15 +259,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-cli", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:2b8d5ab5796acc73b4c87d2e582cc16987c4e83c3252e20e605750bc58df3e8a", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:79dbb2ae600c8860a889226e8ab1f44e51af5b6e5366001f97cd917b3e10cc2d", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -277,15 +277,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" }, { "serviceId": "hwlab-agent-skills", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:510e49228abdef6df28fc7ad0500473c2ad905e1b3e7a5e2d0f163a150c92775", + "commitId": "d42c77d", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:d42c77d", + "imageTag": "d42c77d", + "digest": "sha256:3808b1563287aaf05c8ff0dfc53111c6a5bbd25d0241eb6f6b7546fe45b73ba3", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -295,8 +295,8 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T16:52:07.838Z", + "buildSource": "pikasTech/HWLAB@d42c77d53a86b6ad02f0270302f53b5d8b4d58f9" } ], "serviceInventory": { diff --git a/deploy/deploy.json b/deploy/deploy.json index fd8afd24..3406360a 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -1,7 +1,7 @@ { "manifestVersion": "v1", "environment": "dev", - "commitId": "76e8d90", + "commitId": "d42c77d", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "health": { @@ -181,7 +181,7 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -190,9 +190,9 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:16667", "HWLAB_CLOUD_API_PORT": "6667", - "HWLAB_COMMIT_ID": "76e8d90", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", - "HWLAB_IMAGE_TAG": "76e8d90", + "HWLAB_COMMIT_ID": "d42c77d", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:d42c77d", + "HWLAB_IMAGE_TAG": "d42c77d", "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", "HWLAB_CLOUD_DB_SSL_MODE": "disable", @@ -223,7 +223,7 @@ }, { "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -232,14 +232,14 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "660000", - "HWLAB_COMMIT_ID": "76e8d90", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", - "HWLAB_IMAGE_TAG": "76e8d90" + "HWLAB_COMMIT_ID": "d42c77d", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:d42c77d", + "HWLAB_IMAGE_TAG": "d42c77d" } }, { "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -251,7 +251,7 @@ }, { "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -263,7 +263,7 @@ }, { "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -275,7 +275,7 @@ }, { "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -295,7 +295,7 @@ }, { "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -314,7 +314,7 @@ }, { "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -335,7 +335,7 @@ }, { "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-router:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -347,7 +347,7 @@ }, { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -361,7 +361,7 @@ }, { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -374,7 +374,7 @@ }, { "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -385,13 +385,13 @@ }, { "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:d42c77d", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", "replicas": 1, "env": { - "HWLAB_SKILLS_COMMIT_ID": "76e8d90" + "HWLAB_SKILLS_COMMIT_ID": "d42c77d" } } ], diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 128acbab..59c4936d 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -31,7 +31,7 @@ "containers": [ { "name": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:d42c77d", "ports": [ { "name": "http", @@ -53,15 +53,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "76e8d90" + "value": "d42c77d" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:d42c77d" }, { "name": "HWLAB_IMAGE_TAG", - "value": "76e8d90" + "value": "d42c77d" }, { "name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN", @@ -283,7 +283,7 @@ "containers": [ { "name": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:d42c77d", "ports": [ { "name": "http", @@ -301,15 +301,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "76e8d90" + "value": "d42c77d" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-web:d42c77d" }, { "name": "HWLAB_IMAGE_TAG", - "value": "76e8d90" + "value": "d42c77d" } ], "readinessProbe": { @@ -359,7 +359,7 @@ "containers": [ { "name": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:d42c77d", "ports": [ { "name": "http", @@ -415,7 +415,7 @@ "containers": [ { "name": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:d42c77d", "env": [ { "name": "HWLAB_AGENT_SESSION_MODE", @@ -458,7 +458,7 @@ "containers": [ { "name": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:d42c77d", "ports": [ { "name": "http", @@ -519,7 +519,7 @@ "containers": [ { "name": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:d42c77d", "ports": [ { "name": "http", @@ -592,7 +592,7 @@ "containers": [ { "name": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:d42c77d", "ports": [ { "name": "http", @@ -660,7 +660,7 @@ "containers": [ { "name": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:d42c77d", "ports": [ { "name": "http", @@ -724,7 +724,7 @@ "containers": [ { "name": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-router:d42c77d", "ports": [ { "name": "http", @@ -784,7 +784,7 @@ "containers": [ { "name": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:d42c77d", "ports": [ { "name": "http", @@ -856,7 +856,7 @@ "containers": [ { "name": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:d42c77d", "ports": [ { "name": "http", @@ -921,7 +921,7 @@ "containers": [ { "name": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:d42c77d", "env": [ { "name": "HWLAB_CLI_ENDPOINT", @@ -963,7 +963,7 @@ "containers": [ { "name": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:d42c77d", "ports": [ { "name": "http", @@ -973,7 +973,7 @@ "env": [ { "name": "HWLAB_SKILLS_COMMIT_ID", - "value": "76e8d90" + "value": "d42c77d" } ], "readinessProbe": { diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 8b2e2de7..21bdf964 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -114,8 +114,31 @@ Code Agent trace 在消息卡片内使用独立滚动容器展示。SSE、轮询 conversation list 和按 `messageId/traceId` 记录的 trace list `scrollTop/scrollLeft`。`#conversation-list` 需要绑定 scroll listener 持续记录用户 位置,并在 DOM 替换后立即恢复一次、下一帧再恢复一次,避免布局重算或 scroll -anchoring 把列表拉回顶部。完整 trace 可通过复制/下载获取,页面内默认保持“显示全部 + -内部滚动”的单一模式。 +anchoring 把列表拉回顶部。 + +Trace 展示遵循 UniDesk commander loop 的低噪声完整可读事件线原则:原始 trace +JSON 仍是复制/下载和追责的完整来源;页面内默认展示每个有意义的 agent message、 +command/tool completed、stderr/error、turn/status 和 gateway JSON-RPC 结果。token +delta、assistant chunk、command output chunk、reasoning delta 等噪声可以聚合或隐藏, +但聚合不得吞掉中间的可读 message/tool call。命令和工具行必须至少保留状态、exit +code、耗时、输出体量、关键 operation/evidence id 和可读摘要。 + +Workbench 面向用户的 trace 视图不得展示“压缩窗口”作为可读事件列表,也不得把 +result polling 返回的 head-tail 窗口或少量摘要冒充完整 trace。只要发现 +`eventsCompacted=true`,前端必须自动请求 `/v1/agent/chat/trace/` 回放完整 +trace;回放期间只能显示“完整 trace 回放中/当前已载入”状态,回放完成后才显示 +“显示全部可读事件”。如果完整 trace 已过期或后端缺失,必须显式展示缺失状态。 +assistant chunk、token delta、command output chunk 可以在同一 message/tool output 内 +合并成一条高可读行,但不能隐藏任意 assistant message、tool call、tool completed、 +stderr/error 或 turn/status 边界。 + +Code Agent 工作台状态必须跨浏览器刷新持久化。前端至少保存 +`conversationId/sessionId/threadId/sessionStatus`、最近消息、traceId 和必要的 runtime +摘要,刷新或重新打开页面后继续使用同一 repo-owned Codex session/thread 和固定 +workspace;除非 Pod 重建、Codex supervisor 重启、用户显式清空对话或登出,不得把下一轮 +请求降级成“首轮新会话”。登录态采用 server cookie 与本地 session 双轨:server session +可用时优先使用;server 内存 session 因 Pod/replica 切换丢失时,只要本地 session 未过期, +刷新页面不得要求用户重新登录。 ## Lightweight Checks diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 8bb344d8..a99b15e3 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -12,17 +12,17 @@ Secret 或 token。 同源聊天入口的真实回复证据。 - `internal/cloud/code-agent-chat.mjs` 是 `/v1/agent/chat` 的后端处理入口。 - `scripts/code-agent-chat-smoke.mjs` 是 Code Agent chat schema 与 readiness - gate。 + 合同检查。 - `scripts/dev-cloud-workbench-smoke.mjs --static` 只验证 Workbench 源码合同和 `/v1/agent/chat` 前端接线;它不是 DEV-LIVE 回复证明。 ## Provider 前置条件 当前 DEV 部署合同中 `hwlab-cloud-api` 的 Code Agent provider 是 -`HWLAB_CODE_AGENT_PROVIDER=codex-stdio`。运行时必须具备 repo-owned Codex MCP +`HWLAB_CODE_AGENT_PROVIDER=codex-stdio`。运行时必须具备 repo-owned Codex app-server stdio session supervisor,并证明 `/workspace/hwlab` 可读写、`CODEX_HOME=/codex-home` -可写、`/app/node_modules/.bin/codex --version` 可执行、`codex mcp-server` -可通过 MCP initialize/tools/list 观测到 `codex` 与 `codex-reply`。 +可写、`/app/node_modules/.bin/codex --version` 可执行、`codex app-server --listen stdio://` +可创建和复用同一个 Codex thread/session。 Codex token boundary 仍由授权路径把 `OPENAI_API_KEY` 注入到 DEV runtime。DEV Pod 必须使用 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 指向受控 DEV egress/proxy 路径 @@ -41,7 +41,7 @@ report、issue、PR 或截图。 | 观测结果 | readiness | | --- | --- | | `status: "failed"`,`error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 | -| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-session-tools"`、`session.status` 为 `idle/ready/busy`、`session.idleTimeoutMs` 和 `session.lastTraceId` 存在、`session.longLivedSession: true`、`longLivedSessionGate.status: "blocked"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `process-local-session-registry` | 可标为 #317 read-only long-lived session registry pass;不能关闭 full Codex stdio/session capability blocker。 | +| `provider: "codex-readonly-runner"` 或 `sessionMode: "controlled-readonly-session-registry"` | 历史只读状态,只能作为 `BLOCKED/not-codex-stdio` 诊断;不能满足当前自然语言单一路由或 DEV-LIVE reply pass。 | | `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`codex_cli_not_executable`、`codex_cli_native_dependency_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`workspace_write_boundary_blocked`、`codex_home_missing`、`codex_home_write_blocked`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 | | `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 | | 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 | @@ -50,45 +50,37 @@ report、issue、PR 或截图。 只有“真实 DEV 路由 + `completed` + 非空 assistant reply”能作为 DEV-LIVE 回复通过依据。 不得把 mock、fixture、本地 echo、source report、静态检查或前端状态当作通过。 -## Runner 能力边界 +## 自然语言单一路由 -`/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分: +`/v1/agent/chat` 的自然语言请求唯一执行路径是 repo-owned Codex stdio long-lived +session。cloud-api 不再把自然语言预分类到 M3 Skill CLI、`/v1/m3/io`、 +`external.network.check`、`session_context`、`security.hardware-boundary`、 +`hardware.invoke.shell` shortcut 或 OpenAI text fallback。 -- `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存 - `conversationId/sessionId` 映射、`status`、workspace、sandbox、`createdAt`、`updatedAt`、 - `idleTimeoutMs`、`lastTraceId`、`turn` 计数与只读工具 trace。它可以覆盖 `pwd`、 - `skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。该模式是 - read-only long-lived session,但 `longLivedSessionGate` 必须保持 `blocked`,直到 Codex - stdio 或等价 full Code Agent 协议通道真实接通。 -- 该模式必须同时标记 `not-codex-stdio`、`not-write-capable`、`process-local-session-registry`。它不是 - Codex stdio / workspace-write session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL - 读取,也不证明 M3/M4/M5 trusted green。 -- OpenAI Responses fallback 只能标记为 `openai-responses-fallback` / - `text-chat-only`,不得满足 Codex runner capability gate。默认路径不得用 fallback - 冒充完整 Code Agent;仅明确允许 fallback 时才可作为普通文本备用通道返回。 -- 已登记 PC gateway `shell.exec` capability 是受控硬件能力例外:当请求能从显式 - `projectId/gatewaySessionId/resourceId/capabilityId` 或唯一 DEV MVP topology 解析时, - `/v1/agent/chat` 可以选择 `hwlab-hardware-capability` runner,经 cloud-api 进程内 - JSON-RPC helper 调用 `hardware.invoke.shell`。该 helper 必须使用冻结的 - `hwlab-cloud-api` service id 常量,返回 payload 需包含 `toolCalls[]`、`operationId`、 - `dispatchStatus`、`exitCode`、stdout/stderr 摘要、`runnerTrace.eventLabels` 与 - `capabilityLevel`。拓扑缺失、不唯一、gateway offline、session/capability missing、 - dispatch failed 或 JSON-RPC meta/serviceId 无效时必须返回结构化 blocker,不能转入 - Codex stdio 或 OpenAI 文本 fallback 冒充成功。 -- `security.hardware-boundary` 仍然阻断直接 gateway/box-simu/patch-panel URL、`/invoke`、 - `/sync/tick` 和泛化硬件写绕过;受控 `hardware.invoke.shell` capability route 只允许 - 已登记、可解析的 cloud-api 调用,不开放任意 shell。JSON-RPC/REST bridge 拒绝未知 - `serviceId` 时,错误原因应可见为 `unknown serviceId ...` 或等价结构化 reason,且不得泄露 - Secret/token。 +自然语言里即使出现 M3、DO/DI、DAP、PWM、gateway、box-simu、patch-panel、Keil、 +serial-monitor、Windows skill、串口、下载、烧录、启动日志等词,也必须把完整请求交给 +Codex stdio turn。Codex turn 自己根据仓库、skill 文档和可用工具决定调用 repo wrapper、 +Windows skill CLI、项目脚本或其他真实可达路径;cloud-api 只负责 session 生命周期、trace、 +result 轮询和 schema 化返回。 -当前 DEV/runtime 若要升级为完整 #275 runner,至少需要 repo-owned 的 Codex CLI/stdio 或等价 -runner 二进制/协议适配、session supervisor 生命周期、workspace mount 与 sandbox 合同、token/Secret -注入边界、trace/cancel/reap 机制,以及与 cloud-api/workbench 的持久 session 映射。缺任一项时,必须 -在 `codexStdioFeasibility` 中报告 blocker。 +如果 Codex stdio 不具备运行条件,`/v1/agent/chat` 只能返回 Codex stdio readiness +blocker,不能降级到 M3 Skill CLI、受控硬件 shortcut、外网专用检查或普通 OpenAI 文本回复。 +显式 `/v1/m3/io` 控制面可以作为独立 API 或 UI 控制面继续存在,但聊天自然语言不得自动路由 +到该 API,也不得保留要求自然语言先满足 M3 白名单的源码检查或测试。该显式控制面也不得在 +进入 gateway 前保留固定 `DO1/DI1` 或固定 gateway 身份白名单预拦截;真实下游执行失败可以 +返回执行失败,但不能由 cloud-api 用旧白名单提前拒绝。 + +持久 session 是默认合同:同一个 `conversationId/sessionId` 必须映射到 repo-owned Codex +thread 和固定 workspace,刷新前端、重新打开页面或短连接 result 轮询不得创建新的短期 runner。 +除非 Pod 重建或 Codex supervisor 明确重启,workspace、thread/session 绑定和可见 trace 应持续 +存在。Workbench 前端必须把这些会话标识和最近消息持久化到浏览器本地状态;用户显式清空 +对话或登出时才清除该本地状态。刷新后下一轮自然语言请求必须携带已保存的 +`conversationId/sessionId/threadId`,不能只因为 JS 内存重建就显示“首轮请求”或重新分配 +Codex workspace。 ## PC Gateway Windows Skill 调用 -Code Agent 通过已登记 PC gateway 执行 Windows 侧命令时,必须让 Codex turn 自己调用仓库 wrapper,不能由 cloud-api 字符串匹配短路到 gateway,也不能直接访问 gateway URL: +Code Agent 通过已登记 PC gateway 执行 Windows 侧命令时,必须让 Codex turn 自己调用仓库 wrapper,不能由 cloud-api 字符串匹配短路到 gateway: ```sh node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --powershell-stdin <<'PS1' @@ -102,7 +94,7 @@ Workbench 会把“Gateway 命令超时”控件的毫秒值随 `/v1/agent/chat` 调大 timeout 不能替代正确的长任务控制语义。Gateway poll loop 必须支持后台 in-flight 执行,长 Keil/UV4 命令运行期间仍能处理短 `job-status`、state/log 读取和健康探测;如果 trace 出现 `shellExecuted=false` 的 dispatch timeout,优先检查 gateway 是否队头阻塞或离线,而不是把所有 wrapper 调用改成长等待。 -Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:首行展示 `tool hardware.invoke.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 +Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:前端首行用中性 `tool gateway.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 ## 短连接 result 轮询 @@ -110,7 +102,7 @@ Workbench 与 Code Agent 的用户请求必须是短连接 submit + 短连接 re cloud-web 同源代理必须把短连接语义原样转发给 cloud-api,至少包括 `Prefer: respond-async`、`X-HWLAB-Short-Connection` 和 `X-Trace-Id`。如果这些 header 在 cloud-web 层被过滤,cloud-api 会把同一个请求当成长同步请求处理,用户入口会表现为 16666 卡住或代理超时,而 16667 直连 cloud-api 正常。此类问题应先比对同一 trace 在 16666 与 16667 的 submit 行为,再修代理 header 透传,而不是调大前端等待时间。 -`/v1/agent/chat/result/` 是终态摘要接口,不是完整 trace 下载接口。它可以携带压缩后的 `runnerTrace` 窗口用于当前 UI 刷新,但必须保留 `eventCount`、`lastEvent`、`providerTrace`、`threadId/sessionId` 和终态 reply/blocker;完整 trace 只能从 `/v1/agent/chat/trace/`、复制 JSON 或下载 trace 入口取得。默认 result trace 窗口上限由 `HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT` 控制;不要把数百个大 chunk 原样塞进 result 响应,避免 cloud-web 代理层或浏览器 fetch 把“正常执行中的大响应”表现成 503、非 JSON 或空响应。 +`/v1/agent/chat/result/` 是终态摘要接口,不是完整 trace 下载接口。它可以携带压缩后的 `runnerTrace` 窗口用于传输保活,但 Workbench 用户界面不得把该窗口显示为“压缩窗口”或“显示全部”。只要结果或轮询快照声明 `eventsCompacted=true`,前端必须自动请求 `/v1/agent/chat/trace/` 并用完整 trace 替换可视事件线;回放完成前只能显示“完整 trace 回放中/当前已载入”状态。result 响应仍必须保留 `eventCount`、`lastEvent`、`providerTrace`、`threadId/sessionId` 和终态 reply/blocker;完整 trace 只能从 `/v1/agent/chat/trace/`、复制 JSON 或下载 trace 入口取得。默认 result trace 窗口上限由 `HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT` 控制;不要把数百个大 chunk 原样塞进 result 响应,避免 cloud-web 代理层或浏览器 fetch 把“正常执行中的大响应”表现成 503、非 JSON 或空响应。 result 轮询的 408/425/429/5xx、浏览器 timeout、非 JSON 或空响应应按“可恢复传输抖动”处理:前端先拉取一次 trace 刷新活性,再带退避继续轮询,只有后端返回结构化 terminal blocker、真实终态失败,或 trace 按无新事件 idle timeout 超时,才向用户显示失败。只要 `/trace` 仍显示新事件或 `waitingFor` 仍在推进,就不能把一次 result poll 失败标成“Code Agent API 错误”并停止。 @@ -141,7 +133,19 @@ py -3 keil-cli.py job-status 对 build/download 这类长任务,Code Agent 应优先使用 skill 自带的异步 job 语义:启动命令用短 wrapper timeout 拿到 job id 或明确的启动失败,再用短 `job-status`、state 文件和日志读取轮询进展。除非用户明确要求同步等待并设置了足够大的 Gateway 命令超时,不要通过 gateway 执行 `--wait` 长轮询;同步等待会占用一个 in-flight 槽位,旧 gateway 还会造成队头阻塞。 -## Smoke Gate +串口启动日志请求必须优先使用 Windows 侧 `serial-monitor` skill,而不是在 cloud-api 新增串口专用 route: + +```sh +cd C:\Users\liang\.agents\skills\serial-monitor +npm run cli -- server status +npm run cli -- server start +npm run cli -- monitor start -p -b +npm run cli -- fetch --session-only --no-dedup +``` + +Keil 下载后的启动日志抓取应和 build/download 共用同一个 Codex stdio session 与 gateway wrapper trace。71-FREQ 类项目的串口参数以 Windows 侧 `serial-monitor\SKILL.md` 和实时设备枚举为准;需要轮询时用短 wrapper 调用读取 session/state/log,而不是新增聊天层白名单或 blocker。 + +## Smoke Checks 本地合同检查: diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 131e6f65..95c7bcb2 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -31,19 +31,6 @@ import { codeAgentSessionLifecycleSummary, decorateCodeAgentSession } from "./code-agent-session-lifecycle.mjs"; -import { - HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - HWLAB_M3_IO_CAPABILITY_LEVELS, - HWLAB_M3_IO_API_BASE_URL_ENV, - HWLAB_M3_IO_API_BASE_URL_ENVS, - HWLAB_M3_IO_API_ROUTE, - HWLAB_M3_STATUS_API_ROUTE, - HWLAB_M3_IO_DEV_SERVICE_BASE_URL, - HWLAB_M3_IO_SKILL_NAME, - configuredCloudApiBaseUrl, - runM3IoSkillCommand -} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; - const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model; const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench"; const READONLY_RUNNER_PROVIDER = "codex-readonly-runner"; @@ -54,31 +41,10 @@ const READONLY_RUNNER_SANDBOX = "read-only"; const READONLY_SESSION_MODE = "controlled-readonly-session-registry"; const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry"; const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools"; -const M3_IO_SKILL_PROVIDER = "hwlab-skill-cli"; -const M3_IO_SKILL_BACKEND = "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"; -const M3_IO_SKILL_MODEL = "controlled-m3-io"; -const M3_IO_SKILL_RUNNER_KIND = "hwlab-m3-io-skill-cli"; -const M3_IO_SKILL_SANDBOX = "hwlab-api-route-only"; -const M3_IO_SKILL_SESSION_MODE = "controlled-m3-io-skill-cli"; -const M3_IO_SKILL_IMPLEMENTATION_TYPE = "skill-cli-hwlab-api-adapter"; -const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ - "m3-io-only", - "hwlab-api-route-only", - "not-generic-hardware-control", - "not-durable-session" -]); -const M3_IO_TOPOLOGY = Object.freeze({ - sourceResourceId: "res_boxsimu_1", - sourcePort: "DO1", - patchPanelServiceId: "hwlab-patch-panel", - targetResourceId: "res_boxsimu_2", - targetPort: "DI1" -}); const HARDWARE_INVOKE_SHELL_METHOD = "hardware.invoke.shell"; const LEGACY_CODE_AGENT_HARDWARE_PROVIDER = "hwlab-hardware-capability"; const LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED = "blocked"; const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; -const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu; const READONLY_TOOL_OUTPUT_LIMIT = 4000; const MAX_READONLY_SESSIONS = 200; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); @@ -89,10 +55,6 @@ const READONLY_LIMITATION_FLAGS = Object.freeze([ "process-local-session-registry" ]); const CODEX_STDIO_LIMITATION_FLAGS = Object.freeze([ - "hardware-control-via-cloud-api-only", - "no-direct-gateway-link", - "no-direct-box-simu-link", - "no-direct-patch-panel-link", "secret-values-redacted" ]); const CODE_AGENT_SYSTEM_PROMPT = [ @@ -145,127 +107,36 @@ export async function handleCodeAgentChat(params = {}, options = {}) { try { const message = normalizeUserMessage(params.message); - const runnerIntent = detectReadOnlyRunnerIntent(message, { - params, - env: options.env ?? process.env - }); + const runnerIntent = { + kind: "none", + toolName: "codex-stdio.session" + }; traceRecorder.append({ type: "request", status: "accepted", label: "request:accepted", promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message, - waitingFor: runnerIntent.kind === "m3_io" - ? HWLAB_M3_IO_API_ROUTE - : "codex-stdio-readiness" + waitingFor: "codex-stdio-readiness" }); - const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; - if (securityIntent) { - traceRecorder.append({ - type: "error", - status: "blocked", - label: "security:blocked", - errorCode: "security_blocked", - message: securityIntent.reason, - terminal: true - }); - throw runnerError("security_blocked", securityIntent.reason, { - provider: CODEX_STDIO_PROVIDER, - model: providerPlan.model, - backend: CODEX_STDIO_BACKEND, - workspace: options.workspace ?? repoRoot, - sandbox: "workspace-write", - toolCalls: [securityBlockedToolCall({ - traceId, - toolName: securityIntent.toolName, - reason: securityIntent.reason, - cwd: options.workspace ?? repoRoot - })], - skills: notRequestedSkills(), - runner: codexStdioBlockedRunnerDescriptor({ workspace: options.workspace, session: null }), - runnerTrace: traceRecorder.runnerTrace({ - runnerKind: CODEX_STDIO_RUNNER_KIND, - workspace: options.workspace ?? repoRoot, - sandbox: "workspace-write", - sessionMode: CODEX_STDIO_SESSION_MODE, - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - limitations: ["security-blocked-before-stdio"] - }), - capabilityLevel: "blocked", - sessionMode: CODEX_STDIO_SESSION_MODE, - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - runnerLimitations: ["security-blocked-before-stdio"], - blockers: [{ - code: "security_blocked", - sourceIssue: "pikasTech/HWLAB#275", - summary: securityIntent.reason - }], - route: null, - toolName: securityIntent.toolName ?? null - }); - } - - if (runnerIntent.kind === "m3_io") { - const runnerResult = await callM3IoSkillRunner({ - intent: runnerIntent, + const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); + if (codexStdioLongLivedReady(codexStdioAvailability)) { + const stdioResult = await callCodexStdioRunner({ + message, conversationId, sessionId: requestedSessionId, traceId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, - sessionRegistry, - requestJson: options.m3IoSkillRequestJson, - traceRecorder + timeoutMs: options.timeoutMs, + hardTimeoutMs: options.hardTimeoutMs, + model: providerPlan.model, + codexStdioManager: options.codexStdioManager, + traceRecorder, + conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), + externalNetworkIntent: null }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); - } - - if (runnerIntent.kind === "session_context") { - traceRecorder.append({ - type: "session", - status: "context_requested", - label: "session:context_requested", - waitingFor: "codex-stdio" - }); - } - - const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); - if (codexStdioLongLivedReady(codexStdioAvailability)) { - const stdioResult = runnerIntent.kind === "m3_io" - ? await callCodexStdioWithM3IoSkillRunner({ - message, - intent: runnerIntent, - conversationId, - sessionId: requestedSessionId, - traceId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - timeoutMs: options.timeoutMs, - hardTimeoutMs: options.hardTimeoutMs, - model: providerPlan.model, - codexStdioManager: options.codexStdioManager, - traceRecorder, - conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), - requestJson: options.m3IoSkillRequestJson - }) - : await callCodexStdioRunner({ - message, - conversationId, - sessionId: requestedSessionId, - traceId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - timeoutMs: options.timeoutMs, - hardTimeoutMs: options.hardTimeoutMs, - model: providerPlan.model, - codexStdioManager: options.codexStdioManager, - traceRecorder, - conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), - externalNetworkIntent: runnerIntent.kind === "external_network" ? runnerIntent : null - }); return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry }); } @@ -380,7 +251,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { payload.availability = error.availability; } else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); - } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed", "external_network_blocked", "network_tool_unavailable", "network_timeout"].includes(error.code)) { + } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } return decorateChatSessionLifecycle(payload); @@ -480,52 +351,9 @@ function validateCodeAgentToolCallContract(payload) { if (!toolCall || typeof toolCall !== "object") { throw new Error(`code agent toolCalls[${index}] must be an object`); } - const directTarget = findDirectHardwareTarget(toolCall); - if (directTarget) { - throw new Error(`code agent toolCalls[${index}] must not include direct gateway/box/patch-panel target ${directTarget}`); - } - if (!isM3IoToolCall(toolCall, payload)) continue; - const structuredBlocked = toolCall.status === "blocked" && Boolean(toolCall.blocker?.code || toolCall.capabilityBlocker?.code || toolCall.error?.code); - if (!m3ToolCallRouteAllowed(toolCall.route) && !structuredBlocked) { - throw new Error(`code agent M3 toolCalls[${index}] must show route ${HWLAB_M3_IO_API_ROUTE}/${HWLAB_M3_STATUS_API_ROUTE} or structured blocked`); - } - if (toolCall.route === HWLAB_M3_IO_API_ROUTE && (toolCall.method ?? "POST") !== "POST") { - throw new Error(`code agent M3 toolCalls[${index}] must use POST ${HWLAB_M3_IO_API_ROUTE}`); - } - if (toolCall.route === HWLAB_M3_STATUS_API_ROUTE && (toolCall.method ?? "GET") !== "GET") { - throw new Error(`code agent M3 toolCalls[${index}] must use GET ${HWLAB_M3_STATUS_API_ROUTE}`); - } } } -function isM3IoToolCall(toolCall, payload) { - return toolCall.name === HWLAB_M3_IO_SKILL_NAME || - toolCall.route === HWLAB_M3_IO_API_ROUTE || - toolCall.route === HWLAB_M3_STATUS_API_ROUTE || - payload.provider === M3_IO_SKILL_PROVIDER || - payload.runner?.kind === M3_IO_SKILL_RUNNER_KIND || - payload.runnerTrace?.runnerKind === M3_IO_SKILL_RUNNER_KIND; -} - -function m3ToolCallRouteAllowed(route) { - return route === HWLAB_M3_IO_API_ROUTE || route === HWLAB_M3_STATUS_API_ROUTE; -} - -function findDirectHardwareTarget(value, seen = new Set()) { - if (typeof value === "string") { - const match = value.match(DIRECT_HARDWARE_TARGET_PATTERN); - return match?.[0] ?? null; - } - if (!value || typeof value !== "object") return null; - if (seen.has(value)) return null; - seen.add(value); - for (const child of Object.values(value)) { - const match = findDirectHardwareTarget(child, seen); - if (match) return match; - } - return null; -} - function finalizeCodeAgentChatPayload(payload) { validateCodeAgentChatSchema(payload); return payload; @@ -797,10 +625,10 @@ function codexStdioRunnerAvailability(codexStdio = {}) { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, - hardwareControlViaCloudApiOnly: true, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, + hardwareControlViaCloudApiOnly: false, + directGatewayCallsAllowed: true, + directBoxSimuCallsAllowed: true, + directPatchPanelCallsAllowed: true, m3m4m5AcceptanceClaimsAllowed: false } }; @@ -842,10 +670,10 @@ function codexStdioBlockedRunnerAvailability(codexStdio = {}) { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, - hardwareControlViaCloudApiOnly: true, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, + hardwareControlViaCloudApiOnly: false, + directGatewayCallsAllowed: true, + directBoxSimuCallsAllowed: true, + directPatchPanelCallsAllowed: true, m3m4m5AcceptanceClaimsAllowed: false } }; @@ -888,7 +716,7 @@ function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) { function codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }) { if (codexStdio.ready) { - return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat can create/reuse/cancel/reap sessions with trace capture. Hardware control still must use cloud-api/HWLAB API/skill CLI."; + return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat sends natural-language requests to the persistent Codex session with full workspace and tool access."; } if (runnerAvailability.ready) return "Codex stdio long-lived session is blocked; /v1/agent/chat will not use controlled-readonly-session-registry, local skills discovery, shell/file shortcuts, or text fallback."; return blocked @@ -922,7 +750,7 @@ function resolveProviderPlan(env, options = {}) { mode: "codex-cli" }; } - if (provider === "codex-stdio" || provider === "codex-mcp-stdio") { + if (provider === "codex-stdio") { return { provider: CODEX_STDIO_PROVIDER, model, @@ -946,7 +774,6 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); - const m3IoApiBaseUrl = configuredCloudApiBaseUrl(env); return { kind: READONLY_RUNNER_KIND, backend: READONLY_RUNNER_BACKEND, @@ -976,486 +803,36 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { sessionRegistry, skillsDirs, skillsDirsPresent, - m3IoSkill: { - status: m3IoApiBaseUrl ? "available" : "blocked", - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route: HWLAB_M3_IO_API_ROUTE, - statusRoute: HWLAB_M3_STATUS_API_ROUTE, - capabilityLevel: m3IoApiBaseUrl - ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready - : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - blockedCapabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - hwlabApi: { - source: m3IoApiBaseUrl ? m3IoSkillApiBaseUrlSource(env) : "missing-config", - baseUrlConfigured: Boolean(m3IoApiBaseUrl), - redactedBaseUrl: m3IoApiBaseUrl ? redactUrl(m3IoApiBaseUrl) : null, - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS] - }, - blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(), - allowedRoutes: [`POST ${HWLAB_M3_IO_API_ROUTE}`, `GET ${HWLAB_M3_STATUS_API_ROUTE}`], - directGatewayCallsAllowed: false, - directBoxCallsAllowed: false, - directPatchPanelCallsAllowed: false, - fallbackAllowed: false - }, safety: runnerSafetyContract() }; } -async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, traceRecorder }) { - const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); - const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); - const startedAt = nowIso(now); - const sessionCapabilityLevel = configuredCloudApiBaseUrl(env) && m3IoIntentHasSupportedAction(intent) - ? m3SessionCapabilityLevelForIntent(intent) - : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; - const sessionAcquire = registry.acquire({ - conversationId, - sessionId, - workspace: resolvedWorkspace, - sandbox: M3_IO_SKILL_SANDBOX, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - sessionMode: M3_IO_SKILL_SESSION_MODE, - capabilityLevel: sessionCapabilityLevel, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - traceId, - now - }); - const codexStdioFeasibility = skippedCodexStdioFeasibilityForM3Io({ workspace: resolvedWorkspace }); - - if (!sessionAcquire.ok) { - const blockedSession = sessionAcquire.session; - throw runnerError(sessionAcquire.code, sessionAcquire.message, { - provider: M3_IO_SKILL_PROVIDER, - model: M3_IO_SKILL_MODEL, - backend: M3_IO_SKILL_BACKEND, - workspace: resolvedWorkspace ?? null, - sandbox: M3_IO_SKILL_SANDBOX, - session: blockedSession, - toolCalls: [], - skills: notRequestedSkills(), - runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), - runnerTrace: m3IoSkillRunnerTrace({ - traceId, - workspace: resolvedWorkspace ?? repoRoot, - session: blockedSession, - events: ["request:accepted", `session:${sessionAcquire.code}`], - startedAt, - outputTruncated: false - }), - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: blockedSession ? sessionReuseEvidence(blockedSession) : null, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - session: blockedSession, - sessionMode: M3_IO_SKILL_SESSION_MODE, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME +function structuredCompletionBlocker(result, context = {}) { + if (!result || typeof result !== "object") return null; + if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") { + return structuredBlocker({ + code: "text_chat_only_fallback", + layer: "provider", + message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.", + userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。", + retryable: false, + traceId: context.traceId, + provider: result.provider ?? context.provider, + backend: result.backend ?? context.backend, + runner: result.runner ?? context.runner, + capabilityLevel: result.capabilityLevel ?? context.capabilityLevel, + blockers: result.longLivedSessionGate?.blockers }); } + return null; +} - let session = sessionAcquire.session; - const requestId = `req_${randomUUID()}`; - const actorId = "usr_code_agent"; - traceRecorder?.append({ - type: "tool_call", - status: "started", - label: "tool:hwlab-m3-io:started", - toolName: HWLAB_M3_IO_SKILL_NAME, - waitingFor: HWLAB_M3_IO_API_ROUTE - }); - const { commandArgs, skillResult } = await executeM3IoSkillIntent({ - intent, - traceId, - requestId, - actorId, - env, - now, - requestJson - }); - const finishedAt = nowIso(now); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - - const capabilityLevel = skillResult.capabilityLevel ?? ( - skillResult.ok ? m3SessionCapabilityLevelForIntent(intent) : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked - ); - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - const directRunnerSeed = { - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - runner: { - kind: M3_IO_SKILL_RUNNER_KIND, - codexStdio: false, - durableSession: false, - sessionReused: session?.reused ?? false, - turn: session?.turn ?? null - } - }; - const m3Io = m3IoStructuredResult({ - skillResult, - stdioResult: directRunnerSeed, - commandArgs, - route, - method - }); - const toolCall = m3IoSkillToolCall({ - skillResult, - commandArgs, - cwd: resolvedWorkspace, - capabilityLevel, - route, - method, - responseType: m3Io.type - }); - const events = [ - "request:accepted", - "runner:m3-skill-cli:selected", - "tool:skill-cli:started", - `route:${route}`, - `tool:${toolCall.status}` - ]; - const runnerTrace = m3IoSkillRunnerTrace({ - traceId, - events, - startedAt, - finishedAt, - outputTruncated: toolCall.outputTruncated, - workspace: resolvedWorkspace, - session, - skillResult - }); - traceRecorder?.append({ - type: "tool_call", - status: skillResult.ok ? "completed" : "blocked", - label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`, - toolName: HWLAB_M3_IO_SKILL_NAME, - outputSummary: `route=${route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`, - terminal: false - }); - - const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); - const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult }); +function notRequestedSkills() { return { - provider: M3_IO_SKILL_PROVIDER, - model: M3_IO_SKILL_MODEL, - backend: M3_IO_SKILL_BACKEND, - content: m3IoSkillReply(skillResult, m3Io), - responseType: m3Io.type, - m3Io, - workspace: resolvedWorkspace, - sandbox: M3_IO_SKILL_SANDBOX, - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - toolCalls: [toolCall], - skills: { - status: "used", - items: [m3IoSkillItem({ capabilityLevel, route })], - count: 1, - blockers - }, - runner, - runnerTrace, - capabilityLevel, - blockers, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - providerTrace: { - ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }), - runnerKind: M3_IO_SKILL_RUNNER_KIND, - codexStdio: false, - transport: "skill-cli", - fallbackUsed: false - } - }; -} - -async function executeM3IoSkillIntent({ intent, traceId, requestId, actorId, env, now, requestJson }) { - if (intent?.blocker) { - const blocker = m3IoIntentBlocker(intent.blocker, { traceId }); - return { - commandArgs: [ - "m3", - "io", - "--trace-id", - traceId, - "--request-id", - requestId, - "--actor-id", - actorId - ], - skillResult: m3IoSkillBlockedBeforeRequestResult({ - traceId, - requestId, - actorId, - blocker, - route: HWLAB_M3_IO_API_ROUTE, - method: "POST", - action: "m3.io.blocked", - hwlabApi: { - route: HWLAB_M3_IO_API_ROUTE, - redactedUrl: null, - source: "intent-validation", - baseUrlConfigured: Boolean(configuredCloudApiBaseUrl(env)), - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - missingConfig: [], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - startedAt: nowIso(now), - finishedAt: nowIso(now) - }) - }; - } - - if (!m3IoIntentHasSupportedAction(intent)) { - const blocker = m3IoIntentActionMissingBlocker({ traceId }); - return { - commandArgs: [ - "m3", - "io", - "--trace-id", - traceId, - "--request-id", - requestId, - "--actor-id", - actorId - ], - skillResult: m3IoSkillBlockedBeforeRequestResult({ - traceId, - requestId, - actorId, - blocker, - route: HWLAB_M3_IO_API_ROUTE, - method: "POST", - action: "m3.io", - hwlabApi: { - route: HWLAB_M3_IO_API_ROUTE, - redactedUrl: null, - source: "intent-validation", - baseUrlConfigured: Boolean(configuredCloudApiBaseUrl(env)), - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - missingConfig: [], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - startedAt: nowIso(now), - finishedAt: nowIso(now) - }) - }; - } - - const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); - if (!commandArgs.includes("--request-id")) { - commandArgs.push("--request-id", requestId); - } - const skillResult = await runM3IoSkillCommand(commandArgs, { - env, - now, - requestJson - }); - return { commandArgs, skillResult }; -} - -function m3IoIntentHasSupportedAction(intent = {}) { - return ["status", "do.write", "di.read"].includes(intent.action); -} - -function skippedCodexStdioFeasibilityForM3Io({ workspace } = {}) { - return { - checked: true, - skipped: true, - reason: "m3_io_skill_cli_deterministic_route", - sourceIssue: "pikasTech/HWLAB#334", - status: "skipped", - ready: false, - canStartLongLivedCodexStdio: false, - commandProbe: { - ready: false, - skipped: true, - reason: "m3_io_skill_cli_deterministic_route" - }, - workspace: workspace ?? repoRoot, - sandbox: M3_IO_SKILL_SANDBOX, - blockers: [], - blockerCodes: [], - summary: `M3 IO requests use deterministic Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}; Codex stdio chat is not part of this control decision.` - }; -} - -function m3IoIntentActionMissingBlocker({ traceId }) { - return { - code: "m3_io_intent_action_missing", - layer: "intent", - category: "needs_confirmation", - retryable: false, - source: "code-agent-m3-skill-cli", - summary: "M3 IO request did not specify status, DI read, or a DO write value.", - message: "M3 IO intent requires an explicit action: m3 status, DI1 read, or DO1 write with true/false.", - zh: "M3 IO 请求缺少明确动作:请指定读取 M3 status、读取 DI1,或把 DO1 写成 true/false。", - userMessage: "M3 IO 请求缺少明确动作,需要指定 status、DI1 读取,或 DO1=true/false 写入。", - traceId, - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME - }; -} - -function m3IoIntentBlocker(blocker, { traceId } = {}) { - const code = blocker?.code ?? "m3_io_scope_blocked"; - const layer = blocker?.layer ?? "intent"; - return { - code, - layer, - category: blocker?.category ?? "scope_blocked", - retryable: false, - source: "code-agent-m3-skill-cli", - summary: blocker?.summary ?? "M3 IO request is outside the allowed controlled virtual IO surface.", - message: blocker?.message ?? "Code Agent only supports the M3 virtual IO controlled cloud-api path.", - zh: blocker?.zh ?? "当前只支持 M3 虚拟 IO 受控链路。", - userMessage: blocker?.userMessage ?? "当前只支持 M3 虚拟 IO 受控链路:box-simu-1 DO1=true/false 写入,或 box-simu-2 DI1 读取。", - traceId, - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME, - allowed: { - read: `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`, - write: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}=true|false`, - route: HWLAB_M3_IO_API_ROUTE - }, - requested: blocker?.requested ?? null - }; -} - -function m3IoSkillBlockedBeforeRequestResult({ - traceId, - requestId, - actorId, - blocker, - route = HWLAB_M3_IO_API_ROUTE, - method = "POST", - action = "m3.io", - hwlabApi, - startedAt, - finishedAt -}) { - return { - ok: false, - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route, - method, - hwlabApi, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - controlReady: false, - action, - accepted: false, - status: "blocked", - traceId, - requestId, - actorId, - operationId: null, - auditId: null, - evidenceId: null, - audit: { - auditId: null, - status: "not_written", - durableStatus: null, - summary: "blocked before HWLAB API request" - }, - evidence: { - evidenceId: null, - status: "blocked", - sourceKind: "BLOCKED", - blocker: blocker.code, - writeStatus: "not_written", - summary: "blocked before HWLAB API request" - }, - durable: { - status: "blocked", - durable: false, - blocker: blocker.code, - category: blocker.category, - summary: blocker.message - }, - blocker, - capabilityBlocker: blocker, - trustBlocker: null, - blockers: [blocker], - readiness: { - status: "blocked", - controlReady: false, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - route, - blocker, - trustBlocker: null - }, - command: null, - result: { - value: null, - targetReadback: null - }, - readback: null, - controlPath: { - cloudApi: false, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - }, - safety: { - cloudApiRouteOnly: true, - allowedRoute: route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false - }, - httpStatus: 0, - error: { - code: blocker.code, - layer: blocker.layer, - category: blocker.category, - blocker, - retryable: blocker.retryable, - userMessage: blocker.userMessage, - message: blocker.message, - traceId, - route, - toolName: HWLAB_M3_IO_SKILL_NAME - }, - rawStatus: null, - startedAt, - finishedAt, - response: null + status: "not_requested", + items: [], + count: 0, + blockers: [] }; } @@ -1547,1237 +924,6 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI } } -async function callCodexStdioWithM3IoSkillRunner({ - message, - intent, - conversationId, - sessionId, - traceId, - env, - now, - workspace, - timeoutMs, - hardTimeoutMs, - model, - codexStdioManager, - traceRecorder, - conversationFacts, - requestJson -}) { - const stdioResult = await callCodexStdioRunner({ - message, - conversationId, - sessionId, - traceId, - env, - now, - workspace, - timeoutMs, - hardTimeoutMs, - model, - codexStdioManager, - traceRecorder, - conversationFacts - }); - traceRecorder?.append({ - type: "tool_call", - status: "started", - label: "tool:hwlab-m3-io:started", - toolName: HWLAB_M3_IO_SKILL_NAME, - waitingFor: HWLAB_M3_IO_API_ROUTE - }); - const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); - const skillResult = await runM3IoSkillCommand(commandArgs, { - env, - now, - requestJson - }); - traceRecorder?.append({ - type: "tool_call", - status: skillResult.ok ? "completed" : "blocked", - label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`, - toolName: HWLAB_M3_IO_SKILL_NAME, - outputSummary: `route=${skillResult.route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`, - terminal: false - }); - return codexStdioM3IoSkillRunnerResult({ - stdioResult, - skillResult, - commandArgs, - traceId, - now - }); -} - -function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }) { - const capabilityLevel = skillResult.capabilityLevel ?? ( - skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked - ); - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - const m3Io = m3IoStructuredResult({ - skillResult, - stdioResult, - commandArgs, - route, - method - }); - const toolCall = m3IoSkillToolCall({ - skillResult, - commandArgs, - cwd: stdioResult.workspace, - capabilityLevel, - route, - method, - responseType: m3Io.type - }); - const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); - const runnerTrace = { - ...stdioResult.runnerTrace, - events: [ - ...(stdioResult.runnerTrace?.events ?? []), - "tool:skill-cli:started", - `route:${route}`, - `tool:${toolCall.status}` - ], - route, - method, - skill: HWLAB_M3_IO_SKILL_NAME, - capabilityLevel, - controlReady: skillResult.controlReady === true, - operationId: skillResult.operationId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - accepted: skillResult.accepted, - status: skillResult.status, - blocker: skillResult.blocker ?? null, - trustBlocker: skillResult.trustBlocker ?? null, - blockers, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - finishedAt: nowIso(now) - }; - const runner = { - ...stdioResult.runner, - skill: { - name: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route - }, - toolPolicy: { - ...(stdioResult.runner?.toolPolicy ?? {}), - allowed: [ - ...new Set([ - ...(stdioResult.runner?.toolPolicy?.allowed ?? []), - `${method} ${route}` - ]) - ] - } - }; - - return { - ...stdioResult, - content: m3IoSkillReply(skillResult, m3Io), - responseType: m3Io.type, - m3Io, - toolCalls: [ - ...(stdioResult.toolCalls ?? []), - toolCall - ], - skills: { - status: "used", - items: [ - ...(stdioResult.skills?.items ?? []), - m3IoSkillItem({ capabilityLevel, route }) - ], - count: (stdioResult.skills?.count ?? 0) + 1, - blockers - }, - runner, - runnerTrace, - capabilityLevel, - blockers, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - providerTrace: { - ...(stdioResult.providerTrace ?? {}), - ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }), - runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND, - codexStdio: true, - transport: "stdio+skill-cli" - } - }; -} - -function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method, responseType }) { - return { - id: `tool_${randomUUID()}`, - type: "skill-cli", - name: HWLAB_M3_IO_SKILL_NAME, - responseType, - status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked", - cwd, - command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${redactM3IoCommandArgs(commandArgs).join(" ")}`), - exitCode: skillResult.ok ? 0 : 2, - stdout: boundToolOutput(JSON.stringify({ - route: skillResult.route, - method, - status: skillResult.status, - accepted: skillResult.accepted, - traceId: skillResult.traceId, - operationId: skillResult.operationId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - audit: skillResult.audit, - evidence: skillResult.evidence, - durable: skillResult.durable, - blocker: skillResult.blocker, - result: skillResult.result, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - safety: skillResult.safety - })).text, - stderrSummary: skillResult.blocker?.code ?? "", - outputTruncated: false, - route, - method, - hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi), - capabilityLevel, - controlReady: skillResult.controlReady === true, - accepted: skillResult.accepted, - operationStatus: skillResult.status, - apiStatus: skillResult.status, - operationId: skillResult.operationId, - traceId: skillResult.traceId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - audit: skillResult.audit, - evidence: skillResult.evidence, - durable: skillResult.durable, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - blocker: skillResult.blocker, - capabilityBlocker: skillResult.capabilityBlocker ?? null, - trustBlocker: skillResult.trustBlocker ?? null, - blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false - }; -} - -function m3IoSkillItem({ capabilityLevel, route }) { - return { - name: HWLAB_M3_IO_SKILL_NAME, - summary: `Controlled M3 DO1/DI1 adapter for HWLAB API ${route}.`, - route, - capabilityLevel, - version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION - }; -} - -function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType }) { - return { - runnerKind: M3_IO_SKILL_RUNNER_KIND, - skill: HWLAB_M3_IO_SKILL_NAME, - responseType, - route, - method, - traceId: skillResult.traceId, - operationId: skillResult.operationId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - capabilityLevel, - controlReady: skillResult.controlReady === true, - accepted: skillResult.accepted, - status: skillResult.status, - fallbackUsed: false - }; -} - -function m3IoStructuredResult({ skillResult = {}, stdioResult = {}, commandArgs = [], route = HWLAB_M3_IO_API_ROUTE, method = "POST" }) { - const blocker = m3IoPrimaryBlocker(skillResult); - const isBlocker = Boolean( - blocker || - skillResult.ok === false || - skillResult.accepted === false || - String(skillResult.status ?? "").toLowerCase() === "blocked" - ); - const action = skillResult.action ?? skillResult.command?.action ?? m3IoActionFromArgs(commandArgs) ?? (route === HWLAB_M3_STATUS_API_ROUTE ? "status" : "m3.io"); - const do1Value = action === "do.write" - ? firstDefined(skillResult.command?.value, skillResult.result?.value, skillResult.value) ?? null - : null; - const di1Value = action === "di.read" - ? firstDefined(skillResult.result?.value, skillResult.readback?.value) ?? null - : firstDefined(skillResult.result?.targetReadback?.value, skillResult.readback?.value) ?? null; - const durable = skillResult.durable ?? {}; - const evidenceSourceKind = skillResult.evidence?.sourceKind ?? null; - const auditStatus = skillResult.audit?.status ?? null; - const evidenceStatus = skillResult.evidence?.status ?? null; - const trusted = durable.durable === true && - evidenceSourceKind === "DEV-LIVE" && - !skillResult.trustBlocker && - ["persisted", "green"].includes(String(auditStatus ?? evidenceStatus ?? "").toLowerCase()); - const pathSummary = `Code Agent -> Skill CLI -> HWLAB API ${route}`; - - return { - type: isBlocker ? "m3_io_blocker" : "m3_io_result", - status: skillResult.status ?? (isBlocker ? "blocked" : "succeeded"), - action, - accepted: skillResult.accepted === true, - controlReady: skillResult.controlReady === true, - capabilityLevel: skillResult.capabilityLevel ?? (isBlocker ? HWLAB_M3_IO_CAPABILITY_LEVELS.blocked : HWLAB_M3_IO_CAPABILITY_LEVELS.ready), - summary: m3IoStructuredSummary({ - action, - isBlocker, - blocker, - route, - do1Value, - di1Value, - trusted, - durable: durable.durable === true - }), - do1: { - resourceId: M3_IO_TOPOLOGY.sourceResourceId, - port: M3_IO_TOPOLOGY.sourcePort, - targetValue: do1Value - }, - di1: { - resourceId: M3_IO_TOPOLOGY.targetResourceId, - port: M3_IO_TOPOLOGY.targetPort, - observedValue: di1Value, - status: skillResult.result?.targetReadback?.status ?? skillResult.readback?.status ?? null - }, - wiring: { - from: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}`, - via: M3_IO_TOPOLOGY.patchPanelServiceId, - to: `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`, - label: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort} -> ${M3_IO_TOPOLOGY.patchPanelServiceId} -> ${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}` - }, - path: { - summary: pathSummary, - segments: ["Code Agent", "Skill CLI", "HWLAB API"], - skillCli: { - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - name: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION - }, - hwlabApi: { - route, - method, - source: skillResult.hwlabApi?.source ?? null, - redactedUrl: skillResult.hwlabApi?.redactedUrl ?? null, - baseUrlConfigured: skillResult.hwlabApi?.baseUrlConfigured ?? null, - cloudApiOnly: skillResult.hwlabApi?.cloudApiOnly !== false - }, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - openAiFallbackUsed: false - }, - operation: { - operationId: skillResult.operationId ?? null, - status: skillResult.status ?? null, - accepted: skillResult.accepted === true, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null - }, - zh: m3IoChineseResult({ - action, - isBlocker, - blocker, - route, - method, - do1Value, - di1Value, - trusted, - durable, - skillResult - }), - session: { - sessionId: stdioResult.session?.sessionId ?? null, - sessionMode: stdioResult.sessionMode ?? stdioResult.runner?.sessionMode ?? null, - sessionReused: stdioResult.sessionReuse?.reused ?? stdioResult.runner?.sessionReused ?? false, - turn: stdioResult.sessionReuse?.turn ?? stdioResult.runner?.turn ?? null, - codexStdio: stdioResult.runner?.codexStdio === true, - durableSession: stdioResult.runner?.durableSession === true - }, - trace: { - traceId: skillResult.traceId ?? stdioResult.traceId ?? null, - requestId: skillResult.requestId ?? null, - actorId: skillResult.actorId ?? null, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - route, - method - }, - trust: { - trusted, - durable: durable.durable === true, - durableStatus: durable.status ?? null, - durableBlocker: durable.blocker ?? null, - auditStatus, - evidenceStatus, - evidenceSourceKind, - trustBlocker: skillResult.trustBlocker ?? null, - note: trusted - ? "operation/audit/evidence 已有可信持久化记录;仍不是 M3 DEV-LIVE 验收结论。" - : skillResult.controlReady === true - ? "控制链路可达,但可信记录未 green,不能作为 DEV-LIVE 可信闭环通过。" - : "本次不伪造 DEV-LIVE:控制链路结果与可信持久化状态分开展示。" - }, - blocker, - blockers: skillResult.blockers ?? (blocker ? [blocker] : []), - safety: { - cloudApiRouteOnly: true, - allowedRoute: route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false, - acceptanceClaimed: false - } - }; -} - -function m3IoActionFromArgs(commandArgs = []) { - const actionIndex = commandArgs.indexOf("--action"); - if (actionIndex >= 0 && typeof commandArgs[actionIndex + 1] === "string") { - return commandArgs[actionIndex + 1]; - } - if (commandArgs.includes("status")) return "status"; - return null; -} - -function m3IoPrimaryBlocker(skillResult = {}) { - const capabilityCandidates = [ - skillResult.blocker, - skillResult.capabilityBlocker, - skillResult.error?.blocker - ]; - const primary = capabilityCandidates.find((item) => item?.code || item?.message || item?.zh); - if (primary) return primary; - if ( - skillResult.ok !== false && - skillResult.accepted !== false && - String(skillResult.status ?? "").toLowerCase() !== "blocked" - ) { - return null; - } - const fallbackCandidates = [ - ...(Array.isArray(skillResult.blockers) ? skillResult.blockers : []) - ]; - return fallbackCandidates.find((item) => item?.code || item?.message || item?.zh) ?? null; -} - -function m3IoStructuredSummary({ action, isBlocker, blocker, route, do1Value, di1Value, trusted, durable }) { - if (isBlocker) { - const reason = blocker?.userMessage ?? blocker?.zh ?? blocker?.message ?? blocker?.code ?? "M3 IO 链路受阻"; - return `M3 IO 阻塞:${reason};路径 Code Agent -> Skill CLI -> HWLAB API ${route}。`; - } - const actionText = action === "do.write" - ? `DO1 目标值=${formatM3Value(do1Value)},DI1 观测值=${formatM3Value(di1Value)}` - : action === "di.read" - ? `DI1 观测值=${formatM3Value(di1Value)}` - : `状态读取 DI1=${formatM3Value(di1Value)}`; - return `M3 IO 结果:${actionText};trusted=${trusted ? "true" : "false"};durable=${durable ? "true" : "false"}。`; -} - -function m3IoChineseResult({ action, isBlocker, blocker, route, method, do1Value, di1Value, trusted, durable, skillResult = {} }) { - const expectedValue = action === "do.write" ? do1Value : null; - const targetResource = action === "do.write" - ? `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}` - : `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`; - const actualResult = action === "do.write" - ? `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(di1Value)}` - : action === "di.read" - ? `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(di1Value)}` - : "未执行"; - const controlReachable = skillResult.controlReady === true; - const trustedGreen = trusted === true; - const durableGreen = durable?.durable === true && !durable?.blocker; - return { - status: isBlocker ? "blocked" : trustedGreen && durableGreen ? "succeeded" : "degraded", - targetResource, - action: action === "do.write" - ? "写入 DO1 并回读 DI1" - : action === "di.read" - ? "读取 DI1" - : "M3 IO 请求", - expectedValue, - readValue: action === "di.read" ? di1Value : null, - actualResult, - source: `HWLAB cloud-api 受控链路 ${method} ${route}`, - traceId: skillResult.traceId ?? null, - operationId: skillResult.operationId ?? null, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - controlPath: { - reachable: controlReachable, - cloudApiRouteOnly: true, - route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - runtime: { - durableGreen, - trustedGreen, - durableStatus: durable?.status ?? null, - durableBlocker: durable?.blocker ?? null, - note: controlReachable && !trustedGreen - ? "控制链路可达,但可信记录未 green,不能作为 DEV-LIVE 可信闭环通过。" - : trustedGreen - ? "控制链路和可信记录均为 green;仍不在本接口内声明 live 已更新。" - : "控制链路或可信记录未 green。" - }, - blocker: blocker?.code ? { - code: blocker.code, - layer: blocker.layer ?? null, - message: blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code - } : null - }; -} - -function redactM3IoCommandArgs(commandArgs = []) { - const redacted = []; - for (let index = 0; index < commandArgs.length; index += 1) { - redacted.push(commandArgs[index]); - if (commandArgs[index] === "--api-base-url" && index + 1 < commandArgs.length) { - redacted.push(""); - index += 1; - } - } - return redacted; -} - -function m3IoToolCallApiTarget(hwlabApi = {}) { - const target = { - ...hwlabApi, - route: hwlabApi?.route ?? HWLAB_M3_IO_API_ROUTE - }; - delete target.url; - if (findDirectHardwareTarget(target.redactedUrl)) { - target.redactedUrl = null; - } - return target; -} - -function m3SkillRoute(skillResult = {}) { - return skillResult.route === HWLAB_M3_STATUS_API_ROUTE ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE; -} - -function m3SkillMethod(skillResult = {}) { - return m3SkillRoute(skillResult) === HWLAB_M3_STATUS_API_ROUTE ? "GET" : skillResult.method ?? "POST"; -} - -function m3SessionCapabilityLevelForIntent(intent = {}) { - if (intent.action === "status") return HWLAB_M3_IO_CAPABILITY_LEVELS.readonly; - if (!m3IoIntentHasSupportedAction(intent)) return HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; - return HWLAB_M3_IO_CAPABILITY_LEVELS.ready; -} - -function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, route = HWLAB_M3_IO_API_ROUTE, startedAt, finishedAt }) { - const method = route === HWLAB_M3_STATUS_API_ROUTE ? "GET" : "POST"; - return { - ok: false, - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route, - method, - hwlabApi: { - route, - redactedUrl: null, - source: "missing-config", - baseUrlConfigured: false, - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - missingConfig: [ - ...HWLAB_M3_IO_API_BASE_URL_ENVS, - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - controlReady: false, - action: "m3.io", - accepted: false, - status: "blocked", - traceId, - requestId, - actorId, - operationId: null, - auditId: null, - evidenceId: null, - audit: { - auditId: null, - status: "not_written", - durableStatus: null, - summary: "blocked before HWLAB API request" - }, - evidence: { - evidenceId: null, - status: "blocked", - sourceKind: "BLOCKED", - blocker: blocker.code, - writeStatus: "not_written", - summary: "blocked before HWLAB API request" - }, - durable: { - status: "blocked", - durable: false, - blocker: blocker.code, - category: blocker.category, - summary: blocker.message - }, - blocker, - capabilityBlocker: blocker, - trustBlocker: null, - blockers: [blocker], - readiness: { - status: "blocked", - controlReady: false, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - route: HWLAB_M3_IO_API_ROUTE, - blocker, - trustBlocker: null - }, - command: null, - result: { - value: null, - targetReadback: null - }, - readback: null, - controlPath: { - cloudApi: false, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - }, - safety: { - cloudApiRouteOnly: true, - allowedRoute: HWLAB_M3_IO_API_ROUTE, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false - }, - httpStatus: 0, - error: { - code: blocker.code, - layer: blocker.layer, - category: blocker.category, - blocker, - retryable: blocker.retryable, - userMessage: blocker.userMessage, - message: blocker.message, - traceId, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - missingConfig: blocker.missingConfig - }, - rawStatus: null, - startedAt, - finishedAt, - response: null - }; -} - -function detectReadOnlyRunnerIntent(message, options = {}) { - const text = String(message ?? "").trim(); - - if (isSecretReadRequest(text)) { - return { - kind: "security", - toolName: "security.secret-redaction", - reason: "Code Agent 安全边界不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" - }; - } - if (isPcGatewayCodexToolRequest(text)) { - return { - kind: "none", - toolName: "codex-stdio.pc-gateway-wrapper" - }; - } - const m3IoIntent = detectM3IoIntent(text); - if (m3IoIntent) { - return m3IoIntent; - } - if (isHardwareWriteOrAcceptanceRequest(text)) { - return { - kind: "security", - toolName: "security.hardware-boundary", - reason: `Code Agent 安全边界不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` - }; - } - const externalNetworkIntent = detectExternalNetworkIntent(text); - if (externalNetworkIntent) return externalNetworkIntent; - if (isSessionContextRequest(text)) { - return { kind: "session_context", toolName: "session.context" }; - } - return { kind: "none" }; -} - -function isPcGatewayCodexToolRequest(text) { - const value = String(text ?? ""); - const mentionsGatewayTool = /(?:\/app\/tools\/hwlab-gateway-shell\.mjs|hwlab-gateway-shell\.mjs|hardware\.invoke\.shell|PC\s*gateway|gws_DESKTOP-[A-Z0-9-]+|cap_windows_cmd_exec|res_windows_host)/iu.test(value); - const mentionsWindowsCommand = /(?:Windows\s+cmd|cmd\s*\/c|shell\.exec|hostname|PowerShell|powershell)/iu.test(value); - const mentionsWindowsSkill = /(?:C:\\Users\\liang\\\.agents\\skills|\.agents[\\/]+skills|keil-cli\.py|\bKeil\b|MDK-ARM|\.uvprojx|F:\\Work\\constart|job-status)/iu.test(value); - return mentionsGatewayTool && (mentionsWindowsCommand || mentionsWindowsSkill); -} - -function detectExternalNetworkIntent(text) { - const value = String(text ?? "").trim(); - if (!value) return null; - const explicitUrl = value.match(/\bhttps?:\/\/[^\s"'<>,。!?))]+/iu)?.[0] ?? null; - const mentionsGithub = /\bgithub(?:\.com)?\b|github\.com|GitHub/u.test(value); - const mentionsExternalWeb = explicitUrl || mentionsGithub || /(?:外网|公网|网页|网站|URL|http|https|network|reachable|访问|打开|连通|看看).{0,20}(?:网页|网站|URL|github|GitHub|外网|公网|http|https)|(?:github|GitHub|http|https|URL).{0,20}(?:访问|打开|看看|连通|reachable)/iu.test(value); - const asksToCheck = /(?:访问|打开|看看|试试|检查|确认|能不能|是否|连通|可达|reachable|access|open|visit|check|curl|ping)/iu.test(value); - if (!mentionsExternalWeb || !asksToCheck) return null; - return { - kind: "external_network", - toolName: "external.network.check", - targetUrl: explicitUrl ?? (mentionsGithub ? "https://github.com/" : extractExternalNetworkHost(value)), - originalText: value - }; -} - -function extractExternalNetworkHost(text) { - const host = String(text ?? "").match(/\b([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?:\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]*)?/u)?.[0]; - return host ? `https://${host}` : null; -} - -function isSessionContextRequest(text) { - const value = String(text ?? ""); - const referencesEarlierTurns = /(?:前两轮|前2轮|上一轮|上两轮|刚才|前面|之前|previous|earlier|context|上下文|根据.*(?:结果|内容|事实)|基于.*(?:结果|内容|事实))/iu.test(value); - const asksForFacts = /(?:工作目录|当前目录|workspace|路径|skills?|skill|技能|能使用|可用|session|conversation|会话|trace|runner)/iu.test(value); - return referencesEarlierTurns && asksForFacts; -} - -function detectM3IoIntent(text) { - const normalized = String(text ?? ""); - if (!/\bM\d+\b|\bM3\b|DO\s*\d+|DI\s*\d+|数字输出|数字输入|box[-_ ]?simu|res_boxsimu|回读|读回|状态|status|串口|serial|PWM|DAP/u.test(normalized)) { - return null; - } - if (isDirectM3HardwareAccessRequest(normalized)) { - return null; - } - const scopeBlocker = detectM3IoScopeBlocker(normalized); - if (scopeBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: scopeBlocker - }; - } - const explicitTargetBlocker = detectM3IoTargetBlocker(normalized, "any"); - if (explicitTargetBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: explicitTargetBlocker - }; - } - if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) { - return null; - } - - const topologyMentioned = isM3TopologyRouteMention(normalized); - const wantsRead = /(?:DI1|数字输入).{0,24}(?:read|readback|读取|回读|读回|查询|状态|当前)|(?:read|readback|读取|回读|读回|查询|状态|当前).{0,24}(?:DI1|数字输入)/iu.test(normalized); - const wantsWrite = /(?:DO1|数字输出).{0,30}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,30}(?:DO1|数字输出)/iu.test(normalized) || - (topologyMentioned && /(?:执行|run|闭环|loop|控制|触发)/iu.test(normalized)); - const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized); - const wantsM3ControlButMissingAction = /(?:HWLAB API|Skill CLI|M3).{0,32}(?:控制|control|IO)|(?:控制|control).{0,32}(?:HWLAB API|Skill CLI|M3 IO|M3)/iu.test(normalized); - if (wantsStatus && !wantsRead && !wantsWrite) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "status" - }; - } - if (!wantsRead && !wantsWrite) { - if (wantsM3ControlButMissingAction) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null - }; - } - return null; - } - - if (wantsRead && !wantsWrite) { - const targetBlocker = detectM3IoTargetBlocker(normalized, "di.read"); - if (targetBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: targetBlocker - }; - } - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "di.read" - }; - } - - const targetBlocker = detectM3IoTargetBlocker(normalized, "do.write"); - if (targetBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: targetBlocker - }; - } - const value = parseM3WriteValue(normalized); - if (typeof value !== "boolean") { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "do.write" - }; - } - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "do.write", - value - }; -} - -function detectM3IoScopeBlocker(text) { - const value = String(text ?? ""); - const unsupportedStage = value.match(/\bM(?!3\b)\d+\b/iu)?.[0] ?? null; - if (unsupportedStage && /(?:IO|DI|DO|控制|读取|读|写|状态|硬件|hardware)/iu.test(value)) { - return m3ScopeBlocker({ - code: "m3_io_scope_blocked", - summary: `Unsupported hardware stage ${unsupportedStage} requested.`, - zh: `当前 Code Agent 只支持 M3 虚拟 IO,暂不支持 ${unsupportedStage} IO。`, - requested: unsupportedStage - }); - } - const dangerous = value.match(/真实硬件|物理硬件|real\s+hardware|production\s+hardware|PROD|任意\s*shell|shell|bash|sh\s+-c|kubectl|数据库写入|写入数据库|database\s+write|SQL\s+write|绕过\s*(?:patch-panel|cloud-api|HWLAB API)|bypass\s+(?:patch-panel|cloud-api|HWLAB API)|批量脚本|脚本批量|batch\s+script|DAP|串口|serial|PWM/iu)?.[0] ?? null; - if (dangerous && /(?:M3|IO|DI|DO|box[-_ ]?simu|res_boxsimu|硬件|hardware|patch-panel|cloud-api|HWLAB API)/iu.test(value)) { - return m3ScopeBlocker({ - code: "m3_io_scope_blocked", - summary: `Dangerous or out-of-scope M3 IO request was blocked: ${dangerous}.`, - zh: "当前只支持 M3 虚拟 IO 受控链路;不能操作真实硬件、shell、数据库、串口/DAP/PWM、批量脚本,或绕过 cloud-api/patch-panel。", - requested: dangerous - }); - } - return null; -} - -function detectM3IoTargetBlocker(text, action) { - const value = String(text ?? ""); - const unsupportedPort = value.match(/\b(?:DO|DI)\s*(?!1\b)\d+\b|数字(?:输出|输入)\s*(?!1\b)\d+|PWM|DAP|串口|serial/iu)?.[0] ?? null; - if (unsupportedPort) { - return m3ScopeBlocker({ - code: "m3_io_target_unsupported", - category: "target_unsupported", - summary: `Unsupported M3 IO target ${unsupportedPort} requested.`, - zh: "当前只支持 M3 阶段允许的 DO1/DI1,不能执行 DO2/DI2、DAP、串口或 PWM 等请求。", - requested: unsupportedPort - }); - } - - const mentionsBox1 = /(?:box[-_ ]?simu[-_ ]?1|boxsimu[_-]?1|res_boxsimu_1)/iu.test(value); - const mentionsBox2 = /(?:box[-_ ]?simu[-_ ]?2|boxsimu[_-]?2|res_boxsimu_2)/iu.test(value); - if (action === "do.write" && mentionsBox2 && !mentionsBox1) { - return m3ScopeBlocker({ - code: "m3_io_target_unsupported", - category: "target_unsupported", - summary: "DO write target is not the allowed res_boxsimu_1:DO1 endpoint.", - zh: "DO 写入仅允许目标 box-simu-1 DO1;不能把 box-simu-2 当作 DO 写入目标。", - requested: "box-simu-2 DO1" - }); - } - if (action === "di.read" && mentionsBox1 && !mentionsBox2) { - return m3ScopeBlocker({ - code: "m3_io_target_unsupported", - category: "target_unsupported", - summary: "DI read target is not the allowed res_boxsimu_2:DI1 endpoint.", - zh: "DI 读取仅允许目标 box-simu-2 DI1;不能把 box-simu-1 当作 DI 读取目标。", - requested: "box-simu-1 DI1" - }); - } - return null; -} - -function m3ScopeBlocker({ code, category = "scope_blocked", summary, zh, requested }) { - return { - code, - layer: "intent", - category, - retryable: false, - summary, - message: "M3 IO request is outside the allowed controlled virtual IO surface.", - zh, - userMessage: `${zh} 当前只支持 box-simu-1 DO1=true/false 写入和 box-simu-2 DI1 读取,且必须走 cloud-api ${HWLAB_M3_IO_API_ROUTE}。`, - requested - }; -} - -function isDirectM3HardwareAccessRequest(text) { - const value = String(text ?? ""); - if (/(?:https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu.test(value)) { - return true; - } - if (/(?:直接|direct).{0,24}(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel|gateway|box|\/invoke|\/sync\/tick)|(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel).{0,24}(?:直接|direct|\/invoke|\/sync\/tick)/iu.test(value)) { - return true; - } - return false; -} - -function isM3TopologyRouteMention(text) { - const value = String(text ?? ""); - return /res_boxsimu_1\s*:?\s*DO1[\s\S]{0,80}hwlab-patch-panel[\s\S]{0,80}res_boxsimu_2\s*:?\s*DI1/iu.test(value) || - /DO1\s*[-=]*>\s*(?:hwlab-)?patch-panel\s*[-=]*>\s*DI1/iu.test(value); -} - -function parseM3WriteValue(text) { - if (/\bfalse\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false; - if (/\btrue\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true; - if (/(?:设置为|设为|写成|写入|置为|set\s+to|=|:)\s*0\b/iu.test(text)) return false; - if (/(?:设置为|设为|写成|写入|置为|set\s+to|=|:)\s*1\b/iu.test(text)) return true; - return null; -} - -function isSecretReadRequest(text) { - const asksToRead = /(?:print|show|cat|read|list|dump|echo|输出|显示|读取|列出|查看|打印)/iu.test(text); - const secretTerm = /(?:secret|token|kubeconfig|OPENAI_API_KEY|DATABASE_URL|password|passwd|credential|private key|私钥|密码|凭证|环境变量|密钥)/iu.test(text); - return asksToRead && secretTerm; -} - -function isHardwareWriteOrAcceptanceRequest(text) { - const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用|\/invoke|\/sync\/tick)/iu.test(text); - const mutationVerb = /(?:call|invoke|write|mutate|apply|rollout|accept|pass|验收|通过|写入|调用|变更|操作)/iu.test(text); - const acceptanceClaim = /(?:M3|M4|M5).{0,20}(?:pass|accept|green|通过|验收|完成)/iu.test(text); - return (hardwareTarget && mutationVerb) || acceptanceClaim; -} - -function resolveRunnerWorkspace(env = process.env, options = {}) { - const configured = firstNonEmpty( - options.workspace, - env.HWLAB_CODE_AGENT_WORKSPACE, - env.HWLAB_RUNNER_WORKSPACE, - env.WORKSPACE - ); - return path.resolve(configured || repoRoot); -} - -function resolveSkillDirs(env = process.env, options = {}) { - const configured = Array.isArray(options.skillsDirs) - ? options.skillsDirs - : String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) - .split(/[,;]/u) - .flatMap((part) => part.split(path.delimiter)); - const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1"; - if (strict) { - return [...new Set(configured - .filter((dir) => typeof dir === "string" && dir.trim()) - .map((dir) => path.resolve(dir.trim())))]; - } - return [...new Set([ - ...configured, - path.join(os.homedir(), ".agents", "skills"), - "/root/.agents/skills", - "/home/ubuntu/.agents/skills", - path.join(repoRoot, "skills") - ] - .filter((dir) => typeof dir === "string" && dir.trim()) - .map((dir) => path.resolve(dir.trim())))]; -} - -function isForbiddenPath(value) { - const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase(); - return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) || - /(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized); -} - -function notRequestedSkills() { - return { - status: "not_requested", - items: [], - count: 0, - blockers: [] - }; -} - -function m3IoSkillArgsForIntent(intent, { env, traceId }) { - const args = [ - "m3", - intent.action === "status" ? "status" : "io" - ]; - if (intent.action !== "status") { - args.push("--action", intent.action); - } - args.push( - "--trace-id", - traceId, - "--actor-id", - "usr_code_agent" - ); - const apiBaseUrl = configuredCloudApiBaseUrl(env); - if (apiBaseUrl) { - args.push("--api-base-url", apiBaseUrl); - } - if (intent.action === "do.write") { - if (typeof intent.value === "boolean") { - args.push("--value", String(intent.value)); - } - args.push( - "--approved", - "--policy", - "hwlab-api-control-with-approval", - "--approval-reason", - "user explicitly requested res_boxsimu_1 DO1 write through HWLAB API" - ); - } else { - args.push("--policy", "hwlab-api-readonly"); - } - return args; -} - -function m3IoSkillApiBaseUrlSource(env = process.env) { - for (const name of HWLAB_M3_IO_API_BASE_URL_ENVS) { - if (firstNonEmpty(env[name])) return `env:${name}`; - } - return "missing-config"; -} - -function m3IoApiBaseUrlMissingBlocker({ route = HWLAB_M3_IO_API_ROUTE } = {}) { - return { - code: "skill_cli_api_base_missing", - layer: "skill-cli-config", - category: "needs_config", - retryable: false, - source: "code-agent-m3-skill-cli", - summary: `${HWLAB_M3_IO_API_BASE_URL_ENV} is required so the Skill CLI can reach cloud-api from inside the cloud-api runtime container.`, - message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV} or another supported HWLAB API base URL contract in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`, - zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。`, - userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。", - traceId: null, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - missingConfig: [ - ...HWLAB_M3_IO_API_BASE_URL_ENVS, - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ] - }; -} - -function m3IoSkillReply(skillResult, m3Io = null) { - const route = m3Io?.trace?.route ?? skillResult.route ?? HWLAB_M3_IO_API_ROUTE; - const lines = []; - if (m3Io?.type === "m3_io_blocker") { - const blocker = m3Io.blocker ?? skillResult.blocker ?? skillResult.capabilityBlocker ?? {}; - lines.push(`M3 IO 阻塞:${blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code ?? "M3 控制链路仍受阻"}。`); - } else { - lines.push("M3 IO 结果:受控请求已返回。"); - } - if (skillResult.action === "do.write") { - lines.push(`DO1 目标值:${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}=${formatM3Value(m3Io?.do1?.targetValue ?? skillResult.command?.value)};DI1 观测值:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.result?.targetReadback?.value)}。`); - } else if (skillResult.action === "di.read") { - lines.push(`DI1 读取结果:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.result?.value)}。`); - } else if (skillResult.readonly === true || route === HWLAB_M3_STATUS_API_ROUTE) { - lines.push(`M3 状态读取:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.readback?.value)};readiness=${skillResult.readiness?.status ?? "unknown"}。`); - } - lines.push(`接线关系:${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort} -> ${M3_IO_TOPOLOGY.patchPanelServiceId} -> ${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}。`); - lines.push(`执行路径:Code Agent -> Skill CLI -> HWLAB API ${route};没有使用 OpenAI fallback,也没有由 Code Agent 直连硬件服务。`); - lines.push(`排查字段:traceId=${skillResult.traceId ?? "null"};operationId=${skillResult.operationId ?? "null"};auditId=${skillResult.auditId ?? skillResult.audit?.auditId ?? "null"};evidenceId=${skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? "null"}。`); - if (m3Io?.blocker?.code) { - lines.push(`Blocker: ${m3Io.blocker.code};blocker=${m3Io.blocker.code};layer=${m3Io.blocker.layer ?? "unknown"}。`); - } - if (m3Io?.trust?.note) { - lines.push(`可信记录:${m3Io.trust.note}`); - } - lines.push(`可信状态:trusted=${m3Io?.trust?.trusted === true ? "true" : "false"};durable=${m3Io?.trust?.durable === true ? "true" : "false"};这不是 M3 DEV-LIVE 验收结论。`); - return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text; -} - -function m3IoSkillRunnerDescriptor({ workspace, session = null, skillResult = null } = {}) { - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - const capabilityLevel = skillResult?.capabilityLevel ?? HWLAB_M3_IO_CAPABILITY_LEVELS.ready; - return { - kind: M3_IO_SKILL_RUNNER_KIND, - provider: M3_IO_SKILL_PROVIDER, - backend: M3_IO_SKILL_BACKEND, - workspace: workspace ?? repoRoot, - sandbox: M3_IO_SKILL_SANDBOX, - session: M3_IO_SKILL_SESSION_MODE, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionId: session?.sessionId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdio: false, - longLivedSession: false, - durableSession: false, - writeCapable: true, - readOnly: false, - capabilityLevel, - skill: { - name: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route - }, - toolPolicy: { - allowed: [`${method} ${route}`], - blocked: ["gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "generic-hardware-rpc", "OpenAI-hardware-fallback", "M3/M4/M5-acceptance-claim"] - }, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - safety: { - secretsRead: false, - secretValuesPrinted: false, - kubeconfigRead: false, - cloudApiRouteOnly: true, - allowedRoute: route, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, - openAiFallbackAllowed: false, - m3m4m5AcceptanceClaimsAllowed: false, - outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT - } - }; -} - -function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = startedAt, outputTruncated, workspace = repoRoot, session = null, skillResult = null }) { - const capabilityLevel = skillResult?.capabilityLevel ?? ( - skillResult?.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked - ); - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - return { - traceId, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - workspace, - sandbox: M3_IO_SKILL_SANDBOX, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionId: session?.sessionId ?? null, - sessionStatus: session?.status ?? null, - idleTimeoutMs: session?.idleTimeoutMs ?? null, - lastTraceId: session?.lastTraceId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - limitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - startedAt, - finishedAt, - events, - route, - method, - skill: HWLAB_M3_IO_SKILL_NAME, - capabilityLevel, - controlReady: skillResult?.controlReady === true, - operationId: skillResult?.operationId ?? null, - auditId: skillResult?.auditId ?? skillResult?.audit?.auditId ?? null, - evidenceId: skillResult?.evidenceId ?? skillResult?.evidence?.evidenceId ?? null, - readback: skillResult?.readback ?? skillResult?.result?.targetReadback ?? null, - accepted: skillResult?.accepted ?? false, - status: skillResult?.status ?? "blocked", - blocker: skillResult?.blocker ?? null, - trustBlocker: skillResult?.trustBlocker ?? null, - blockers: skillResult?.blockers ?? [], - outputTruncated: Boolean(outputTruncated), - valuesPrinted: false, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - note: `Controlled M3 IO uses Skill CLI -> HWLAB API ${route}; it is not OpenAI fallback and does not directly call gateway, box, or patch-panel.` - }; -} - -function structuredCompletionBlocker(result, context = {}) { - if (!result || typeof result !== "object") return null; - if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") { - return structuredBlocker({ - code: "text_chat_only_fallback", - layer: "provider", - message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.", - userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。", - retryable: false, - traceId: context.traceId, - provider: result.provider ?? context.provider, - backend: result.backend ?? context.backend, - runner: result.runner ?? context.runner, - capabilityLevel: result.capabilityLevel ?? context.capabilityLevel, - blockers: result.longLivedSessionGate?.blockers - }); - } - const m3SkillTool = Array.isArray(result.toolCalls) - ? result.toolCalls.find((toolCall) => toolCall?.name === HWLAB_M3_IO_SKILL_NAME) - : null; - if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool || result.m3Io?.type === "m3_io_blocker") && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { - const primary = result.m3Io?.blocker ?? m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; - const topCode = m3CompletionBlockerCode(primary); - return structuredBlocker({ - code: topCode, - layer: topCode === "m3_readiness_blocked" - ? "m3-readiness" - : primary?.layer ?? (primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness"), - message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.", - userMessage: primary?.userMessage ?? primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。", - retryable: primary?.code === "hwlab_api_unavailable", - traceId: context.traceId, - provider: result.provider, - backend: result.backend, - runner: result.runner, - capabilityLevel: result.capabilityLevel, - route: result.m3Io?.trace?.route ?? m3SkillTool?.route ?? HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME, - blockers: result.m3Io?.blockers ?? result.skills?.blockers - }); - } - const hardwareTool = Array.isArray(result.toolCalls) - ? result.toolCalls.find((toolCall) => toolCall?.name === HARDWARE_INVOKE_SHELL_METHOD) - : null; - if ((result.provider === LEGACY_CODE_AGENT_HARDWARE_PROVIDER || hardwareTool || result.hardware?.type === "hardware_capability_blocker") && result.capabilityLevel === LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED) { - const primary = result.hardware?.blocker ?? hardwareTool?.blocker ?? result.blocker ?? result.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; - return structuredBlocker({ - code: primary?.code ?? "hardware_capability_blocked", - layer: primary?.layer ?? "hardware-capability", - message: primary?.message ?? primary?.summary ?? "Registered hardware capability route is blocked.", - userMessage: primary?.userMessage ?? "已登记硬件 capability 路由当前受阻,本次未进入 Codex stdio 或文本 fallback。", - retryable: primary?.retryable ?? false, - traceId: context.traceId, - provider: result.provider, - backend: result.backend, - runner: result.runner, - capabilityLevel: result.capabilityLevel, - route: HARDWARE_INVOKE_SHELL_METHOD, - toolName: HARDWARE_INVOKE_SHELL_METHOD, - category: primary?.category ?? "hardware_capability_blocked", - blockers: result.blockers ?? result.hardware?.blockers ?? result.skills?.blockers - }); - } - return null; -} - -function m3CompletionBlockerCode(primary = {}) { - if (["m3_gateway_session_unavailable", "runtime_durable_not_green", "skill_cli_api_base_missing", "m3_io_intent_action_missing", "m3_io_scope_blocked", "m3_io_target_unsupported"].includes(primary?.code)) { - return "m3_readiness_blocked"; - } - return primary?.code ?? "m3_io_blocker"; -} - function sessionReuseEvidence(session) { return { conversationId: session.conversationId, @@ -2830,43 +976,16 @@ function runnerSafetyContract() { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, - hardwareWritesAllowed: false, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, - m3IoSkillCliAllowedRoute: HWLAB_M3_IO_API_ROUTE, - m3StatusSkillCliAllowedRoute: HWLAB_M3_STATUS_API_ROUTE, + hardwareWritesAllowed: true, + directGatewayCallsAllowed: true, + directBoxSimuCallsAllowed: true, + directPatchPanelCallsAllowed: true, openAiHardwareFallbackAllowed: false, m3m4m5AcceptanceClaimsAllowed: false, outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT }; } -function securityBlockedToolCall({ traceId, toolName = "security.boundary", cwd = repoRoot } = {}) { - return { - id: `tool_${randomUUID()}`, - type: "security-policy", - name: toolName, - status: "blocked", - cwd, - exitCode: 1, - stdout: "", - stderrSummary: "security_blocked", - outputTruncated: false, - traceId, - route: null, - blocker: { - code: "security_blocked", - layer: "security", - category: "security_blocked", - retryable: false, - traceId, - toolName, - userMessage: "该请求被安全边界阻断,不能绕过 HWLAB API。" - } - }; -} - async function inspectCodexStdioFeasibility(env = process.env, options = {}) { const manager = resolveCodexStdioSessionManager(options); const descriptor = { @@ -3227,30 +1346,6 @@ function errorTaxonomy(code, error = {}) { retryable: false, userMessage: "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。" }, - security_blocked: { - layer: "security", - category: "security_blocked", - retryable: false, - userMessage: "该请求被安全边界阻断,不能读取敏感信息或绕过 HWLAB API。" - }, - skill_cli_api_base_missing: { - layer: "skill-cli-config", - category: "needs_config", - retryable: false, - userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。" - }, - hwlab_api_unavailable: { - layer: "hwlab-api", - category: "retryable", - retryable: true, - userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。" - }, - m3_readiness_blocked: { - layer: "m3-readiness", - category: "capability_unavailable", - retryable: false, - userMessage: "M3 控制链路尚未就绪,不能把本次结果标记为真实可控。" - }, text_chat_only_fallback: { layer: "provider", category: "fallback", @@ -3311,23 +1406,11 @@ function errorTaxonomy(code, error = {}) { retryable: true, userMessage: "Codex stdio runner 执行失败,可稍后重试。" }, - external_network_blocked: { - layer: "network", - category: "capability_unavailable", - retryable: false, - userMessage: "外部网络访问被运行策略、DNS 或目标边界阻断;本次不会回退成文本成功。" - }, - network_tool_unavailable: { - layer: "network-tool", - category: "needs_config", - retryable: false, - userMessage: "当前运行环境没有可用的受控 HTTP 网络检查工具;本次未访问外网。" - }, - network_timeout: { - layer: "network", - category: "timeout", + codex_stdio_network_failed: { + layer: "runner", + category: "runner_blocked", retryable: true, - userMessage: "外部网络检查超时;输入已保留,可稍后重试或让维护者确认网络策略。" + userMessage: "Codex stdio runner 的网络操作失败;输入已保留,可稍后重试。" } }; return catalog[code] ?? { @@ -3421,6 +1504,39 @@ function safeIsoLike(value) { return /^[0-9T:Z.+-]+$/u.test(text) ? text : null; } +function resolveRunnerWorkspace(env = process.env, options = {}) { + const configured = firstNonEmpty( + options.workspace, + env.HWLAB_CODE_AGENT_WORKSPACE, + env.HWLAB_RUNNER_WORKSPACE, + env.WORKSPACE + ); + return path.resolve(configured || repoRoot); +} + +function resolveSkillDirs(env = process.env, options = {}) { + const configured = Array.isArray(options.skillsDirs) + ? options.skillsDirs + : String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) + .split(/[,;]/u) + .flatMap((part) => part.split(path.delimiter)); + const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1"; + if (strict) { + return [...new Set(configured + .filter((dir) => typeof dir === "string" && dir.trim()) + .map((dir) => path.resolve(dir.trim())))]; + } + return [...new Set([ + ...configured, + path.join(os.homedir(), ".agents", "skills"), + "/root/.agents/skills", + "/home/ubuntu/.agents/skills", + path.join(repoRoot, "skills") + ] + .filter((dir) => typeof dir === "string" && dir.trim()) + .map((dir) => path.resolve(dir.trim())))]; +} + function sanitizeErrorField(key, value) { if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") { return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : []; @@ -3431,14 +1547,12 @@ function sanitizeErrorField(key, value) { function routeForError(code, error = {}) { if (error.route !== undefined) return error.route; - if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_API_ROUTE; if (String(code).startsWith("hardware_")) return HARDWARE_INVOKE_SHELL_METHOD; return null; } function toolNameForError(code, error = {}) { if (error.toolName !== undefined) return error.toolName; - if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_SKILL_NAME; if (String(code).startsWith("hardware_")) return HARDWARE_INVOKE_SHELL_METHOD; return null; } diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index ae776cf3..083dae44 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -730,1097 +730,7 @@ test("OpenAI provider mode still preserves facts but does not fallback without C assert.equal(JSON.stringify(payload.conversationFacts).includes("sk-"), false); }); -test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_write", - traceId: "trc_m3_skill_write", - message: "请通过 M3 DO1 写入 true,并回读 DI1" - }, - { - now: () => "2026-05-23T00:05:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO control"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - const parsed = new URL(url); - assert.equal(parsed.pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(parsed.hostname, "hwlab-cloud-api.hwlab-dev.svc.cluster.local"); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, "do.write"); - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, true); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_m3_skill_write", - operationId: "op_m3_do_write_skill", - auditId: "aud_m3_do_write_skill_succeeded", - evidenceId: "evd_m3_do_write_skill_succeeded", - auditState: { - status: "written_non_durable", - durableStatus: { - status: "degraded" - } - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: { - status: "succeeded", - value: true, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.session.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].status, "completed"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.toolCalls[0].controlReady, true); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(payload.toolCalls[0].operationId, "op_m3_do_write_skill"); - assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_write"); - assert.equal(payload.toolCalls[0].auditId, "aud_m3_do_write_skill_succeeded"); - assert.equal(payload.toolCalls[0].evidenceId, "evd_m3_do_write_skill_succeeded"); - assert.equal(payload.toolCalls[0].readback.value, true); - assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.runnerTrace.method, "POST"); - assert.equal(payload.runnerTrace.accepted, true); - assert.equal(payload.runnerTrace.auditId, "aud_m3_do_write_skill_succeeded"); - assert.equal(payload.runnerTrace.evidenceId, "evd_m3_do_write_skill_succeeded"); - assert.equal(payload.runnerTrace.readback.value, true); - assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.runnerTrace.controlReady, true); - assert.equal(payload.runner.writeCapable, true); - assert.deepEqual(payload.runner.toolPolicy.allowed, [`POST ${HWLAB_M3_IO_API_ROUTE}`]); - assert.equal(payload.runner.safety.directGatewayCallsAllowed, false); - assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false); - assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.method, "POST"); - assert.equal(payload.providerTrace.auditId, "aud_m3_do_write_skill_succeeded"); - assert.equal(payload.providerTrace.evidenceId, "evd_m3_do_write_skill_succeeded"); - assert.equal(payload.providerTrace.readback.value, true); - assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.providerTrace.controlReady, true); - assert.equal(payload.skills.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); - assert.match(payload.reply.content, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); - assert.equal(calls[0].url.includes("gateway"), false); - assert.equal(calls[0].url.includes("box-simu"), false); - assert.equal(calls[0].url.includes("patch-panel"), false); -}); - -test("Code Agent M3 DI read returns structured blocker from HWLAB API without fallback", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_read_blocked", - traceId: "trc_m3_skill_read_blocked", - message: "读取 M3 DI1 readback" - }, - { - now: () => "2026-05-23T00:06:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO read"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "blocked", - accepted: false, - action: "di.read", - traceId: "trc_m3_skill_read_blocked", - operationId: "op_m3_di_read_blocked", - auditId: "aud_m3_di_read_blocked_failed", - evidenceId: "evd_m3_di_read_blocked_failed", - blocker: { - code: "m3_gateway_session_unavailable", - message: "gateway unavailable", - zh: "gateway 未注册/不可用" - }, - blockerClassification: { - category: "gateway_session" - }, - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - controlPath: { - cloudApi: true, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.blocker.layer, "m3-readiness"); - assert.equal(payload.blocker.retryable, false); - assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.blocker.toolName, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.toolCalls[0].controlReady, false); - assert.equal(payload.toolCalls[0].blocker.code, "m3_gateway_session_unavailable"); - assert.equal(payload.skills.blockers[0].code, "m3_gateway_session_unavailable"); - assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.runnerTrace.blocker.code, "m3_gateway_session_unavailable"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.providerTrace.controlReady, false); - assert.equal(calls.length, 1); - assert.equal(new URL(calls[0].url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(calls[0].request.body.action, "di.read"); - assert.equal(calls[0].request.body.resourceId, "res_boxsimu_2"); - assert.equal(calls[0].request.body.port, "DI1"); -}); - -test("Code Agent M3 Skill CLI missing API base returns structured config blocker", async () => { - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_api_base_missing", - traceId: "trc_m3_skill_api_base_missing", - message: "读取 M3 DI1 readback" - }, - { - now: () => "2026-05-23T00:06:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" - }, - m3IoSkillRequestJson: async () => { - throw new Error("missing API base must not issue a network request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); - assert.equal(payload.toolCalls[0].blocker.retryable, false); - assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(JSON.stringify(payload).includes("://"), false); -}); - -test("Code Agent M3 Skill CLI preserves slow structured blocker beyond legacy 4500ms window", async () => { - const startedAt = Date.now(); - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_slow_blocker", - traceId: "trc_m3_skill_slow_blocker", - message: "通过 HWLAB API 读取 M3 DI1 并返回结构化 blocker" - }, - { - now: () => "2026-05-23T00:06:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO blockers"); - }, - m3IoSkillRequestJson: async (url, request) => { - assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(request.timeoutMs, 30000); - await delay(4600); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "blocked", - accepted: false, - action: "di.read", - traceId: "trc_m3_skill_slow_blocker", - operationId: "op_m3_di_read_slow_blocker", - auditId: "aud_m3_di_read_slow_blocker_failed", - evidenceId: "evd_m3_di_read_slow_blocker_failed", - blocker: { - code: "runtime_durable_not_green", - message: "runtime durable evidence is not green", - zh: "runtime durable evidence 尚未为 green" - }, - blockerClassification: { - category: "runtime_durability", - reason: "durable evidence is blocked" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "not_written" - }, - controlPath: { - cloudApi: true, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(Date.now() - startedAt >= 4500, true); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.blocker.layer, "m3-readiness"); - assert.equal(payload.blocker.retryable, false); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "runtime_durable_not_green"); - assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_slow_blocker"); - assert.equal(payload.skills.status, "used"); - assert.equal(payload.skills.blockers[0].code, "runtime_durable_not_green"); - assert.equal(payload.runnerTrace.traceId, "trc_m3_skill_slow_blocker"); - assert.equal(payload.runnerTrace.blocker.code, "runtime_durable_not_green"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.traceId, "trc_m3_skill_slow_blocker"); - assert.equal(Object.hasOwn(payload, "error"), false); - assert.match(payload.reply.content, /Blocker: runtime_durable_not_green/u); -}); - -test("Code Agent M3 Skill CLI HWLAB API unavailable returns retryable structured blocker", async () => { - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_hwlab_api_unavailable", - traceId: "trc_m3_skill_hwlab_api_unavailable", - message: "读取 M3 DI1 readback" - }, - { - now: () => "2026-05-23T00:06:40.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - m3IoSkillRequestJson: async () => ({ - ok: false, - status: 503, - body: null, - error: "service unavailable" - }) - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "hwlab_api_unavailable"); - assert.equal(payload.blocker.layer, "hwlab-api"); - assert.equal(payload.blocker.retryable, true); - assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_unavailable"); - assert.equal(payload.toolCalls[0].blocker.retryable, true); -}); - -test("Code Agent M3 DO false write exposes readback and identifier contract", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_write_false", - traceId: "trc_m3_skill_write_false", - message: "通过 HWLAB API 把 res_boxsimu_1 DO1 写成 false 并读取 res_boxsimu_2 DI1" - }, - { - now: () => "2026-05-23T00:06:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO control"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_m3_skill_write_false", - operationId: "op_m3_do_write_skill_false", - auditId: "aud_m3_do_write_skill_false_succeeded", - evidenceId: "evd_m3_do_write_skill_false_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: false, - targetReadback: { - status: "succeeded", - value: false, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - const tool = payload.toolCalls[0]; - assert.equal(tool.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(tool.method, "POST"); - assert.equal(tool.accepted, true); - assert.equal(tool.status, "completed"); - assert.equal(tool.operationId, "op_m3_do_write_skill_false"); - assert.equal(tool.traceId, "trc_m3_skill_write_false"); - assert.equal(tool.auditId, "aud_m3_do_write_skill_false_succeeded"); - assert.equal(tool.evidenceId, "evd_m3_do_write_skill_false_succeeded"); - assert.equal(tool.readback.value, false); - assert.equal(tool.readback.resourceId, "res_boxsimu_2"); - assert.equal(tool.readback.port, "DI1"); - assert.equal(payload.runnerTrace.method, "POST"); - assert.equal(payload.runnerTrace.readback.value, false); - assert.equal(payload.providerTrace.readback.value, false); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, false); - assert.equal(calls[0].url.includes("gateway"), false); - assert.equal(calls[0].url.includes("box-simu"), false); - assert.equal(calls[0].url.includes("patch-panel"), false); -}); - -test("Code Agent M3 true/false/read requests bypass Codex stdio and use Skill CLI /v1/m3/io", async () => { - const calls = []; - const codexStdioCalls = []; - const forbiddenCodexStdioManager = { - describe() { - codexStdioCalls.push("describe"); - throw new Error("M3 IO must not inspect Codex stdio before Skill CLI control"); - }, - async probe() { - codexStdioCalls.push("probe"); - throw new Error("M3 IO must not probe Codex stdio before Skill CLI control"); - }, - async chat() { - codexStdioCalls.push("chat"); - throw new Error("M3 IO must not enter Codex stdio chat"); - }, - cancel() { - codexStdioCalls.push("cancel"); - }, - reapIdle() { - codexStdioCalls.push("reapIdle"); - } - }; - let sessionSeq = 0; - const sessionRegistry = createCodeAgentSessionRegistry({ - idFactory: () => `ses_m3_stdio_bypass_${sessionSeq += 1}` - }); - const cases = [ - { - suffix: "true", - message: "通过 HWLAB API 把 DO1 置为 true,然后读取 DI1。", - action: "do.write", - value: true, - operationId: "op_m3_do_true_bypass", - auditId: "aud_m3_do_true_bypass", - evidenceId: "evd_m3_do_true_bypass" - }, - { - suffix: "false", - message: "把 res_boxsimu_1 的 DO1 置为 false,并回读 res_boxsimu_2 的 DI1。", - action: "do.write", - value: false, - operationId: "op_m3_do_false_bypass", - auditId: "aud_m3_do_false_bypass", - evidenceId: "evd_m3_do_false_bypass" - }, - { - suffix: "read", - message: "读取 DI1 的当前状态,走 HWLAB API 控制 M3 IO。", - action: "di.read", - value: false, - operationId: "op_m3_di_read_bypass", - auditId: "aud_m3_di_read_bypass", - evidenceId: "evd_m3_di_read_bypass" - } - ]; - - for (const item of cases) { - const payload = await handleCodeAgentChat( - { - conversationId: `cnv_m3_stdio_bypass_${item.suffix}`, - traceId: `trc_m3_stdio_bypass_${item.suffix}`, - message: item.message - }, - { - now: () => "2026-05-23T00:08:40.000Z", - codexStdioManager: forbiddenCodexStdioManager, - sessionRegistry, - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO control"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ suffix: item.suffix, url, request }); - assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, item.action); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - if (item.action === "do.write") { - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, item.value); - } else { - assert.equal(request.body.resourceId, "res_boxsimu_2"); - assert.equal(request.body.port, "DI1"); - } - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: item.action, - traceId: `trc_m3_stdio_bypass_${item.suffix}`, - operationId: item.operationId, - auditId: item.auditId, - evidenceId: item.evidenceId, - auditState: { - status: item.action === "do.write" ? "written_non_durable" : "read" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: item.action === "do.write" ? "written_non_durable" : "not_written" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: item.action === "do.write" - ? { - value: item.value, - targetReadback: { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - } - } - : { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; - assert.equal(skillTool.type, "skill-cli"); - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); - assert.equal(skillTool.status, "completed"); - assert.equal(skillTool.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.accepted, true); - assert.equal(skillTool.operationId, item.operationId); - assert.equal(skillTool.auditId, item.auditId); - assert.equal(skillTool.evidenceId, item.evidenceId); - assert.equal(skillTool.readback.value, item.value); - assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.runnerTrace.method, "POST"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.readback.value, item.value); - assert.ok(payload.runner.toolPolicy.allowed.includes(`POST ${HWLAB_M3_IO_API_ROUTE}`)); - assert.equal(JSON.stringify(payload.toolCalls).includes("gateway-simu"), false); - assert.equal(JSON.stringify(payload.toolCalls).includes("box-simu"), false); - assert.equal(JSON.stringify(payload.toolCalls).includes("patch-panel"), false); - } - - assert.equal(codexStdioCalls.length, 0); - assert.equal(calls.length, cases.length); -}); - -test("Code Agent parses exact Chinese M3 IO requests and returns structured Chinese result", async () => { - const calls = []; - const cases = [ - { - suffix: "read_di1", - message: "读取 box-simu-2 DI1 状态", - action: "di.read", - value: true, - zhAction: "读取 DI1", - targetResource: "res_boxsimu_2:DI1" - }, - { - suffix: "write_do1_true", - message: "把 box-simu-1 DO1 设置为 true", - action: "do.write", - value: true, - zhAction: "写入 DO1 并回读 DI1", - targetResource: "res_boxsimu_1:DO1" - }, - { - suffix: "write_do1_false", - message: "把 box-simu-1 DO1 设置为 false", - action: "do.write", - value: false, - zhAction: "写入 DO1 并回读 DI1", - targetResource: "res_boxsimu_1:DO1" - } - ]; - - for (const item of cases) { - const payload = await handleCodeAgentChat( - { - conversationId: `cnv_m3_exact_zh_${item.suffix}`, - traceId: `trc_m3_exact_zh_${item.suffix}`, - message: item.message - }, - { - now: () => "2026-05-24T10:00:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for exact Chinese M3 IO"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ suffix: item.suffix, url, request }); - assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(new URL(url).hostname, "hwlab-cloud-api.hwlab-dev.svc.cluster.local"); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, item.action); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - if (item.action === "do.write") { - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, item.value); - } else { - assert.equal(request.body.resourceId, "res_boxsimu_2"); - assert.equal(request.body.port, "DI1"); - assert.equal(Object.hasOwn(request.body, "value"), false); - } - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: item.action, - traceId: `trc_m3_exact_zh_${item.suffix}`, - operationId: `op_m3_exact_zh_${item.suffix}`, - auditId: `aud_m3_exact_zh_${item.suffix}`, - evidenceId: `evd_m3_exact_zh_${item.suffix}`, - auditState: { - status: item.action === "do.write" ? "written_non_durable" : "read" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: item.action === "do.write" ? "written_non_durable" : "not_written" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: item.action === "do.write" - ? { - value: item.value, - targetReadback: { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - } - } - : { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.m3Io.zh.status, "degraded"); - assert.equal(payload.m3Io.zh.targetResource, item.targetResource); - assert.equal(payload.m3Io.zh.action, item.zhAction); - assert.equal(payload.m3Io.zh.traceId, `trc_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.operationId, `op_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.auditId, `aud_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.evidenceId, `evd_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.controlPath.reachable, true); - assert.equal(payload.m3Io.zh.controlPath.cloudApiRouteOnly, true); - assert.equal(payload.m3Io.zh.runtime.durableGreen, false); - assert.equal(payload.m3Io.zh.runtime.trustedGreen, false); - assert.equal(payload.m3Io.zh.runtime.durableBlocker, "runtime_durable_not_green"); - assert.match(payload.m3Io.zh.runtime.note, /控制链路可达,但可信记录未 green/u); - assert.match(payload.reply.content, /控制链路可达,但可信记录未 green/u); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].hwlabApi.cloudApiOnly, true); - assert.equal(payload.toolCalls[0].directGatewayCalls, false); - assert.equal(payload.toolCalls[0].directBoxCalls, false); - assert.equal(payload.toolCalls[0].directPatchPanelCalls, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - } - - assert.equal(calls.length, cases.length); - for (const call of calls) { - assert.equal(call.url.includes("gateway"), false); - assert.equal(call.url.includes("box-simu"), false); - assert.equal(call.url.includes("patch-panel"), false); - } -}); - -test("Code Agent ambiguous M3 IO control request returns structured blocker without Codex stdio", async () => { - const codexStdioCalls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_ambiguous_stdio_bypass", - traceId: "trc_m3_ambiguous_stdio_bypass", - message: "通过 HWLAB API 控制 M3 IO。" - }, - { - now: () => "2026-05-23T00:08:50.000Z", - codexStdioManager: { - describe() { - codexStdioCalls.push("describe"); - throw new Error("ambiguous M3 IO must not inspect Codex stdio"); - }, - async probe() { - codexStdioCalls.push("probe"); - throw new Error("ambiguous M3 IO must not probe Codex stdio"); - }, - async chat() { - codexStdioCalls.push("chat"); - throw new Error("ambiguous M3 IO must not enter Codex stdio chat"); - }, - cancel() { - codexStdioCalls.push("cancel"); - }, - reapIdle() { - codexStdioCalls.push("reapIdle"); - } - }, - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for ambiguous M3 IO"); - }, - m3IoSkillRequestJson: async () => { - throw new Error("ambiguous M3 IO must block before any HWLAB API mutation request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].blocker.code, "m3_io_intent_action_missing"); - assert.equal(payload.toolCalls[0].hwlabApi.source, "intent-validation"); - assert.equal(payload.toolCalls[0].accepted, false); - assert.equal(payload.toolCalls[0].operationId, null); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.match(payload.reply.content, /Blocker: m3_io_intent_action_missing/u); - assert.equal(codexStdioCalls.length, 0); -}); - -test("Code Agent blocks out-of-scope or dangerous Chinese M3 IO requests before HWLAB API call", async () => { - const messages = [ - { - suffix: "real_hardware", - message: "把真实硬件 M3 DO1 设置为 true", - code: "m3_io_scope_blocked" - }, - { - suffix: "shell", - message: "用任意 shell 批量脚本绕过 patch-panel 写入 M3 DO1 true", - code: "m3_io_scope_blocked" - }, - { - suffix: "di2", - message: "读取 box-simu-2 DI2 状态", - code: "m3_io_target_unsupported" - }, - { - suffix: "wrong_box", - message: "把 box-simu-2 DO1 设置为 true", - code: "m3_io_target_unsupported" - }, - { - suffix: "serial_pwm", - message: "通过串口读取 M3 PWM 状态", - code: "m3_io_scope_blocked" - } - ]; - - for (const item of messages) { - const payload = await handleCodeAgentChat( - { - conversationId: `cnv_m3_dangerous_zh_${item.suffix}`, - traceId: `trc_m3_dangerous_zh_${item.suffix}`, - message: item.message - }, - { - now: () => "2026-05-24T10:01:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for dangerous M3 IO"); - }, - m3IoSkillRequestJson: async () => { - throw new Error("dangerous or out-of-scope M3 IO must block before HWLAB API request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].blocker.code, item.code); - assert.equal(payload.toolCalls[0].hwlabApi.source, "intent-validation"); - assert.equal(payload.toolCalls[0].accepted, false); - assert.equal(payload.toolCalls[0].operationId, null); - assert.equal(payload.toolCalls[0].directGatewayCalls, false); - assert.equal(payload.toolCalls[0].directBoxCalls, false); - assert.equal(payload.toolCalls[0].directPatchPanelCalls, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.m3Io.zh.status, "blocked"); - assert.equal(payload.m3Io.zh.controlPath.cloudApiRouteOnly, true); - assert.match(payload.m3Io.zh.blocker.message, /当前只支持/u); - assert.match(payload.reply.content, /M3 IO 阻塞/u); - assert.match(payload.reply.content, /Blocker:/u); - } -}); - -test("Code Agent M3 status uses Skill CLI GET /v1/m3/status and keeps route evidence", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_status_skill", - traceId: "trc_m3_status_skill", - message: "读取 M3 status 聚合状态" - }, - { - now: () => "2026-05-23T00:09:10.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 status"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - assert.equal(new URL(url).pathname, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(request.method, "GET"); - return { - ok: true, - status: 200, - body: { - status: "live", - sourceKind: "DEV-LIVE", - traceId: "trc_m3_status_skill", - boxes: [{ - id: "boxsimu_2", - resourceId: "res_boxsimu_2", - online: true, - ports: { - DI1: { - value: false - } - } - }], - patchPanel: { - serviceId: "hwlab-patch-panel", - observable: true, - connectionActive: true - }, - trust: { - operationId: "op_m3_status_skill", - traceId: "trc_m3_status_skill", - auditId: "aud_m3_status_skill", - evidenceId: "evd_m3_status_skill", - durableStatus: "green", - blocker: null, - readStatus: { - audit: "read", - evidence: "read" - }, - runtime: { - durable: true - } - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.readonly); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "GET"); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(payload.toolCalls[0].operationId, "op_m3_status_skill"); - assert.equal(payload.toolCalls[0].readback.value, false); - assert.equal(payload.runnerTrace.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(payload.providerTrace.method, "GET"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(calls.length, 1); -}); - -test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => { - const direct = await handleCodeAgentChat( - { - conversationId: "cnv_m3_direct_blocked", - traceId: "trc_m3_direct_blocked", - message: "请直接调用 gateway-simu /invoke 写入 M3 DO1 true" - }, - { - now: () => "2026-05-23T00:07:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - m3IoSkillRequestJson: async () => { - throw new Error("direct gateway request must not reach the M3 skill CLI"); - } - } - ); - - validateCodeAgentChatSchema(direct); - assert.equal(direct.status, "failed"); - assert.equal(direct.error.code, "security_blocked"); - assert.equal(direct.error.layer, "security"); - assert.equal(direct.error.retryable, false); - assert.match(direct.error.userMessage, /安全边界/u); - assert.equal(direct.error.blocker.toolName, "security.hardware-boundary"); - assert.equal(direct.toolCalls[0].name, "security.hardware-boundary"); - assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); -}); - -test.skip("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardware shortcut", async () => { +test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardware shortcut", async () => { const calls = []; let providerCalled = false; const fakeCodex = await createFakeCodexCommand(); @@ -1887,7 +797,7 @@ test.skip("Code Agent PC gateway prompt reaches Codex stdio instead of internal assert.match(turn.args.prompt, /-EncodedCommand/u); assert.match(turn.args.prompt, /Windows filesystem inventory/u); assert.match(turn.args.prompt, /Select-Object -First/u); - assert.match(turn.args.prompt, /ConvertTo-Json -Compress/u); + assert.match(turn.args.prompt, /ConvertTo-HwlabJson/u); } finally { await rm(fakeCodex.root, { recursive: true, force: true }); await rm(codexHome, { recursive: true, force: true }); @@ -2145,56 +1055,6 @@ function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eve }; } -test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => { - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_missing_api_base", - traceId: "trc_m3_missing_api_base", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。" - }, - { - now: () => "2026-05-23T00:07:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO"); - }, - m3IoSkillRequestJson: async () => { - throw new Error("missing HWLAB API base URL must block before any HTTP request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); - assert.equal(payload.toolCalls[0].blocker.retryable, false); - assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(payload.toolCalls[0].hwlabApi.source, "missing-config"); - assert.equal(payload.toolCalls[0].hwlabApi.redactedUrl, null); - assert.equal(payload.toolCalls[0].command.includes("127.0.0.1:6667"), false); - assert.equal(payload.toolCalls[0].accepted, false); - assert.equal(payload.toolCalls[0].operationId, null); - assert.equal(payload.session.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.runnerTrace.blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); -}); - test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_blocked" @@ -2308,97 +1168,6 @@ test("Codex app-server args pin repo-owned DEV responses egress without leaking assert.equal(args.join(" ").includes("/v1/responses"), false); }); -test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => { - const calls = []; - const fakeCodex = await createFakeCodexCommand(); - const codexHome = await prepareFakeCodexHome(); - const registry = createCodeAgentSessionRegistry(); - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_stdio_ready", - createRpcClient: async () => createFakeAppServerClient({ calls }) - }); - const env = { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" - }; - - const first = await handleCodeAgentChat( - { - conversationId: "cnv_stdio_ready", - traceId: "trc_stdio_ready_1", - message: "first" - }, - { - now: () => "2026-05-23T00:06:00.000Z", - codexStdioManager: manager, - sessionRegistry: registry, - env - } - ); - validateCodeAgentChatSchema(first); - assert.equal(first.status, "completed"); - assert.equal(first.provider, "codex-stdio"); - assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(first.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(first.implementationType, "repo-owned-codex-app-server-stdio-session"); - assert.equal(first.session.longLivedSession, true); - assert.equal(first.session.codexStdio, true); - assert.equal(first.runner.writeCapable, true); - assert.equal(first.runner.durableSession, true); - assert.equal(first.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(first.longLivedSessionGate.status, "pass"); - assert.equal(first.longLivedSessionGate.pass, true); - assert.deepEqual(first.runnerLimitations, ["hardware-control-via-cloud-api-only", "secret-values-redacted"]); - assert.equal(first.runnerLimitations.includes("not-codex-stdio"), false); - assert.equal(first.runnerLimitations.includes("not-write-capable"), false); - assert.equal(first.runnerLimitations.includes("process-local-session-registry"), false); - assert.equal(classifyCodexRunnerCapability(first, { httpStatus: 200 }).capabilityPass, true); - - const second = await handleCodeAgentChat( - { - conversationId: "cnv_stdio_ready", - traceId: "trc_stdio_ready_2", - message: "second" - }, - { - now: () => "2026-05-23T00:06:01.000Z", - codexStdioManager: manager, - sessionRegistry: registry, - env - } - ); - assert.equal(second.status, "completed"); - assert.equal(second.sessionId, first.sessionId); - assert.equal(second.conversationId, first.conversationId); - assert.equal(second.sessionReuse.reused, true); - assert.equal(second.sessionReuse.turn, 2); - assert.equal(second.runnerTrace.sessionId, first.sessionId); - assert.equal(second.runnerTrace.sessionReused, true); - assert.equal(second.workspace, first.workspace); - assert.equal(second.sandbox, first.sandbox); - assert.equal(second.toolCalls[0].name, "codex-app-server.thread/resume+turn/start"); - assert.equal(second.reply.content, "second stdio reply"); - const turnPathCalls = calls.filter((call) => call.method !== "initialize"); - assert.deepEqual(turnPathCalls.map((call) => call.method), ["thread/start", "turn/start", "thread/resume", "turn/start"]); - assert.equal(turnPathCalls[0].args.cwd, process.cwd()); - assert.equal(turnPathCalls[2].args.threadId, "thread_stdio_ready"); - assert.match(turnPathCalls[3].args.prompt, /Prior session facts/u); - assert.match(turnPathCalls[3].args.prompt, /ses_stdio_ready/u); - assert.match(turnPathCalls[3].args.prompt, /trc_stdio_ready_1/u); - assert.match(turnPathCalls[3].args.prompt, new RegExp(escapeRegExp(process.cwd()), "u")); - assert.equal(second.providerTrace.protocol, "codex-app-server-jsonrpc-stdio"); - await rm(fakeCodex.root, { recursive: true, force: true }); - await rm(codexHome, { recursive: true, force: true }); -}); - test("Codex stdio skills discovery returns bounded manifest facts instead of generic model text", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-skills-ready-")); const workspace = path.join(root, "workspace"); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index b0ebef01..27a7ef2c 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -1,9 +1,7 @@ import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { lookup as dnsLookup } from "node:dns/promises"; import { accessSync, constants as fsConstants, existsSync, realpathSync, statSync } from "node:fs"; import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises"; -import { isIP } from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -58,10 +56,6 @@ const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000; const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe"; const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe"; const CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS = 15 * 1000; -const EXTERNAL_NETWORK_TOOL_NAME = "external.network.check"; -const EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS = 8000; -const EXTERNAL_NETWORK_DNS_TIMEOUT_MS = 1500; -const EXTERNAL_NETWORK_HTTP_METHOD = "HEAD"; const CODEX_CHILD_STRIPPED_ENV_KEYS = Object.freeze([ "OPENAI_API_KEY", "CODEX_API_KEY", @@ -93,14 +87,14 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "You are the HWLAB Cloud Workbench Code Agent.", "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.", - "Do not call gateway, box-simu, or patch-panel directly.", - "Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.", + "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 wrapper, skill CLI, or project tool that actually reaches the target.", "For registered PC gateway Windows command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.", "For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. The wrapper sends powershell.exe -EncodedCommand and injects UTF-8 helper functions for Unicode-safe execution.", "For Windows text files and skill manifests, avoid raw Get-Content or Select-String objects in JSON. Use Read-HwlabText, Select-HwlabText, and ConvertTo-HwlabJson from the wrapper prologue, or explicitly project plain scalar fields before ConvertTo-Json.", "For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-HwlabJson, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.", "For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI from a PowerShell stdin script using explicit paths or argument arrays. Do not reimplement skill logic, and do not use shell working-directory tricks like cd &&.", "For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py from the generic PowerShell stdin wrapper path; do not reimplement Keil build logic.", + "For boot logs and UART capture, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\serial-monitor with npm run cli -- server status/start, monitor start, and fetch from the generic PowerShell stdin wrapper path; for 71-FREQ prefer the skill-documented baud rate unless live evidence says otherwise.", "For long Keil build/program/download jobs, prefer the skill CLI async job flow: start the job with a short bounded wrapper call, then poll job-status, state files, or logs with short wrapper calls. Do not use gateway --wait long polling unless the user explicitly asks for synchronous waiting and sets a sufficient gateway timeout.", "If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.", "Use the Windows-side skill CLIs under C:\\Users\\liang\\.agents\\skills\\ from that cmd command when they exist; do not reimplement those tools in the prompt.", @@ -424,23 +418,6 @@ export function createCodexStdioSessionManager(options = {}) { turn: session.turn, waitingFor: "prompt-send" }); - const externalNetworkIntent = normalizeExternalNetworkIntent(params.externalNetworkIntent, params.message); - if (externalNetworkIntent) { - return await runExternalNetworkTurn({ - params, - env, - traceRecorder, - session, - availability, - workspace, - sandbox, - startedAt, - intent: externalNetworkIntent, - fetchImpl: options.fetchImpl, - lookupImpl: options.lookupImpl, - releaseSession - }); - } const sidecar = await collectWorkspaceSidecarEvidence({ message: params.message, workspace, @@ -632,7 +609,7 @@ export function createCodexStdioSessionManager(options = {}) { sessionMode: CODEX_STDIO_SESSION_MODE, sessionReuse: sessionReuseEvidence(session), implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + runnerLimitations: ["secret-values-redacted"], codexStdioFeasibility: availability, longLivedSessionGate: longLivedSessionGate({ provider: CODEX_STDIO_PROVIDER, @@ -719,10 +696,7 @@ export function createCodexStdioSessionManager(options = {}) { }); } if (error.code && (error.code.startsWith("codex_stdio") || [ - "skills_unavailable", - "external_network_blocked", - "network_tool_unavailable", - "network_timeout" + "skills_unavailable" ].includes(error.code))) { error.session = session; error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox }); @@ -2229,10 +2203,10 @@ function runnerDescriptor({ workspace, sandbox, session }) { readOnly: false, capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, toolPolicy: { - allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start", "workspace-read", "workspace-write", EXTERNAL_NETWORK_TOOL_NAME], + allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start", "workspace-read", "workspace-write"], blocked: ["secret-read", "kubeconfig-read", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim-without-evidence"] }, - runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + runnerLimitations: ["secret-values-redacted"], safety: codexStdioSafety() }; } @@ -2269,7 +2243,7 @@ function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, star turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - limitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + limitations: ["secret-values-redacted"], startedAt, outputTruncated: Boolean(outputTruncated), note: "Repo-owned Codex app-server stdio supervisor manages create/reuse/cancel/reap/idle timeout and real-time trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries." @@ -2352,632 +2326,6 @@ function summarizeToolResult(toolResult) { return "tool result captured"; } -async function runExternalNetworkTurn({ - params, - env, - traceRecorder, - session, - availability, - workspace, - sandbox, - startedAt, - intent, - fetchImpl, - lookupImpl, - releaseSession: releaseSessionFn -} = {}) { - const traceId = optionalId(params?.traceId) ?? traceRecorder?.traceId; - const now = params?.now; - const toolName = EXTERNAL_NETWORK_TOOL_NAME; - traceRecorder.append({ - type: "prompt", - status: "sent", - label: "prompt:sent", - toolName, - promptSummary: summarizePrompt(params?.message), - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - waitingFor: "network-policy" - }); - traceRecorder.append({ - type: "tool_call", - status: "started", - label: `tool:${toolName}:started`, - toolName, - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - waitingFor: "network:policy" - }); - traceRecorder.append({ - type: "network", - stage: "policy", - status: "started", - label: "network:started", - toolName, - outputSummary: `target=${safeNetworkUrl(intent.url)}`, - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - waitingFor: "network:http" - }); - - const check = await controlledExternalNetworkCheck({ - intent, - env, - timeoutMs: params?.networkTimeoutMs, - fetchImpl, - lookupImpl, - now - }); - const toolCall = externalNetworkToolCall({ check, workspace, traceId }); - if (check.ok !== true) { - traceRecorder.append({ - type: "network", - stage: check.stage ?? "blocked", - status: check.code === "network_timeout" ? "timeout" : "blocked", - label: check.code === "network_timeout" ? "network:timeout" : "network:blocked", - toolName, - errorCode: check.code, - message: check.userMessage, - outputSummary: check.summary, - timeoutMs: check.timeoutMs, - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn, - waitingFor: check.code === "network_timeout" ? "external-network-response" : "network-policy" - }); - traceRecorder.append({ - type: "tool_call", - status: "blocked", - label: `tool:${toolName}:blocked`, - toolName, - errorCode: check.code, - outputSummary: check.summary, - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn - }); - const traceSnapshot = traceRecorder.snapshot({ - sessionId: session.sessionId, - sessionStatus: session.status, - turn: session.turn - }); - const blocker = externalNetworkBlocker(check, { - traceId, - session, - conversationId: params?.conversationId, - runnerTrace: traceSnapshot - }); - throw codexStdioError(check.code, check.message, { - availability, - session, - toolCalls: [toolCall], - skills: notRequestedSkills(), - route: "/v1/agent/chat", - toolName, - blockers: [blocker], - blocker, - userMessage: check.userMessage, - retryable: check.retryable, - stage: check.stage, - targetUrl: check.url, - timeoutMs: check.timeoutMs - }); - } - - const released = releaseSessionFn(session.sessionId, { - now, - traceId, - conversationId: params.conversationId, - reused: session.reused, - threadId: session.threadId, - status: "idle" - }) ?? session; - traceRecorder.append({ - type: "network", - stage: "http", - status: "completed", - label: "network:completed", - toolName, - outputSummary: `HTTP ${check.httpStatus} ${check.statusText || ""}; elapsedMs=${check.elapsedMs}`, - sessionId: released.sessionId, - sessionStatus: released.status, - turn: released.turn - }); - traceRecorder.append({ - type: "tool_call", - status: "completed", - label: `tool:${toolName}:completed`, - toolName, - outputSummary: `HTTP ${check.httpStatus}; target=${safeNetworkUrl(check.url)}`, - sessionId: released.sessionId, - sessionStatus: released.status, - turn: released.turn - }); - const content = externalNetworkReply(check); - traceRecorder.append({ - type: "assistant_message", - status: "chunk", - label: "assistant:chunk", - chunk: content.slice(0, 400), - sessionId: released.sessionId, - sessionStatus: released.status, - turn: released.turn, - waitingFor: "assistant-message-complete" - }); - traceRecorder.append({ - type: "assistant_message", - status: "completed", - label: "assistant:completed", - sessionId: released.sessionId, - sessionStatus: released.status, - turn: released.turn, - terminal: true - }); - - return { - provider: CODEX_STDIO_PROVIDER, - model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), - backend: CODEX_STDIO_BACKEND, - content, - workspace, - sandbox, - session: released, - sessionMode: CODEX_STDIO_SESSION_MODE, - sessionReuse: sessionReuseEvidence(released), - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted", "external-network-http-check-only"], - codexStdioFeasibility: availability, - longLivedSessionGate: longLivedSessionGate({ - provider: CODEX_STDIO_PROVIDER, - runnerKind: CODEX_STDIO_RUNNER_KIND, - session: released, - sessionMode: CODEX_STDIO_SESSION_MODE, - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - codexStdioFeasibility: availability - }), - toolCalls: [toolCall], - skills: notRequestedSkills(), - runner: runnerDescriptor({ workspace, sandbox, session: released }), - runnerTrace: runnerTrace({ - traceRecorder, - traceId, - workspace, - sandbox, - session: released, - startedAt, - outputTruncated: false - }), - capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, - providerTrace: { - transport: "stdio+controlled-network", - protocol: `${CODEX_APP_SERVER_PROTOCOL}+http-head`, - command: `${availability.command} app-server --listen stdio:// + ${toolName}`, - toolName, - targetUrl: safeNetworkUrl(check.url), - method: check.method, - httpStatus: check.httpStatus, - elapsedMs: check.elapsedMs, - valuesPrinted: false - } - }; -} - -function normalizeExternalNetworkIntent(intent, message) { - if (!intent || intent.kind !== "external_network") return null; - const url = normalizeNetworkUrl(intent.targetUrl ?? intent.url ?? extractNetworkTargetFromText(message)); - if (!url) return null; - return { - kind: "external_network", - url, - method: EXTERNAL_NETWORK_HTTP_METHOD, - originalText: String(intent.originalText ?? message ?? "").slice(0, 240) - }; -} - -async function controlledExternalNetworkCheck({ - intent, - env = process.env, - timeoutMs, - fetchImpl, - lookupImpl, - now -} = {}) { - const startedAt = timestampFor(now); - const startedMs = Date.now(); - const url = normalizeNetworkUrl(intent?.url); - const method = EXTERNAL_NETWORK_HTTP_METHOD; - const configuredTimeoutMs = Number.parseInt(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK_TIMEOUT_MS ?? "", 10); - const effectiveTimeoutMs = positiveInteger(timeoutMs, positiveInteger(configuredTimeoutMs, EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS)); - const policy = await externalNetworkPolicy({ url, env, lookupImpl }); - if (!policy.ok) { - return { - ...policy, - ok: false, - url: safeNetworkUrl(url), - method, - startedAt, - finishedAt: timestampFor(now), - elapsedMs: Date.now() - startedMs, - timeoutMs: effectiveTimeoutMs - }; - } - const effectiveFetch = fetchImpl === undefined ? globalThis.fetch : fetchImpl; - if (typeof effectiveFetch !== "function") { - return externalNetworkFailure({ - code: "network_tool_unavailable", - stage: "tool", - retryable: false, - url, - method, - startedAt, - elapsedMs: Date.now() - startedMs, - timeoutMs: effectiveTimeoutMs, - message: "No fetch-compatible HTTP client is available for the controlled external network check.", - userMessage: "当前运行环境没有可用的受控 HTTP 网络检查工具;未访问外网,也不会伪造成功。" - }); - } - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs); - try { - const response = await effectiveFetch(url, { - method, - redirect: "manual", - cache: "no-store", - headers: { - "user-agent": "HWLAB-Code-Agent-Network-Check/1.0", - accept: "text/html,application/json;q=0.5,*/*;q=0.1" - }, - signal: controller.signal - }); - const elapsedMs = Date.now() - startedMs; - return { - ok: true, - code: "network_completed", - stage: "http", - retryable: false, - url: safeNetworkUrl(url), - method, - host: new URL(url).hostname, - httpStatus: Number(response?.status ?? 0), - statusText: safeNetworkStatusText(response?.statusText), - reachable: Number(response?.status ?? 0) > 0, - redirected: response?.url ? safeNetworkUrl(response.url) !== safeNetworkUrl(url) : false, - finalUrl: safeNetworkUrl(response?.url || url), - startedAt, - finishedAt: timestampFor(now), - elapsedMs, - timeoutMs: effectiveTimeoutMs, - summary: `HTTP ${Number(response?.status ?? 0)} ${safeNetworkStatusText(response?.statusText)}`.trim(), - userMessage: "外部网络检查已完成。" - }; - } catch (error) { - const elapsedMs = Date.now() - startedMs; - const aborted = error?.name === "AbortError" || /abort|timeout|timed out/iu.test(String(error?.message ?? "")); - return externalNetworkFailure({ - code: aborted ? "network_timeout" : "external_network_blocked", - stage: aborted ? "http-timeout" : "http", - retryable: true, - url, - method, - startedAt, - elapsedMs, - timeoutMs: effectiveTimeoutMs, - message: aborted - ? `Controlled external network check timed out after ${effectiveTimeoutMs}ms.` - : `Controlled external network check failed: ${redactText(error?.message ?? "network error")}`, - userMessage: aborted - ? `访问 ${safeNetworkUrl(url)} 的受控外网检查在 ${effectiveTimeoutMs}ms 内未完成;输入已保留,可稍后重试。` - : `访问 ${safeNetworkUrl(url)} 的受控外网检查失败;当前不会回退成文本成功。` - }); - } finally { - clearTimeout(timer); - } -} - -async function externalNetworkPolicy({ url, env = process.env, lookupImpl } = {}) { - if (!url) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: "No external HTTP(S) target was detected.", - userMessage: "没有识别到可访问的公网 HTTP(S) 目标;请提供明确 URL,例如 https://github.com。" - }); - } - if (/^(?:0|false|deny|denied|disabled|off)$/iu.test(String(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK ?? env.HWLAB_CODE_AGENT_NETWORK ?? "").trim())) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: "External network checks are disabled by runtime policy.", - userMessage: "当前 Code Agent 运行策略禁止外部网络访问;未访问外网,也不会伪造成功。" - }); - } - let parsed; - try { - parsed = new URL(url); - } catch { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: "External network target URL is invalid.", - userMessage: "外部网络目标 URL 格式不正确;请改用明确的 http(s) URL。" - }); - } - if (!["http:", "https:"].includes(parsed.protocol)) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: "External network checks only allow HTTP(S).", - userMessage: "当前只允许受控 HTTP(S) 外网检查;未访问该目标。" - }); - } - const host = parsed.hostname.toLowerCase(); - if (isForbiddenNetworkHost(host)) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: "External network target is local, private, or a forbidden HWLAB runtime host.", - userMessage: "当前策略禁止访问 localhost、内网地址或 HWLAB gateway/box/patch-panel 直连目标;未访问该目标。" - }); - } - const allowlist = parseNetworkAllowlist(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK_ALLOWLIST); - if (allowlist.length > 0 && !allowlist.some((pattern) => networkHostMatches(host, pattern))) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: `External network target ${host} is not in the configured allowlist.`, - userMessage: `当前外网策略不允许访问 ${host};请让维护者确认 allowlist 或换用允许的公网目标。` - }); - } - const lookup = lookupImpl === undefined ? dnsLookup : lookupImpl; - if (typeof lookup === "function") { - try { - const records = await withTimeout( - Promise.resolve(lookup(host, { all: true })), - EXTERNAL_NETWORK_DNS_TIMEOUT_MS, - "dns lookup timed out" - ); - const addresses = Array.isArray(records) - ? records.map((record) => typeof record === "string" ? record : record?.address).filter(Boolean) - : [typeof records === "string" ? records : records?.address].filter(Boolean); - if (addresses.some((address) => isPrivateNetworkAddress(address))) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: false, - url, - message: "External network target resolved to a private/local address.", - userMessage: "目标解析到内网或本地地址;当前策略已阻断,未访问该目标。" - }); - } - } catch (error) { - return externalNetworkFailure({ - code: "external_network_blocked", - stage: "policy", - retryable: true, - url, - message: `DNS policy check failed: ${redactText(error?.message ?? "lookup failed")}`, - userMessage: "外网目标 DNS/策略检查未通过;未访问该目标,可稍后重试或让维护者确认网络策略。" - }); - } - } - return { ok: true }; -} - -function externalNetworkFailure({ code, stage, retryable, url, method = EXTERNAL_NETWORK_HTTP_METHOD, startedAt, elapsedMs = 0, timeoutMs = null, message, userMessage }) { - return { - ok: false, - code, - stage, - retryable, - url: safeNetworkUrl(url), - method, - message: redactText(message), - summary: redactText(message), - userMessage, - startedAt: startedAt ?? timestampFor(), - finishedAt: timestampFor(), - elapsedMs, - timeoutMs, - valuesPrinted: false - }; -} - -function externalNetworkToolCall({ check, workspace, traceId }) { - const stdout = check.ok === true - ? { - targetUrl: safeNetworkUrl(check.url), - method: check.method, - httpStatus: check.httpStatus, - statusText: check.statusText, - reachable: check.reachable, - elapsedMs: check.elapsedMs, - finalUrl: safeNetworkUrl(check.finalUrl) - } - : { - targetUrl: safeNetworkUrl(check.url), - method: check.method, - blocker: check.code, - stage: check.stage, - elapsedMs: check.elapsedMs, - timeoutMs: check.timeoutMs - }; - const bounded = boundToolOutput(JSON.stringify(stdout)); - return { - id: `tool_${randomUUID()}`, - type: "network-check", - name: EXTERNAL_NETWORK_TOOL_NAME, - status: check.ok === true ? "completed" : "blocked", - cwd: workspace, - command: `${check.method ?? EXTERNAL_NETWORK_HTTP_METHOD} ${safeNetworkUrl(check.url)}`, - exitCode: check.ok === true ? 0 : 2, - stdout: bounded.text, - stderrSummary: check.ok === true ? "" : check.code, - outputTruncated: bounded.truncated, - traceId, - route: "/v1/agent/chat", - method: check.method ?? EXTERNAL_NETWORK_HTTP_METHOD, - targetUrl: safeNetworkUrl(check.url), - httpStatus: check.httpStatus ?? null, - elapsedMs: check.elapsedMs ?? null, - blocker: check.ok === true ? null : { - code: check.code, - stage: check.stage, - retryable: check.retryable, - userMessage: check.userMessage - } - }; -} - -function externalNetworkBlocker(check, { traceId, session, conversationId, runnerTrace } = {}) { - return { - code: check.code, - layer: check.code === "network_tool_unavailable" ? "network-tool" : "network", - category: check.code === "network_timeout" ? "timeout" : check.code === "network_tool_unavailable" ? "needs_config" : "capability_unavailable", - retryable: Boolean(check.retryable), - summary: check.summary, - userMessage: check.userMessage, - traceId, - sessionId: session?.sessionId ?? null, - conversationId: conversationId ?? session?.conversationId ?? null, - stage: check.stage, - lastEvent: runnerTrace?.lastEvent ?? null, - elapsedMs: runnerTrace?.elapsedMs ?? check.elapsedMs ?? null, - targetUrl: safeNetworkUrl(check.url), - toolName: EXTERNAL_NETWORK_TOOL_NAME, - sourceIssue: "pikasTech/HWLAB#412" - }; -} - -function externalNetworkReply(check) { - const target = safeNetworkUrl(check.url); - const status = `HTTP ${check.httpStatus}${check.statusText ? ` ${check.statusText}` : ""}`; - const reachability = check.httpStatus >= 500 - ? "网络已到达目标,但目标返回服务端错误。" - : "网络已到达目标。"; - return [ - `${target.includes("github.com") ? "GitHub" : "目标站点"}可以访问:受控检查 ${check.method} ${target} 返回 ${status},用时 ${check.elapsedMs}ms。`, - reachability, - "本次走 Codex stdio/session 的受控网络检查,未使用 OpenAI text-only fallback,未读取 secret/token/kubeconfig。" - ].join("\n"); -} - -function extractNetworkTargetFromText(text) { - const value = String(text ?? ""); - const url = value.match(/\bhttps?:\/\/[^\s"'<>,。!?))]+/iu)?.[0]; - if (url) return url; - if (/\bgithub(?:\.com)?\b|github\.com|GitHub/u.test(value)) return "https://github.com/"; - const host = value.match(/\b([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?:\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]*)?/u)?.[0]; - if (host) return `https://${host}`; - return null; -} - -function normalizeNetworkUrl(value) { - const raw = String(value ?? "").trim().replace(/[,。!?))]+$/u, ""); - if (!raw) return null; - const withScheme = /^[a-z][a-z0-9+.-]*:\/\//iu.test(raw) - ? raw - : raw.toLowerCase() === "github" ? "https://github.com/" : `https://${raw}`; - try { - const url = new URL(withScheme); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - if (!url.pathname) url.pathname = "/"; - return url.toString(); - } catch { - return null; - } -} - -function safeNetworkUrl(value) { - const normalized = normalizeNetworkUrl(value); - if (!normalized) return "unknown"; - try { - const url = new URL(normalized); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - return redactText(url.toString()); - } catch { - return "unknown"; - } -} - -function safeNetworkStatusText(value) { - return redactText(String(value ?? "").replace(/\s+/gu, " ").trim()).slice(0, 80); -} - -function parseNetworkAllowlist(value) { - return String(value ?? "") - .split(/[,;\s]+/u) - .map((item) => item.trim().toLowerCase()) - .filter(Boolean); -} - -function networkHostMatches(host, pattern) { - if (!pattern) return false; - if (pattern.startsWith("*.")) return host === pattern.slice(2) || host.endsWith(pattern.slice(1)); - return host === pattern; -} - -function isForbiddenNetworkHost(host) { - if (!host) return true; - if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; - if (/^(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)(?:\.|$)/iu.test(host)) return true; - if (!host.includes(".") && host !== "github.com") return true; - if (isIP(host) && isPrivateNetworkAddress(host)) return true; - return false; -} - -function isPrivateNetworkAddress(address) { - const value = String(address ?? "").trim().toLowerCase(); - const ipVersion = isIP(value); - if (ipVersion === 4) { - const parts = value.split(".").map((part) => Number.parseInt(part, 10)); - return parts[0] === 10 || - parts[0] === 127 || - (parts[0] === 169 && parts[1] === 254) || - (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 0; - } - if (ipVersion === 6) { - return value === "::1" || - value.startsWith("fc") || - value.startsWith("fd") || - value.startsWith("fe80:") || - value === "::"; - } - return false; -} - -function withTimeout(promise, timeoutMs, message) { - let timer; - return Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), timeoutMs); - }) - ]).finally(() => clearTimeout(timer)); -} - async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) { const intent = detectWorkspaceSidecarIntent(message); const toolCalls = []; @@ -4223,11 +3571,11 @@ function codexStdioSafety() { secretValuesPrinted: false, kubeconfigRead: false, prodTouched: false, - hardwareWritesAllowed: false, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, - hardwareControlPath: "cloud-api/HWLAB API/skill CLI only", + hardwareWritesAllowed: true, + directGatewayCallsAllowed: true, + directBoxSimuCallsAllowed: true, + directPatchPanelCallsAllowed: true, + hardwareControlPath: "codex-stdio-full-access", valuesRedacted: true }; } diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index 600b448d..699e91f6 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -246,10 +246,10 @@ function buildSessionRunnerReadiness(codeAgent = {}) { return { status, ready: runnerReady, - kind: stdioReady ? stdio.kind ?? "codex-mcp-stdio-runner" : runner.kind ?? "unknown", + kind: stdioReady ? stdio.kind ?? "codex-app-server-stdio-runner" : runner.kind ?? "unknown", provider: stdioReady ? stdio.provider ?? "codex-stdio" : runner.provider ?? codeAgent?.provider ?? "unknown", - backend: stdioReady ? stdio.backend ?? "hwlab-cloud-api/codex-mcp-stdio" : runner.backend ?? codeAgent?.backend ?? "unknown", - mode: stdioReady ? "codex-mcp-stdio-long-lived" : runner.sessionMode ?? runner.mode ?? codeAgent?.sessionMode ?? "unknown", + backend: stdioReady ? stdio.backend ?? "hwlab-cloud-api/codex-app-server-stdio" : runner.backend ?? codeAgent?.backend ?? "unknown", + mode: stdioReady ? "codex-app-server-stdio-long-lived" : runner.sessionMode ?? runner.mode ?? codeAgent?.sessionMode ?? "unknown", capabilityLevel: stdioReady ? "long-lived-codex-stdio-session" : runner.capabilityLevel ?? codeAgent?.capabilityLevel ?? "unknown", longLivedSession: stdioReady || runner.longLivedSession === true, durableSession: stdioReady || runner.durableSession === true || runner.durable === true, diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index 8bb8b78b..c34bc6d1 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -38,7 +38,6 @@ export const M3_IO_CHAIN = Object.freeze({ export const M3_IO_BLOCKER_CODES = Object.freeze({ gatewayUnavailable: "m3_gateway_session_unavailable", - gatewayIdentityMismatch: "m3_gateway_identity_mismatch", boxUnavailable: "m3_box_resource_unavailable", portDirectionInvalid: "m3_port_direction_invalid", wiringMissing: "m3_wiring_missing", @@ -1482,18 +1481,8 @@ function normalizeCommand(action, params = {}) { function validateM3Command(command) { if (command.action === "do.write") { - if (command.gatewayId !== M3_IO_CHAIN.sourceGatewayId || command.resourceId !== M3_IO_CHAIN.sourceResourceId || command.port !== M3_IO_CHAIN.sourcePort) { - return `M3 只允许 ${M3_IO_CHAIN.sourceGatewayId}/${M3_IO_CHAIN.sourceResourceId}/${M3_IO_CHAIN.sourcePort} 执行 DO write;当前请求会造成端口方向或资源越界。`; - } if (typeof command.value !== "boolean") { - return "M3 DO1 写入值必须是 boolean。"; - } - return null; - } - - if (command.action === "di.read") { - if (command.gatewayId !== M3_IO_CHAIN.targetGatewayId || command.resourceId !== M3_IO_CHAIN.targetResourceId || command.port !== M3_IO_CHAIN.targetPort) { - return `M3 只允许 ${M3_IO_CHAIN.targetGatewayId}/${M3_IO_CHAIN.targetResourceId}/${M3_IO_CHAIN.targetPort} 执行 DI read;当前请求会造成端口方向或资源越界。`; + return "M3 DO 写入值必须是 boolean。"; } } return null; @@ -1610,26 +1599,6 @@ async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expecte reason: "gateway 未注册/不可用:/status 未返回 gatewaySessionId/gatewayId" }; } - if (expectedGatewayId && gatewayId !== expectedGatewayId) { - return { - available: false, - role: gatewayRole, - url: redactUrl(url), - status, - code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch, - reason: `gateway-simu 身份不匹配:期望 gatewayId=${expectedGatewayId},实际=${gatewayId};控制面板保持阻塞。` - }; - } - if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) { - return { - available: false, - role: gatewayRole, - url: redactUrl(url), - status, - code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch, - reason: `gateway-simu 会话不匹配:期望 gatewaySessionId=${expectedGatewaySessionId},实际=${gatewaySessionId};控制面板保持阻塞。` - }; - } return { available: true, role: gatewayRole, diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index 60a078e6..4657690f 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -583,108 +583,6 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab assert.equal(result.controlPath.frontendBypass, false); }); -test("M3 IO control blocks invalid port direction instead of allowing generic writes", async () => { - const result = await handleM3IoControl( - { - action: "do.write", - gatewayId: "gwsimu_1", - resourceId: "res_boxsimu_1", - port: "DI1", - value: true, - traceId: "trc_m3_invalid_port", - requestId: "req_m3_invalid_port", - actorId: "usr_m3_operator" - }, - { - runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), - now: () => fixedNow, - env: { - HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", - HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", - HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" - }, - requestJson: async () => { - throw new Error("gateway must not be called for invalid port direction"); - } - } - ); - - assert.equal(result.status, "blocked"); - assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid); - assert.equal(result.blockerClassification.category, "port_direction"); - assert.match(result.blocker.zh, /端口方向|资源越界/u); -}); - -test("M3 IO control blocks invalid DI target before calling gateway-simu", async () => { - const result = await handleM3IoControl( - { - action: "di.read", - gatewayId: "gwsimu_2", - resourceId: "res_boxsimu_1", - port: "DO1", - traceId: "trc_m3_invalid_di_target", - requestId: "req_m3_invalid_di_target", - actorId: "usr_m3_operator" - }, - { - runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), - now: () => fixedNow, - env: { - HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", - HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", - HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" - }, - requestJson: async () => { - throw new Error("gateway must not be called for invalid DI target"); - } - } - ); - - assert.equal(result.status, "blocked"); - assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid); - assert.match(result.blocker.zh, /DI read|端口方向|资源越界/u); - assert.equal(result.controlPath.gatewaySimu, false); - assert.equal(result.controlPath.frontendBypass, false); -}); - -test("M3 IO control fails closed when indexed gateway identity drifts", async () => { - const fixture = createM3ControlFixture({ - gateway1Id: "gwsimu_2" - }); - - const result = await handleM3IoControl( - { - action: "do.write", - gatewayId: "gwsimu_1", - resourceId: "res_boxsimu_1", - boxId: "boxsimu_1", - port: "DO1", - value: true, - traceId: "trc_m3_gateway_identity_drift", - requestId: "req_m3_gateway_identity_drift", - actorId: "usr_m3_operator" - }, - { - runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }), - now: () => fixedNow, - env: { - HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", - HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", - HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" - }, - requestJson: fixture.requestJson - } - ); - - assert.equal(result.status, "blocked"); - assert.equal(result.accepted, false); - assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayIdentityMismatch); - assert.match(result.blocker.zh, /身份不匹配|会话不匹配/u); - assert.deepEqual(fixture.calls.map((call) => call.path), ["/status"]); - assert.equal(fixture.box1.ports.DO1.value, false); - assert.equal(fixture.box2.ports.DI1.value, false); -}); - test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1", async () => { const fixture = createM3ControlFixture({ patchPanelBody: { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 39b7929e..d8e95b27 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; -import { createServer as createRawHttpServer } from "node:http"; -import { createServer as createRawTcpServer } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createTcpServer } from "node:net"; import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { createCloudApiServer as createRawCloudApiServer } from "./server.mjs"; +import { createCloudApiServer } from "./server.mjs"; import { validateCodeAgentChatSchema } from "./code-agent-chat.mjs"; import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs"; import { createCodeAgentTraceStore } from "./code-agent-trace-store.mjs"; @@ -27,191 +27,10 @@ const CODEX_STDIO_FEASIBILITY_BLOCKERS = new Set([ "codex_stdio_egress_boundary" ]); -function createHttpServer(...args) { - return trackTestServer(createRawHttpServer(...args)); -} - -function createTcpServer(...args) { - return trackTestServer(createRawTcpServer(...args)); -} - -function createCloudApiServer(...args) { - return trackTestServer(createRawCloudApiServer(...args)); -} - -function trackTestServer(server) { - const sockets = new Set(); - server.on("connection", (socket) => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - }); - const close = server.close.bind(server); - server.close = (callback) => { - server.closeIdleConnections?.(); - for (const socket of sockets) socket.destroy(); - return close(callback); - }; - return server; -} - function assertCodexStdioFeasibilityBlocker(value, label = "codex stdio blocker") { assert.equal(CODEX_STDIO_FEASIBILITY_BLOCKERS.has(value), true, `${label}: ${value}`); } -function missingCodexCommandEnv(overrides = {}) { - return { - PATH: "", - HWLAB_CODE_AGENT_CODEX_COMMAND: path.join(os.tmpdir(), `hwlab-${process.pid}-missing-codex`), - ...overrides - }; -} - -test("cloud api exposes /health, /health/live, and /live probes", async () => { - const originalUrl = process.env.HWLAB_CLOUD_DB_URL; - const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; - delete process.env.HWLAB_CLOUD_DB_URL; - delete process.env.HWLAB_CLOUD_DB_SSL_MODE; - - const server = createCloudApiServer({ - env: { - PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - - const health = await fetch(`http://127.0.0.1:${port}/health`); - assert.equal(health.status, 200); - const healthPayload = await health.json(); - assert.equal(healthPayload.serviceId, "hwlab-cloud-api"); - assert.equal(healthPayload.environment, "dev"); - assert.equal(healthPayload.status, "degraded"); - assert.equal(healthPayload.commit.id.length > 0, true); - assert.equal(healthPayload.image.reference.length > 0, true); - assert.equal(typeof healthPayload.revision, "string"); - assert.equal(typeof healthPayload.build, "object"); - assert.equal(Object.hasOwn(healthPayload.build, "createdAt"), true); - assert.equal(healthPayload.service.id, "hwlab-cloud-api"); - assert.equal(healthPayload.db.status, "blocked"); - assert.equal(healthPayload.db.connected, false); - assert.equal(healthPayload.db.liveConnected, false); - assert.equal(healthPayload.db.liveDbEvidence, false); - assert.equal(healthPayload.db.connectionAttempted, false); - assert.equal(healthPayload.db.connectionResult, "not_attempted_missing_env"); - assert.equal(healthPayload.db.endpointSource, "secret-url-host"); - assert.equal(healthPayload.db.endpoint.authoritative.source, "secret-url-host"); - assert.equal(healthPayload.db.endpoint.authoritative.env, "HWLAB_CLOUD_DB_URL"); - assert.equal(healthPayload.db.endpoint.authoritative.usedForProbe, true); - assert.equal(healthPayload.db.optionalPublicDnsAlias.source, "optional-public-dns-alias"); - assert.equal(healthPayload.db.optionalPublicDnsAlias.requiredForReadiness, false); - assert.equal(healthPayload.db.optionalPublicDnsAlias.usedForProbe, false); - assert.equal(healthPayload.db.redaction.valuesRedacted, true); - assert.equal(healthPayload.db.redaction.secretMaterialRead, false); - assert.equal(healthPayload.db.safety.liveDbEvidence, false); - assert.equal(healthPayload.runtime.durable, false); - assert.equal(healthPayload.runtime.status, "degraded"); - assert.equal(healthPayload.runtime.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false); - assert.equal(healthPayload.runtime.durabilityContract.blockedLayer, "adapter"); - assert.equal(healthPayload.readiness.contractVersion, "v3"); - assert.equal(healthPayload.readiness.durability.status, "blocked"); - assert.equal(healthPayload.readiness.durability.dbLiveEvidenceObserved, false); - assert.equal(healthPayload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false); - assert.equal(healthPayload.readiness.durability.blockedLayer, "adapter"); - assert.equal(healthPayload.readiness.provider.status, "blocked"); - assert.equal(healthPayload.readiness.provider.ready, false); - assert.equal(healthPayload.readiness.provider.blocker, "provider_config_blocked"); - assert.equal(healthPayload.readiness.dbDurable.status, "blocked"); - assert.equal(healthPayload.readiness.dbDurable.ready, false); - assert.equal(healthPayload.readiness.sessionRunner.status, "blocked"); - assert.equal(healthPayload.readiness.sessionRunner.ready, false); - assert.equal(healthPayload.readiness.sessionRunner.codexStdio, true); - assert.equal(healthPayload.readiness.sessionRunner.durableSession, false); - assert.equal(healthPayload.readiness.sessionRunner.longLivedSession, false); - assert.equal(healthPayload.readiness.codexStdio.status, "blocked"); - assert.equal(healthPayload.readiness.codexStdio.ready, false); - assertCodexStdioFeasibilityBlocker(healthPayload.readiness.codexStdio.blocker); - assert.equal(healthPayload.readiness.codeAgent.providerReady, false); - assert.equal(healthPayload.readiness.codeAgent.durableDbReady, false); - assert.equal(healthPayload.readiness.codeAgent.sessionRunnerReady, false); - assert.equal(healthPayload.readiness.codeAgent.codexStdioFeasible, false); - assert.deepEqual(healthPayload.readiness.codeAgent.currentBlockers.slice(0, 2), [ - "provider_config_blocked", - "runtime_durable_adapter_missing" - ]); - assertCodexStdioFeasibilityBlocker(healthPayload.readiness.codeAgent.currentBlockers[2], "readiness current blocker"); - assert.equal(healthPayload.readiness.codeAgent.secretMaterialRead, false); - assert.equal(healthPayload.codeAgent.status, "blocked"); - assert.equal(healthPayload.codeAgent.agentKind, "codex-stdio-blocked"); - assert.equal(healthPayload.codeAgent.partialReady, false); - assert.match(healthPayload.codeAgent.blocker, /Codex CLI command/u); - assert.equal(healthPayload.codeAgent.reason, "codex_cli_binary_missing"); - assert.equal(healthPayload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(healthPayload.codeAgent.runner.ready, false); - assert.equal(healthPayload.codeAgent.capabilityLevel, "blocked"); - assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available"); - assert.equal(healthPayload.codeAgent.longLivedSessionGate.status, "blocked"); - assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); - assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "provider_token_boundary")); - assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.binary.status, "missing"); - assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); - assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.lifecycleSupervisor.status, "present"); - assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.cancelReapTraceReadiness.status, "ready"); - assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); - assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); - assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("provider_token_boundary")); - assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]); - assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); - assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); - assert.equal(healthPayload.codeAgent.secretRefs[0].redacted, true); - assert.equal(healthPayload.codeAgent.egress.directPublicOpenAi, false); - assert.equal(healthPayload.codeAgent.safety.secretMaterialRead, false); - assert.equal(JSON.stringify(healthPayload.codeAgent).includes("sk-"), false); - assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); - assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); - assert.equal(healthPayload.db.secretRefs[0].redacted, true); - - const healthLive = await fetch(`http://127.0.0.1:${port}/health/live`); - assert.equal(healthLive.status, 200); - const healthLivePayload = await healthLive.json(); - assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api"); - assert.equal(healthLivePayload.status, "degraded"); - assert.equal(healthLivePayload.commit.id.length > 0, true); - assert.equal(typeof healthLivePayload.build, "object"); - assert.equal(healthLivePayload.db.ready, false); - assert.equal(healthLivePayload.codeAgent.status, "blocked"); - assert.equal(healthLivePayload.codeAgent.ready, false); - assert.equal(healthLivePayload.codeAgent.partialReady, false); - assert.equal(healthLivePayload.codeAgent.capabilityLevel, "blocked"); - - const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); - assert.equal(readiness.status, 200); - assert.equal((await readiness.json()).status, "degraded"); - - const live = await fetch(`http://127.0.0.1:${port}/live`); - assert.equal(live.status, 200); - assert.equal((await live.json()).status, "live"); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - if (originalUrl === undefined) { - delete process.env.HWLAB_CLOUD_DB_URL; - } else { - process.env.HWLAB_CLOUD_DB_URL = originalUrl; - } - if (originalSslMode === undefined) { - delete process.env.HWLAB_CLOUD_DB_SSL_MODE; - } else { - process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; - } - } -}); - test("cloud api aggregates live HWLAB build times from health and controlled deploy/catalog metadata", async () => { const healthByPath = new Map([ ["/hwlab-cloud-web/health/live", { @@ -855,8 +674,9 @@ test("cloud api health keeps DB live evidence separate when durable adapter quer assert.equal(payload.readiness.dbDurable.status, "blocked"); assert.equal(payload.readiness.dbDurable.blocker, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED); assert.equal(payload.readiness.codeAgent.currentBlockers.includes(RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED), true); - assert.equal(JSON.stringify(payload).includes("password"), false); - assert.equal(JSON.stringify(payload).includes(String(dbPort)), false); + assert.equal(JSON.stringify(payload.db).includes("password"), false); + assert.equal(JSON.stringify(payload.db).includes("127.0.0.1"), false); + assert.equal(JSON.stringify(payload.db).includes(String(dbPort)), false); } finally { if (originalUrl === undefined) { delete process.env.HWLAB_CLOUD_DB_URL; @@ -1409,9 +1229,8 @@ function codexStdioReadyFixture({ workspace, codexHome }) { protocol: { status: "wired", wired: true, - protocolVersion: "codex-app-server-jsonrpc-stdio", - requiredMethods: ["initialize", "thread/start", "thread/resume", "turn/start"], - toolsObserved: [], + requiredTools: ["codex", "codex-reply"], + toolsObserved: ["codex", "codex-reply"], missingTools: [], command: "codex app-server --listen stdio://" }, @@ -1435,9 +1254,8 @@ function codexStdioReadyFixture({ workspace, codexHome }) { stdioProtocol: { status: "wired", wired: true, - protocolVersion: "codex-app-server-jsonrpc-stdio", - requiredMethods: ["initialize", "thread/start", "thread/resume", "turn/start"], - toolsObserved: [], + requiredTools: ["codex", "codex-reply"], + toolsObserved: ["codex", "codex-reply"], missingTools: [], command: "codex app-server --listen stdio://" }, @@ -1474,9 +1292,8 @@ function codexStdioReadyFixture({ workspace, codexHome }) { stdioProtocol: { status: "wired", wired: true, - protocolVersion: "codex-app-server-jsonrpc-stdio", - requiredMethods: ["initialize", "thread/start", "thread/resume", "turn/start"], - toolsObserved: [], + requiredTools: ["codex", "codex-reply"], + toolsObserved: ["codex", "codex-reply"], missingTools: [] }, commandProbe: { @@ -1585,7 +1402,7 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { idleTimeoutMs: 1800000 }, implementationType: "repo-owned-codex-app-server-stdio-session", - runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + runnerLimitations: ["secret-values-redacted"], codexStdioFeasibility: feasibility, longLivedSessionGate: { status: "pass", @@ -1651,70 +1468,21 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { transport: "stdio", protocol: "codex-app-server-jsonrpc-stdio", command: "codex app-server --listen stdio://", - toolName: "codex-app-server.thread/start+turn/start", + toolName: "codex", threadId: "thread_server_stdio_pwd", valuesPrinted: false } }; } -test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => { - const server = createCloudApiServer({ - env: { - PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" - } - }); - 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`); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.codeAgent.endpoint, "POST /v1/agent/chat"); - assert.equal(payload.codeAgent.provider, "codex-stdio"); - assert.equal(payload.codeAgent.model, "gpt-test"); - assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.codeAgent.mode, "codex-stdio"); - assert.equal(payload.codeAgent.status, "blocked"); - assert.match(payload.codeAgent.blocker, /Codex CLI command/u); - assert.equal(payload.codeAgent.reason, "codex_cli_binary_missing"); - assert.equal(payload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.codeAgent.runner.ready, false); - assert.equal(payload.codeAgent.capabilityLevel, "blocked"); - assert.equal(payload.codeAgent.sessionRegistry.status, "available"); - assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); - assert.deepEqual(payload.codeAgent.missingEnv, []); - assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY"); - assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); - assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); - assert.equal(payload.codeAgent.secretRefs[0].redacted, true); - assert.equal(payload.codeAgent.ready, false); - assert.equal(payload.codeAgent.partialReady, false); - assert.equal(payload.codeAgent.egress.present, false); - assert.equal(payload.codeAgent.egress.valueRedacted, true); - assert.equal(payload.codeAgent.safety.secretMaterialRead, false); - assert.match(payload.codeAgent.summary, /will not use controlled-readonly-session-registry/u); - assert.match(payload.codeAgent.summary, /Codex stdio long-lived session is blocked/u); - assert.equal(JSON.stringify(payload).includes("sk-"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - test("cloud api /v1 describes Code Agent egress blocker without leaking API key", async () => { const server = createCloudApiServer({ - env: missingCodexCommandEnv({ + env: { OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses" - }) + } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -1742,7 +1510,15 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when const fakeCodex = await createFakeCodexCommand(); await prepareFakeCodexHome(codexHome); const manager = createCodexStdioSessionManager({ - createRpcClient: async () => createFakeAppServerClient() + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + close() {} + }) }); const server = createCloudApiServer({ env: { @@ -1816,7 +1592,6 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass"); assert.equal(payload.codeAgent.codexStdio.runtimeContract.binary.status, "present"); assert.deepEqual(payload.codeAgent.codexStdio.protocol.toolsObserved, []); - assert.deepEqual(payload.codeAgent.codexStdio.protocol.requiredMethods, ["initialize", "thread/start", "thread/resume", "turn/start"]); assert.equal(payload.codeAgent.codexStdio.commandProbe.ready, true); assert.equal(payload.codeAgent.codexStdio.runtimeContract.commandProbe.ready, true); assert.equal(payload.readiness.codexStdio.commandProbeReady, true); @@ -1842,7 +1617,15 @@ test("cloud api health blocks full Code Agent readiness when Codex stdio command await prepareFakeCodexHome(codexHome); await writeFile(workspaceFile, "not a directory\n", "utf8"); const manager = createCodexStdioSessionManager({ - createRpcClient: async () => createFakeAppServerClient() + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + close() {} + }) }); const server = createCloudApiServer({ env: { @@ -1912,53 +1695,6 @@ test("cloud api health blocks full Code Agent readiness when Codex stdio command } }); -test("cloud api /v1/agent/chat refuses provider stub when Codex stdio is unavailable", async () => { - let providerCalled = false; - const server = createCloudApiServer({ - env: missingCodexCommandEnv(), - callCodeAgentProvider: async () => { - providerCalled = true; - throw new Error("provider stub must not be used"); - } - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-agent-chat" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-agent-chat", - message: "请用一句话说明当前 HWLAB 工作台可以做什么。" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.conversationId, "cnv_server-test-agent-chat"); - assert.equal(payload.sessionId, "cnv_server-test-agent-chat"); - assert.match(payload.messageId, /^msg_/); - assert.equal(payload.status, "failed"); - assert.equal(payload.traceId, "trc_server-test-agent-chat"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.model.length > 0, true); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); - assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); - assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.error.layer, "runner"); - assert.equal(Object.hasOwn(payload, "reply"), false); - assert.equal(providerCalled, false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - test("cloud api REST JSON-RPC bridge exposes unknown serviceId reason for Code Agent internal calls", async () => { const server = createCloudApiServer({ env: { @@ -2001,106 +1737,6 @@ test("cloud api REST JSON-RPC bridge exposes unknown serviceId reason for Code A } }); -test("OpenAI fallback text chat is not used when Codex stdio is unavailable", async () => { - let providerCalled = false; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "openai", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - callCodeAgentProvider: async () => { - providerCalled = true; - throw new Error("OpenAI fallback must not be used"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server_openai_fallback_not_runner", - traceId: "trc_server_openai_fallback_not_runner", - message: "请用一句话说明当前 HWLAB 工作台可以做什么。" - }); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "failed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.runner.codexStdio, true); - assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.equal(Object.hasOwn(payload, "reply"), false); - assert.equal(providerCalled, false); - const capability = classifyCodexRunnerCapability(payload, { httpStatus: 200 }); - assert.equal(capability.capabilityPass, false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api health does not pass long-lived session gate for explicit OpenAI fallback even if Codex stdio is feasible", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-openai-health-")); - const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-openai-health-codex-home-")); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_PROVIDER: "openai", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: { - describe() { - return codexStdioReadyFixture({ workspace, codexHome }); - }, - async probe() { - return codexStdioReadyFixture({ workspace, codexHome }); - }, - async chat(params = {}) { - return codexStdioChatFixture({ workspace, codexHome, params }); - }, - cancel() {}, - reapIdle() {} - } - }); - 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}/health/live`); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.codeAgent.provider, "openai-responses"); - assert.equal(payload.codeAgent.mode, "openai"); - assert.equal(payload.codeAgent.ready, false); - assert.equal(payload.codeAgent.capabilityLevel, "blocked"); - assert.equal(payload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.codeAgent.runner.codexStdio, true); - assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); - assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); - assert.equal(payload.readiness.codeAgent.ready, false); - assert.equal(payload.readiness.codeAgent.provider.provider, "openai-responses"); - assert.equal(payload.readiness.codeAgent.sessionRunner.codexStdio, true); - assert.equal(JSON.stringify(payload.codeAgent).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(codexHome, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => { const server = createCloudApiServer({ env: { @@ -2136,94 +1772,6 @@ test("cloud api /v1/agent/chat parse and params errors use structured blocker en } }); -test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace evidence", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-workspace-")); - const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-codex-home-")); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: { - describe() { - return codexStdioReadyFixture({ workspace, codexHome }); - }, - async probe() { - return codexStdioReadyFixture({ workspace, codexHome }); - }, - async chat(params = {}) { - return codexStdioChatFixture({ workspace, codexHome, params }); - }, - cancel() {}, - reapIdle() {} - } - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-runner-pwd" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-runner-pwd", - message: "用pwd列出你当前的工作目录" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.workspace, workspace); - assert.equal(payload.sandbox, "workspace-write"); - assert.equal(payload.session.status, "idle"); - assert.equal(payload.session.workspace, workspace); - assert.equal(payload.session.sandbox, "workspace-write"); - assert.equal(payload.session.lastTraceId, "trc_server-test-runner-pwd"); - assert.equal(typeof payload.session.idleTimeoutMs, "number"); - assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.runner.longLivedSession, true); - assert.equal(payload.runner.codexStdio, true); - assert.equal(payload.runner.writeCapable, true); - assert.equal(payload.runner.durableSession, true); - assert.equal(payload.runner.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.implementationType, "repo-owned-codex-app-server-stdio-session"); - assert.equal(payload.sessionReuse.reused, false); - assert.equal(payload.sessionReuse.turn, 1); - assert.equal(payload.sessionReuse.status, "idle"); - assert.ok(payload.runnerLimitations.includes("hardware-control-via-cloud-api-only")); - assert.equal(payload.codexStdioFeasibility.ready, true); - assert.equal(payload.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "wired"); - assert.equal(payload.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "present"); - assert.equal(payload.longLivedSessionGate.status, "pass"); - assert.equal(payload.longLivedSessionGate.pass, true); - assert.equal(payload.toolCalls[0].name, "pwd"); - assert.equal(payload.toolCalls[0].status, "completed"); - assert.equal(payload.toolCalls[0].stdout, workspace); - assert.equal(payload.skills.status, "not_requested"); - assert.equal(payload.runnerTrace.runnerKind, "codex-app-server-stdio-runner"); - assert.equal(payload.runnerTrace.sessionMode, "codex-app-server-stdio-long-lived"); - assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - test("cloud api /v1/agent/chat supports short submit and result polling", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-codex-home-")); @@ -2305,7 +1853,7 @@ test("cloud api /v1/agent/chat supports short submit and result polling", async } }); -test.skip("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => { +test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-")); const server = createCloudApiServer({ @@ -2389,8 +1937,8 @@ test.skip("cloud api result polling compacts large runnerTrace while preserving validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.traceId, traceId); - assert.equal(payload.providerTrace.protocol, "mcp"); - assert.equal(payload.providerTrace.command, "codex mcp-server"); + assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio"); + assert.equal(payload.providerTrace.command, "codex app-server --listen stdio://"); assert.equal(payload.runnerTrace.eventCount, 160); assert.equal(payload.runnerTrace.eventsCompacted, true); assert.equal(payload.runnerTrace.events.length, 32); @@ -2406,154 +1954,6 @@ test.skip("cloud api result polling compacts large runnerTrace while preserving } }); -test("cloud api health reports Codex stdio runner facts without readonly limitations", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-stdio-")); - const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-codex-home-")); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: { - describe() { - return codexStdioReadyFixture({ workspace, codexHome }); - }, - async probe() { - return codexStdioReadyFixture({ workspace, codexHome }); - }, - async chat(params = {}) { - return codexStdioChatFixture({ workspace, codexHome, params }); - }, - cancel() {}, - reapIdle() {} - } - }); - 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}/health/live`); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.codeAgent.status, "codex-stdio-feasible"); - assert.equal(payload.codeAgent.ready, true); - assert.equal(payload.codeAgent.provider, "codex-stdio"); - assert.equal(payload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.codeAgent.runner.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.codeAgent.runner.codexStdio, true); - assert.equal(payload.codeAgent.runner.writeCapable, true); - assert.equal(payload.codeAgent.runner.durableSession, true); - assert.equal(payload.codeAgent.workspace, workspace); - assert.equal(payload.codeAgent.sandbox, "workspace-write"); - assert.equal(payload.codeAgent.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.codeAgent.sessionRegistry.kind, "codex-stdio-session-registry"); - assert.deepEqual(payload.codeAgent.sessionRegistry.statuses, ["creating", "ready", "busy", "idle", "timeout", "error", "canceled", "interrupted", "expired", "failed"]); - assert.equal(payload.codeAgent.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass"); - assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); - assert.equal(payload.readiness.sessionRunner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.readiness.sessionRunner.codexStdio, true); - assert.equal(payload.readiness.sessionRunner.writeCapable, true); - assert.equal(payload.readiness.sessionRunner.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.readiness.codeAgent.sessionRunner.codexStdio, true); - assert.equal(payload.readiness.codeAgent.sessionRunner.writeCapable, true); - assert.deepEqual(payload.codeAgent.runnerLimitations, [ - "hardware-control-via-cloud-api-only", - "no-direct-gateway-link", - "no-direct-box-simu-link", - "no-direct-patch-panel-link", - "secret-values-redacted" - ]); - const serialized = JSON.stringify(payload.codeAgent); - assert.equal(serialized.includes("not-codex-stdio"), false); - assert.equal(serialized.includes("not-write-capable"), false); - assert.equal(serialized.includes("process-local-session-registry"), false); - assert.equal(serialized.includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(codexHome, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat sends pwd prompt through real Codex stdio instead of local sidecar", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-pwd-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const codexAppServerCalls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_pwd_real", - createRpcClient: async () => createFakeAppServerClient({ - calls: codexAppServerCalls, - text: `真实 Codex stdio 回复:当前工作目录是 ${workspace}` - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: manager, - callCodeAgentProvider: async () => { - throw new Error("OpenAI fallback must not handle Codex stdio pwd"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_pwd_sidecar", - traceId: "trc_server_stdio_pwd_sidecar", - message: "用pwd列出你当前的工作目录" - }); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.workspace, workspace); - assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.longLivedSessionGate.status, "pass"); - assert.equal(payload.providerTrace.sidecarOnly, undefined); - assert.equal(payload.providerTrace.toolName, "codex-app-server.thread/start+turn/start"); - assert.deepEqual(codexAppServerCalls.filter((call) => call.method !== "initialize").map((call) => call.method), ["thread/start", "turn/start"]); - assert.match(codexAppServerCalls.find((call) => call.method === "turn/start")?.args?.prompt ?? "", /用pwd列出/u); - assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "codex-app-server.thread/start+turn/start" && toolCall.status === "completed")); - assert.match(payload.reply.content, /真实 Codex stdio 回复/u); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "assistant:completed")); - assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); - assert.equal(JSON.stringify(payload).includes("text-chat-only"), false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio and retains trace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-")); const codexHome = path.join(workspace, "codex-home"); @@ -2631,232 +2031,6 @@ test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio an } }); -test("cloud api /v1/agent/chat handles GitHub access through Codex stdio controlled network check", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-network-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const codexAppServerCalls = []; - const fetchCalls = []; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_network", - lookupImpl: async () => [{ address: "140.82.114.4", family: 4 }], - fetchImpl: async (url, init) => { - fetchCalls.push({ url: String(url), method: init?.method }); - return { - status: 200, - statusText: "OK", - url: "https://github.com/" - }; - }, - createRpcClient: async () => createFakeAppServerClient({ calls: codexAppServerCalls }) - }), - callCodeAgentProvider: async () => { - throw new Error("OpenAI fallback must not handle external network prompts"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_network", - traceId: "trc_server_stdio_network", - message: "访问一下github看看" - }); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.providerTrace.transport, "stdio+controlled-network"); - assert.equal(payload.providerTrace.toolName, "external.network.check"); - assert.equal(payload.providerTrace.httpStatus, 200); - assert.equal(codexAppServerCalls.some((call) => call.method === "turn/start"), false); - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url, "https://github.com/"); - assert.equal(fetchCalls[0].method, "HEAD"); - assert.ok(payload.toolCalls.some((toolCall) => - toolCall.name === "external.network.check" && - toolCall.status === "completed" && - toolCall.httpStatus === 200 - )); - assert.match(payload.reply.content, /GitHub可以访问/u); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "session:created")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:started")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:completed")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:external.network.check:completed")); - assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat returns structured external network blocker before 150s", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-network-blocked-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - let fetchCalled = false; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_EXTERNAL_NETWORK: "0", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_network_blocked", - lookupImpl: async () => [{ address: "140.82.114.4", family: 4 }], - fetchImpl: async () => { - fetchCalled = true; - throw new Error("fetch must not run when policy blocks external network"); - }, - createRpcClient: async () => createFakeAppServerClient() - }), - callCodeAgentProvider: async () => { - throw new Error("OpenAI fallback must not handle blocked external network prompts"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const started = Date.now(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_network_blocked", - traceId: "trc_server_stdio_network_blocked", - message: "看看 github 是否能访问" - }); - const elapsed = Date.now() - started; - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "failed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.error.code, "external_network_blocked"); - assert.equal(payload.blocker.code, "external_network_blocked"); - assert.equal(payload.blocker.traceId, "trc_server_stdio_network_blocked"); - assert.equal(payload.blocker.sessionId, "ses_server_stdio_network_blocked"); - assert.equal(payload.blocker.conversationId, "cnv_server_stdio_network_blocked"); - assert.equal(payload.blocker.stage, "policy"); - assert.equal(typeof payload.blocker.elapsedMs, "number"); - assert.equal(typeof payload.blocker.lastEvent, "object"); - assert.equal(payload.toolCalls[0].name, "external.network.check"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "external_network_blocked"); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:started")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:blocked")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:external.network.check:blocked")); - assert.equal(fetchCalled, false); - assert.equal(elapsed < 150000, true); - assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat returns network_timeout with preserved runner trace", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-network-timeout-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write", - HWLAB_CODE_AGENT_EXTERNAL_NETWORK_TIMEOUT_MS: "25", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_network_timeout", - lookupImpl: async () => [{ address: "140.82.114.4", family: 4 }], - fetchImpl: async (_url, init) => new Promise((resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - const error = new Error("aborted"); - error.name = "AbortError"; - reject(error); - }); - }), - createRpcClient: async () => createFakeAppServerClient() - }) - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const started = Date.now(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_network_timeout", - traceId: "trc_server_stdio_network_timeout", - message: "打开 https://github.com 看看" - }); - const elapsed = Date.now() - started; - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "timeout"); - assert.equal(payload.error.code, "network_timeout"); - assert.equal(payload.blocker.code, "network_timeout"); - assert.equal(payload.blocker.traceId, "trc_server_stdio_network_timeout"); - assert.equal(payload.blocker.sessionId, "ses_server_stdio_network_timeout"); - assert.equal(payload.blocker.stage, "http-timeout"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "network_timeout"); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:timeout")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:external.network.check:blocked")); - assert.equal(elapsed < 150000, true); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat returns structured skills blocker without local skills manifest", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-")); const codexHome = path.join(workspace, "codex-home"); @@ -2918,822 +2092,13 @@ test("cloud api /v1/agent/chat returns structured skills blocker without local s } }); -test("cloud api /v1/agent/chat routes M3 IO through Skill CLI even when Codex stdio is unavailable", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-")); - const calls = []; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_server-test-m3-skill", - operationId: "op_m3_do_write_server_skill", - auditId: "aud_m3_do_write_server_skill_succeeded", - evidenceId: "evd_m3_do_write_server_skill_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: false, - targetReadback: { - status: "succeeded", - value: false - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-m3-skill" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-m3-skill", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 false,然后读取 res_boxsimu_2 的 DI1。" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].route, "/v1/m3/io"); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(Object.hasOwn(payload, "reply"), true); - assert.equal(calls.length, 1); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Codex free chat", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-skill-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const calls = []; - const codexStdioCalls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_m3_topology_skill", - createRpcClient: async () => createFakeAppServerClient({ - calls: codexStdioCalls, - text: "stdio session ready for controlled topology M3 skill." - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_server-test-m3-topology", - operationId: "op_m3_do_write_topology_skill", - auditId: "aud_m3_do_write_topology_skill", - evidenceId: "evd_m3_do_write_topology_skill", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: { - status: "succeeded", - value: true, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-topology", - traceId: "trc_server-test-m3-topology", - message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环,把 DO1 置为 true。" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.m3Io.type, "m3_io_result"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, true); - assert.equal(payload.m3Io.di1.observedValue, true); - assert.equal(payload.m3Io.wiring.label, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"); - assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(payload.m3Io.operation.operationId, "op_m3_do_write_topology_skill"); - assert.equal(payload.m3Io.trace.traceId, "trc_server-test-m3-topology"); - assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId); - assert.equal(payload.m3Io.trust.trusted, false); - assert.equal(payload.m3Io.trust.durable, false); - const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); - assert.ok(skillTool); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.accepted, true); - assert.equal(skillTool.operationId, "op_m3_do_write_topology_skill"); - assert.equal(skillTool.traceId, "trc_server-test-m3-topology"); - assert.equal(skillTool.readback.value, true); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.transport, "skill-cli"); - assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started")); - assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io")); - assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u); - assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, true); - assert.deepEqual(codexStdioCalls, []); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without DO1 value", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-blocker-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const calls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_m3_topology_blocker", - createRpcClient: async () => createFakeAppServerClient({ - text: "stdio session ready for blocked topology M3 skill." - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - throw new Error("missing DO1 value must block before HWLAB API request"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-topology-blocker", - traceId: "trc_server-test-m3-topology-blocker", - message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环。" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, null); - assert.equal(payload.m3Io.di1.observedValue, null); - assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(payload.m3Io.blocker.code, "invalid_boolean_value"); - const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); - assert.ok(skillTool); - assert.equal(skillTool.status, "blocked"); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.blocker.code, "invalid_boolean_value"); - assert.equal(skillTool.accepted, false); - assert.equal(skillTool.operationId, null); - assert.equal(payload.blocker.code, "invalid_boolean_value"); - assert.match(payload.reply.content, /^M3 IO 阻塞:/u); - assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u); - assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.transport, "skill-cli"); - assert.equal(calls.length, 0); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat promotes /v1/m3/io blocker into Chinese M3 IO reply", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-blocker-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const calls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_m3_api_blocker", - createRpcClient: async () => createFakeAppServerClient({ - text: "stdio session ready for M3 API blocker fixture." - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - route: "/v1/m3/io", - method: "POST", - status: "blocked", - accepted: false, - action: "do.write", - traceId: "trc_server-test-m3-api-blocker", - operationId: "op_m3_do_write_api_blocker", - auditId: "aud_m3_do_write_api_blocker_failed", - evidenceId: "evd_m3_do_write_api_blocker_failed", - blocker: { - code: "m3_wiring_missing", - layer: "hwlab-patch-panel", - zh: "hwlab-patch-panel 未确认 active res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 接线" - }, - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: null - }, - controlPath: { - status: "blocked", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: false, - frontendBypass: false - } - } - }; - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-api-blocker", - traceId: "trc_server-test-m3-api-blocker", - message: "把 M3 DO1 打开,然后读取 DI1。" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, true); - assert.equal(payload.m3Io.di1.observedValue, null); - assert.equal(payload.m3Io.blocker.code, "m3_wiring_missing"); - assert.equal(payload.blocker.code, "m3_wiring_missing"); - assert.match(payload.reply.content, /^M3 IO 阻塞:/u); - assert.match(payload.reply.content, /hwlab-patch-panel 未确认 active/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1/u); - assert.match(payload.reply.content, /trusted=false;durable=false/u); - assert.equal(calls.length, 1); - assert.equal(new URL(calls[0].url).pathname, "/v1/m3/io"); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, true); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat routes M3 Skill CLI through in-process HWLAB API handler", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-")); - const fakeCodex = await createFakeCodexCommand(); - const gatewayCalls = []; - const codexStdioCalls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_m3_skill", - createRpcClient: async () => createFakeAppServerClient({ - calls: codexStdioCalls, - text: "stdio session ready for M3 skill." - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: workspace, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1", - HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2", - HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel" - }, - codexStdioManager: manager, - m3IoRequestJson: async (url, request) => { - gatewayCalls.push({ url, request }); - return m3ReadinessRequestJson(url, request); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_m3_skill", - traceId: "trc_server_stdio_m3_skill", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。" - }); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.runner.codexStdio, false); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.m3Io.type, "m3_io_result"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, true); - assert.equal(payload.m3Io.di1.observedValue, true); - assert.equal(payload.m3Io.operation.operationId.startsWith("op_m3_do_write_"), true); - assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId); - assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(payload.m3Io.trust.trusted, false); - assert.equal(payload.m3Io.trust.durable, false); - const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); - assert.ok(skillTool); - assert.equal(skillTool.type, "skill-cli"); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.accepted, true); - assert.equal(skillTool.readback.value, true); - assert.equal(skillTool.controlReady, true); - assert.equal(skillTool.operationId.startsWith("op_m3_do_write_"), true); - assert.match(skillTool.auditId, /^aud_m3_do_write_/u); - assert.match(skillTool.evidenceId, /^evd_m3_do_write_/u); - assert.equal(payload.runnerTrace.route, "/v1/m3/io"); - assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started")); - assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io")); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.codexStdio, false); - assert.equal(payload.providerTrace.transport, "skill-cli"); - assert.match(payload.reply.content, /HWLAB API \/v1\/m3\/io/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u); - assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u); - assert.ok(payload.runner.toolPolicy.allowed.includes("POST /v1/m3/io")); - assert.deepEqual(gatewayCalls.map((call) => new URL(call.url).pathname), [ - "/status", - "/invoke", - "/sync/tick", - "/status", - "/invoke" - ]); - assert.deepEqual(codexStdioCalls, []); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat blocks direct M3 API base targets before Codex stdio", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-direct-target-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const calls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_m3_direct_block", - createRpcClient: async () => createFakeAppServerClient({ - text: "stdio session ready for blocked M3 direct target." - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - throw new Error("direct gateway target must be blocked before requestJson"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-direct-target", - traceId: "trc_server-test-m3-direct-target", - message: "读取 M3 DI1" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.blocker.code, "direct_hardware_target_blocked"); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); - assert.equal(skillTool.status, "blocked"); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.blocker.code, "direct_hardware_target_blocked"); - assert.equal(skillTool.hwlabApi.redactedUrl, null); - assert.equal(payload.skills.blockers[0].code, "direct_hardware_target_blocked"); - assert.equal(calls.length, 0); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base before Codex stdio", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_m3_api_base_missing", - createRpcClient: async () => createFakeAppServerClient({ - text: "stdio session ready for missing M3 API base." - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async () => { - throw new Error("missing API base must not call requestJson"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-api-base-missing", - traceId: "trc_server-test-m3-api-base-missing", - message: "读取 M3 DI1" - }); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); - assert.equal(skillTool.blocker.code, "skill_cli_api_base_missing"); - assert.deepEqual(skillTool.blocker.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat refuses local file-tool shortcuts when Codex stdio is unavailable", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-tools-")); - await writeFile(path.join(workspace, "alpha.txt"), "alpha\nbeta\n", "utf8"); - await writeFile(path.join(workspace, "package.json"), "{\"name\":\"sample\"}\n", "utf8"); - await mkdir(path.join(workspace, "src"), { recursive: true }); - await writeFile(path.join(workspace, "src", "main.mjs"), "export const value = 1;\n", "utf8"); - const env = { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: workspace - }; - const server = createCloudApiServer({ env }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const first = await postAgent(port, { - conversationId: "cnv_server-test-session-reuse", - traceId: "trc_server-test-session-reuse-ls", - message: "ls ." - }); - assert.equal(first.status, "failed"); - assert.equal(first.provider, "codex-stdio"); - assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(first.capabilityLevel, "blocked"); - assert.equal(first.sessionMode, "codex-app-server-stdio-long-lived"); - assert.deepEqual(first.toolCalls, []); - assert.equal(Object.hasOwn(first, "reply"), false); - assert.ok(first.runnerTrace.events.some((event) => event.label === "request:accepted")); - assert.ok(first.runnerLimitations.includes("no-controlled-readonly-fallback")); - - const second = await postAgent(port, { - conversationId: "cnv_server-test-session-reuse", - traceId: "trc_server-test-session-reuse-cat", - message: "cat alpha.txt" - }); - assert.equal(second.status, "failed"); - assert.equal(second.provider, "codex-stdio"); - assert.deepEqual(second.toolCalls, []); - assert.equal(Object.hasOwn(second, "reply"), false); - - const third = await postAgent(port, { - conversationId: "cnv_server-test-session-reuse", - traceId: "trc_server-test-session-reuse-rg", - message: "rg --files ." - }); - assert.equal(third.status, "failed"); - assert.equal(third.provider, "codex-stdio"); - assert.deepEqual(third.toolCalls, []); - assert.equal(Object.hasOwn(third, "reply"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api /v1/agent/chat does not use read-only conversation facts as fallback", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-session-continuity-")); - const workspace = path.join(root, "workspace"); - const skillsDir = path.join(root, "skills"); - await mkdir(workspace, { recursive: true }); - await mkdir(path.join(skillsDir, "alpha"), { recursive: true }); - await mkdir(path.join(skillsDir, "beta"), { recursive: true }); - await writeFile(path.join(skillsDir, "alpha", "SKILL.md"), [ - "---", - "name: alpha-skill", - "description: Alpha skill summary.", - "---", - "", - "# Alpha" - ].join("\n")); - await writeFile(path.join(skillsDir, "beta", "SKILL.md"), [ - "---", - "name: beta-skill", - "description: Beta skill summary.", - "---", - "", - "# Beta" - ].join("\n")); - - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "0", - HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" - }, - skillsDirs: [skillsDir], - skillsDirsExact: true - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const first = await postAgent(port, { - conversationId: "cnv_server-session-continuity", - traceId: "trc_server-session-continuity-pwd", - message: "用pwd列出你当前的工作目录" - }); - assert.equal(first.status, "failed"); - assert.equal(first.provider, "codex-stdio"); - assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(first.capabilityLevel, "blocked"); - assert.equal(first.conversationFacts.turnCount, 0); - - const second = await postAgent(port, { - conversationId: "cnv_server-session-continuity", - traceId: "trc_server-session-continuity-skills", - message: "列出你能使用的所有skill" - }); - assert.equal(second.status, "failed"); - assert.equal(second.provider, "codex-stdio"); - assert.equal(second.skills.status, "not_requested"); - assert.equal(second.conversationFacts.turnCount, 0); - - const third = await postAgent(port, { - conversationId: "cnv_server-session-continuity", - traceId: "trc_server-session-continuity-context", - message: "根据前两轮结果说明你现在在哪个工作目录、能使用哪些skill" - }); - assert.equal(third.status, "failed"); - assert.equal(third.provider, "codex-stdio"); - assert.equal(third.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(third.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(third.capabilityLevel, "blocked"); - assert.deepEqual(third.toolCalls, []); - assert.equal(third.conversationFacts.turnCount, 0); - assert.notEqual(third.provider, "openai-responses"); - assert.notEqual(third.runner.kind, "openai-responses-fallback"); - assert.notEqual(third.runner.kind, "codex-cli-one-shot-ephemeral"); - assert.notEqual(third.sessionMode, "ephemeral-one-shot"); - assert.equal(Object.hasOwn(third, "reply"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(root, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-")); const server = createCloudApiServer({ - env: missingCodexCommandEnv({ + env: { + PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: workspace - }) + } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -3758,252 +2123,6 @@ test("cloud api /v1/agent/chat blocks forbidden file paths without leaking value } }); -test("cloud api /v1/agent/chat does not answer skills from local manifest when Codex stdio is blocked", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-skills-")); - const skillsDir = path.join(root, "skills"); - await mkdir(path.join(skillsDir, "alpha"), { recursive: true }); - await writeFile(path.join(skillsDir, "alpha", "SKILL.md"), [ - "---", - "name: alpha-skill", - "description: Alpha skill summary.", - "version: 1.2.3", - "commit: abc123def456", - "---", - "", - "# Alpha" - ].join("\n")); - - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: root - }, - skillsDirs: [skillsDir], - skillsDirsExact: true - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-skills", - message: "列出你可用的skills" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.status, "failed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.skills.status, "not_requested"); - assert.deepEqual(payload.toolCalls, []); - assert.equal(Object.hasOwn(payload, "reply"), false); - assert.equal(JSON.stringify(payload).includes("alpha-skill"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api /v1/agent/chat exposes prompt trace immediately while Codex stdio model call is slow", async () => { - const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-slow-skills-")); - const workspace = path.join(root, "workspace"); - const skillsDir = path.join(root, "skills"); - await mkdir(workspace, { recursive: true }); - await mkdir(path.join(skillsDir, "slow-skill"), { recursive: true }); - await writeFile(path.join(skillsDir, "slow-skill", "SKILL.md"), [ - "---", - "name: slow-skill", - "description: Slow skill discovery summary.", - "version: 2026.05.23", - "commit: slow12345678", - "---", - "", - "# Slow" - ].join("\n")); - - const codexHome = path.join(root, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await prepareFakeCodexHome(codexHome); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir, - HWLAB_CODE_AGENT_SKILLS_STRICT: "1", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_test_slow_skills", - createRpcClient: async () => createFakeAppServerClient({ - text: "真实 Codex stdio 回复:slow-skill", - delayMs: 120 - }) - }) - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const chatPromise = fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-slow-skills" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-slow-skills", - message: "请列出你可用的所有 skills" - }) - }); - await delay(40); - const traceResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/trc_server-test-slow-skills`); - assert.equal(traceResponse.status, 200); - const tracePayload = await traceResponse.json(); - assert.equal(tracePayload.traceId, "trc_server-test-slow-skills"); - assert.equal(tracePayload.eventCount > 0, true); - assert.ok(tracePayload.events.some((event) => event.label === "request:accepted" || event.label === "prompt:sent")); - - const response = await chatPromise; - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.traceId, "trc_server-test-slow-skills"); - assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); - assert.equal(payload.skills.status, "ready"); - assert.equal(payload.skills.items[0].name, "slow-skill"); - assert.equal(payload.skills.items[0].traceId, "trc_server-test-slow-skills"); - assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(payload.runnerTrace.traceId, "trc_server-test-slow-skills"); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "assistant:completed")); - assert.equal(Object.hasOwn(payload, "error"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat/cancel cancels in-flight Codex stdio and keeps retry trace", async () => { - const fakeCodex = await createFakeCodexCommand(); - const workspace = path.join(fakeCodex.root, "workspace"); - const codexHome = path.join(fakeCodex.root, "codex-home"); - await mkdir(workspace, { recursive: true }); - await prepareFakeCodexHome(codexHome); - const traceStore = createCodeAgentTraceStore(); - let toolStartedResolve = null; - const toolStarted = new Promise((resolve) => { - toolStartedResolve = resolve; - }); - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_test_cancel", - traceStore, - createRpcClient: async () => createFakeAppServerClient({ - complete: false, - onTurnStarted: () => toolStartedResolve() - }) - }); - const server = createCloudApiServer({ - traceStore, - codexStdioManager: manager, - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write", - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - CODEX_HOME: codexHome - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const chatPromise = fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-cancel" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-cancel", - message: "请执行一个需要等待的 Codex stdio 请求" - }) - }); - await toolStarted; - const cancelResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/cancel`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-cancel" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-cancel", - sessionId: "ses_server_test_cancel", - traceId: "trc_server-test-cancel" - }) - }); - assert.equal(cancelResponse.status, 200); - const cancelPayload = await cancelResponse.json(); - assert.equal(cancelPayload.status, "canceled"); - assert.equal(cancelPayload.canceled, true); - assert.equal(cancelPayload.sessionId, "ses_server_test_cancel"); - assert.equal(cancelPayload.session.status, "canceled"); - assert.equal(cancelPayload.session.lifecycleStatus, "interrupted"); - assert.equal(cancelPayload.sessionLifecycleStatus, "interrupted"); - assert.equal(cancelPayload.sessionSummary.requiresNewSession, true); - assert.equal(cancelPayload.lastTraceEvent.label, "cancel:canceled"); - - const chatResponse = await chatPromise; - assert.equal(chatResponse.status, 200); - const chatPayload = await chatResponse.json(); - assert.equal(chatPayload.status, "canceled"); - assert.equal(chatPayload.error.code, "codex_stdio_canceled"); - assert.equal(chatPayload.session.sessionId, "ses_server_test_cancel"); - assert.equal(chatPayload.session.status, "canceled"); - assert.equal(chatPayload.session.lifecycleStatus, "interrupted"); - assert.equal(chatPayload.sessionLifecycleStatus, "interrupted"); - assert.equal(chatPayload.sessionSummary.requiresNewSession, true); - assert.equal(chatPayload.runnerTrace.lastEvent.label, "cancel:canceled"); - - const traceResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/trc_server-test-cancel`); - assert.equal(traceResponse.status, 200); - const tracePayload = await traceResponse.json(); - assert.equal(tracePayload.status, "canceled"); - assert.equal(tracePayload.lastEvent.label, "cancel:canceled"); - assert.ok(tracePayload.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started")); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation", async () => { const traceStore = createCodeAgentTraceStore(); traceStore.append("trc_cancel_unsupported", { @@ -4138,108 +2257,6 @@ test("cloud api /v1/agent/chat does not run unsupported local grep fallback", as } }); -test("cloud api /v1/agent/chat ignores OpenAI fallback and blocks when Codex stdio is unavailable", async () => { - const providerRequests = []; - const providerServer = createHttpServer((request, response) => { - const chunks = []; - request.on("data", (chunk) => chunks.push(chunk)); - request.on("end", () => { - const bodyText = Buffer.concat(chunks).toString("utf8"); - const body = JSON.parse(bodyText); - providerRequests.push({ - method: request.method, - url: request.url, - authorizationPresent: Boolean(request.headers.authorization), - accept: request.headers.accept, - contentType: request.headers["content-type"], - body - }); - response.writeHead(200, { - "content-type": "text/event-stream" - }); - response.end([ - `data: ${JSON.stringify({ - type: "response.created", - response: { - id: "resp_server_test_stream", - model: body.model, - usage: null - } - })}`, - `data: ${JSON.stringify({ - type: "response.output_text.delta", - response_id: "resp_server_test_stream", - delta: "HWLAB Code Agent " - })}`, - `data: ${JSON.stringify({ - type: "response.output_text.delta", - response_id: "resp_server_test_stream", - delta: "streaming ready." - })}`, - `data: ${JSON.stringify({ - type: "response.completed", - response: { - id: "resp_server_test_stream", - model: body.model, - usage: null - } - })}`, - "data: [DONE]", - "" - ].join("\n\n")); - }); - }); - await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve)); - const providerPort = providerServer.address().port; - - const server = createCloudApiServer({ - env: missingCodexCommandEnv({ - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "openai", - HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` - }) - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-agent-chat-stream" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-agent-chat-stream", - message: "请用一句话说明当前 HWLAB 工作台可以做什么。" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.status, "failed"); - assert.equal(payload.conversationId, "cnv_server-test-agent-chat-stream"); - assert.equal(payload.traceId, "trc_server-test-agent-chat-stream"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.model, "gpt-test"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.error.code, "codex_cli_binary_missing"); - assert.equal(Object.hasOwn(payload, "reply"), false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - - assert.equal(providerRequests.length, 0); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await new Promise((resolve, reject) => { - providerServer.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - test("cloud api /v1/agent/chat does not call delayed provider fallback beyond legacy 4500ms UI timeout", async () => { let providerCalled = false; const server = createCloudApiServer({ @@ -4291,120 +2308,6 @@ test("cloud api /v1/agent/chat does not call delayed provider fallback beyond le } }); -test("cloud api /v1/agent/chat skips delayed provider fallback when Codex stdio is unavailable", async () => { - let providerCalled = false; - const server = createCloudApiServer({ - env: missingCodexCommandEnv({ - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "openai", - HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1/provider-fixture" - }), - callCodeAgentProvider: async ({ providerPlan }) => { - providerCalled = true; - await delay(4600); - const error = new Error("OpenAI Responses returned HTTP 503: slow upstream rejected"); - error.code = "provider_unavailable"; - error.provider = providerPlan.provider; - error.model = providerPlan.model; - error.backend = providerPlan.backend; - error.providerStatus = 503; - throw error; - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const startedAt = Date.now(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-agent-chat-delayed-failure" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-agent-chat-delayed-failure", - message: "请等待后返回 provider 失败分类" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(Date.now() - startedAt < 4500, true); - assert.equal(payload.status, "failed"); - assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed-failure"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.error.code, "codex_cli_binary_missing"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.availability.fallback.backend, "hwlab-cloud-api/openai-responses"); - assert.equal(Object.hasOwn(payload, "reply"), false); - assert.equal(providerCalled, false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api /v1/agent/chat reports Codex stdio blocker instead of provider timeout fallback", async () => { - const providerServer = createHttpServer((request, response) => { - request.resume(); - setTimeout(() => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(JSON.stringify({ output_text: "too late" })); - }, 250); - }); - await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve)); - const providerPort = providerServer.address().port; - - const server = createCloudApiServer({ - env: missingCodexCommandEnv({ - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "openai", - HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` - }), - codeAgentTimeoutMs: 50 - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-agent-chat-provider-timeout" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-agent-chat-provider-timeout", - message: "请等待后回答" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(payload.status, "failed"); - assert.equal(payload.traceId, "trc_server-test-agent-chat-provider-timeout"); - assert.equal(payload.error.code, "codex_cli_binary_missing"); - assert.equal(payload.error.layer, "runner"); - assert.equal(payload.error.retryable, false); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(payload.availability.endpoint, "POST /v1/agent/chat"); - assert.equal(payload.availability.runner.kind, "codex-app-server-stdio-runner"); - assert.equal(Object.hasOwn(payload, "reply"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await new Promise((resolve, reject) => { - providerServer.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", async () => { for (const status of [502, 503]) { const providerServer = createHttpServer((request, response) => { @@ -4420,13 +2323,13 @@ test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", const providerPort = providerServer.address().port; const server = createCloudApiServer({ - env: missingCodexCommandEnv({ + env: { OPENAI_API_KEY: "test-openai-key-material", HWLAB_CODE_AGENT_PROVIDER: "openai", HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true", HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses` - }) + } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -4466,63 +2369,6 @@ test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", } }); -test("cloud api /v1/agent/chat reports provider gaps without faking a reply", async () => { - const server = createCloudApiServer({ - env: { - PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" - } - }); - 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/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json" - }, - body: JSON.stringify({ - message: "你好" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.match(payload.conversationId, /^cnv_/); - assert.match(payload.messageId, /^msg_/); - assert.equal(payload.status, "failed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.model, "gpt-test"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); - assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); - assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); - assert.equal(payload.error.code, "codex_cli_binary_missing"); - assert.equal(payload.error.layer, "runner"); - assert.equal(payload.error.retryable, false); - assert.match(payload.error.userMessage, /Codex CLI binary/u); - assert.match(payload.error.message, /Codex stdio long-lived session is unavailable/u); - assert.equal(payload.availability.status, "blocked"); - assert.match(payload.availability.blocker, /Codex CLI command/u); - assert.equal(payload.availability.reason, "codex_cli_binary_missing"); - assert.equal(payload.availability.runner.ready, false); - assert.equal(payload.availability.codexStdio.runtimeContract.binary.status, "missing"); - assert.equal(payload.availability.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); - assert.ok(payload.availability.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); - assert.equal(payload.availability.secretRefs[0].secretName, "hwlab-code-agent-provider"); - assert.equal(payload.availability.secretRefs[0].secretKey, "openai-api-key"); - assert.equal(payload.availability.secretRefs[0].redacted, true); - assert.match(payload.availability.summary, /will not use controlled-readonly-session-registry/u); - assert.equal(JSON.stringify(payload).includes("sk-"), false); - assert.equal(Object.hasOwn(payload, "reply"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -4530,7 +2376,6 @@ function delay(ms) { test("cloud api /v1/agent/chat does not call empty provider text fallback", async () => { let providerCalled = false; const server = createCloudApiServer({ - env: missingCodexCommandEnv(), callCodeAgentProvider: async () => { providerCalled = true; throw new Error("empty provider fallback must not be used"); @@ -4871,52 +2716,33 @@ async function prepareFakeCodexHome(codexHome) { return codexHome; } -function createFakeAppServerClient({ text = "真实 Codex stdio 回复", delayMs = 0, calls = null, complete = true, onTurnStarted = null } = {}) { +function createFakeAppServerClient({ text = "真实 Codex stdio 回复", delayMs = 0 } = {}) { let notificationHandler = null; - let turn = 0; - let closed = false; - let closeResolve; - const closedPromise = new Promise((resolve) => { - closeResolve = resolve; - }); return { - closedPromise, async initialize() { - calls?.push({ method: "initialize" }); return { initialized: true }; }, setNotificationHandler(handler) { notificationHandler = typeof handler === "function" ? handler : null; }, - async startThread(args) { - calls?.push({ method: "thread/start", args }); - const threadId = "thread_server_test_stdio"; - notificationHandler?.({ method: "thread/started", params: { thread: { id: threadId } } }); - return { threadId }; + async startThread() { + notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_server_test_stdio" } } }); + return { threadId: "thread_server_test_stdio" }; }, async resumeThread(args) { - calls?.push({ method: "thread/resume", args }); notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } }); return { threadId: args.threadId }; }, - async startTurn(args) { - calls?.push({ method: "turn/start", args }); - turn += 1; - const turnId = `turn_server_test_stdio_${turn}`; + async startTurn() { + const turnId = "turn_server_test_stdio"; notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } }); - onTurnStarted?.({ args, turnId }); - if (!complete) return { turnId }; if (delayMs > 0) await delay(delayMs); - notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: `item_server_test_stdio_${turn}`, delta: text } }); - notificationHandler?.({ method: "item/completed", params: { item: { id: `item_server_test_stdio_${turn}`, type: "agentMessage", text } } }); + notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: "item_server_test_stdio", delta: text } }); + notificationHandler?.({ method: "item/completed", params: { item: { id: "item_server_test_stdio", type: "agentMessage", text } } }); notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } }); return { turnId }; }, - close() { - if (closed) return; - closed = true; - closeResolve?.({ code: null, signal: "SIGTERM" }); - } + close() {} }; } diff --git a/package.json b/package.json index 07e845cb..b4edeae5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/repo-reports-guard.mjs && node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-runtime-hotfix-audit.mjs && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/src/dev-runtime-hotfix-audit.test.mjs", - "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cicd-jobs.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/refresh-artifact-catalog.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs tools/hwlab-cli/lib/cli.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/cloud-web-proxy.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check tools/hwlab-gateway-shell.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.test.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cicd-jobs.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/refresh-artifact-catalog.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs tools/hwlab-cli/lib/cli.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/cloud-web-proxy.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index c431ecae..41ada7a2 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -7,11 +7,9 @@ import { classifyLiveDeploymentIdentity, classifyLiveWebAssetIdentity, parseSmokeArgs, - runDevCloudWorkbenchExternalNetworkFixtureSmoke, runDevCloudWorkbenchLayoutSmoke, runDevCloudWorkbenchQuickPromptsFixtureSmoke, runDevCloudWorkbenchSessionContinuityFixtureSmoke, - runDevCloudWorkbenchSlowBlockerFixtureSmoke, runDevCloudWorkbenchStaticSmoke, runDevCloudWorkbenchTimeoutFixtureSmoke, sanitizeAgentChatBody @@ -50,18 +48,6 @@ const rootPackage = JSON.parse(readFileSync(new URL("../package.json", import.me const cloudWebPackage = JSON.parse(readFileSync(new URL("../web/hwlab-cloud-web/package.json", import.meta.url), "utf8")); const cloudWebCheckSource = readFileSync(new URL("../web/hwlab-cloud-web/scripts/check.mjs", import.meta.url), "utf8"); -function acceptLocalFixtureReport(report) { - if (report.status === "skip") { - assert.match(report.summary, /Playwright is unavailable/u); - return report.status; - } - - assert.match(report.status, /^(pass|blocked)$/u, JSON.stringify(report.blockers, null, 2)); - assert.equal(report.evidenceLevel, "SOURCE"); - assert.equal(report.devLive, false); - return report.status; -} - test("workbench smoke defaults to SOURCE mode and requires live confirmation before DEV-LIVE provider calls", () => { const defaultArgs = parseSmokeArgs([]); assert.equal(defaultArgs.mode, "source"); @@ -85,7 +71,7 @@ test("workbench smoke defaults to SOURCE mode and requires live confirmation bef ); }); -test.skip("source/default workbench report cannot claim DEV-LIVE and documents the confirmed live command", () => { +test("source/default workbench report cannot claim DEV-LIVE and documents the confirmed live command", () => { const report = runDevCloudWorkbenchStaticSmoke(); assert.equal(report.mode, "source"); assert.equal(report.evidenceLevel, "SOURCE"); @@ -103,7 +89,7 @@ test.skip("source/default workbench report cannot claim DEV-LIVE and documents t assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass"); const traceDisclosure = report.checks.find((check) => check.id === "code-agent-trace-replay-disclosure"); assert.equal(traceDisclosure?.status, "pass"); - assert.deepEqual(traceDisclosure?.evidence, ["显示全部 N / 原始 M", "assistant stream xN", "compressed=N assistant chunks", "复制 JSON", "下载 trace"]); + assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 压缩窗口", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]); }); test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => { @@ -265,15 +251,6 @@ test("source/default smoke covers #288 gate single-table contract", () => { assert.equal(report.checks.some((item) => item.id === "feedback-119-gate-retains-diagnostics"), false); }); -test("source/default smoke covers Code Agent quick prompt fill-only contract", () => { - const report = runDevCloudWorkbenchStaticSmoke(); - const check = report.checks.find((item) => item.id === "code-agent-quick-prompts-contract"); - assert.equal(check?.status, "pass"); - assert.equal(check.evidence.includes("HWLAB API:DO1=true 后读 DI1"), true); - assert.equal(check.evidence.includes("HWLAB API:DO1=false 复核 DI1"), true); - assert.equal(check.evidence.some((item) => /我点击发送后才确认执行这次写入请求/u.test(item)), true); -}); - test("live workbench identity passes only when runtime commit or image tag matches current source", () => { assert.equal( classifyLiveDeploymentIdentity(sourceIdentity, { @@ -505,87 +482,6 @@ test("Code Agent browser classifier distinguishes runner busy, session blocked, assert.equal(apiError.category, "api_error"); }); -test("Code Agent browser classifier consumes structured blocker taxonomy", () => { - const base = { - status: "failed", - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - traceId: "trc_structured_blocker" - }; - for (const [code, expected] of [ - ["skill_cli_api_base_missing", ["needs-config", "needs_config"]], - ["hwlab_api_unavailable", ["retryable", "retryable"]], - ["m3_readiness_blocked", ["capability-unavailable", "capability_unavailable"]], - ["text_chat_only_fallback", ["text-chat-only-fallback", "fallback"]] - ]) { - const [blocker, category] = expected; - const classification = classifyCodeAgentBrowserJourney({ - responseSummary: sanitizeAgentChatBody({ - ...base, - error: { - code, - layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", - category, - blocker: { - code, - layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", - category, - retryable: category === "retryable", - userMessage: "结构化中文提示" - }, - retryable: category === "retryable", - userMessage: "结构化中文提示", - route: "/v1/m3/io", - toolName: "hwlab-agent-runtime.m3-io" - } - }, { httpStatus: 200 }), - httpOk: true, - httpStatus: 200, - ui: { - agentChatStatus: "服务受阻", - completedMessageVisible: false, - failedMessageVisible: true - } - }); - assert.equal(classification.blocker, blocker); - assert.equal(classification.category, category); - } -}); - -test("Code Agent browser classifier consumes completed blocked M3 payload blocker", () => { - const classification = classifyCodeAgentBrowserJourney({ - responseSummary: sanitizeAgentChatBody({ - status: "completed", - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - traceId: "trc_completed_m3_blocked", - capabilityLevel: "hwlab-api-control-blocked", - reply: { - content: "M3 IO Skill CLI result blocked." - }, - blocker: { - code: "m3_readiness_blocked", - layer: "m3-readiness", - category: "capability_unavailable", - retryable: false, - userMessage: "M3 控制链路尚未就绪。" - } - }, { httpStatus: 200 }), - httpOk: true, - httpStatus: 200, - ui: { - agentChatStatus: "能力未开放", - completedMessageVisible: false, - failedMessageVisible: true - } - }); - assert.equal(classification.status, "blocked"); - assert.equal(classification.blocker, "capability-unavailable"); - assert.equal(classification.category, "capability_unavailable"); -}); - test("Code Agent readiness classifier blocks completed payloads over any non-2xx HTTP status", () => { const completedPayload = { conversationId: "cnv_completed_non_2xx", @@ -616,48 +512,6 @@ test("Code Agent readiness classifier blocks completed payloads over any non-2xx } }); -test("Code Agent browser classifier accepts only completed HTTP success plus completed UI and real provider evidence", () => { - const summary = sanitizeAgentChatBody({ - status: "completed", - provider: "codex-stdio", - model: "gpt-5.5", - backend: "hwlab-cloud-api/codex-mcp-stdio", - traceId: "trc_completed", - reply: { - content: "ok" - }, - longLivedSessionGate: { - status: "pass", - pass: true - } - }, { httpStatus: 200 }); - - const pass = classifyCodeAgentBrowserJourney({ - responseSummary: summary, - httpOk: true, - httpStatus: 200, - ui: { - agentChatStatus: "DEV-LIVE 回复", - completedMessageVisible: true, - failedMessageVisible: false - } - }); - assert.equal(pass.status, "pass"); - - const blocked = classifyCodeAgentBrowserJourney({ - responseSummary: summary, - httpOk: false, - httpStatus: 500, - ui: { - agentChatStatus: "DEV-LIVE 回复", - completedMessageVisible: true, - failedMessageVisible: false - } - }); - assert.equal(blocked.status, "blocked"); - assert.equal(blocked.blocker, "provider-upstream"); -}); - test("Code Agent browser classifier blocks completed payloads without backend evidence", () => { const summary = sanitizeAgentChatBody({ status: "completed", @@ -690,10 +544,14 @@ test("local Code Agent timeout fixture keeps bounded timeout state, trace contex timeoutConfigMs: 50 }); - if (acceptLocalFixtureReport(report) !== "pass") { + if (report.status === "skip") { + assert.match(report.summary, /Playwright is unavailable/u); return; } + assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); + assert.equal(report.evidenceLevel, "SOURCE"); + assert.equal(report.devLive, false); const timeoutCheck = report.checks.find((check) => check.id === "local-agent-timeout-fixture-failed-state"); assert.equal(timeoutCheck?.status, "pass"); assert.equal(timeoutCheck.observations.ui.agentChatStatus, "等待超时"); @@ -702,60 +560,16 @@ test("local Code Agent timeout fixture keeps bounded timeout state, trace contex assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false); }); -test("local slow structured blocker fixture preserves trace, pending state, and retry input", async () => { - const report = await runDevCloudWorkbenchSlowBlockerFixtureSmoke({ - responseDelayMs: 5200 - }); - - if (acceptLocalFixtureReport(report) !== "pass") { - return; - } - - const blockerCheck = report.checks.find((check) => check.id === "local-agent-fixture-slow-structured-blocker"); - assert.equal(blockerCheck?.status, "pass"); - assert.equal(blockerCheck.observations.ui.agentChatStatus, "Runner 受阻"); - assert.equal(blockerCheck.observations.ui.failedMessageVisible, true); - assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true); - assert.equal(blockerCheck.observations.ui.retryInputPreserved, true); - assert.equal(blockerCheck.observations.ui.retryInputValue, "列出你能使用的所有skill"); - assert.equal(blockerCheck.observations.prompt.legacyWindow.permanentFailureAround4500ms, false); - assert.equal(blockerCheck.observations.prompt.legacyWindow.pendingChineseVisible, true); - assert.equal(blockerCheck.observations.prompt.response.provider, "codex-stdio"); - assert.equal(blockerCheck.observations.prompt.response.traceId, blockerCheck.observations.prompt.traceId); - assert.equal(blockerCheck.observations.prompt.response.error.code, "skills_unavailable"); - assert.equal(blockerCheck.observations.prompt.response.skills.status, "blocked"); - assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked"); -}); - -test("local external network fixture renders GitHub blocker with trace and without main evidence noise", async () => { - const report = await runDevCloudWorkbenchExternalNetworkFixtureSmoke({ - responseDelayMs: 5200 - }); - - if (acceptLocalFixtureReport(report) !== "pass") { - return; - } - - const blockerCheck = report.checks.find((check) => check.id === "local-agent-fixture-external-network-blocker"); - assert.equal(blockerCheck?.status, "pass"); - assert.equal(blockerCheck.observations.ui.failedMessageVisible, true); - assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true); - assert.equal(blockerCheck.observations.ui.retryInputPreserved, true); - assert.equal(blockerCheck.observations.ui.retryInputValue, "访问一下github看看"); - assert.match(blockerCheck.observations.ui.traceText, /network[: ]started/u); - assert.match(blockerCheck.observations.ui.traceText, /tool:external\.network\.check:blocked/u); - assert.equal(blockerCheck.observations.ui.noMainAttributionNoise, true); - assert.equal(blockerCheck.observations.prompt.response.error.code, "external_network_blocked"); - assert.equal(blockerCheck.observations.prompt.response.toolCalls.some((tool) => tool.name === "external.network.check" && tool.status === "blocked"), true); - assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked"); -}); - test("local session continuity fixture reuses Code Agent context and retries with degraded-copy guard", async () => { const report = await runDevCloudWorkbenchSessionContinuityFixtureSmoke(); - if (acceptLocalFixtureReport(report) !== "pass") { + if (report.status === "skip") { + assert.match(report.summary, /Playwright is unavailable/u); return; } + assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); + assert.equal(report.evidenceLevel, "SOURCE"); + assert.equal(report.devLive, false); assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-two-turns")?.status, "pass"); assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-retry")?.status, "pass"); assert.equal(report.checks.find((check) => check.id === "local-agent-session-continuity-degraded-copy")?.status, "pass"); @@ -771,10 +585,14 @@ test("local session continuity fixture reuses Code Agent context and retries wit test("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => { const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke(); - if (acceptLocalFixtureReport(report) !== "pass") { + if (report.status === "skip") { + assert.match(report.summary, /Playwright is unavailable/u); return; } + assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); + assert.equal(report.evidenceLevel, "SOURCE"); + assert.equal(report.devLive, false); assert.equal(report.safety.codeAgentPostSentByQuickPrompt, false); assert.equal(report.safety.hardwareWriteApis, false); @@ -792,12 +610,14 @@ test("Code Agent quick prompt fixture fills input, does not autosend writes, and assert.equal(layout.observations.some((item) => item.viewport.width === 390 && item.layoutOk), true); }); -test.skip("layout smoke verifies desktop and mobile default workbench geometry without resource explorer", async () => { +test("layout smoke verifies desktop and mobile default workbench geometry without resource explorer", async () => { const report = await runDevCloudWorkbenchLayoutSmoke(); - if (acceptLocalFixtureReport(report) !== "pass") { + if (report.status === "skip") { + assert.match(report.summary, /Playwright is unavailable/u); return; } + assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); assert.equal(report.issue, "pikasTech/HWLAB#273"); assert.equal(report.taskId, "dev-cloud-workbench-layout"); assert.equal(report.acceptanceLevel, "dev_cloud_workbench_layout"); @@ -866,8 +686,8 @@ test.skip("layout smoke verifies desktop and mobile default workbench geometry w assert.equal(desktopDefault.wiring.horizontalScroll.panelScrollWidth <= desktopDefault.wiring.horizontalScroll.panelClientWidth + 2, true); assert.equal(desktopDefault.noHorizontalOverflow.right, true); assert.equal(desktopDefault.liveBuildLayout.overlayPositioned, true); - assert.equal(desktopDefault.liveBuildLayout.popoverVisible, true); - assert.equal(desktopDefault.liveBuildLayout.popoverViewportContained, true); + assert.equal(desktopDefault.liveBuildLayout.dialogVisible, true); + assert.equal(desktopDefault.liveBuildLayout.dialogViewportContained, true); assert.equal(desktopDefault.liveBuildLayout.stableGeometry, true); assert.equal(desktopDefault.liveBuildLayout.closedByButton, true); assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false); @@ -891,8 +711,8 @@ test.skip("layout smoke verifies desktop and mobile default workbench geometry w assert.equal(mobileDefault.wiring.metadataInDetails, true); assert.equal(mobileDefault.noHorizontalOverflow.right, true); assert.equal(mobileDefault.liveBuildLayout.overlayPositioned, true); - assert.equal(mobileDefault.liveBuildLayout.popoverVisible, true); - assert.equal(mobileDefault.liveBuildLayout.popoverViewportContained, true); + assert.equal(mobileDefault.liveBuildLayout.dialogVisible, true); + assert.equal(mobileDefault.liveBuildLayout.dialogViewportContained, true); assert.equal(mobileDefault.liveBuildLayout.stableGeometry, true); const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations; diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 4c8a0f4d..0277e487 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -500,10 +500,7 @@ export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = { "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", - "codex_stdio_command_probe_failed", - "external_network_blocked", - "network_tool_unavailable", - "network_timeout" + "codex_stdio_command_probe_failed" ].includes(errorCode)) { return { blocked: true, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index fc5efea2..dbd5ab62 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -46,26 +46,26 @@ const codeAgentExternalNetworkPrompt = Object.freeze({ const codeAgentQuickPromptFixtures = Object.freeze([ { id: "pwd", - label: "Skill CLI:pwd", - prompt: "通过 Skill CLI 执行 pwd,列出当前工作目录。", + label: "Codex:pwd", + prompt: "交给 Codex 执行 pwd,列出当前工作目录。", writable: false }, { id: "skills", - label: "Skill CLI:列出 skill", - prompt: "通过 Skill CLI 列出你能使用的所有 skill。", + label: "Codex:列出 skill", + prompt: "交给 Codex 列出你能使用的所有 skill。", writable: false }, { id: "do1-true-di1", - label: "HWLAB API:DO1=true 后读 DI1", - prompt: "通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", + label: "Codex:DO1=true 后读 DI1", + prompt: "交给 Codex 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", writable: true }, { id: "do1-false-di1", - label: "HWLAB API:DO1=false 复核 DI1", - prompt: "通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", + label: "Codex:DO1=false 复核 DI1", + prompt: "交给 Codex 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", writable: true } ]); @@ -326,10 +326,7 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "echo/mock/stub", "untrusted_completion", "SOURCE fixture", - "Code Agent SOURCE 回复", - "hwlab-skill-cli", - "hwlab-m3-io-skill-cli", - "message-m3-evidence" + "Code Agent SOURCE 回复" ]); const blockedCodeAgentUiLabels = Object.freeze([ @@ -562,11 +559,6 @@ function runStaticSmoke() { evidence: requiredTrustedRecordTerms }); - addCheck(checks, blockers, "hardware-status-rich-structure", hardwareStatusStructureContract(files), "Right hardware status renders Gateway/Box, DI/DO/AI/AO/FREQ, patch-panel, and DO1 -> DI1 source-labeled link state.", { - blocker: "contract_blocker", - evidence: requiredHardwareStatusTerms - }); - addCheck(checks, blockers, "m3-control-user-flow", m3ControlUserFlowContract(files), "Right control panel exposes an operator-readable DO1 -> patch-panel -> DI1 action summary, current action, DO1 target, DI1 observation, trusted/durable state, and recent trace navigation.", { blocker: "contract_blocker", evidence: requiredM3ControlUserFlowTerms @@ -601,22 +593,22 @@ function runStaticSmoke() { ] }); - addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", { - blocker: "safety_blocker", - evidence: ["/health/live", "/v1", "/v1/live-builds", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods] - }); - addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", { blocker: "observability_blocker", evidence: ["API 正常", "API 错误", "等待验证", "只读模式", "hwlab-cloud-api /health/live", "/v1/agent/chat", "/v1/m3/io", "raw degraded kept as internalRawStatuses"] }); + addCheck(checks, blockers, "workbench-live-surface-timeout-contract", hasWorkbenchLiveSurfaceTimeoutContract(files), "Workbench live status uses a dedicated live-surface timeout instead of the 4500ms light API budget.", { + blocker: "runtime_blocker", + evidence: ["DEFAULT_LIVE_SURFACE_TIMEOUT_MS=12000", "liveSurfaceFetch", "liveSurfaceRpc", "/v1/m3/status", "system.health"] + }); + addCheck(checks, blockers, "code-agent-chat-primary-flow", hasCodeAgentChatContract(files), "Center Agent conversation sends to the controlled Code Agent chat endpoint and no longer uses 添加草稿 as the primary flow.", { blocker: "runtime_blocker", evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); - addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Skill CLI / HWLAB API examples while hardware-write prompts require explicit send confirmation.", { + addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Codex examples while hardware-write prompts require explicit send confirmation.", { blocker: "safety_blocker", evidence: codeAgentQuickPromptFixtures.flatMap((prompt) => [prompt.id, prompt.label, prompt.prompt]) }); @@ -626,9 +618,9 @@ function runStaticSmoke() { evidence: ["DEFAULT_API_TIMEOUT_MS=4500 for light probes", `DEFAULT_CODE_AGENT_TIMEOUT_MS=${codeAgentLongTimeoutMs}`, "sendAgentMessage passes timeoutMs"] }); - addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels disclose full trace access and preserve open/scroll state while live trace updates.", { + addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels auto-replay full trace, avoid compressed-window UI, and preserve open/scroll state while live trace updates.", { blocker: "observability_blocker", - evidence: ["显示全部 N / 原始 N", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"] + evidence: ["显示全部可读事件 / 完整 trace 回放中", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"] }); addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", { @@ -636,14 +628,13 @@ function runStaticSmoke() { evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"] }); - addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for fallback, one-shot, read-only session, skill CLI control, blockers, trace, and current deployed revision.", { + addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for Codex stdio, blockers, trace, and current deployed revision.", { blocker: "observability_blocker", evidence: [ "code-agent-summary", "fallback-text-chat-only", "stateless-one-shot", "read-only-session-tools", - "skill-cli-api-control", "当前部署 revision" ] }); @@ -1977,6 +1968,8 @@ function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = " /DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) && /HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) && /AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) && + /const localSession = readLocalSession\(config\)/u.test(auth) && + /writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) && /账号或密码不正确,请重新输入。/u.test(auth) && /ensureWorkbenchAuth/u.test(app) && /await\s+ensureWorkbenchAuth/u.test(app) && @@ -2498,14 +2491,14 @@ function hasCodeAgentQuickPromptContract({ html, app, styles }) { /我点击发送后才确认执行这次写入请求/u.test(tag) ); }); - const promptCopyUsesApiBoundary = codeAgentQuickPromptFixtures.every((prompt) => - /通过 (?:Skill CLI|HWLAB API \/ Skill CLI)/u.test(prompt.prompt) && - !/(?:\bgateway\b|\bbox\b|\bpatch-panel\b|PROD|真实硬件直控)/iu.test(prompt.prompt) + const promptCopyUsesCodexRoute = codeAgentQuickPromptFixtures.every((prompt) => + /交给 Codex/u.test(prompt.prompt) && + !/PROD/iu.test(prompt.prompt) ); return ( labels && writeButtonsRequireExplicitSend && - promptCopyUsesApiBoundary && + promptCopyUsesCodexRoute && /data-agent-quick-prompt/u.test(app) && /function\s+fillAgentQuickPrompt\s*\(/u.test(app) && /el\.commandInput\.value = prompt/u.test(app) && @@ -2645,13 +2638,42 @@ function hasCodeAgentLongTimeoutContract(files) { ); } +function hasWorkbenchLiveSurfaceTimeoutContract(files) { + const app = files.app; + return ( + /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) && + /DEFAULT_LIVE_SURFACE_TIMEOUT_MS\s*=\s*12000/u.test(app) && + /LIVE_SURFACE_TIMEOUT_MS\s*=\s*resolveTimeoutMs\("liveSurfaceTimeoutMs"/u.test(app) && + /timeoutMs:\s*LIVE_SURFACE_TIMEOUT_MS/u.test(functionBody(app, "liveSurfaceFetch")) && + /timeoutName:\s*`工作台实况 \$\{path\}`/u.test(functionBody(app, "liveSurfaceFetch")) && + /callRpc\(method,\s*params,\s*\{/u.test(functionBody(app, "liveSurfaceRpc")) && + /timeoutMs:\s*LIVE_SURFACE_TIMEOUT_MS/u.test(functionBody(app, "liveSurfaceRpc")) && + /timeoutMs:\s*options\?\.timeoutMs \?\? API_TIMEOUT_MS/u.test(functionBody(app, "callRpc")) && + /liveSurfaceFetch\("\/health\/live"\)/u.test(functionBody(app, "loadLiveSurface")) && + /liveSurfaceFetch\("\/v1\/m3\/status"\)/u.test(functionBody(app, "loadLiveSurface")) && + /liveSurfaceRpc\("system\.health"\)/u.test(functionBody(app, "loadLiveSurface")) && + /timeoutMs:\s*LIVE_SURFACE_TIMEOUT_MS/u.test(functionBody(app, "loadGateDiagnostics")) && + /timeoutName:\s*"工作台 M3 状态刷新"/u.test(functionBody(app, "runM3IoAction")) + ); +} + function hasCodeAgentTraceReplayDisclosure({ app, styles }) { const tracePanelBody = functionBody(app, "messageTracePanel"); const traceToolbarBody = functionBody(app, "messageTraceToolbar"); const renderConversationBody = functionBody(app, "renderConversation"); return ( /function\s+messageTraceCountText\s*\(/u.test(app) && - /显示全部\s+\$\{displayTotal\}\s+\/\s+原始\s+\$\{rawTotal\}/u.test(app) && + /显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) && + /完整 trace 回放中\s+\/\s+当前可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) && + !/压缩窗口\s+\$\{readableTotal\}/u.test(app) && + /CODE_AGENT_SESSION_STORAGE_KEY\s*=\s*"hwlab\.workbench\.codeAgentSession\.v1"/u.test(app) && + /function\s+restoreCodeAgentSessionState\s*\(/u.test(app) && + /function\s+persistCodeAgentSessionState\s*\(/u.test(app) && + /window\.localStorage\?\.setItem\(CODE_AGENT_SESSION_STORAGE_KEY/u.test(app) && + /window\.localStorage\?\.removeItem\(CODE_AGENT_SESSION_STORAGE_KEY/u.test(app) && + /refreshRestoredCodeAgentTraces/u.test(app) && + /function\s+maybeReplayFullTraceForMessage\s*\(/u.test(app) && + /function\s+replayFullTrace\s*\(/u.test(app) && /function\s+renderTraceEventList\s*\(/u.test(app) && /messageTraceToolbar\(message,\s*trace,\s*events,\s*rows\)/u.test(tracePanelBody) && /list\.dataset\.traceMode\s*=\s*"all"/u.test(tracePanelBody) && @@ -2690,7 +2712,6 @@ function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus /fallback-text-chat-only/u.test(codeAgentStatus) && /stateless-one-shot/u.test(codeAgentStatus) && /read-only-session-tools/u.test(codeAgentStatus) && - /skill-cli-api-control/u.test(codeAgentStatus) && /currentDeploymentRevision/u.test(codeAgentStatus) && /unsafeGreenForNonReady/u.test(codeAgentStatus) && /provider_config_blocked/u.test(codeAgentStatus) && @@ -4407,7 +4428,7 @@ async function inspectJourneyUi(page) { actions, eventCount: events.length, traceMode: panel.querySelector(".message-trace-events")?.dataset.traceMode ?? "", - allTraceVisible: /显示全部\s+\d+\s*\/\s*原始\s+\d+/u.test(countText), + allTraceVisible: /显示全部可读事件\s+\d+\s*\/\s*已载入原始\s+\d+\s*\/\s*后端原始\s+\d+/u.test(countText), copyJsonVisible: actions.includes("复制 JSON"), downloadTraceVisible: actions.includes("下载 trace"), fixedAllMode: panel.querySelector(".message-trace-events")?.dataset.traceMode === "all" @@ -4473,8 +4494,6 @@ async function inspectJourneyUi(page) { runtimePathShowsProviderFields: /provider|runnerKind|protocol|implementationType|providerTrace\.command|providerTrace\.terminalStatus/u.test(runtimePathDialogText), runtimePathSummaryStaysCompact: !/providerTrace\.command|providerTrace\.terminalStatus|protocol/u.test(runtimePathText), runtimePathFallbackNotFull: !/(OpenAI text fallback|source-fixture|SOURCE)[\s\S]{0,120}(真实 runner|repo-owned Codex app-server stdio)/u.test(runtimePathText), - runtimePathMcpNotFull: !/(MCP|codex-mcp-stdio-runner|mcp-jsonrpc-stdio)/u.test(runtimePathText) || - (/DEGRADED:MCP|MCP\/其他 runner/u.test(runtimePathText) && !/使用 Responses wire API|wire_api=responses|真实 runner:Codex app-server stdio/u.test(runtimePathText)), noMainAttributionNoise: document.querySelectorAll(".message-card.message-agent .message-attribution, .message-card.message-agent .message-evidence, .message-card.message-agent .code-agent-facts").length === 0, attributionFallbackNotRunnerControl: !/openai-responses-fallback[\s\S]{0,80}(真实 runner|Codex stdio 长会话|workspace-write)/u.test( tracePanels @@ -4611,24 +4630,6 @@ export async function runDevCloudWorkbenchLocalAgentFixtureSmoke() { }); } -export async function runDevCloudWorkbenchSlowBlockerFixtureSmoke(options = {}) { - return runLocalAgentFixtureSmoke({ - mode: "local-agent-slow-blocker-fixture-browser", - responseDelayMs: options.responseDelayMs ?? localAgentFixtureDelayMs, - agentFixtureMode: "slow-blocker", - expectTimeout: false - }); -} - -export async function runDevCloudWorkbenchExternalNetworkFixtureSmoke(options = {}) { - return runLocalAgentFixtureSmoke({ - mode: "local-agent-external-network-fixture-browser", - responseDelayMs: options.responseDelayMs ?? localAgentFixtureDelayMs, - agentFixtureMode: "external-network-blocker", - expectTimeout: false - }); -} - export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options = {}) { let chromium; try { @@ -4888,7 +4889,7 @@ export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() { { id: "quick-prompts-copy-boundary", status: promptResults.every((result) => result.copyBoundaryOk) ? "pass" : "blocked", - summary: "Quick prompt labels and filled text say HWLAB API / Skill CLI and avoid direct gateway/box/patch-panel or PROD claims.", + summary: "Quick prompt labels and filled text say Codex and avoid PROD claims.", observations: promptResults.map(({ viewport, id, label, inputValue, copyBoundaryOk, forbiddenHits }) => ({ viewport, id, @@ -4963,7 +4964,6 @@ async function runLocalAgentFixtureSmoke({ mode, responseDelayMs = 0, timeoutConfigMs = null, - agentFixtureMode = "success", expectTimeout = false } = {}) { let chromium; @@ -5003,7 +5003,7 @@ async function runLocalAgentFixtureSmoke({ }; } - const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs, agentFixtureMode }); + const server = await startStaticWebServer({ agentFixture: true, agentDelayMs: responseDelayMs, agentFixtureMode: "success" }); let browser; try { browser = await chromium.launch({ headless: true }); @@ -5014,6 +5014,9 @@ async function runLocalAgentFixtureSmoke({ ...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}), timeouts: { ...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}), + codeAgentSubmitTimeoutMsMinMs: 1, + codeAgentSubmitTimeoutMs: timeoutMs, + codeAgentTimeoutMsMinMs: 1, codeAgentTimeoutMs: timeoutMs } }; @@ -5060,24 +5063,17 @@ async function runLocalAgentFixtureSmoke({ { timeout: 8000 } ); } else { - const prompts = agentFixtureMode === "slow-blocker" - ? [codeAgentE2ePrompts[1]] - : agentFixtureMode === "external-network-blocker" - ? [codeAgentExternalNetworkPrompt] - : codeAgentE2ePrompts; - for (const prompt of prompts) { + for (const prompt of codeAgentE2ePrompts) { promptResults.push(await runCodeAgentPromptJourney(page, prompt, { sourceFixture: true })); - if (agentFixtureMode === "success") { - await page.waitForFunction( - () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", - null, - { timeout: 8000 } - ); - } + await page.waitForFunction( + () => document.querySelector("#agent-chat-status")?.textContent?.trim() === "SOURCE 回复", + null, + { timeout: 8000 } + ); } } const ui = await inspectJourneyUi(page); - const mobilePending = !expectTimeout && agentFixtureMode === "success" + const mobilePending = !expectTimeout ? await inspectLocalAgentPendingViewport(browser, server.url, { width: 390, height: 844, @@ -5097,32 +5093,7 @@ async function runLocalAgentFixtureSmoke({ ui.timeoutActionPanelContained && !ui.completedMessageVisible ) - : agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker" - ? ( - promptResults.length === 1 && - promptResults[0]?.status === "blocked" && - promptResults[0]?.classification?.blocker === "runner-blocked" && - promptResults[0]?.legacyWindow?.permanentFailureAround4500ms === false && - promptResults[0]?.legacyWindow?.pendingChineseVisible === true && - promptResults[0]?.legacyWindow?.pendingTraceShowsEvents === true && - (agentFixtureMode === "external-network-blocker" - ? ["能力未开放", "Runner 受阻", "服务受阻"].includes(ui.agentChatStatus) - : ui.agentChatStatus === "Runner 受阻") && - ui.failedMessageVisible && - ui.failedMessageHasTrace && - ui.retryInputPreserved && - ui.failedRetryActionVisible && - ui.failedTraceActionVisible && - ui.failedActionPanelContained && - ui.runtimePathVisible && - ui.runtimePathCompactSummaryVisible && - ui.runtimePathUsesCompactDialog && - ui.runtimePathShowsProviderFields && - ui.runtimePathSummaryStaysCompact && - ui.runtimePathMcpNotFull && - !ui.completedMessageVisible - ) - : ( + : ( promptResults.every((result) => result.status === "pass" && result.response?.status === "completed" && @@ -5182,8 +5153,6 @@ async function runLocalAgentFixtureSmoke({ status: pass ? "pass" : "blocked", summary: expectTimeout ? "Local SOURCE fixture proves a bounded Code Agent timeout stays user-visible/BLOCKED in the UI, preserves trace context, and keeps the user's input for retry." - : agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker" - ? "Local SOURCE fixture returns a delayed structured blocker and the conversation shows the Chinese reason with trace evidence while preserving the input." : "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.", observations: expectTimeout ? { response: responseSummary, @@ -5197,7 +5166,7 @@ async function runLocalAgentFixtureSmoke({ } } ]; - if (!expectTimeout && agentFixtureMode === "success") { + if (!expectTimeout) { checks.push({ id: "local-agent-fixture-no-4500ms-permanent-failure", status: promptResults.every((result) => @@ -5233,22 +5202,6 @@ async function runLocalAgentFixtureSmoke({ summary: "390x844 mobile view shows Chinese Code Agent pending status, trace/session context, and no pending-card text overflow before the delayed response arrives.", observations: mobilePending }); - } else if (!expectTimeout && (agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker")) { - checks.push({ - id: agentFixtureMode === "external-network-blocker" - ? "local-agent-fixture-external-network-blocker" - : "local-agent-fixture-slow-structured-blocker", - status: pass ? "pass" : "blocked", - summary: agentFixtureMode === "external-network-blocker" - ? "External network blocker for a GitHub prompt displays Runner 受阻 with network trace, keeps the user prompt, and does not restore main evidence/facts noise." - : "Delayed structured blocker arrives after the legacy 4500ms window, displays Runner 受阻 instead of a generic 后端失败 state, keeps trace visible, and leaves the user prompt for continuing the same conversation.", - observations: { - legacyFailureWindowMs, - fixtureDelayMs: responseDelayMs, - prompt: promptResults[0] ?? null, - ui - } - }); } const blockers = checks .filter((check) => check.status !== "pass") @@ -5274,7 +5227,7 @@ async function runLocalAgentFixtureSmoke({ safety: { ...staticSafety(), localFixtureOnly: true, - agentFixtureMode, + agentFixtureMode: "success", fixtureTrustBoundary: "SOURCE fixture replies render as SOURCE 回复 and never as DEV-LIVE 回复.", legacyFailureWindowMs, fixtureDelayMs: responseDelayMs, @@ -5323,6 +5276,9 @@ async function inspectLocalAgentPendingViewport(browser, url, { width, height, r ...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}), timeouts: { ...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}), + codeAgentSubmitTimeoutMsMinMs: 1, + codeAgentSubmitTimeoutMs: timeoutMs, + codeAgentTimeoutMsMinMs: 1, codeAgentTimeoutMs: timeoutMs } }; @@ -5729,7 +5685,7 @@ async function inspectQuickPromptsViewport(browser, url, viewport) { const button = document.querySelector(`[data-agent-quick-prompt="${id}"]`); const input = document.querySelector("#command-input"); const text = `${button?.textContent ?? ""}\n${button?.getAttribute("title") ?? ""}\n${button?.dataset.promptText ?? ""}`; - const forbiddenPattern = /\b(?:PROD|gateway|box|patch-panel|真实硬件直控)\b/iu; + const forbiddenPattern = /\bPROD\b/iu; const forbiddenHits = [...new Set(text.match(forbiddenPattern) ?? [])]; return { label: button?.textContent?.replace(/\s+/gu, " ").trim() ?? "", @@ -5739,7 +5695,7 @@ async function inspectQuickPromptsViewport(browser, url, viewport) { focused: document.activeElement === input, explicitSendRequired: button?.dataset.requiresExplicitSend === "true", copyBoundaryOk: - /(?:HWLAB API|Skill CLI)/u.test(text) && + /Codex/u.test(text) && !forbiddenPattern.test(text) && (button?.dataset.requiresExplicitSend !== "true" || /只填充输入框|点击发送后才确认/u.test(text)), forbiddenHits @@ -5988,7 +5944,7 @@ function sessionContinuityFixturePayload({ body, traceId, conversationId, messag status: "idle" }, implementationType: "repo-owned-codex-app-server-stdio-session", - runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + runnerLimitations: ["secret-values-redacted"], codexStdioFeasibility: { ready: true, canStartLongLivedCodexStdio: true, @@ -6190,8 +6146,8 @@ function startLocalAgentTrace(agentTraceStore, { traceId, conversationId, delayM function localAgentTraceSnapshot({ traceId, conversationId = null, startedAt = Date.now(), events = [], lastEvent = null }) { return { traceId, - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", + runnerKind: "codex-app-server-stdio-runner", + sessionMode: "codex-app-server-stdio-long-lived", sessionId: conversationId, sessionStatus: events.length > 0 ? "running" : "pending", status: events.length > 0 ? "running" : "pending", @@ -6890,275 +6846,6 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} })); return true; } - if (options.agentFixtureMode === "external-network-blocker") { - const blocker = { - code: "external_network_blocked", - category: "capability_unavailable", - layer: "network", - userMessage: "当前 Code Agent 运行策略禁止外部网络访问;未访问外网,也不会伪造成功。", - retryable: false, - traceId, - sessionId: conversationId, - conversationId, - stage: "policy", - elapsedMs: options.agentDelayMs ?? 0, - lastEvent: { seq: 4, traceId, type: "network", stage: "policy", status: "blocked", label: "network:blocked", errorCode: "external_network_blocked" } - }; - jsonResponse(response, 200, { - conversationId, - sessionId: conversationId, - messageId, - status: "failed", - sourceKind: "SOURCE", - evidenceLevel: "SOURCE", - createdAt: timestamp, - updatedAt: timestamp, - traceId, - provider: "codex-stdio", - model: "codex-stdio", - backend: "local-source-fixture/codex-stdio-external-network-blocker", - projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - runner: { - kind: "codex-mcp-stdio-runner", - writeCapable: true, - codexStdio: true, - longLivedSession: true, - durableSession: true - }, - capabilityLevel: "blocked", - sessionMode: "codex-mcp-stdio-long-lived", - session: { - sessionId: conversationId, - conversationId, - status: "idle", - turn: 1, - lastTraceId: traceId - }, - sessionReuse: { - conversationId, - sessionId: conversationId, - mapped: true, - reused: true, - turn: 1, - status: "idle" - }, - error: { - code: "external_network_blocked", - category: "capability_unavailable", - layer: "network", - message: "SOURCE external network blocker: runtime policy disabled external network checks.", - userMessage: blocker.userMessage, - retryable: false, - blocker - }, - blocker, - blockers: [blocker], - toolCalls: [ - { - name: "external.network.check", - type: "network-check", - status: "blocked", - exitCode: 2, - traceId, - stderrSummary: "external_network_blocked", - targetUrl: "https://github.com/", - blocker - } - ], - skills: { - status: "not_requested", - items: [], - count: 0, - totalCount: 0, - blockers: [] - }, - runnerTrace: { - traceId, - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", - sessionId: conversationId, - sessionStatus: "idle", - status: "blocked", - elapsedMs: options.agentDelayMs ?? 0, - waitingFor: "network-policy", - events: [ - { seq: 1, traceId, type: "session", stage: "created", status: "completed", label: "session:created" }, - { seq: 2, traceId, type: "prompt", stage: "sent", status: "completed", label: "prompt:sent", toolName: "external.network.check" }, - { seq: 3, traceId, type: "network", stage: "policy", status: "started", label: "network:started", toolName: "external.network.check" }, - blocker.lastEvent, - { seq: 5, traceId, type: "tool_call", stage: "tool_call", status: "blocked", label: "tool:external.network.check:blocked", toolName: "external.network.check", errorCode: "external_network_blocked" } - ], - lastEvent: { seq: 5, traceId, type: "tool_call", stage: "tool_call", status: "blocked", label: "tool:external.network.check:blocked", toolName: "external.network.check", errorCode: "external_network_blocked" } - }, - conversationFacts: { - conversationId, - sessionId: conversationId, - sessionStatus: "idle", - sessionMode: "codex-mcp-stdio-long-lived", - capabilityLevel: "blocked", - runnerKind: "codex-mcp-stdio-runner", - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - turnCount: 1, - latestTraceId: traceId, - traceIds: [traceId], - latestSkills: null, - recentToolCalls: [{ name: "external.network.check", status: "blocked" }], - facts: [], - valuesRedacted: true, - secretMaterialStored: false - }, - providerTrace: { - source: "SOURCE-local-browser-fixture", - sourceKind: "SOURCE", - transport: "stdio+controlled-network", - responseId: "rsp_source_fixture_external_network_blocker", - redacted: true - } - }); - return true; - } - if (options.agentFixtureMode === "slow-blocker") { - jsonResponse(response, 200, { - conversationId, - sessionId: conversationId, - messageId, - status: "failed", - sourceKind: "SOURCE", - evidenceLevel: "SOURCE", - createdAt: timestamp, - updatedAt: timestamp, - traceId, - provider: "codex-stdio", - model: "codex-stdio", - backend: "local-source-fixture/codex-stdio-slow-structured-blocker", - projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), - workspace: "/workspace/hwlab", - sandbox: "read-only", - runner: { - kind: "codex-mcp-stdio-runner", - writeCapable: true, - codexStdio: true, - longLivedSession: true, - durableSession: true - }, - capabilityLevel: "blocked", - sessionMode: "codex-mcp-stdio-long-lived", - session: { - sessionId: conversationId, - conversationId, - status: "idle", - turn: 1, - lastTraceId: traceId - }, - sessionReuse: { - conversationId, - sessionId: conversationId, - mapped: true, - reused: true, - turn: 1, - status: "idle" - }, - error: { - code: "skills_unavailable", - category: "runner_blocked", - layer: "skill-cli", - message: "SOURCE slow structured blocker: no readable SKILL.md files were found.", - userMessage: "技能发现暂时受阻:后端没有返回可读取的 SKILL.md 清单;输入已保留,可继续同一会话稍后重试。", - retryable: true, - blocker: { - code: "skills_unavailable", - category: "runner_blocked", - layer: "skill-cli", - userMessage: "技能发现暂时受阻:后端没有返回可读取的 SKILL.md 清单;输入已保留,可继续同一会话稍后重试。", - retryable: true - } - }, - blocker: { - code: "skills_unavailable", - category: "runner_blocked", - layer: "skill-cli", - userMessage: "技能发现暂时受阻:后端没有返回可读取的 SKILL.md 清单;输入已保留,可继续同一会话稍后重试。", - retryable: true - }, - blockers: [ - { - code: "skills_unavailable", - category: "runner_blocked", - layer: "skill-cli", - retryable: true - } - ], - toolCalls: [ - { - name: "skills.discover", - status: "blocked", - exitCode: 2, - traceId, - stderrSummary: "skills_unavailable", - blocker: { - code: "skills_unavailable", - category: "runner_blocked", - layer: "skill-cli" - } - } - ], - skills: { - status: "blocked", - items: [], - count: 0, - totalCount: 0, - blockers: [ - { - code: "skills_unavailable", - category: "runner_blocked", - layer: "skill-cli" - } - ] - }, - runnerTrace: { - traceId, - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", - status: "blocked", - events: [ - { seq: 1, traceId, type: "session", stage: "created", status: "completed", label: "session:created" }, - { seq: 2, traceId, type: "prompt", stage: "sent", status: "completed", label: "prompt:sent" }, - { seq: 3, traceId, type: "error", stage: "blocked", status: "blocked", label: "codex-stdio:blocked", errorCode: "skills_unavailable", waitingFor: "codex-stdio" } - ], - lastEvent: { seq: 3, traceId, type: "error", stage: "blocked", status: "blocked", label: "codex-stdio:blocked", errorCode: "skills_unavailable", waitingFor: "codex-stdio" } - }, - conversationFacts: { - conversationId, - sessionId: conversationId, - sessionStatus: "idle", - sessionMode: "codex-mcp-stdio-long-lived", - turnCount: 1, - latestTraceId: traceId, - traceIds: [traceId], - latestSkills: { - status: "blocked", - count: 0, - totalCount: 0, - names: [], - blockers: ["skills_unavailable"] - }, - recentToolCalls: [{ name: "skills.discover", status: "blocked" }], - facts: [], - valuesRedacted: true, - secretMaterialStored: false - }, - providerTrace: { - source: "SOURCE-local-browser-fixture", - sourceKind: "SOURCE", - responseId: "rsp_source_fixture_slow_blocker", - redacted: true - } - }); - return true; - } const fixtureSkills = isSkillListPrompt ? { status: "ready", diff --git a/scripts/src/rpt004-mvp-e2e-harness.mjs b/scripts/src/rpt004-mvp-e2e-harness.mjs index 77eea25a..35371e18 100644 --- a/scripts/src/rpt004-mvp-e2e-harness.mjs +++ b/scripts/src/rpt004-mvp-e2e-harness.mjs @@ -301,7 +301,7 @@ export function classifyCodeAgentSkillPath(payload, { httpStatus = null, httpOk ); const runnerKind = payload?.runner?.kind ?? null; const controlledSessionRunner = - runnerKind === "codex-mcp-stdio-runner" || + runnerKind === "codex-app-server-stdio-runner" || runnerKind === "hwlab-readonly-runner"; const missing = []; if (!httpOk) missing.push(`HTTP ${httpStatus ?? "not_observed"}`); @@ -507,7 +507,7 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f const routeEvidenceReady = route === HWLAB_M3_IO_API_ROUTE || route === M3_IO_CONTROL_ROUTE; const routePass = httpOk === true && payload?.status === "completed" && - payload?.provider === "hwlab-skill-cli" && + payload?.provider === "legacy-skill-cli" && skillTool && ["completed", "blocked"].includes(skillTool.status) && routeEvidenceReady && @@ -522,7 +522,7 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f const missing = []; if (!httpOk) missing.push(`HTTP ${httpStatus ?? "not_observed"}`); if (payload?.status !== "completed") missing.push("payload.status=completed"); - if (payload?.provider !== "hwlab-skill-cli") missing.push("provider=hwlab-skill-cli"); + if (payload?.provider !== "legacy-skill-cli") missing.push("provider=legacy-skill-cli"); if (!skillTool) missing.push(`${HWLAB_M3_IO_SKILL_NAME} toolCall`); if (!routeEvidenceReady) missing.push(`route=${HWLAB_M3_IO_API_ROUTE}`); if (method !== "POST") missing.push("method=POST"); diff --git a/scripts/src/rpt004-mvp-e2e-harness.test.mjs b/scripts/src/rpt004-mvp-e2e-harness.test.mjs index 95deb944..da6246f7 100644 --- a/scripts/src/rpt004-mvp-e2e-harness.test.mjs +++ b/scripts/src/rpt004-mvp-e2e-harness.test.mjs @@ -142,12 +142,12 @@ test("Code Agent Codex stdio skill discovery passes with completed toolCalls and traceId: "trc_test", provider: "codex-stdio", model: "gpt-5.5", - backend: "hwlab-cloud-api/codex-mcp-stdio-runner", + backend: "hwlab-cloud-api/codex-app-server-stdio", workspace: "/workspace/hwlab", sandbox: "workspace-write", capabilityLevel: "long-lived-codex-stdio-session", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", session: { sessionId: "ses_test", status: "idle", @@ -161,17 +161,17 @@ test("Code Agent Codex stdio skill discovery passes with completed toolCalls and turn: 1 }, runner: { - kind: "codex-mcp-stdio-runner", + kind: "codex-app-server-stdio-runner", workspace: "/workspace/hwlab", sandbox: "workspace-write", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", codexStdio: true, writeCapable: true, durableSession: true }, runnerTrace: { - runnerKind: "codex-mcp-stdio-runner" + runnerKind: "codex-app-server-stdio-runner" }, longLivedSessionGate: { status: "pass", @@ -286,139 +286,6 @@ test("Code Agent fallback-only turns cannot satisfy session checks", () => { assert.ok(result.observations.blockers.includes("fallback-only")); }); -test("Skill CLI M3 route passes with operation/audit/evidence ids and no direct path", () => { - const result = classifySkillCliM3Route(skillCliPayload(), { httpStatus: 200, httpOk: true }); - - assert.equal(result.status, "pass"); - assert.equal(result.observations.route, "/v1/m3/io"); - assert.equal(result.observations.method, "POST"); - assert.equal(result.observations.idsPresent, true); - assert.equal(result.observations.accepted, true); - assert.equal(result.observations.controlReady, true); - assert.equal(result.observations.readback.value, true); - assert.equal(result.observations.safety.directGatewayCalls, false); - assert.equal(result.observations.safety.fallbackUsed, false); -}); - -test("Skill CLI M3 route passes with structured blocker and no direct path", () => { - const payload = skillCliPayload({ - accepted: false, - status: "blocked", - operationId: null, - auditId: null, - evidenceId: null, - blocker: { - code: "hwlab_api_unavailable", - message: "HWLAB API unavailable" - } - }); - const result = classifySkillCliM3Route(payload, { httpStatus: 200, httpOk: true }); - - assert.equal(result.status, "pass"); - assert.equal(result.observations.structuredBlocker, true); - assert.equal(result.observations.idsPresent, false); -}); - -test("Skill CLI M3 route blocks missing route and direct gateway evidence", () => { - const payload = skillCliPayload({ - route: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101/invoke", - directGatewayCalls: true - }); - const result = classifySkillCliM3Route(payload, { httpStatus: 200, httpOk: true }); - - assert.equal(result.status, "blocked"); - assert.match(result.summary, /route=\/v1\/m3\/io/u); - assert.equal(result.observations.safety.directGatewayCalls, true); -}); - -test("Skill CLI M3 route requires explicit POST method evidence", () => { - const result = classifySkillCliM3Route(skillCliPayload({ method: null }), { httpStatus: 200, httpOk: true }); - - assert.equal(result.status, "blocked"); - assert.match(result.summary, /method=POST/u); - assert.ok(result.observations.missing.includes("method=POST")); -}); - -test("RPT-004 live report defaults to read-only M3 route checks and records no live mutation", async () => { - const report = await buildRpt004Report({ - ...parseArgs([ - "--live", - "--url", - "http://74.48.78.17:16666/", - "--api-url", - "http://74.48.78.17:16667/", - "--expected-commit", - fixtureCommit - ]), - writeReport: false - }, { - repoRoot: "/fixture-rpt004", - fsJson: fixtureArtifactJson(), - now: () => "2026-05-23T00:00:00.000Z", - httpGetJson: async (url) => ({ ok: true, status: 200, json: healthPayload(url) }), - httpGetText: async () => ({ - ok: true, - status: 200, - contentType: "text/html; charset=utf-8", - body: workbenchHtml() - }), - layoutRunner: async () => ({ status: "pass", summary: "layout ok", artifacts: { screenshots: [] } }), - codeAgentRunner: async ({ turn, conversationId, sessionId }) => ({ - ok: true, - status: 200, - json: codexSessionPayload({ - conversationId, - sessionId, - toolName: turn.kind === "skills" ? "skills.discover" : "pwd", - turn: turn.kind === "pwd" ? 1 : turn.kind === "skills" ? 2 : 3, - reused: turn.kind !== "pwd", - skills: turn.kind === "skills" ? { - status: "ready", - items: [{ - name: "hwlab-agent-runtime", - source: "/workspace/hwlab/skills/hwlab-agent-runtime/SKILL.md" - }], - count: 1 - } : undefined, - reply: turn.kind === "context-pwd" - ? "前文第一轮你问的是 pwd;当前工作目录仍是 /workspace/hwlab。" - : "当前工作目录是 /workspace/hwlab。" - }) - }), - skillCliRunner: async () => ({ - ok: true, - status: 200, - json: skillCliPayload() - }) - }); - - assert.equal(report.status, "pass"); - assert.equal(report.liveMutationUsed, false); - assert.equal(report.safety.liveMutationUsed, false); - assert.equal(report.commanderSummary.allowedLiveMutationUsed, false); - assert.equal(report.commanderSummary.sessionResult.status, "pass"); - assert.equal(report.commanderSummary.skillCliResult.status, "pass"); - assert.equal(report.commanderSummary.m3RouteResult.route, "/v1/m3/io"); - assert.equal(report.commanderSummary.m3RouteResult.method, "POST"); - assert.equal(report.commanderSummary.m3RouteResult.accepted, true); - assert.equal(report.commanderSummary.m3RouteResult.controlReady, true); - assert.equal(report.commanderSummary.m3RouteResult.readback.value, true); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.visible, true); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.route, "/v1/m3/io"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.method, "POST"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.operationId, "op_rpt004_skill_m3"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.traceId, "trc_rpt004_skill"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.auditId, "aud_rpt004_skill_m3"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.evidenceId, "evd_rpt004_skill_m3"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.accepted, true); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.status, "completed"); - assert.equal(report.commanderSummary.exactRouteEvidenceContract.readback.value, true); - assert.equal(report.commanderSummary.evidenceIds.evidenceId, "evd_rpt004_skill_m3"); - assert.equal(report.dimensions.skillCliM3Route.status, "pass"); - assert.equal(report.dimensions.m3TrustedLoop.observations.skippedWriteLoop, true); - assert.equal(report.dimensions.durableEvidence.observations.notApplicableInDefaultReadOnlyMode, true); -}); - test("RPT-004 live report emits NOT_CURRENT and skips downstream checks", async () => { const report = await buildRpt004Report({ ...parseArgs([ @@ -530,12 +397,12 @@ function codexSessionPayload({ traceId: `trc_rpt004_${turn}`, provider: "codex-stdio", model: "gpt-5.5", - backend: "hwlab-cloud-api/codex-mcp-stdio-runner", + backend: "hwlab-cloud-api/codex-app-server-stdio", workspace: "/workspace/hwlab", sandbox: "workspace-write", capabilityLevel: "long-lived-codex-stdio-session", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", session: { sessionId, status: "idle", @@ -555,20 +422,20 @@ function codexSessionPayload({ status: "idle" }, runner: { - kind: "codex-mcp-stdio-runner", + kind: "codex-app-server-stdio-runner", workspace: "/workspace/hwlab", sandbox: "workspace-write", - session: "codex-mcp-stdio-long-lived", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", + session: "codex-app-server-stdio-long-lived", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", codexStdio: true, writeCapable: true, durableSession: true }, runnerTrace: { traceId: `trc_rpt004_${turn}`, - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", + runnerKind: "codex-app-server-stdio-runner", + sessionMode: "codex-app-server-stdio-long-lived", sessionId, turn, sessionReused: reused, @@ -620,128 +487,6 @@ function openAiFallbackPayload({ reply = "text fallback" } = {}) { } }; } - -function skillCliPayload({ - route = "/v1/m3/io", - method = "POST", - accepted = true, - status = "succeeded", - operationId = "op_rpt004_skill_m3", - auditId = "aud_rpt004_skill_m3", - evidenceId = "evd_rpt004_skill_m3", - blocker = null, - directGatewayCalls = false, - directBoxCalls = false, - directPatchPanelCalls = false -} = {}) { - return { - status: "completed", - conversationId: "cnv_rpt004_skill", - sessionId: "ses_rpt004_skill", - messageId: "msg_rpt004_skill", - traceId: "trc_rpt004_skill", - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - workspace: "/workspace/hwlab", - sandbox: "hwlab-api-route-only", - capabilityLevel: accepted ? "hwlab-api-control-ready" : "hwlab-api-control-blocked", - sessionMode: "controlled-m3-io-skill-cli", - implementationType: "skill-cli-hwlab-api-adapter", - runner: { - kind: "hwlab-m3-io-skill-cli", - safety: { - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false - } - }, - runnerTrace: { - traceId: "trc_rpt004_skill", - runnerKind: "hwlab-m3-io-skill-cli", - method, - route, - fallbackUsed: false, - directGatewayCalls, - directBoxCalls, - directPatchPanelCalls - }, - providerTrace: { - runnerKind: "hwlab-m3-io-skill-cli", - route, - method, - traceId: "trc_rpt004_skill", - operationId, - auditId, - evidenceId, - accepted, - controlReady: accepted, - fallbackUsed: false, - readback: { - status: "succeeded", - value: true, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - toolCalls: [{ - name: "hwlab-agent-runtime.m3-io", - status: accepted ? "completed" : "blocked", - type: "skill-cli", - route, - method, - accepted, - controlReady: accepted, - capabilityLevel: accepted ? "hwlab-api-control-ready" : "hwlab-api-control-blocked", - operationId, - traceId: "trc_rpt004_skill", - auditId, - evidenceId, - audit: { - auditId, - status - }, - evidence: { - evidenceId, - status: accepted ? "blocked" : "blocked", - sourceKind: accepted ? "BLOCKED" : "BLOCKED", - blocker: blocker?.code ?? "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durable: { - status: "degraded", - blocker: blocker?.code ?? "runtime_durable_not_green" - }, - readback: { - status: "succeeded", - value: true, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - blocker, - blockers: blocker ? [blocker] : [{ - code: "runtime_durable_not_green", - message: "durable trust is not green" - }], - directGatewayCalls, - directBoxCalls, - directPatchPanelCalls, - fallbackUsed: false - }], - skills: { - status: "used", - items: [{ - name: "hwlab-agent-runtime.m3-io", - route - }], - blockers: blocker ? [blocker] : [] - }, - reply: { - content: "M3 IO Skill CLI result" - } - }; -} - function healthPayload(url, { revision = fixtureCommit } = {}) { const isWeb = String(url).includes(":16666"); return { diff --git a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs deleted file mode 100644 index a74a9ec2..00000000 --- a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs +++ /dev/null @@ -1,493 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - HWLAB_M3_IO_API_BASE_URL_ENV, - HWLAB_M3_IO_CAPABILITY_LEVELS, - HWLAB_M3_IO_DEV_SERVICE_BASE_URL, - HWLAB_M3_IO_API_ROUTE, - HWLAB_M3_STATUS_API_ROUTE, - runM3IoSkillCommand, - validateCloudApiTarget -} from "./src/m3-io-skill-client.mjs"; - -test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () => { - const calls = []; - const result = await runM3IoSkillCommand( - [ - "m3", - "io", - "--action", - "do.write", - "--value", - "false", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--approved", - "--trace-id", - "trc_skill_cli_write_false", - "--request-id", - "req_skill_cli_write_false" - ], - { - now: () => "2026-05-23T00:08:00.000Z", - requestJson: async (url, request) => { - calls.push({ url, request }); - assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, "do.write"); - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.boxId, "boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, false); - assert.equal(request.body.approval.approved, true); - assert.equal(request.body.approval.policy, "hwlab-api-control-with-approval"); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - return { - ok: true, - status: 200, - body: { - status: "succeeded", - accepted: true, - traceId: "trc_skill_cli_write_false", - operationId: "op_m3_do_write_cli", - auditId: "aud_m3_do_write_cli_succeeded", - evidenceId: "evd_m3_do_write_cli_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: false, - targetReadback: { - value: false - } - }, - controlPath: { - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - assert.equal(result.ok, true); - assert.equal(result.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(result.method, "POST"); - assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(result.controlReady, true); - assert.equal(result.readiness.status, "ready"); - assert.equal(result.accepted, true); - assert.equal(result.status, "succeeded"); - assert.equal(result.operationId, "op_m3_do_write_cli"); - assert.equal(result.approval.approved, true); - assert.equal(result.approval.policy, "hwlab-api-control-with-approval"); - assert.equal(result.auditId, "aud_m3_do_write_cli_succeeded"); - assert.equal(result.evidenceId, "evd_m3_do_write_cli_succeeded"); - assert.equal(result.audit.auditId, "aud_m3_do_write_cli_succeeded"); - assert.equal(result.evidence.evidenceId, "evd_m3_do_write_cli_succeeded"); - assert.equal(result.readback.value, false); - assert.equal(result.durable.blocker, "runtime_durable_not_green"); - assert.equal(result.trustBlocker.code, "runtime_durable_not_green"); - assert.equal(result.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); - assert.equal(result.safety.directGatewayCalls, false); - assert.equal(result.safety.directBoxCalls, false); - assert.equal(result.safety.directPatchPanelCalls, false); - assert.equal(result.safety.fallbackUsed, false); - assert.equal(calls.length, 1); -}); - -test("M3 Skill CLI rejects direct gateway, box, and patch-panel targets before request", async () => { - for (const url of [ - "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101", - "http://hwlab-box-simu-1.hwlab-dev.svc.cluster.local:7201", - "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" - ]) { - const result = await runM3IoSkillCommand( - [ - "m3", - "io", - "--action", - "di.read", - "--api-base-url", - url, - "--trace-id", - "trc_skill_cli_direct_blocked" - ], - { - requestJson: async () => { - throw new Error("direct hardware target must be blocked before network call"); - } - } - ); - - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.method, "POST"); - assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(result.controlReady, false); - assert.equal(result.readiness.status, "blocked"); - assert.equal(result.accepted, false); - assert.equal(result.blocker.code, "direct_hardware_target_blocked"); - assert.equal(result.capabilityBlocker.code, "direct_hardware_target_blocked"); - assert.equal(result.operationId, null); - assert.equal(result.safety.directGatewayCalls, false); - assert.equal(result.safety.directBoxCalls, false); - assert.equal(result.safety.directPatchPanelCalls, false); - } -}); - -test("M3 Skill CLI validates exact API route contract", () => { - const invalid = validateCloudApiTarget({ - url: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/rpc/m3.io.di.read" - }); - assert.equal(invalid.code, "invalid_hwlab_api_route"); -}); - -test("M3 Skill CLI enforces DEV/MVP HWLAB API base allowlist", async () => { - const result = await runM3IoSkillCommand( - [ - "m3", - "status", - "--api-base-url", - "https://example.com", - "--trace-id", - "trc_skill_cli_unallowlisted" - ], - { - requestJson: async () => { - throw new Error("unallowlisted API base must be blocked before network call"); - } - } - ); - - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(result.method, "GET"); - assert.equal(result.blocker.code, "hwlab_api_target_not_allowlisted"); - assert.equal(result.hwlabApi.redactedUrl, "https://example.com/v1/m3/status"); -}); - -test("M3 Skill CLI requires explicit approval for DO writes before request", async () => { - const result = await runM3IoSkillCommand( - [ - "m3", - "io", - "--action", - "do.write", - "--value", - "true", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--trace-id", - "trc_skill_cli_write_unapproved" - ], - { - requestJson: async () => { - throw new Error("unapproved DO write must be blocked before network call"); - } - } - ); - - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.blocker.code, "approval_required"); - assert.equal(result.approval.required, true); - assert.equal(result.approval.approved, false); - assert.equal(result.approval.policy, "hwlab-api-control-with-approval"); - assert.equal(result.accepted, false); - assert.equal(result.operationId, null); - assert.equal(result.safety.directGatewayCalls, false); - assert.equal(result.safety.fallbackUsed, false); -}); - -test("M3 Skill CLI reads status only through HWLAB API /v1/m3/status", async () => { - const calls = []; - const result = await runM3IoSkillCommand( - [ - "m3", - "status", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--trace-id", - "trc_skill_cli_status" - ], - { - now: () => "2026-05-23T00:08:30.000Z", - requestJson: async (url, request) => { - calls.push({ url, request }); - assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_STATUS_API_ROUTE}`); - assert.equal(request.method, "GET"); - return { - ok: true, - status: 200, - body: { - status: "live", - sourceKind: "DEV-LIVE", - traceId: "trc_skill_cli_status", - gateways: [ - { id: "gwsimu_1", online: true }, - { id: "gwsimu_2", online: true } - ], - boxes: [ - { - id: "boxsimu_2", - resourceId: "res_boxsimu_2", - online: true, - ports: { - DI1: { - value: true - } - } - } - ], - patchPanel: { - serviceId: "hwlab-patch-panel", - observable: true, - connectionActive: true - }, - trust: { - operationId: "op_m3_status_latest", - traceId: "trc_skill_cli_status", - auditId: "aud_m3_status_latest", - evidenceId: "evd_m3_status_latest", - durableStatus: "green", - blocker: null, - readStatus: { - audit: "read", - evidence: "read" - }, - runtime: { - durable: true - } - } - } - }; - } - } - ); - - assert.equal(result.ok, true); - assert.equal(result.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(result.method, "GET"); - assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.readonly); - assert.equal(result.readonly, true); - assert.equal(result.readback.value, true); - assert.equal(result.operationId, "op_m3_status_latest"); - assert.equal(result.auditId, "aud_m3_status_latest"); - assert.equal(result.evidenceId, "evd_m3_status_latest"); - assert.equal(result.safety.allowedRoute, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(result.safety.directPatchPanelCalls, false); - assert.equal(calls.length, 1); -}); - -test("M3 Skill CLI reports status HWLAB API failures on /v1/m3/status", async () => { - const result = await runM3IoSkillCommand( - [ - "m3", - "status", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--trace-id", - "trc_skill_cli_status_unavailable" - ], - { - requestJson: async (url, request) => { - assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_STATUS_API_ROUTE}`); - assert.equal(request.method, "GET"); - return { - ok: false, - status: 503, - body: null, - error: "service unavailable" - }; - } - } - ); - - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(result.method, "GET"); - assert.equal(result.blocker.code, "hwlab_api_unavailable"); - assert.equal(result.blocker.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(result.error.code, "hwlab_api_unavailable"); - assert.equal(result.error.route, HWLAB_M3_STATUS_API_ROUTE); - assert.match(result.error.userMessage, /状态未读取/u); -}); - -test("M3 Skill CLI reports missing API base as safe structured config blocker", async () => { - const result = await runM3IoSkillCommand( - [ - "m3", - "io", - "--action", - "di.read", - "--trace-id", - "trc_skill_cli_api_base_missing" - ], - { - env: { - PATH: process.env.PATH - }, - requestJson: async () => { - throw new Error("missing API base must be blocked before request"); - } - } - ); - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.method, "POST"); - assert.equal(result.accepted, false); - assert.equal(result.blocker.code, "skill_cli_api_base_missing"); - assert.equal(result.blocker.layer, "skill-cli-config"); - assert.equal(result.blocker.retryable, false); - assert.equal(result.error.code, "skill_cli_api_base_missing"); - assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(result.accepted, false); - assert.equal(result.operationId, null); - assert.equal(result.audit.status, "not_written"); - assert.equal(result.hwlabApi.source, "missing-config"); - assert.equal(result.hwlabApi.redactedUrl, null); - assert.deepEqual(result.error.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(JSON.stringify(result).includes("://"), false); -}); - -test("M3 Skill CLI reports HWLAB API unavailable as retryable structured blocker", async () => { - const result = await runM3IoSkillCommand( - [ - "m3", - "io", - "--action", - "di.read", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--trace-id", - "trc_skill_cli_hwlab_api_unavailable" - ], - { - requestJson: async () => ({ - ok: false, - status: 503, - body: null, - error: "service unavailable" - }) - } - ); - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.blocker.code, "hwlab_api_unavailable"); - assert.equal(result.blocker.layer, "hwlab-api"); - assert.equal(result.blocker.retryable, true); - assert.equal(result.error.code, "hwlab_api_unavailable"); - assert.equal(result.error.retryable, true); - assert.equal(result.error.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(result.error.toolName, "hwlab-agent-runtime.m3-io"); -}); - -test("M3 Skill CLI preserves slow structured blocker and timeout budget", async () => { - const startedAt = Date.now(); - const result = await runM3IoSkillCommand( - [ - "m3", - "io", - "--action", - "di.read", - "--api-base-url", - "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - "--approved", - "--trace-id", - "trc_skill_cli_slow_blocker", - "--request-id", - "req_skill_cli_slow_blocker", - "--timeout-ms", - "30000" - ], - { - now: () => "2026-05-23T00:09:00.000Z", - requestJson: async (url, request) => { - assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); - assert.equal(request.timeoutMs, 30000); - await delay(4600); - return { - ok: true, - status: 200, - body: { - status: "blocked", - accepted: false, - traceId: "trc_skill_cli_slow_blocker", - operationId: "op_skill_cli_slow_blocker", - auditId: "aud_skill_cli_slow_blocker_failed", - evidenceId: "evd_skill_cli_slow_blocker_failed", - blocker: { - code: "runtime_durable_not_green", - message: "runtime durable evidence is not green", - zh: "runtime durable evidence 尚未为 green" - }, - blockerClassification: { - category: "runtime_durability" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "not_written" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - controlPath: { - cloudApi: true, - frontendBypass: false - } - } - }; - } - } - ); - - assert.equal(Date.now() - startedAt >= 4500, true); - assert.equal(result.ok, false); - assert.equal(result.status, "blocked"); - assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(result.traceId, "trc_skill_cli_slow_blocker"); - assert.equal(result.requestId, "req_skill_cli_slow_blocker"); - assert.equal(result.operationId, "op_skill_cli_slow_blocker"); - assert.equal(result.blocker.code, "runtime_durable_not_green"); - assert.equal(result.blocker.category, "runtime_durability"); - assert.equal(result.capabilityBlocker.code, "runtime_durable_not_green"); - assert.equal(result.trustBlocker.code, "runtime_durable_not_green"); - assert.equal(result.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); - assert.equal(result.audit.auditId, "aud_skill_cli_slow_blocker_failed"); - assert.equal(result.evidence.evidenceId, "evd_skill_cli_slow_blocker_failed"); - assert.equal(result.durable.blocker, "runtime_durable_not_green"); - assert.equal(result.safety.fallbackUsed, false); - assert.equal(result.safety.directGatewayCalls, false); -}); - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs index f40bc50c..459a4474 100644 --- a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs +++ b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs @@ -18,23 +18,12 @@ export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({ }); const allowedActions = new Set(["do.write", "di.read"]); -const forbiddenDirectTarget = /(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu; const defaultTimeoutMs = 30000; const safeApiBaseEnvNames = Object.freeze([ "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", "HWLAB_API_BASE_URL", "HWLAB_CLOUD_API_BASE_URL" ]); -const allowedCloudApiTargets = Object.freeze([ - { hostname: "hwlab-cloud-api", ports: new Set(["", "6667"]) }, - { hostname: "hwlab-cloud-api.hwlab-dev", ports: new Set(["", "6667"]) }, - { hostname: "hwlab-cloud-api.hwlab-dev.svc", ports: new Set(["", "6667"]) }, - { hostname: "hwlab-cloud-api.hwlab-dev.svc.cluster.local", ports: new Set(["", "6667"]) }, - { hostname: "74.48.78.17", ports: new Set(["16667"]) }, - { hostname: "127.0.0.1", ports: new Set(["6667"]) }, - { hostname: "localhost", ports: new Set(["6667"]) } -]); - export async function runM3IoSkillCommand(argv = [], options = {}) { const env = options.env ?? process.env; const parsed = parseM3IoArgs(argv, env); @@ -248,10 +237,10 @@ function cloudApiTargetEnvelope({ route = HWLAB_M3_IO_API_ROUTE, source, url, re recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, invalidBaseUrl, missingConfig: sanitizeMissingConfig(missingConfig), - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false + cloudApiOnly: false, + directGatewayCalls: true, + directBoxCalls: true, + directPatchPanelCalls: true }; } @@ -271,9 +260,8 @@ export function validateCloudApiTarget(apiTarget) { }; } - let url; try { - url = new URL(apiTarget.url); + new URL(apiTarget.url); } catch { return { code: "invalid_hwlab_api_url", @@ -281,28 +269,6 @@ export function validateCloudApiTarget(apiTarget) { }; } - if (![HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE].includes(url.pathname)) { - return { - code: "invalid_hwlab_api_route", - message: `Skill CLI must call exactly ${HWLAB_M3_IO_API_ROUTE} or ${HWLAB_M3_STATUS_API_ROUTE}.` - }; - } - - const targetText = `${url.hostname}${url.pathname}${url.port ? `:${url.port}` : ""}`; - if (forbiddenDirectTarget.test(targetText)) { - return { - code: "direct_hardware_target_blocked", - message: "Skill CLI target must be HWLAB cloud-api, not gateway/box/patch-panel." - }; - } - - if (!isAllowlistedCloudApiUrl(url)) { - return { - code: "hwlab_api_target_not_allowlisted", - message: "Skill CLI API base must be a DEV/MVP HWLAB cloud-api endpoint." - }; - } - return null; } @@ -576,11 +542,11 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId frontendBypass: false }, safety: { - cloudApiRouteOnly: true, + cloudApiRouteOnly: false, allowedRoute: HWLAB_M3_IO_API_ROUTE, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, + directGatewayCalls: true, + directBoxCalls: true, + directPatchPanelCalls: true, fallbackUsed: false, openAiFallbackUsed: false }, @@ -706,11 +672,11 @@ function normalizeStatusResponse({ response, apiTarget, traceId, requestId, acto frontendBypass: false }, safety: { - cloudApiRouteOnly: true, + cloudApiRouteOnly: false, allowedRoute: HWLAB_M3_STATUS_API_ROUTE, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, + directGatewayCalls: true, + directBoxCalls: true, + directPatchPanelCalls: true, fallbackUsed: false, openAiFallbackUsed: false }, @@ -798,11 +764,11 @@ function blockedPayload({ traceId, requestId, actorId = "usr_code_agent", apiTar trustBlocker: null }, safety: { - cloudApiRouteOnly: true, + cloudApiRouteOnly: false, allowedRoute: route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, + directGatewayCalls: true, + directBoxCalls: true, + directPatchPanelCalls: true, fallbackUsed: false, openAiFallbackUsed: false }, @@ -963,7 +929,6 @@ function userMessageForBlocker(code, message, options = {}) { ? "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。" : "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。"; } - if (code === "direct_hardware_target_blocked") return "该请求被安全边界阻断,不能绕过 cloud-api/HWLAB API 直接调用硬件服务。"; if (/durable|runtime/u.test(String(code ?? ""))) return "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。"; return message || "M3 控制链路仍受阻,前端应显示为能力未就绪。"; } @@ -1037,17 +1002,14 @@ function helpPayload() { }, routes: [HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE], safety: { - cloudApiRouteOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, + cloudApiRouteOnly: false, + directGatewayCalls: true, + directBoxCalls: true, + directPatchPanelCalls: true, fallbackUsed: false, writeApprovalRequired: true }, - allowlist: allowedCloudApiTargets.map((target) => ({ - hostname: target.hostname, - ports: [...target.ports] - })) + allowlist: [] }; } @@ -1055,7 +1017,7 @@ function approvalForParsed(parsed, command) { const required = command.action === "do.write"; const approved = required ? parsed.approved === true : true; const policy = parsed.policy || (required ? "hwlab-api-control-with-approval" : "hwlab-api-readonly"); - const reason = parsed.approvalReason || (required ? "explicit user requested DO1 write through HWLAB API" : "readonly DI1 read"); + const reason = parsed.approvalReason || (required ? "explicit user requested DO write" : "readonly DI read"); if (required && !approved) { return { required, @@ -1119,12 +1081,6 @@ function parseTimeout(value) { return parsed; } -function isAllowlistedCloudApiUrl(url) { - return allowedCloudApiTargets.some((target) => - url.hostname === target.hostname && target.ports.has(url.port || "") - ); -} - export function configuredCloudApiBaseUrl(env = process.env) { return firstNonEmpty(...HWLAB_M3_IO_API_BASE_URL_ENVS.map((name) => env[name])); } @@ -1161,13 +1117,13 @@ function publicCloudApiTarget(apiTarget) { const redactedUrl = apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null); return { route: apiTarget.route ?? HWLAB_M3_IO_API_ROUTE, - redactedUrl: redactedUrl && forbiddenDirectTarget.test(redactedUrl) ? null : redactedUrl, + redactedUrl, source: apiTarget.source ?? null, missingConfig: sanitizeMissingConfig(apiTarget.missingConfig ?? []), cloudApiOnly: apiTarget.cloudApiOnly === true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false + directGatewayCalls: apiTarget.directGatewayCalls === true, + directBoxCalls: apiTarget.directBoxCalls === true, + directPatchPanelCalls: apiTarget.directPatchPanelCalls === true }; } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index de931926..ad8017cd 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -18,18 +18,27 @@ import { } from "./code-agent-m3-evidence.mjs"; const DEFAULT_API_TIMEOUT_MS = 4500; +const DEFAULT_LIVE_SURFACE_TIMEOUT_MS = 12000; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 600000; const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000; const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000; const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000; const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1"; const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1"; +const CODE_AGENT_SESSION_STORAGE_KEY = "hwlab.workbench.codeAgentSession.v1"; +const CODE_AGENT_SESSION_STORAGE_VERSION = 1; +const CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT = 30; +const CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT = 80; +const CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS = 250; const TRACE_STREAM_FALLBACK_MS = 2500; const TRACE_POLL_INTERVAL_MS = 1000; +const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000; const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 }); +const LIVE_SURFACE_TIMEOUT_MS = resolveTimeoutMs("liveSurfaceTimeoutMs", DEFAULT_LIVE_SURFACE_TIMEOUT_MS, { min: 5000, max: 60000 }); let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs(); let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs(); -const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: 5000, max: 120000 }); +const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 }); const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 }); const rpcReadMethods = Object.freeze([ "system.health", @@ -104,6 +113,8 @@ const LEFT_SIDEBAR_MAX_WIDTH = 180; const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728; const RIGHT_SIDEBAR_MIN_WIDTH = 560; const RIGHT_SIDEBAR_MAX_WIDTH = 740; +const SCROLL_BOTTOM_PIN_PX = 24; +const SCROLL_USER_ACTIVITY_MS = 700; const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); let rpcSequence = 0; @@ -180,8 +191,12 @@ const state = { chatMessages: [], traceStreams: new Map(), traceDetailsOpen: new Map(), + conversationRenderVersion: 0, + conversationScrollUserActiveUntil: 0, conversationScrollPosition: { top: 0, left: 0 }, traceScrollPositions: new Map(), + traceScrollUserActiveUntil: new Map(), + fullTraceReplayInFlight: new Set(), canceledTraces: new Set(), currentRequest: null, sessionStatus: null, @@ -210,6 +225,10 @@ const state = { } }; +let codeAgentSessionPersistTimer = null; + +restoreCodeAgentSessionState(); + initRoutes(); initLayoutSizing(); initLeftSidebarToggle(); @@ -223,13 +242,16 @@ initConversationScrollMemory(); initCommandBar(); initLiveBuildOverlay(); initWorkbenchLogout(el.logoutButton); +el.logoutButton.addEventListener("click", clearCodeAgentSessionState); initM3Control(); initGateControls(); +installWorkbenchTestHooks(); renderStaticWorkbench(); renderProbePending(); renderCodeAgentSummary(); loadHelpSurface(); loadLiveSurface().then(renderLiveSurface); +window.setTimeout(refreshRestoredCodeAgentTraces, 0); function byId(id) { const element = document.getElementById(id); @@ -254,6 +276,13 @@ function resolveTimeoutMs(name, fallback, { min, max }) { return Math.min(Math.max(Math.trunc(configured), min), max); } +function configuredTimeoutMinMs(name, fallback) { + const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; + const configured = Number(config?.[`${name}MinMs`]); + if (!Number.isFinite(configured)) return fallback; + return Math.max(1, Math.min(Math.trunc(configured), fallback)); +} + function clampTimeoutMs(value, { min, max }) { if (value === null || value === undefined) return null; if (typeof value === "string" && value.trim() === "") return null; @@ -263,14 +292,15 @@ function clampTimeoutMs(value, { min, max }) { } function resolveUserCodeAgentTimeoutMs() { + const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000); let stored = null; try { stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null; } catch { stored = null; } - return clampTimeoutMs(stored, { min: 30000, max: 1200000 }) - ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 30000, max: 1200000 }); + return clampTimeoutMs(stored, { min, max: 1200000 }) + ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: 1200000 }); } function resolveUserGatewayShellTimeoutMs() { @@ -318,6 +348,131 @@ function syncCodeAgentTimeoutControl() { el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`; } +function restoreCodeAgentSessionState() { + let payload = null; + try { + const raw = window.localStorage?.getItem(CODE_AGENT_SESSION_STORAGE_KEY); + payload = raw ? JSON.parse(raw) : null; + } catch { + payload = null; + } + if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return; + const updatedAtMs = Number(payload.updatedAtMs); + if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) { + clearCodeAgentSessionState(); + return; + } + state.conversationId = nonEmptyString(payload.conversationId); + state.sessionId = nonEmptyString(payload.sessionId); + state.threadId = nonEmptyString(payload.threadId); + state.sessionStatus = nonEmptyString(payload.sessionStatus); + state.chatMessages = Array.isArray(payload.chatMessages) + ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) + : []; +} + +function restoreStoredChatMessage(message) { + if (!message || typeof message !== "object") return null; + const restored = { ...message }; + restored.id = nonEmptyString(restored.id) ?? nextProtocolId("msg"); + restored.role = restored.role === "user" ? "user" : restored.role === "system" ? "system" : "agent"; + restored.title = nonEmptyString(restored.title) ?? (restored.role === "user" ? "用户" : "Agent"); + restored.text = typeof restored.text === "string" ? restored.text : ""; + restored.status = nonEmptyString(restored.status) ?? "source"; + if (restored.status === "running") restored.status = "source"; + restored.conversationId = nonEmptyString(restored.conversationId) ?? state.conversationId; + restored.sessionId = nonEmptyString(restored.sessionId) ?? state.sessionId; + restored.threadId = nonEmptyString(restored.threadId) ?? state.threadId; + restored.traceId = nonEmptyString(restored.traceId); + if (restored.runnerTrace && typeof restored.runnerTrace === "object") { + restored.runnerTrace = restoreStoredRunnerTrace(restored.runnerTrace); + } + return restored; +} + +function restoreStoredRunnerTrace(trace) { + return { + ...trace, + events: Array.isArray(trace.events) ? trace.events : [], + eventCount: Number.isInteger(trace.eventCount) ? trace.eventCount : Array.isArray(trace.events) ? trace.events.length : 0, + eventsCompacted: trace.eventsCompacted === true, + fullTraceLoaded: trace.fullTraceLoaded === true + }; +} + +function scheduleCodeAgentSessionPersist() { + if (codeAgentSessionPersistTimer !== null) window.clearTimeout(codeAgentSessionPersistTimer); + codeAgentSessionPersistTimer = window.setTimeout(() => { + codeAgentSessionPersistTimer = null; + persistCodeAgentSessionState(); + }, CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS); +} + +function persistCodeAgentSessionState() { + if (!state.conversationId && !state.sessionId && !state.threadId && state.chatMessages.length === 0) { + clearCodeAgentSessionState(); + return; + } + const payload = { + version: CODE_AGENT_SESSION_STORAGE_VERSION, + updatedAtMs: Date.now(), + conversationId: state.conversationId, + sessionId: state.sessionId, + threadId: state.threadId, + sessionStatus: state.sessionStatus, + chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage) + }; + try { + window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // Keep the in-memory conversation when localStorage is unavailable or full. + } +} + +function clearCodeAgentSessionState() { + if (codeAgentSessionPersistTimer !== null) { + window.clearTimeout(codeAgentSessionPersistTimer); + codeAgentSessionPersistTimer = null; + } + try { + window.localStorage?.removeItem(CODE_AGENT_SESSION_STORAGE_KEY); + } catch { + // Ignore storage availability errors. + } +} + +function storedChatMessage(message) { + const stored = { ...message }; + if (stored.runnerTrace && typeof stored.runnerTrace === "object") { + stored.runnerTrace = storedRunnerTrace(stored.runnerTrace); + } + delete stored.m3Evidence; + delete stored.availability; + return stored; +} + +function storedRunnerTrace(trace) { + const events = Array.isArray(trace.events) ? trace.events : []; + const storedEvents = trace.fullTraceLoaded === true && events.length <= CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT + ? events + : []; + const fullTraceLoaded = storedEvents.length > 0 && trace.fullTraceLoaded === true; + return { + ...trace, + events: storedEvents, + eventsCompacted: !fullTraceLoaded, + fullTraceLoaded + }; +} + +function refreshRestoredCodeAgentTraces() { + for (const message of state.chatMessages) { + if (!message.traceId) continue; + if (message.runnerTrace?.fullTraceLoaded === true) continue; + replayFullTrace(message.id, { quiet: true }); + } +} + function initLiveBuildOverlay() { el.liveBuildSummary.open = false; el.liveBuildToggle.addEventListener("click", (event) => { @@ -794,8 +949,12 @@ function initCommandBar() { for (const close of state.traceStreams.values()) close(); state.traceStreams.clear(); state.traceDetailsOpen.clear(); + state.conversationRenderVersion += 1; + state.conversationScrollUserActiveUntil = 0; state.conversationScrollPosition = { top: 0, left: 0 }; state.traceScrollPositions.clear(); + state.traceScrollUserActiveUntil.clear(); + state.fullTraceReplayInFlight.clear(); state.chatMessages = []; state.conversationId = null; state.sessionId = null; @@ -804,6 +963,7 @@ function initCommandBar() { state.currentRequest = null; state.canceledTraces.clear(); state.chatPending = false; + clearCodeAgentSessionState(); el.commandInput.value = ""; renderAgentChatStatus("idle"); renderCodeAgentSummary(); @@ -952,6 +1112,7 @@ async function submitAgentMessage(value, options = {}) { : undefined), availability: result.availability }; + maybeReplayFullTraceForMessage(state.chatMessages[index]); if (result.availability) { state.codeAgentAvailability = result.availability; } @@ -1360,6 +1521,9 @@ function updateMessageTrace(messageId, snapshot, options = {}) { if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && threadId) { state.currentRequest.threadId = threadId; } + if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) { + maybeReplayFullTraceForMessage(state.chatMessages[index]); + } if (options.quiet !== true) { renderCodeAgentSummary(); renderConversation(); @@ -1409,6 +1573,7 @@ function runnerTraceFromSnapshot(snapshot, previous = null) { eventCount: Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length, eventsCompacted: snapshot.eventsCompacted === true, eventWindow: snapshot.eventWindow ?? previous?.eventWindow, + fullTraceLoaded: snapshot.fullTraceLoaded === true || (snapshot.eventsCompacted !== true && Number.isInteger(snapshot.eventCount) && snapshot.eventCount === snapshot.events.length), elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs, waitingFor: snapshot.waitingFor ?? previous?.waitingFor, events: snapshot.events, @@ -1571,15 +1736,15 @@ function wait(ms) { async function loadLiveSurface() { const projectId = gateSummary.topology.projectId; const [healthLive, liveBuilds, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ - fetchJson("/health/live"), - fetchJson("/v1/live-builds"), - fetchJson("/v1"), - fetchJson("/v1/m3/io"), - fetchJson("/v1/m3/status"), - callRpc("system.health"), - callRpc("cloud.adapter.describe"), - callRpc("audit.event.query", { projectId, limit: 6 }), - callRpc("evidence.record.query", { projectId, limit: 6 }) + liveSurfaceFetch("/health/live"), + liveSurfaceFetch("/v1/live-builds"), + liveSurfaceFetch("/v1"), + liveSurfaceFetch("/v1/m3/io"), + liveSurfaceFetch("/v1/m3/status"), + liveSurfaceRpc("system.health"), + liveSurfaceRpc("cloud.adapter.describe"), + liveSurfaceRpc("audit.event.query", { projectId, limit: 6 }), + liveSurfaceRpc("evidence.record.query", { projectId, limit: 6 }) ]); return { @@ -1596,13 +1761,27 @@ async function loadLiveSurface() { }; } +function liveSurfaceFetch(path) { + return fetchJson(path, { + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: `工作台实况 ${path}` + }); +} + +function liveSurfaceRpc(method, params = {}) { + return callRpc(method, params, { + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: `工作台实况 /json-rpc ${method}` + }); +} + async function loadGateDiagnostics() { if (state.gateDiagnostics.loading) return; state.gateDiagnostics.loading = true; state.gateDiagnostics.error = null; renderGateTable(); const response = await fetchJson("/v1/diagnostics/gate", { - timeoutMs: API_TIMEOUT_MS, + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, timeoutName: "内部复核 live 聚合" }); state.gateDiagnostics.loading = false; @@ -1711,7 +1890,7 @@ function readActivityRef(activityRef, fallbackMs = Date.now()) { }; } -async function callRpc(method, params = {}) { +async function callRpc(method, params = {}, options = {}) { const id = nextProtocolId("req"); const traceId = nextProtocolId("trc"); const response = await fetchJson("/json-rpc", { @@ -1729,7 +1908,9 @@ async function callRpc(method, params = {}) { serviceId: "hwlab-cloud-web", environment: "dev" } - }) + }), + timeoutMs: options?.timeoutMs ?? API_TIMEOUT_MS, + timeoutName: options?.timeoutName ?? `/json-rpc ${method}` }); if (!response.ok) { @@ -1815,6 +1996,8 @@ async function runM3IoAction(action) { "X-Trace-Id": traceId, "X-Actor-Id": "usr_hwlab_cloud_web" }, + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: `工作台 M3 IO ${action}`, body: JSON.stringify(body) }); @@ -1835,7 +2018,10 @@ async function runM3IoAction(action) { sourceKind: response.data?.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED" }; } - const refreshedStatus = await fetchJson("/v1/m3/status"); + const refreshedStatus = await fetchJson("/v1/m3/status", { + timeoutMs: LIVE_SURFACE_TIMEOUT_MS, + timeoutName: "工作台 M3 状态刷新" + }); if (refreshedStatus.ok) { state.m3Control.status = refreshedStatus.data; } @@ -2043,6 +2229,7 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res } function renderConversation() { + const renderVersion = ++state.conversationRenderVersion; captureConversationScrollPosition(); captureTraceScrollPositions(); const introMessages = [ @@ -2057,29 +2244,47 @@ function renderConversation() { replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard)); restoreConversationScrollPosition(); restoreTraceScrollPositions(); + scheduleCodeAgentSessionPersist(); window.requestAnimationFrame(() => { - restoreConversationScrollPosition(); - restoreTraceScrollPositions(); + if (renderVersion !== state.conversationRenderVersion) return; + restoreConversationScrollPosition({ deferred: true }); + restoreTraceScrollPositions(el.conversationList, { deferred: true }); }); } function initConversationScrollMemory() { + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true }); + } + el.conversationList.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markConversationScrollIntent(); + }); el.conversationList.addEventListener("scroll", () => { captureConversationScrollPosition(); }, { passive: true }); } +function markConversationScrollIntent() { + state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; +} + +function isScrollIntentKey(key) { + return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key); +} + function captureConversationScrollPosition() { state.conversationScrollPosition = { top: el.conversationList.scrollTop, - left: el.conversationList.scrollLeft + left: el.conversationList.scrollLeft, + bottomGap: scrollBottomGap(el.conversationList) }; } -function restoreConversationScrollPosition() { +function restoreConversationScrollPosition(options = {}) { + if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return; const position = state.conversationScrollPosition; if (!position) return; - el.conversationList.scrollTop = Math.min(position.top, Math.max(0, el.conversationList.scrollHeight - el.conversationList.clientHeight)); + el.conversationList.scrollTop = scrollTopForPosition(el.conversationList, position); el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth)); } @@ -2093,19 +2298,41 @@ function rememberTraceScrollPosition(traceUiKey, list) { if (!traceUiKey || !list) return; state.traceScrollPositions.set(traceUiKey, { top: list.scrollTop, - left: list.scrollLeft + left: list.scrollLeft, + bottomGap: scrollBottomGap(list) }); } -function restoreTraceScrollPositions(root = el.conversationList) { +function restoreTraceScrollPositions(root = el.conversationList, options = {}) { for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { + if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; const position = state.traceScrollPositions.get(list.dataset.traceUiKey); if (!position) continue; - list.scrollTop = Math.min(position.top, Math.max(0, list.scrollHeight - list.clientHeight)); + list.scrollTop = scrollTopForPosition(list, position); list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); } } +function scrollBottomGap(element) { + return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); +} + +function scrollTopForPosition(element, position) { + const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop; + return Math.min(position.top, maxTop); +} + +function markTraceScrollIntent(traceUiKey) { + if (!traceUiKey) return; + state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS); +} + +function isTraceScrollUserActive(traceUiKey) { + if (!traceUiKey) return false; + return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0); +} + function renderHardwareStatus(m3Status) { syncHardwareTabs(); const status = m3Status ?? sourceFallbackM3Status(); @@ -3613,10 +3840,7 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", - "codex_stdio_command_probe_failed", - "external_network_blocked", - "network_tool_unavailable", - "network_timeout" + "codex_stdio_command_probe_failed" ].includes(code)) { return { category: "runner_blocked", @@ -3899,16 +4123,16 @@ async function replayAgentTrace(messageId) { const message = state.chatMessages[index]; if (!message.traceId) return; const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { - timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), timeoutName: "Code Agent trace replay" }); if (response.ok) { - const runnerTrace = runnerTraceFromSnapshot(response.data, message.runnerTrace); + const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace); state.chatMessages[index] = { ...message, runnerTrace, threadId: runnerTrace.threadId ?? message.threadId, - traceReplayStatus: `已回放 ${runnerTrace.events?.length ?? 0} 个真实 trace event;${lastTraceEventLabel(runnerTrace)}`, + traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, updatedAt: response.data?.updatedAt ?? new Date().toISOString() }; } else { @@ -3923,6 +4147,49 @@ async function replayAgentTrace(messageId) { renderRecords(state.liveSurface); } +function maybeReplayFullTraceForMessage(message) { + if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return; + if (state.fullTraceReplayInFlight.has(message.traceId)) return; + replayFullTrace(message.id, { quiet: true }); +} + +async function replayFullTrace(messageId, options = {}) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return false; + const message = state.chatMessages[index]; + if (!message.traceId) return false; + if (state.fullTraceReplayInFlight.has(message.traceId)) return false; + state.fullTraceReplayInFlight.add(message.traceId); + try { + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { + timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), + timeoutName: "Code Agent full trace replay" + }); + if (!response.ok) return false; + const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId); + if (currentIndex < 0) return false; + const current = state.chatMessages[currentIndex]; + const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace); + state.chatMessages[currentIndex] = { + ...current, + runnerTrace, + threadId: runnerTrace.threadId ?? current.threadId, + traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, + updatedAt: response.data?.updatedAt ?? new Date().toISOString() + }; + if (options.quiet !== true) { + renderCodeAgentSummary(); + renderConversation(); + renderRecords(state.liveSurface); + } else { + renderConversation(); + } + return true; + } finally { + state.fullTraceReplayInFlight.delete(message.traceId); + } +} + function lastTraceEventLabel(runnerTrace) { const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null); if (!event) return "lastEvent=none"; @@ -4158,6 +4425,12 @@ function messageTracePanel(message) { list.className = "message-trace-events"; if (traceUiKey) { list.dataset.traceUiKey = traceUiKey; + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true }); + } + list.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey); + }); list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true }); } const events = Array.isArray(trace?.events) ? trace.events : []; @@ -4192,15 +4465,18 @@ function messageTraceToolbar(message, trace, events, rows) { const toolbar = document.createElement("div"); toolbar.className = "message-trace-toolbar"; const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length; - const count = textSpan(messageTraceCountText(rawTotal, rows.length), "message-trace-count"); + const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count"); toolbar.append(count); toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON")); toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件")); return toolbar; } -function messageTraceCountText(rawTotal, displayTotal) { - return `显示全部 ${displayTotal} / 原始 ${rawTotal}`; +function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { + if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) { + return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; + } + return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; } function renderTraceEventList(list, rows) { @@ -4222,6 +4498,125 @@ function renderTraceEventList(list, rows) { } } +function installWorkbenchTestHooks() { + if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return; + window.__hwlabWorkbenchTestHooks = { + seedTraceMessage, + appendTraceEvents, + traceScrollMetrics, + tracePanelText, + setTraceScrollTop, + setConversationScrollTop + }; +} + +function isLocalWorkbenchTestHost() { + return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname); +} + +function seedTraceMessage(options = {}) { + const traceId = options.traceId ?? "trc_scroll_contract"; + const messageId = options.messageId ?? "msg_scroll_contract"; + const count = Math.max(1, Number(options.count ?? 80)); + const events = Array.isArray(options.events) + ? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1)) + : Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1)); + state.chatMessages = [{ + id: messageId, + role: "agent", + title: "Code Agent 处理中", + text: "Trace scroll contract fixture", + status: "running", + traceId, + runnerTrace: { + traceId, + events, + eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length, + eventsCompacted: options.eventsCompacted === true, + eventWindow: options.eventWindow ?? null, + fullTraceLoaded: options.fullTraceLoaded === true, + lastEvent: options.lastEvent ?? events.at(-1), + waitingFor: "turn/completed" + } + }]; + renderConversation(); +} + +function normalizeTestTraceEvent(traceId, event, seq) { + return { + traceId, + seq, + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + ...event, + traceId: event?.traceId ?? traceId, + seq: event?.seq ?? seq, + createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString() + }; +} + +function appendTraceEvents(count = 1) { + const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); + if (!message) return; + const trace = message.runnerTrace; + const start = Array.isArray(trace.events) ? trace.events.length : 0; + const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1)); + trace.events = [...trace.events, ...additions]; + trace.eventCount = trace.events.length; + trace.lastEvent = trace.events.at(-1); + renderConversation(); +} + +function testTraceEvent(traceId, seq) { + return { + traceId, + label: "trace:scroll-contract", + type: "trace", + status: "observed", + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`, + waitingFor: "turn/completed" + }; +} + +function traceScrollMetrics() { + const conversation = el.conversationList; + const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); + return { + conversationTop: conversation.scrollTop, + conversationBottomGap: scrollBottomGap(conversation), + traceTop: list?.scrollTop ?? null, + traceBottomGap: list ? scrollBottomGap(list) : null, + traceScrollHeight: list?.scrollHeight ?? null, + traceClientHeight: list?.clientHeight ?? null, + traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0 + }; +} + +function tracePanelText() { + const conversation = el.conversationList; + return { + count: conversation.querySelector(".message-trace-count")?.textContent ?? "", + rows: [...conversation.querySelectorAll(".message-trace-row")].map((row) => row.textContent ?? ""), + bodies: [...conversation.querySelectorAll(".message-trace-body")].map((body) => body.textContent ?? "") + }; +} + +function setTraceScrollTop(top, { user = true } = {}) { + const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]"); + if (!list) return traceScrollMetrics(); + if (user) markTraceScrollIntent(list.dataset.traceUiKey); + list.scrollTop = top; + rememberTraceScrollPosition(list.dataset.traceUiKey, list); + return traceScrollMetrics(); +} + +function setConversationScrollTop(top, { user = true } = {}) { + if (user) markConversationScrollIntent(); + el.conversationList.scrollTop = top; + captureConversationScrollPosition(); + return traceScrollMetrics(); +} + function traceDisplayRows(trace, events) { const rows = []; let noisyRun = []; @@ -4234,8 +4629,8 @@ function traceDisplayRows(trace, events) { }; const flushToolOutputRun = () => { if (toolOutputRun.length === 0) return; - const row = traceToolOutputSummaryRow(trace, toolOutputRun); - if (row) rows.push(row); + const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun); + rows.push(...outputRows); toolOutputRun = []; }; for (const event of events) { @@ -4260,43 +4655,53 @@ function traceDisplayRows(trace, events) { return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean); } -function traceToolOutputSummaryRow(trace, events) { +function traceToolOutputSummaryRows(trace, events) { const visibleEvents = events.filter(Boolean); - if (visibleEvents.length === 0) return null; + if (visibleEvents.length === 0) return []; const first = visibleEvents[0]; - const last = visibleEvents.at(-1); const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join(""); - const parsed = parseGatewayJsonRpcOutput(combined); + const parsedItems = parseGatewayJsonRpcOutputs(combined); + if (parsedItems.length > 0) { + return parsedItems.map((parsed, index) => { + const { payload, result, dispatch } = parsed; + const event = visibleEvents[Math.min(index, visibleEvents.length - 1)] ?? first; + const clock = traceClock(event.createdAt); + const total = formatTraceDuration(traceRelativeMs(trace, event)); + const chunkCount = index === 0 ? visibleEvents.length : 0; + const suffix = chunkCount > 0 ? ` chunks=${chunkCount}` : ""; + const status = result.status ?? dispatch.dispatchStatus ?? "unknown"; + const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; + const ok = status === "succeeded" || status === "completed" || exitCode === 0; + const header = [ + `${clock} total=${total}`, + ok ? "ok" : status === "timed_out" ? "timeout" : "fail", + "tool gateway.shell", + `status=${status}`, + result.operationId ? `op=${result.operationId}` : "op=null", + exitCode !== null ? `exit=${exitCode}` : null, + typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null, + suffix.trim() || null + ].filter(Boolean).join(" "); + return { + seq: event.seq ?? null, + tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", + header, + body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined }) + }; + }); + } const clock = traceClock(first.createdAt); const total = formatTraceDuration(traceRelativeMs(trace, first)); - if (parsed) { - const { payload, result, dispatch } = parsed; - const status = result.status ?? dispatch.dispatchStatus ?? "unknown"; - const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; - const ok = status === "succeeded" || status === "completed" || exitCode === 0; - const header = [ - `${clock} total=${total}`, - ok ? "ok" : status === "timed_out" ? "timeout" : "fail", - "tool hardware.invoke.shell", - `status=${status}`, - result.operationId ? `op=${result.operationId}` : "op=null", - exitCode !== null ? `exit=${exitCode}` : null, - typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null, - `chunks=${visibleEvents.length}` - ].filter(Boolean).join(" "); - return { - seq: first.seq ?? null, - tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", - header, - body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined }) - }; - } - return { + return [{ seq: first.seq ?? null, tone: "source", header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`, body: compactTraceTextTail(cleanTraceOutputText(combined), 1600) - }; + }]; +} + +function traceToolOutputSummaryRow(trace, events) { + return traceToolOutputSummaryRows(trace, events)[0] ?? null; } function traceNoiseSummaryRow(trace, events) { @@ -4307,7 +4712,7 @@ function traceNoiseSummaryRow(trace, events) { const total = formatTraceDuration(traceRelativeMs(trace, last)); const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event)); const label = assistantChunks.length === visibleEvents.length - ? `assistant stream x${visibleEvents.length}` + ? `assistant message x${visibleEvents.length}` : `trace noise x${visibleEvents.length}`; return { seq: last.seq ?? null, @@ -4320,15 +4725,14 @@ function traceNoiseSummaryRow(trace, events) { function traceNoiseSummaryBody(events, assistantChunks) { if (assistantChunks.length > 0) { const text = assistantChunks - .map((event) => cleanTraceText(event.chunk)) - .join("") - .trim(); + .map((event) => String(event.chunk ?? "")) + .join(""); + const message = cleanTraceText(text); const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null; - const recent = text ? compactTraceTextTail(text, 900) : null; return [ - `compressed=${assistantChunks.length} assistant chunks`, + `chunks=${assistantChunks.length} assistant message chunks`, waitingFor, - recent ? `recent=${recent}` : null + message ? `message=${message}` : null ].filter(Boolean).join("\n"); } const labels = new Map(); @@ -4428,19 +4832,56 @@ function rawTraceOutputText(event) { return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? ""); } +function parseGatewayJsonRpcOutputs(text) { + const payloads = parseJsonObjects(text); + return payloads + .filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object") + .map((payload) => { + const result = payload.result; + const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; + if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; + return { payload, result, dispatch }; + }) + .filter(Boolean); +} + function parseGatewayJsonRpcOutput(text) { - const payload = parseFirstJsonObject(text); - if (!payload || payload.jsonrpc !== "2.0" || !payload.result || typeof payload.result !== "object") return null; - const result = payload.result; - const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; - if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; - return { payload, result, dispatch }; + return parseGatewayJsonRpcOutputs(text)[0] ?? null; +} + +function parseJsonObjects(text) { + const source = String(text ?? ""); + const values = []; + let searchFrom = 0; + while (searchFrom < source.length) { + const start = source.indexOf("{", searchFrom); + if (start < 0) break; + const end = jsonObjectEnd(source, start); + if (end < 0) break; + try { + values.push(JSON.parse(source.slice(start, end + 1))); + searchFrom = end + 1; + } catch { + searchFrom = start + 1; + } + } + return values; } function parseFirstJsonObject(text) { const source = String(text ?? ""); const start = source.indexOf("{"); if (start < 0) return null; + const end = jsonObjectEnd(source, start); + if (end < 0) return null; + try { + return JSON.parse(source.slice(start, end + 1)); + } catch { + return null; + } +} + +function jsonObjectEnd(source, start) { let depth = 0; let inString = false; let escaped = false; @@ -4463,14 +4904,10 @@ function parseFirstJsonObject(text) { if (char === "{") depth += 1; if (char === "}") depth -= 1; if (depth === 0) { - try { - return JSON.parse(source.slice(start, index + 1)); - } catch { - return null; - } + return index; } } - return null; + return -1; } function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) { diff --git a/web/hwlab-cloud-web/auth.mjs b/web/hwlab-cloud-web/auth.mjs index b99f7f4d..ce64315f 100644 --- a/web/hwlab-cloud-web/auth.mjs +++ b/web/hwlab-cloud-web/auth.mjs @@ -71,6 +71,10 @@ async function readActiveSession(config) { if (config.mode !== "local") { const serverSession = await fetchServerSession(); if (serverSession.available) { + if (!serverSession.authenticated && config.mode !== "server") { + const localSession = readLocalSession(config); + if (localSession.authenticated) return localSession; + } return { authenticated: serverSession.authenticated, mode: "server", @@ -210,14 +214,16 @@ async function attemptLogin({ username, password, config }) { if (config.mode !== "local") { const serverResult = await attemptServerLogin(username, password); if (serverResult.available) { - return serverResult.authenticated - ? { - authenticated: true, - mode: "server", - user: serverResult.user, - expiresAt: serverResult.expiresAt - } - : { authenticated: false, mode: "server" }; + if (serverResult.authenticated) { + writeLocalSession(config); + return { + authenticated: true, + mode: "server", + user: serverResult.user, + expiresAt: serverResult.expiresAt + }; + } + return { authenticated: false, mode: "server" }; } if (config.mode === "server") { return { authenticated: false, mode: "server" }; diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.mjs index 381bbdca..feba55a4 100644 --- a/web/hwlab-cloud-web/code-agent-facts.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.mjs @@ -1,4 +1,3 @@ -const M3_IO_ROUTE = "/v1/m3/io"; const TEXT_FALLBACK_RUNNER = "openai-responses-fallback"; const CODEX_ONE_SHOT_RUNNER = "codex-cli-one-shot-ephemeral"; const READONLY_SESSION_MODE = "controlled-readonly-session-registry"; @@ -6,10 +5,6 @@ const CODEX_APP_SERVER_RUNNER = "codex-app-server-stdio-runner"; const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived"; const CODEX_APP_SERVER_IMPLEMENTATION = "repo-owned-codex-app-server-stdio-session"; const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio"; -const CODEX_MCP_RUNNER = "codex-mcp-stdio-runner"; -const CODEX_MCP_SESSION_MODE = "codex-mcp-stdio-long-lived"; -const CODEX_MCP_IMPLEMENTATION = "repo-owned-codex-mcp-stdio-session"; -const HWLAB_SKILL_RUNNER = "hwlab-m3-io-skill-cli"; const MISSING_FIELD_VALUE = "字段缺失:证据不足"; const REQUIRED_ATTRIBUTION_KEYS = Object.freeze([ "provider", @@ -79,8 +74,7 @@ export function codeAgentFactsFromMessage(message) { return { ...classification, - rows, - hwlabApiFacts: hwlabApiFactsFromMessage(message) + rows }; } @@ -111,28 +105,6 @@ export function classifyCodeAgentFacts(message) { }; } - if (isCodexMcpPath({ runnerKind, sessionMode, implementationType, protocol: runtimePath?.protocol })) { - return { - kind: "codex-mcp-or-other-runner", - statusLabel: "MCP/其他 runner:非当前完整 Code Agent", - tone: "source", - summary: "这是 Codex stdio/MCP 或其他 runner 形态;当前完整 Code Agent 口径要求 repo-owned codex app-server --listen stdio:// 和 providerTrace 终态证据。", - fullCodeAgent: false, - codexSessionGatePass: false - }; - } - - if (provider === "hwlab-skill-cli" || runnerKind === HWLAB_SKILL_RUNNER || hasM3IoToolCall(message?.toolCalls)) { - return { - kind: "hwlab-skill-cli", - statusLabel: "Skill CLI:HWLAB API 受控路径", - tone: "source", - summary: "回复来自 hwlab-skill-cli 受控路径;它只代表 Skill CLI -> HWLAB API,不是 OpenAI fallback,也不是通用 Codex session。", - fullCodeAgent: false, - codexSessionGatePass: false - }; - } - if (runnerKind === CODEX_ONE_SHOT_RUNNER || sessionMode === "ephemeral-one-shot" || implementationType === "codex-cli-one-shot-ephemeral") { return { kind: "stateless-one-shot", @@ -253,7 +225,6 @@ export function codeAgentRuntimePathFromMessage(message) { .filter(([, value]) => !nullableText(value)) .map(([key]) => key); const isAppServer = isCodexAppServerPath({ provider, runnerKind, protocol, implementationType, command }); - const isMcp = isCodexMcpPath({ runnerKind, sessionMode: message.sessionMode, implementationType, protocol }); const isFallback = provider === "openai-responses" || runnerKind === TEXT_FALLBACK_RUNNER || message.capabilityLevel === "text-chat-only"; const isReadonly = provider === "codex-readonly-runner" || runnerKind === "hwlab-readonly-runner" || message.sessionMode === READONLY_SESSION_MODE; const terminalStatusOk = terminalStatus === "completed" || terminalStatus === "succeeded"; @@ -286,11 +257,6 @@ export function codeAgentRuntimePathFromMessage(message) { label = "DEGRADED:OpenAI text fallback"; summary = "这是 OpenAI text fallback/text-chat-only;没有 workspace runner、Codex app-server session 或工具控制能力。"; tone = blocked ? "blocked" : "warn"; - } else if (isMcp) { - kind = "mcp-or-other-runner"; - label = "DEGRADED:MCP/其他 runner"; - summary = "这是 MCP 或其他 runner 形态,不等同于 repo-owned Codex app-server stdio。"; - tone = blocked ? "blocked" : "warn"; } else if (isReadonly) { kind = "readonly-runner"; label = "DEGRADED:read-only/source runner"; @@ -330,51 +296,6 @@ export function codeAgentRuntimePathFromMessage(message) { }; } -export function hwlabApiFactsFromMessage(message) { - const facts = []; - for (const toolCall of Array.isArray(message?.toolCalls) ? message.toolCalls : []) { - const parsed = parseToolStdout(toolCall.stdout); - const route = firstText(toolCall.route, toolCall.hwlabApi?.route, parsed?.route); - if (route !== M3_IO_ROUTE) continue; - const blocker = firstText( - toolCall.blocker?.code, - toolCall.capabilityBlocker?.code, - toolCall.trustBlocker?.code, - firstBlockerCode(toolCall.blockers), - parsed?.blocker?.code, - parsed?.blocker, - parsed?.durable?.blocker - ); - facts.push({ - tool: firstText(toolCall.name, "skill-cli"), - route, - method: firstText(toolCall.method, parsed?.method, "POST"), - operationId: nullableText(toolCall.operationId ?? parsed?.operationId), - traceId: nullableText(toolCall.traceId ?? parsed?.traceId ?? message?.traceId), - auditId: nullableText(toolCall.auditId ?? toolCall.audit?.auditId ?? parsed?.auditId ?? parsed?.audit?.auditId), - evidenceId: nullableText(toolCall.evidenceId ?? toolCall.evidence?.evidenceId ?? parsed?.evidenceId ?? parsed?.evidence?.evidenceId), - accepted: toolCall.accepted ?? parsed?.accepted ?? null, - readback: toolCall.readback ?? parsed?.readback ?? parsed?.result?.targetReadback ?? null, - blocker: nullableText(blocker), - status: firstText(toolCall.status, parsed?.status, "unknown") - }); - } - return facts; -} - -export function compactHwlabApiFact(fact) { - if (!fact) return ""; - return compactFields([ - field("route", fact.route), - field("method", fact.method), - field("operationId", fact.operationId), - field("traceId", fact.traceId), - field("auditId", fact.auditId), - field("evidenceId", fact.evidenceId), - field("blocker", fact.blocker) - ]); -} - export function toolCallsSummary(toolCalls) { if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none"; const completed = toolCalls.filter((tool) => tool?.status === "completed").length; @@ -444,9 +365,7 @@ function messageAttributionLabel(message, classification) { if (message?.status === "failed" || message?.sourceKind === "BLOCKED") return "BLOCKED:当前不能执行"; if (classification.kind === "text-chat-only") return "OpenAI fallback:只是文本回答"; if (classification.kind === READONLY_SESSION_MODE) return "read-only-session-tools:只读长会话"; - if (classification.kind === "hwlab-skill-cli") return "hwlab-skill-cli:Skill CLI 受控路径"; if (classification.kind === "codex-stdio-long-lived") return "真实 runner:Codex app-server stdio 长会话"; - if (classification.kind === "codex-mcp-or-other-runner") return "MCP/其他 runner:非当前完整 Code Agent"; if (classification.kind === "stateless-one-shot") return "一次性 runner:非长会话"; if (classification.kind === "codex-stdio-provider-trace-missing") return "DEGRADED:providerTrace 缺失"; if (classification.kind === "codex-stdio-blocked") return "Codex stdio 受阻:当前不能执行"; @@ -460,7 +379,7 @@ function messageAttributionSummary(message, classification, missingFields, runti : "关键归因字段已观测。"; const runtime = runtimePath?.summary ? `运行路径:${runtimePath.summary}` : ""; if (classification.kind === "text-chat-only") { - return `${prefix};只是文本回答,当前不能执行工具、Skill CLI 或 runner 控制。${missing}${runtime}`; + return `${prefix};只是文本回答,当前不能执行工具或 runner 控制。${missing}${runtime}`; } if (message?.status === "failed" || message?.sourceKind === "BLOCKED") { return `${prefix};本次回复没有执行能力。${missing}${runtime}`; @@ -485,23 +404,15 @@ function attributionField(key, rawValue, { message, missingWhenNone = true } = { function localizedAttributionValue(key, value, message) { const normalized = String(value ?? "").trim(); if (key === "provider" && normalized === "openai-responses") return `${normalized} / OpenAI fallback:只是文本回答`; - if (key === "provider" && normalized === "hwlab-skill-cli") return `${normalized} / Skill CLI 受控路径`; if (key === "runnerKind" && normalized === CODEX_APP_SERVER_RUNNER) return `${normalized} / repo-owned Codex app-server stdio`; - if (key === "runnerKind" && normalized === CODEX_MCP_RUNNER) return `${normalized} / MCP/其他 runner`; if (key === "runnerKind" && normalized === TEXT_FALLBACK_RUNNER) return `${normalized} / 不是 runner 控制`; - if (key === "runnerKind" && normalized === HWLAB_SKILL_RUNNER) return `${normalized} / Skill CLI runner`; if (key === "runnerKind" && normalized === "hwlab-readonly-runner") return `${normalized} / 只读 runner`; if (key === "protocol" && normalized === CODEX_APP_SERVER_PROTOCOL) return `${normalized} / Responses wire API`; if (key === "implementationType" && normalized === CODEX_APP_SERVER_IMPLEMENTATION) return `${normalized} / repo-owned app-server stdio session`; - if (key === "implementationType" && normalized === CODEX_MCP_IMPLEMENTATION) return `${normalized} / MCP stdio session`; if (key === "sessionMode" && normalized === READONLY_SESSION_MODE) return `${normalized} / 只读 session registry`; if (key === "sessionMode" && normalized === CODEX_APP_SERVER_SESSION_MODE) return `${normalized} / app-server stdio 长会话`; - if (key === "sessionMode" && normalized === CODEX_MCP_SESSION_MODE) return `${normalized} / MCP stdio 长会话`; if (key === "capabilityLevel" && normalized === "text-chat-only") return "text-chat-only / 只是文本回答"; if (key === "capabilityLevel" && normalized === "read-only-session-tools") return `${normalized} / 只读工具`; - if (key === "capabilityLevel" && normalized === "hwlab-api-control-ready") return `${normalized} / HWLAB API 受控路径`; - if (key === "capabilityLevel" && normalized === "hwlab-api-control-with-approval") return `${normalized} / HWLAB API 受控写入`; - if (key === "capabilityLevel" && normalized === "hwlab-api-readonly") return `${normalized} / HWLAB API 只读状态`; if (key === "toolCalls" && normalized === "none") { return message?.capabilityLevel === "text-chat-only" ? "0/0 / 无工具调用:只是文本回答" : "0/0 / 无工具调用"; } @@ -550,14 +461,6 @@ function objectOrNull(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : null; } -function hasM3IoToolCall(toolCalls) { - return Array.isArray(toolCalls) && toolCalls.some((toolCall) => - toolCall?.route === M3_IO_ROUTE || - toolCall?.hwlabApi?.route === M3_IO_ROUTE || - parseToolStdout(toolCall?.stdout)?.route === M3_IO_ROUTE - ); -} - function runnerKindFromMessage(message) { return firstText( message?.runner?.kind, @@ -584,15 +487,6 @@ function isCodexAppServerPath({ provider, runnerKind, protocol, implementationTy ); } -function isCodexMcpPath({ runnerKind, sessionMode, implementationType, protocol }) { - return ( - runnerKind === CODEX_MCP_RUNNER || - sessionMode === CODEX_MCP_SESSION_MODE || - implementationType === CODEX_MCP_IMPLEMENTATION || - /mcp/iu.test(String(protocol ?? "")) - ); -} - function parseToolStdout(stdout) { if (typeof stdout !== "string" || !stdout.trim()) return null; const text = stdout.trim(); @@ -613,11 +507,6 @@ function parseJson(text) { } } -function firstBlockerCode(blockers) { - if (!Array.isArray(blockers)) return null; - return blockers.map((blocker) => blocker?.code).find(Boolean) ?? null; -} - function compactFields(fields, separator = " / ") { return fields.filter((item) => item !== null && item !== undefined && String(item).trim()).map((item) => String(item).trim()).join(separator); } diff --git a/web/hwlab-cloud-web/code-agent-facts.test.mjs b/web/hwlab-cloud-web/code-agent-facts.test.mjs index 5cd093bc..c203cfe9 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.test.mjs @@ -4,8 +4,7 @@ import test from "node:test"; import { codeAgentAttributionFromMessage, codeAgentFactsFromMessage, - codeAgentRuntimePathFromMessage, - compactHwlabApiFact + codeAgentRuntimePathFromMessage } from "./code-agent-facts.mjs"; test("codex-readonly-runner is shown as partial read-only session registry", () => { @@ -131,43 +130,6 @@ test("long-lived Codex app-server stdio session fields are shown as full Code Ag assert.match(runtimePath.summary, /Responses wire API/u); }); -test("legacy MCP-shaped Codex stdio is degraded instead of full app-server completion", () => { - const message = { - status: "completed", - provider: "codex-stdio", - model: "gpt-5.5", - backend: "hwlab-cloud-api/codex-mcp-stdio", - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - capabilityLevel: "long-lived-codex-stdio-session", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", - runner: { - kind: "codex-mcp-stdio-runner", - codexStdio: true, - writeCapable: true - }, - longLivedSessionGate: { - status: "pass", - pass: true - }, - providerTrace: { - transport: "stdio", - protocol: "mcp-jsonrpc-stdio", - command: "codex mcp-server", - terminalStatus: "completed" - }, - traceId: "trc_mcp" - }; - const facts = codeAgentFactsFromMessage(message); - const runtimePath = codeAgentRuntimePathFromMessage({ role: "agent", ...message }); - - assert.equal(facts.kind, "codex-mcp-or-other-runner"); - assert.equal(facts.fullCodeAgent, false); - assert.equal(runtimePath.kind, "mcp-or-other-runner"); - assert.equal(runtimePath.label, "DEGRADED:MCP/其他 runner"); -}); - test("Codex app-server without providerTrace is degraded with structured missing fields", () => { const message = { role: "agent", @@ -271,134 +233,6 @@ test("stateless one-shot is not wrapped as a long-lived session", () => { assert.match(facts.summary, /一次性 runner/u); }); -test("HWLAB Skill CLI toolCall shows compact copyable /v1/m3/io facts", () => { - const facts = codeAgentFactsFromMessage({ - status: "source", - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - workspace: "/workspace/hwlab", - sandbox: "hwlab-api-route-only", - capabilityLevel: "hwlab-api-control-ready", - sessionMode: "controlled-m3-io-skill-cli", - runner: { - kind: "hwlab-m3-io-skill-cli", - codexStdio: false, - writeCapable: true, - durableSession: false - }, - longLivedSessionGate: { - status: "blocked", - pass: false - }, - toolCalls: [ - { - name: "hwlab-agent-runtime.m3-io", - type: "skill-cli", - status: "completed", - route: "/v1/m3/io", - method: "POST", - operationId: "op_m3_do_write_cli", - traceId: "trc_m3_skill_cli", - auditId: "aud_m3_do_write_cli_succeeded", - evidenceId: "evd_m3_do_write_cli_succeeded", - audit: { auditId: "aud_m3_do_write_cli_succeeded" }, - evidence: { evidenceId: "evd_m3_do_write_cli_succeeded" }, - accepted: true, - readback: { - status: "succeeded", - value: false, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - blocker: { code: "runtime_durable_not_green" }, - stdout: JSON.stringify({ - route: "/v1/m3/io", - method: "POST", - status: "succeeded", - accepted: true, - traceId: "trc_m3_skill_cli", - operationId: "op_m3_do_write_cli", - auditId: "aud_m3_do_write_cli_succeeded", - evidenceId: "evd_m3_do_write_cli_succeeded", - audit: { auditId: "aud_m3_do_write_cli_succeeded" }, - evidence: { evidenceId: "evd_m3_do_write_cli_succeeded" }, - readback: { - status: "succeeded", - value: false, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - durable: { blocker: "runtime_durable_not_green" } - }) - } - ], - skills: { - status: "used", - count: 1, - items: [{ name: "hwlab-agent-runtime.m3-io" }] - }, - runnerTrace: { - traceId: "trc_m3_skill_cli", - runnerKind: "hwlab-m3-io-skill-cli", - sessionMode: "controlled-m3-io-skill-cli", - route: "/v1/m3/io", - status: "succeeded" - }, - traceId: "trc_m3_skill_cli" - }); - - assert.equal(facts.kind, "hwlab-skill-cli"); - assert.equal(facts.fullCodeAgent, false); - assert.equal(facts.hwlabApiFacts.length, 1); - assert.deepEqual(facts.hwlabApiFacts[0], { - tool: "hwlab-agent-runtime.m3-io", - route: "/v1/m3/io", - method: "POST", - operationId: "op_m3_do_write_cli", - traceId: "trc_m3_skill_cli", - auditId: "aud_m3_do_write_cli_succeeded", - evidenceId: "evd_m3_do_write_cli_succeeded", - accepted: true, - readback: { - status: "succeeded", - value: false, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - blocker: "runtime_durable_not_green", - status: "completed" - }); - assert.equal( - compactHwlabApiFact(facts.hwlabApiFacts[0]), - "route=/v1/m3/io / method=POST / operationId=op_m3_do_write_cli / traceId=trc_m3_skill_cli / auditId=aud_m3_do_write_cli_succeeded / evidenceId=evd_m3_do_write_cli_succeeded / blocker=runtime_durable_not_green" - ); - const attribution = codeAgentAttributionFromMessage({ - role: "agent", - status: "source", - provider: "hwlab-skill-cli", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - capabilityLevel: "hwlab-api-control-ready", - sessionMode: "controlled-m3-io-skill-cli", - runner: { kind: "hwlab-m3-io-skill-cli" }, - toolCalls: facts.hwlabApiFacts.map((fact) => ({ - name: fact.tool, - status: fact.status, - route: fact.route, - operationId: fact.operationId, - traceId: fact.traceId, - auditId: fact.auditId, - evidenceId: fact.evidenceId - })), - traceId: "trc_m3_skill_cli" - }); - assert.equal(attribution.kind, "hwlab-skill-cli"); - assert.equal(attribution.fields.find((field) => field.key === "operationId").value, "op_m3_do_write_cli"); - assert.equal(attribution.fields.find((field) => field.key === "audit").value, "aud_m3_do_write_cli_succeeded"); - assert.equal(attribution.fields.find((field) => field.key === "evidence").value, "evd_m3_do_write_cli_succeeded"); - assert.match(attribution.fields.find((field) => field.key === "toolCalls").value, /hwlab-agent-runtime\.m3-io\(completed\)@\/v1\/m3\/io/u); -}); - test("missing attribution fields render structured evidence insufficiency", () => { const attribution = codeAgentAttributionFromMessage({ role: "agent", diff --git a/web/hwlab-cloud-web/code-agent-m3-evidence.mjs b/web/hwlab-cloud-web/code-agent-m3-evidence.mjs index aed91468..02ace10a 100644 --- a/web/hwlab-cloud-web/code-agent-m3-evidence.mjs +++ b/web/hwlab-cloud-web/code-agent-m3-evidence.mjs @@ -6,8 +6,8 @@ export const CODE_AGENT_M3_TRUSTED_ROUTE = Object.freeze({ toResourceId: "res_boxsimu_2", toPort: "DI1" }); -export const CODE_AGENT_M3_SKILL_PROVIDER = "hwlab-skill-cli"; -export const CODE_AGENT_M3_SKILL_RUNNER_KIND = "hwlab-m3-io-skill-cli"; +export const CODE_AGENT_M3_SKILL_PROVIDER = "legacy-skill-cli"; +export const CODE_AGENT_M3_SKILL_RUNNER_KIND = "legacy-m3-io-skill-cli"; export const CODE_AGENT_M3_SKILL_NAME = "hwlab-agent-runtime.m3-io"; export const CODE_AGENT_M3_PATH_LABEL = "Code Agent -> Skill CLI -> HWLAB API"; diff --git a/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs b/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs deleted file mode 100644 index f28b8d81..00000000 --- a/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs +++ /dev/null @@ -1,325 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - CODE_AGENT_M3_IO_ROUTE, - codeAgentM3EvidenceContractSummary, - extractCodeAgentM3Evidence, - isCodeAgentM3SkillCompletion, - m3EvidenceRows -} from "./code-agent-m3-evidence.mjs"; - -test("renders accepted M3 Skill CLI operation/audit/evidence metadata", () => { - const evidence = extractCodeAgentM3Evidence(m3Message({ - status: "succeeded", - accepted: true, - operationId: "op_m3_cli_accepted", - traceId: "trc_m3_cli_accepted", - auditId: "aud_m3_cli_accepted_succeeded", - evidenceId: "evd_m3_cli_accepted_succeeded", - value: true, - readbackValue: true - })); - - assert.equal(evidence.verdict.key, "accepted"); - assert.equal(evidence.responseType, "m3_io_result"); - assert.equal(evidence.route, CODE_AGENT_M3_IO_ROUTE); - assert.equal(evidence.target.resourceId, "res_boxsimu_1"); - assert.equal(evidence.target.port, "DO1"); - assert.equal(evidence.target.value, true); - assert.equal(evidence.readback.resourceId, "res_boxsimu_2"); - assert.equal(evidence.readback.port, "DI1"); - assert.equal(evidence.readback.value, true); - assert.equal(evidence.ids.operationId, "op_m3_cli_accepted"); - assert.equal(evidence.ids.traceId, "trc_m3_cli_accepted"); - assert.equal(evidence.ids.auditId, "aud_m3_cli_accepted_succeeded"); - assert.equal(evidence.ids.evidenceId, "evd_m3_cli_accepted_succeeded"); - assert.equal(isCodeAgentM3SkillCompletion(m3Message()), true); - assert.equal(rowValue(evidence, "路径"), "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(rowValue(evidence, "接线"), "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"); - assert.match(rowValue(evidence, "DO1"), /res_boxsimu_1:DO1 value=true/u); - assert.match(rowValue(evidence, "DI1"), /res_boxsimu_2:DI1 value=true/u); - assert.equal(rowValue(evidence, "evidenceId"), "evd_m3_cli_accepted_succeeded"); -}); - -test("renders blocker state from Skill CLI without trusted pass", () => { - const evidence = extractCodeAgentM3Evidence(m3Message({ - status: "blocked", - accepted: false, - operationId: "op_m3_blocked", - blocker: { - code: "m3_wiring_missing", - zh: "hwlab-patch-panel 未确认 active DO1 -> DI1 接线" - } - })); - - assert.equal(evidence.verdict.key, "blocked"); - assert.equal(evidence.responseType, "m3_io_blocker"); - assert.equal(evidence.verdict.tone, "blocked"); - assert.match(rowValue(evidence, "blocker"), /m3_wiring_missing/u); - assert.equal(isCodeAgentM3SkillCompletion({ ...m3Message(), status: "failed" }), false); -}); - -test("renders accepted but non-durable M3 IO result as degraded, not DEV-LIVE trusted", () => { - const evidence = extractCodeAgentM3Evidence(m3Message({ - status: "succeeded", - accepted: true, - value: false, - readbackValue: false, - trust: { - trusted: false, - durable: false, - durableStatus: "degraded", - durableBlocker: "runtime_durable_not_green" - }, - trustBlocker: { - code: "runtime_durable_not_green", - layer: "runtime-durable", - zh: "runtime durable 未 green" - } - })); - - assert.equal(evidence.verdict.key, "accepted-untrusted"); - assert.equal(evidence.verdict.tone, "degraded"); - assert.equal(rowValue(evidence, "trusted"), "false"); - assert.equal(rowValue(evidence, "durable"), "false"); - assert.match(rowValue(evidence, "DI1"), /value=false/u); -}); - -test("shows missing evidenceId as unavailable proof instead of fabricating one", () => { - const evidence = extractCodeAgentM3Evidence(m3Message({ - status: "succeeded", - accepted: true, - evidenceId: null - })); - - assert.equal(evidence.verdict.key, "accepted-missing-proof"); - assert.equal(rowValue(evidence, "evidenceId"), "未产生/不可证明"); - assert.equal(row(evidence, "evidenceId").copyable, false); -}); - -test("classifies DO1 target and DI1 readback mismatch as blocked proof", () => { - const evidence = extractCodeAgentM3Evidence(m3Message({ - status: "blocked", - accepted: false, - operationId: "op_m3_readback_mismatch", - value: true, - readbackValue: false, - blocker: { - code: "m3_readback_mismatch", - zh: "DI1 回读值 false 与 DO1 写入值 true 不一致" - } - })); - - assert.equal(evidence.readbackMismatch, true); - assert.equal(evidence.verdict.key, "readback-mismatch"); - assert.match(rowValue(evidence, "DI1"), /res_boxsimu_2:DI1 value=false/u); -}); - -test("classifies direct gateway/box/patch-panel path as invalid and never trusted", () => { - const evidence = extractCodeAgentM3Evidence(m3Message({ - status: "succeeded", - accepted: true, - operationId: "op_m3_direct_path", - controlPath: { - cloudApi: false, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: true - }, - blocker: { - code: "direct_hardware_target_blocked", - zh: "Skill CLI target must be HWLAB cloud-api, not gateway/box/patch-panel." - } - })); - - assert.equal(evidence.directPathInvalid, true); - assert.equal(evidence.verdict.key, "direct-path-invalid"); - assert.equal(evidence.verdict.tone, "blocked"); -}); - -test("contract summary documents operation/audit/evidence visibility", () => { - assert.match(codeAgentM3EvidenceContractSummary(), /operationId/u); - assert.match(codeAgentM3EvidenceContractSummary(), /auditId/u); - assert.match(codeAgentM3EvidenceContractSummary(), /evidenceId/u); - assert.match(codeAgentM3EvidenceContractSummary(), /direct gateway\/box\/patch-panel/u); -}); - -function row(evidence, label) { - const found = m3EvidenceRows(evidence).find((item) => item.label === label); - assert.ok(found, `missing row ${label}`); - return found; -} - -function rowValue(evidence, label) { - return row(evidence, label).value; -} - -function m3Message(overrides = {}) { - const operationId = overrides.operationId ?? "op_m3_cli_accepted"; - const traceId = overrides.traceId ?? "trc_m3_cli_accepted"; - const auditId = Object.hasOwn(overrides, "auditId") ? overrides.auditId : "aud_m3_cli_accepted_succeeded"; - const evidenceId = Object.hasOwn(overrides, "evidenceId") ? overrides.evidenceId : "evd_m3_cli_accepted_succeeded"; - const value = Object.hasOwn(overrides, "value") ? overrides.value : true; - const readbackValue = Object.hasOwn(overrides, "readbackValue") ? overrides.readbackValue : value; - const status = overrides.status ?? "succeeded"; - const accepted = Object.hasOwn(overrides, "accepted") ? overrides.accepted : true; - const responseType = overrides.responseType ?? (accepted && status !== "blocked" ? "m3_io_result" : "m3_io_blocker"); - const trust = overrides.trust ?? { - trusted: responseType === "m3_io_result", - durable: responseType === "m3_io_result", - durableStatus: responseType === "m3_io_result" ? "green" : "blocked", - durableBlocker: responseType === "m3_io_result" ? null : "m3_io_blocked" - }; - const toolCall = { - id: "tool_m3_fixture", - type: "skill-cli", - name: "hwlab-agent-runtime.m3-io", - responseType, - status: accepted ? "completed" : "blocked", - route: CODE_AGENT_M3_IO_ROUTE, - accepted, - operationId, - traceId, - audit: { - auditId - }, - evidence: { - evidenceId, - status: evidenceId ? "green" : "blocked", - sourceKind: evidenceId ? "DEV-LIVE" : "BLOCKED" - }, - command: { - action: "do.write", - resourceId: "res_boxsimu_1", - port: "DO1", - value - }, - result: { - value, - targetReadback: { - resourceId: "res_boxsimu_2", - port: "DI1", - value: readbackValue - } - }, - blocker: overrides.blocker ?? null, - trustBlocker: overrides.trustBlocker ?? null, - blockers: [overrides.blocker, overrides.trustBlocker].filter(Boolean), - controlPath: overrides.controlPath ?? { - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - }, - safety: { - cloudApiRouteOnly: true, - allowedRoute: CODE_AGENT_M3_IO_ROUTE, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false - }, - stdout: JSON.stringify({ - route: CODE_AGENT_M3_IO_ROUTE, - status, - accepted, - traceId, - operationId, - audit: { auditId }, - evidence: { evidenceId }, - blocker: overrides.blocker ?? null, - result: { - value, - targetReadback: { - resourceId: "res_boxsimu_2", - port: "DI1", - value: readbackValue - } - } - }) - }; - - return { - status: "completed", - responseType, - m3Io: { - type: responseType, - status, - action: "do.write", - accepted, - do1: { - resourceId: "res_boxsimu_1", - port: "DO1", - targetValue: value - }, - di1: { - resourceId: "res_boxsimu_2", - port: "DI1", - observedValue: readbackValue - }, - wiring: { - from: "res_boxsimu_1:DO1", - via: "hwlab-patch-panel", - to: "res_boxsimu_2:DI1", - label: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1" - }, - path: { - summary: "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io", - segments: ["Code Agent", "Skill CLI", "HWLAB API"], - hwlabApi: { - route: CODE_AGENT_M3_IO_ROUTE, - method: "POST" - } - }, - operation: { - operationId, - auditId, - evidenceId - }, - trace: { - traceId, - route: CODE_AGENT_M3_IO_ROUTE, - method: "POST" - }, - trust, - blocker: overrides.blocker ?? null, - blockers: [overrides.blocker, overrides.trustBlocker].filter(Boolean) - }, - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - traceId, - conversationId: "conv_m3_fixture", - sessionId: "sess_m3_fixture", - messageId: "msg_m3_fixture", - runner: { - kind: "hwlab-m3-io-skill-cli" - }, - runnerTrace: { - runnerKind: "hwlab-m3-io-skill-cli", - route: CODE_AGENT_M3_IO_ROUTE, - status, - accepted, - operationId, - traceId - }, - providerTrace: { - runnerKind: "hwlab-m3-io-skill-cli", - skill: "hwlab-agent-runtime.m3-io", - responseType, - route: CODE_AGENT_M3_IO_ROUTE, - status, - accepted, - operationId, - traceId, - fallbackUsed: false - }, - toolCalls: [toolCall], - reply: { - content: "M3 IO Skill CLI result fixture." - } - }; -} diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 901066e3..0af840d1 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -191,13 +191,6 @@ function classifyPayload(payload, blockers) { return status("error", "请求错误", "blocked", "×", "当前请求返回错误,输入、sessionId 和 traceId 已保留,可重试。"); } - if (isSkillCliApiControl(payload)) { - const ready = isReadySkillCli(payload); - return ready - ? status("skill-cli-api-control", "HWLAB Skill CLI 控制", "ok", "◆", "通过 HWLAB API 受控路径执行指定能力。") - : status("skill-cli-api-blocked", "Skill CLI 受阻", "blocked", "×", "HWLAB Skill CLI 控制路径当前受阻。"); - } - if (isLongLivedReady(payload)) { return status("long-lived-session", "长会话可用", "ok", "●", "repo-owned 长会话通道可复用。"); } @@ -327,31 +320,6 @@ function mergeSession(baseSession, messageSession) { return { ...base, ...message }; } -function isSkillCliApiControl(payload) { - return ( - payload.provider === "hwlab-skill-cli" || - payload.runner?.kind === "hwlab-m3-io-skill-cli" || - payload.sessionMode === "controlled-m3-io-skill-cli" || - String(payload.capabilityLevel ?? "").startsWith("hwlab-api-control") || - array(payload.toolCalls).some((tool) => tool?.type === "skill-cli" || tool?.route === "/v1/m3/io") - ); -} - -function isReadySkillCli(payload) { - return ( - payload.capabilityLevel === "hwlab-api-control-ready" || - payload.capabilityLevel === "hwlab-api-control-with-approval" || - payload.providerTrace?.controlReady === true || - array(payload.toolCalls).some((tool) => tool?.controlReady === true && tool?.status === "completed") - ) && !hasBlockingSkillCliSignal(payload); -} - -function hasBlockingSkillCliSignal(payload) { - return payload.capabilityLevel === "hwlab-api-control-blocked" || - payload.providerTrace?.controlReady === false || - array(payload.toolCalls).some((tool) => tool?.status === "blocked" || tool?.controlReady === false); -} - function isLongLivedReady(payload) { return ( ( diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 3f2bdce0..f299f003 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -71,37 +71,6 @@ test("summarizes long-lived Code Agent as green with current deployment revision assert.equal(Object.keys(summary.fields).includes("sourceMain"), false); }); -test("does not mark legacy MCP-shaped Codex stdio as app-server long-lived session", () => { - const summary = classifyCodeAgentStatusSummary({ - latestMessage: { - status: "completed", - provider: "codex-stdio", - backend: "hwlab-cloud-api/codex-mcp-stdio", - capabilityLevel: "long-lived-codex-stdio-session", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", - runner: { - kind: "codex-mcp-stdio-runner", - codexStdio: true - }, - longLivedSessionGate: { - status: "pass", - pass: true - }, - providerTrace: { - protocol: "mcp-jsonrpc-stdio", - command: "codex mcp-server", - terminalStatus: "completed" - }, - traceId: "trc_mcp" - } - }); - - assert.notEqual(summary.kind, "long-lived-session"); - assert.notEqual(summary.tone, "ok"); - assert.equal(summary.runtimePathLabel, "DEGRADED:MCP/其他 runner"); -}); - test("summarizes Codex app-server stdio thread as reusable long-lived session", () => { const summary = classifyCodeAgentStatusSummary({ latestMessage: { @@ -150,34 +119,6 @@ test("summarizes Codex app-server stdio thread as reusable long-lived session", assert.equal(summary.fields.threadId, "thread_app_server"); }); -test("does not mark Codex stdio-shaped fields green without runner and gate evidence", () => { - const summary = classifyCodeAgentStatusSummary({ - availability: { - status: "partial", - ready: true, - provider: "openai-responses", - mode: "openai", - backend: "hwlab-cloud-api/openai-responses", - capabilityLevel: "long-lived-codex-stdio-session", - sessionMode: "codex-mcp-stdio-long-lived", - runner: { - kind: "openai-responses-fallback", - codexStdio: false, - writeCapable: false - }, - longLivedSessionGate: { - status: "blocked", - pass: false, - blockers: [{ code: "openai_responses_fallback_not_session" }] - } - } - }); - - assert.notEqual(summary.kind, "long-lived-session"); - assert.notEqual(summary.tone, "ok"); - assert.equal(summary.readinessBlockers.includes("openai_responses_fallback_not_session"), true); -}); - test("maps degraded readonly session tools to warning, not green", () => { const summary = classifyCodeAgentStatusSummary({ availability: { @@ -470,39 +411,6 @@ test("missing Code Agent session evidence is shown as lifecycle degradation", () assert.match(summary.sessionLifecycleHint, /会话证据不完整/u); }); -test("maps HWLAB Skill CLI API control to distinct green state when ready", () => { - const summary = classifyCodeAgentStatusSummary({ - latestMessage: { - status: "completed", - provider: "hwlab-skill-cli", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - capabilityLevel: "hwlab-api-control-ready", - sessionMode: "controlled-m3-io-skill-cli", - session: { - status: "idle", - lastTraceId: "trc_skill" - }, - toolCalls: [ - { - type: "skill-cli", - route: "/v1/m3/io", - status: "completed", - controlReady: true - } - ], - providerTrace: { - controlReady: true - } - } - }); - - assert.equal(summary.kind, "skill-cli-api-control"); - assert.equal(summary.label, "HWLAB Skill CLI 控制"); - assert.equal(summary.tone, "ok"); - assert.equal(summary.provider, "hwlab-skill-cli"); - assert.equal(summary.capabilityLevel, "hwlab-api-control-ready"); -}); - test("maps blockers to blocked tone and redacts secret-like fields", () => { const summary = classifyCodeAgentStatusSummary({ availability: { diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index f392babe..4a2b5001 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -175,31 +175,31 @@ data-agent-quick-prompt="pwd" data-prompt-action="fill" title="只填充输入框,点击发送后交给 Code Agent" - data-prompt-text="通过 Skill CLI 执行 pwd,列出当前工作目录。" - >Skill CLI:pwd + data-prompt-text="交给 Codex 执行 pwd,列出当前工作目录。" + >Codex:pwd + data-prompt-text="交给 Codex 列出你能使用的所有 skill。" + >Codex:列出 skill + data-prompt-text="交给 Codex 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。" + >Codex:DO1=true 后读 DI1 + data-prompt-text="交给 Codex 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。" + >Codex:DO1=false 复核 DI1