diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 8da1c8bf..58d9abe9 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -2233,7 +2233,17 @@ function m3IoSkillArgsForIntent(intent, { env, traceId }) { args.push("--api-base-url", apiBaseUrl); } if (intent.action === "do.write") { - args.push("--value", String(intent.value)); + args.push( + "--value", + String(intent.value), + "--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; } diff --git a/package.json b/package.json index a99feff3..4b21760a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "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/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 --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "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/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/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-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 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/dev-artifact-publish.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/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 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 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/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 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/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 --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/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/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-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 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/dev-artifact-publish.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/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/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 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/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/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", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index 1d50fed9..40ec9e3b 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -31,9 +31,12 @@ All commands emit JSON and stay local: - `node cmd/hwlab-agent-mgr/main.mjs evidence --agent-session-id ID` - `node cmd/hwlab-agent-mgr/main.mjs cleanup --agent-session-id ID` - `node cmd/hwlab-agent-worker/main.mjs dry-run --agent-session-id ID` -- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url URL` -- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url URL` +- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 status --api-base-url URL` +- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --approved --api-base-url URL` +- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --approved --api-base-url URL` - `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url URL` +- `node tools/hwlab-cli/bin/hwlab-cli.mjs m3 status --api-base-url URL` +- `node tools/hwlab-cli/bin/hwlab-cli.mjs m3 io --action do.write --value true --approved --api-base-url URL` Use `--state-dir DIR` to isolate local state. The default state directory is `.state/agent-runtime`, which is intended for local runtime state only. @@ -55,13 +58,25 @@ gateway/box/patch-panel targets. If no API base URL is configured, the CLI returns `skill_cli_api_base_missing` and does not fall back to `127.0.0.1` or any direct hardware service. +Use `m3 status` for read-only status aggregation; it performs `GET +/v1/m3/status` through the HWLAB API base and never calls gateway, box, or +patch-panel URLs. Use `m3 io --action di.read` for a controlled DI read through +`POST /v1/m3/io`. DO writes must include `--approved` or `--confirm-control`; +without that explicit approval flag the CLI returns `approval_required` before +any HTTP request. The Code Agent runner adds this flag only after the user's +message explicitly asks to write `res_boxsimu_1` / `DO1` through the HWLAB API, +and the request body carries an `approval` object with policy +`hwlab-api-control-with-approval`. + The JSON response includes `route`, `method`, `traceId`, `operationId`, `audit`, `evidence`, `accepted`, `status`, `readback`, `capabilityLevel`, `readiness`, `blocker`, `trustBlocker`, and direct-call safety flags. `capabilityLevel` is -`hwlab-api-control-ready` only after the HWLAB API accepts the request through -`/v1/m3/io`; direct target rejection, gateway unavailability, missing wiring, or -other control blockers return `hwlab-api-control-blocked`. Durable runtime -blockers are preserved as structured `durable`, `trustBlocker`, and `evidence` -state; a reachable control path with blocked durable persistence must not be -called DEV-LIVE or trusted green. +`hwlab-api-control-with-approval` only after the HWLAB API accepts an approved +write request through `/v1/m3/io`; read-only status uses +`hwlab-api-readonly`. Direct target rejection, gateway unavailability, missing +wiring, approval absence, or other control blockers return +`hwlab-api-control-blocked`. Durable runtime blockers are preserved as +structured `durable`, `trustBlocker`, and `evidence` state; a reachable control +path with blocked durable persistence must not be called DEV-LIVE or trusted +green. 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 index 9faeb4a9..a74a9ec2 100644 --- a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs +++ b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs @@ -6,6 +6,7 @@ import { 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"; @@ -22,6 +23,7 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () = "false", "--api-base-url", "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + "--approved", "--trace-id", "trc_skill_cli_write_false", "--request-id", @@ -38,6 +40,8 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () = 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, @@ -91,6 +95,8 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () = 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"); @@ -153,6 +159,179 @@ test("M3 Skill CLI validates exact API route contract", () => { 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( [ @@ -237,6 +416,7 @@ test("M3 Skill CLI preserves slow structured blocker and timeout budget", async "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", 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 2faed4e4..d45ce106 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 @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; export const HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION = "hwlab-agent-runtime-skill-cli-v1"; export const HWLAB_M3_IO_API_ROUTE = "/v1/m3/io"; +export const HWLAB_M3_STATUS_API_ROUTE = "/v1/m3/status"; export const HWLAB_M3_IO_SKILL_NAME = "hwlab-agent-runtime.m3-io"; export const HWLAB_M3_IO_API_BASE_URL_ENV = "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL"; export const HWLAB_M3_IO_DEV_SERVICE_BASE_URL = "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"; @@ -12,7 +13,8 @@ export const HWLAB_M3_IO_API_BASE_URL_ENVS = Object.freeze([ ]); export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({ blocked: "hwlab-api-control-blocked", - ready: "hwlab-api-control-ready" + readonly: "hwlab-api-readonly", + ready: "hwlab-api-control-with-approval" }); const allowedActions = new Set(["do.write", "di.read"]); @@ -23,6 +25,15 @@ const safeApiBaseEnvNames = Object.freeze([ "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; @@ -34,12 +45,15 @@ export async function runM3IoSkillCommand(argv = [], options = {}) { const traceId = parsed.traceId || `trc_${randomUUID()}`; const requestId = parsed.requestId || `req_${randomUUID()}`; const actorId = parsed.actorId || "usr_code_agent"; - const apiTarget = resolveCloudApiTarget(parsed.apiBaseUrl, env); + const apiTarget = resolveCloudApiTarget(parsed.apiBaseUrl, env, { + route: parsed.mode === "status" ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE + }); const validation = validateCloudApiTarget(apiTarget); if (validation) { return blockedPayload({ traceId, requestId, + actorId, apiTarget, code: validation.code, message: validation.message, @@ -47,11 +61,24 @@ export async function runM3IoSkillCommand(argv = [], options = {}) { }); } + if (parsed.mode === "status") { + return runM3StatusSkillCommand({ + apiTarget, + parsed, + traceId, + requestId, + actorId, + requestJson: options.requestJson, + now: options.now + }); + } + const command = normalizeCommand(parsed); if (!allowedActions.has(command.action)) { return blockedPayload({ traceId, requestId, + actorId, apiTarget, code: "invalid_action", message: "M3 Skill CLI only allows do.write or di.read." @@ -61,13 +88,26 @@ export async function runM3IoSkillCommand(argv = [], options = {}) { return blockedPayload({ traceId, requestId, + actorId, apiTarget, code: "invalid_boolean_value", message: "M3 DO1 write requires --value true or --value false." }); } + const approval = approvalForParsed(parsed, command); + if (approval.blocker) { + return blockedPayload({ + traceId, + requestId, + actorId, + apiTarget, + code: approval.blocker.code, + message: approval.blocker.message, + approval + }); + } - const payload = requestPayload(command, { traceId, requestId, actorId }); + const payload = requestPayload(command, { traceId, requestId, actorId, approval }); const startedAt = nowIso(options.now); const response = await postJson(apiTarget.url, payload, { requestJson: options.requestJson, @@ -100,18 +140,22 @@ export function parseM3IoArgs(argv = [], env = process.env) { const parsed = { command, subcommand, + mode: command === "m3" && subcommand === "status" ? "status" : "io", action: null, value: undefined, apiBaseUrl: null, traceId: null, requestId: null, actorId: null, + approved: false, + approvalReason: null, + policy: null, timeoutMs: defaultTimeoutMs, pretty: false, help: command === "help" || command === "--help" || command === "-h" }; - if (command !== "m3" || subcommand !== "io") { + if (command !== "m3" || !["io", "status"].includes(subcommand)) { parsed.help = true; return parsed; } @@ -134,11 +178,17 @@ export function parseM3IoArgs(argv = [], env = process.env) { parsed.requestId = readOption(args, ++index, arg); } else if (arg === "--actor-id") { parsed.actorId = readOption(args, ++index, arg); + } else if (arg === "--approved" || arg === "--confirm-control") { + parsed.approved = true; + } else if (arg === "--approval-reason") { + parsed.approvalReason = readOption(args, ++index, arg); + } else if (arg === "--policy") { + parsed.policy = readOption(args, ++index, arg); } else if (arg === "--timeout-ms") { parsed.timeoutMs = parseTimeout(readOption(args, ++index, arg)); - } else if (arg === "write") { + } else if (subcommand === "io" && arg === "write") { parsed.action = "do.write"; - } else if (arg === "read") { + } else if (subcommand === "io" && arg === "read") { parsed.action = "di.read"; } else { throw new Error(`unknown argument ${arg}`); @@ -149,11 +199,13 @@ export function parseM3IoArgs(argv = [], env = process.env) { return parsed; } -export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { +export function resolveCloudApiTarget(apiBaseUrl, env = process.env, options = {}) { + const route = options.route ?? HWLAB_M3_IO_API_ROUTE; const source = cloudApiBaseUrlSource(apiBaseUrl, env); const base = firstNonEmpty(apiBaseUrl, configuredCloudApiBaseUrl(env)); if (!base) { return cloudApiTargetEnvelope({ + route, source, url: null, redactedUrl: null, @@ -164,9 +216,10 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { let url; try { - url = new URL(HWLAB_M3_IO_API_ROUTE, ensureTrailingSlash(base)); + url = new URL(route, ensureTrailingSlash(base)); } catch { return cloudApiTargetEnvelope({ + route, source, url: String(base), redactedUrl: redactUrl(base), @@ -176,6 +229,7 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { } return cloudApiTargetEnvelope({ + route, source, url: url.toString(), redactedUrl: redactUrl(url.toString()), @@ -183,9 +237,9 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { }); } -function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false, missingConfig = [] }) { +function cloudApiTargetEnvelope({ route = HWLAB_M3_IO_API_ROUTE, source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false, missingConfig = [] }) { return { - route: HWLAB_M3_IO_API_ROUTE, + route, url, redactedUrl, source, @@ -227,10 +281,10 @@ export function validateCloudApiTarget(apiTarget) { }; } - if (url.pathname !== HWLAB_M3_IO_API_ROUTE) { + 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}.` + message: `Skill CLI must call exactly ${HWLAB_M3_IO_API_ROUTE} or ${HWLAB_M3_STATUS_API_ROUTE}.` }; } @@ -242,6 +296,13 @@ export function validateCloudApiTarget(apiTarget) { }; } + 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; } @@ -278,10 +339,41 @@ function requestPayload(command, meta) { traceId: meta.traceId, requestId: meta.requestId, actorId: meta.actorId, + approval: meta.approval + ? { + approved: meta.approval.approved, + policy: meta.approval.policy, + reason: meta.approval.reason, + required: meta.approval.required + } + : undefined, source: HWLAB_M3_IO_SKILL_NAME }; } +async function runM3StatusSkillCommand({ apiTarget, parsed, traceId, requestId, actorId, requestJson, now }) { + const startedAt = nowIso(now); + const response = await getJson(apiTarget.url, { + requestJson, + timeoutMs: parsed.timeoutMs, + headers: { + "x-trace-id": traceId, + "x-request-id": requestId, + "x-actor-id": actorId + } + }); + const finishedAt = nowIso(now); + return normalizeStatusResponse({ + response, + apiTarget, + traceId, + requestId, + actorId, + startedAt, + finishedAt + }); +} + async function postJson(url, body, { requestJson, timeoutMs, headers }) { if (typeof requestJson === "function") { try { @@ -339,6 +431,59 @@ async function postJson(url, body, { requestJson, timeoutMs, headers }) { } } +async function getJson(url, { requestJson, timeoutMs, headers }) { + if (typeof requestJson === "function") { + try { + const result = await requestJson(url, { + method: "GET", + headers: { + accept: "application/json", + ...headers + }, + timeoutMs + }); + return normalizeJsonResponse(result); + } catch (error) { + return { + ok: false, + status: 0, + body: null, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "GET", + headers: { + accept: "application/json", + ...headers + }, + signal: controller.signal + }); + const text = await response.text(); + const parsed = parseJsonOrNull(text); + return { + ok: response.ok, + status: response.status, + body: parsed, + error: response.ok ? null : parsed?.error?.message ?? parsed?.message ?? response.statusText + }; + } catch (error) { + return { + ok: false, + status: 0, + body: null, + error: error.name === "AbortError" ? `HWLAB API request timed out after ${timeoutMs}ms` : error.message + }; + } finally { + clearTimeout(timer); + } +} + function normalizeJsonResponse(result = {}) { if (Object.hasOwn(result, "ok") || Object.hasOwn(result, "body")) { return { @@ -368,7 +513,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; const capabilityBlocker = controlReady ? null : normalizeCapabilityBlocker(blocker, response); - const trustBlocker = normalizeTrustBlocker(body); + const trustBlocker = normalizeTrustBlocker(body, HWLAB_M3_IO_API_ROUTE); const blockers = dedupeBlockers([capabilityBlocker, trustBlocker]); return { @@ -386,6 +531,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId traceId: body.traceId ?? traceId, requestId, actorId, + approval: payload.approval ?? approvalForAction(command.action), operationId: body.operationId ?? null, auditId: body.auditId ?? body.auditState?.auditId ?? null, evidenceId: body.evidenceId ?? body.evidenceState?.evidenceId ?? null, @@ -453,11 +599,142 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId rawStatus: body.status ?? null, startedAt, finishedAt, - response: body + responseSummary: responseSummary(body) }; } -function blockedPayload({ traceId, requestId, apiTarget, code, message, missingConfig = [] }) { +function normalizeStatusResponse({ response, apiTarget, traceId, requestId, actorId, startedAt, finishedAt }) { + const body = response.body ?? {}; + const blocker = response.ok + ? normalizeStatusBlocker(body, response) + : normalizeBlocker(body, response, { + route: HWLAB_M3_STATUS_API_ROUTE, + unavailableUserMessage: "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。", + defaultMessage: "M3 status request was blocked." + }); + const ready = response.ok && !blocker && !["blocked", "error", "failed"].includes(String(body.status ?? "").toLowerCase()); + const trustBlocker = normalizeTrustBlocker(body, HWLAB_M3_STATUS_API_ROUTE); + const blockers = dedupeBlockers([blocker, trustBlocker]); + return { + ok: response.ok && !blocker, + service: HWLAB_M3_IO_SKILL_NAME, + contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, + route: HWLAB_M3_STATUS_API_ROUTE, + method: "GET", + hwlabApi: publicCloudApiTarget(apiTarget), + capabilityLevel: ready ? HWLAB_M3_IO_CAPABILITY_LEVELS.readonly : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + controlReady: ready, + readonly: true, + accepted: response.ok && !blocker, + status: body.status ?? (response.ok ? "succeeded" : "blocked"), + traceId: body.traceId ?? body.trust?.traceId ?? traceId, + requestId, + actorId, + operationId: body.trust?.operationId ?? body.operationId ?? null, + auditId: body.trust?.auditId ?? body.auditId ?? null, + evidenceId: body.trust?.evidenceId ?? body.evidenceId ?? null, + audit: { + auditId: body.trust?.auditId ?? body.auditId ?? null, + status: body.trust?.readStatus?.audit ?? null, + durableStatus: body.trust?.durableStatus ?? null, + summary: body.trust?.auditId ? `${body.trust.auditId}; status=${body.trust.readStatus?.audit ?? "unknown"}` : "audit not reported" + }, + evidence: { + evidenceId: body.trust?.evidenceId ?? body.evidenceId ?? null, + status: body.trust?.readStatus?.evidence ?? null, + sourceKind: body.sourceKind ?? null, + blocker: body.trust?.blocker ?? null, + writeStatus: null, + summary: body.trust?.evidenceId ? `${body.trust.evidenceId}; status=${body.trust.readStatus?.evidence ?? "unknown"}` : "evidence not reported" + }, + durable: { + status: body.trust?.durableStatus ?? body.trust?.runtime?.status ?? null, + durable: body.trust?.runtime?.durable === true, + blocker: body.trust?.blocker ?? body.trust?.runtime?.blocker ?? null, + category: body.trust?.blocker ? blockerCategory(body.trust.blocker) : null, + summary: body.trust?.blocker ? `durable evidence blocked: ${body.trust.blocker}` : "status read completed" + }, + blocker, + capabilityBlocker: blocker, + trustBlocker, + blockers, + readiness: { + status: ready ? "ready" : "blocked", + controlReady: ready, + capabilityLevel: ready ? HWLAB_M3_IO_CAPABILITY_LEVELS.readonly : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + route: HWLAB_M3_STATUS_API_ROUTE, + blocker, + trustBlocker + }, + result: { + value: body.boxes?.find?.((box) => box?.resourceId === "res_boxsimu_2")?.ports?.DI1?.value ?? null, + targetReadback: null + }, + readback: { + status: body.status ?? null, + value: body.boxes?.find?.((box) => box?.resourceId === "res_boxsimu_2")?.ports?.DI1?.value ?? null, + resourceId: "res_boxsimu_2", + port: "DI1" + }, + statusSummary: { + sourceKind: body.sourceKind ?? null, + readinessStatus: body.readinessStatus ?? null, + gateways: Array.isArray(body.gateways) ? body.gateways.map((gateway) => ({ + id: gateway.id, + online: gateway.online, + blocker: gateway.blocker ?? null + })) : [], + boxes: Array.isArray(body.boxes) ? body.boxes.map((box) => ({ + id: box.id, + resourceId: box.resourceId, + online: box.online, + ports: box.ports + })) : [], + patchPanel: body.patchPanel ? { + serviceId: body.patchPanel.serviceId, + observable: body.patchPanel.observable, + connectionActive: body.patchPanel.connectionActive, + blocker: body.patchPanel.blocker ?? null, + lastError: body.patchPanel.lastError ?? null + } : null + }, + controlPath: { + cloudApi: true, + gatewaySimu: false, + boxSimu: false, + patchPanel: false, + frontendBypass: false + }, + safety: { + cloudApiRouteOnly: true, + allowedRoute: HWLAB_M3_STATUS_API_ROUTE, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false, + fallbackUsed: false, + openAiFallbackUsed: false + }, + httpStatus: response.status, + error: response.ok ? body.error ?? null : { + code: "hwlab_api_unavailable", + layer: "hwlab-api", + blocker, + retryable: true, + userMessage: "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。", + message: response.error ?? "HWLAB API request failed", + traceId, + route: HWLAB_M3_STATUS_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME + }, + rawStatus: body.status ?? null, + startedAt, + finishedAt + }; +} + +function blockedPayload({ traceId, requestId, actorId = "usr_code_agent", apiTarget, code, message, missingConfig = [], approval = null }) { + const route = apiTarget?.route ?? HWLAB_M3_IO_API_ROUTE; + const method = route === HWLAB_M3_STATUS_API_ROUTE ? "GET" : "POST"; const capabilityBlocker = { code, layer: blockerLayer(code), @@ -467,7 +744,7 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC retryable: retryableBlocker(code), userMessage: userMessageForBlocker(code, message), traceId, - route: HWLAB_M3_IO_API_ROUTE, + route, toolName: HWLAB_M3_IO_SKILL_NAME, missingConfig: sanitizeMissingConfig(missingConfig) }; @@ -475,8 +752,8 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC ok: false, service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route: HWLAB_M3_IO_API_ROUTE, - method: "POST", + route, + method, hwlabApi: publicCloudApiTarget(apiTarget), capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, controlReady: false, @@ -484,6 +761,8 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC status: "blocked", traceId, requestId, + actorId, + approval: approvalPayload(approval) ?? approvalForAction(null), operationId: null, auditId: null, evidenceId: null, @@ -514,13 +793,13 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC status: "blocked", controlReady: false, capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - route: HWLAB_M3_IO_API_ROUTE, + route, blocker: capabilityBlocker, trustBlocker: null }, safety: { cloudApiRouteOnly: true, - allowedRoute: HWLAB_M3_IO_API_ROUTE, + allowedRoute: route, directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, @@ -535,27 +814,58 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC userMessage: capabilityBlocker.userMessage, message, traceId, - route: HWLAB_M3_IO_API_ROUTE, + route, toolName: HWLAB_M3_IO_SKILL_NAME, missingConfig: capabilityBlocker.missingConfig } }; } -function normalizeBlocker(body, response) { +function normalizeStatusBlocker(body, response) { + if (!response.ok) { + return normalizeBlocker(body, response, { + route: HWLAB_M3_STATUS_API_ROUTE, + unavailableUserMessage: "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。", + defaultMessage: "M3 status request was blocked." + }); + } + const raw = body.blocker ?? body.error ?? null; + const trustBlockerCode = body.trust?.blocker ?? null; + if (!raw && !trustBlockerCode && !["blocked", "error", "failed"].includes(String(body.status ?? "").toLowerCase())) { + return null; + } + const code = raw?.code ?? trustBlockerCode ?? "m3_status_blocked"; + return { + code, + layer: blockerLayer(code), + message: raw?.message ?? raw?.reason ?? raw?.zh ?? body.summary ?? "M3 status request was blocked.", + zh: raw?.zh ?? raw?.message ?? raw?.reason ?? body.summary ?? "M3 status request was blocked.", + category: blockerCategory(code), + retryable: retryableBlocker(code), + userMessage: raw?.userMessage ?? raw?.zh ?? userMessageForBlocker(code, raw?.message ?? raw?.reason), + traceId: body.traceId ?? body.trust?.traceId ?? null, + route: HWLAB_M3_STATUS_API_ROUTE, + toolName: HWLAB_M3_IO_SKILL_NAME + }; +} + +function normalizeBlocker(body, response, options = {}) { + const route = options.route ?? HWLAB_M3_IO_API_ROUTE; + const unavailableUserMessage = options.unavailableUserMessage ?? "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。"; + const defaultMessage = options.defaultMessage ?? "M3 IO request was blocked."; const raw = body.blocker ?? body.error ?? null; if (raw) { const code = raw.code ?? body.blockerClassification?.code ?? "m3_io_blocked"; return { code, layer: blockerLayer(code), - message: raw.message ?? raw.reason ?? raw.zh ?? body.blockerClassification?.reason ?? "M3 IO request was blocked.", - zh: raw.zh ?? raw.message ?? raw.reason ?? "M3 IO request was blocked.", + message: raw.message ?? raw.reason ?? raw.zh ?? body.blockerClassification?.reason ?? defaultMessage, + zh: raw.zh ?? raw.message ?? raw.reason ?? defaultMessage, category: body.blockerClassification?.category ?? blockerCategory(code), retryable: retryableBlocker(code), - userMessage: raw.userMessage ?? raw.zh ?? userMessageForBlocker(code, raw.message ?? raw.reason), + userMessage: raw.userMessage ?? raw.zh ?? userMessageForBlocker(code, raw.message ?? raw.reason, { route }), traceId: body.traceId ?? null, - route: HWLAB_M3_IO_API_ROUTE, + route, toolName: HWLAB_M3_IO_SKILL_NAME }; } @@ -567,8 +877,8 @@ function normalizeBlocker(body, response) { zh: response.error ?? `HWLAB API HTTP ${response.status}`, category: "hwlab_api", retryable: true, - userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。", - route: HWLAB_M3_IO_API_ROUTE, + userMessage: unavailableUserMessage, + route, toolName: HWLAB_M3_IO_SKILL_NAME }; } @@ -590,8 +900,8 @@ function normalizeCapabilityBlocker(blocker, response) { }; } -function normalizeTrustBlocker(body) { - const code = body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? null; +function normalizeTrustBlocker(body, route = HWLAB_M3_IO_API_ROUTE) { + const code = body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? body.trust?.blocker ?? null; if (!code) return null; return { code, @@ -601,7 +911,7 @@ function normalizeTrustBlocker(body) { layer: "runtime-durable", retryable: false, userMessage: "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。", - route: HWLAB_M3_IO_API_ROUTE, + route, toolName: HWLAB_M3_IO_SKILL_NAME, source: body.evidenceState?.blocker ? "evidenceState.blocker" : "durableStatus.blocker" }; @@ -646,9 +956,13 @@ function retryableBlocker(code) { return ["hwlab_api_unavailable"].includes(String(code ?? "")); } -function userMessageForBlocker(code, message) { +function userMessageForBlocker(code, message, options = {}) { if (code === "skill_cli_api_base_missing") return "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。"; - if (code === "hwlab_api_unavailable") return "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。"; + if (code === "hwlab_api_unavailable") { + return options.route === HWLAB_M3_STATUS_API_ROUTE + ? "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 控制链路仍受阻,前端应显示为能力未就绪。"; @@ -713,21 +1027,72 @@ function helpPayload() { usage: [ `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`, `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`, - `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}` + `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`, + `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 status --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}` ], env: { recommended: HWLAB_M3_IO_API_BASE_URL_ENV, supported: [...HWLAB_M3_IO_API_BASE_URL_ENVS], devValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL }, - route: HWLAB_M3_IO_API_ROUTE, + routes: [HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE], safety: { cloudApiRouteOnly: true, directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, - fallbackUsed: false - } + fallbackUsed: false, + writeApprovalRequired: true + }, + allowlist: allowedCloudApiTargets.map((target) => ({ + hostname: target.hostname, + ports: [...target.ports] + })) + }; +} + +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"); + if (required && !approved) { + return { + required, + approved: false, + policy, + reason, + blocker: { + code: "approval_required", + message: "M3 DO write requires --approved or --confirm-control so the Skill CLI records explicit controlled approval semantics." + } + }; + } + return { + required, + approved, + policy, + reason + }; +} + +function approvalForAction(action) { + const required = action === "do.write"; + return { + required, + approved: !required, + policy: required ? "hwlab-api-control-with-approval" : "hwlab-api-readonly", + reason: required ? null : "readonly or blocked before command normalization" + }; +} + +function approvalPayload(approval) { + if (!approval || typeof approval !== "object") return null; + return { + required: approval.required === true, + approved: approval.approved === true, + policy: approval.policy ?? null, + reason: approval.reason ?? null }; } @@ -754,6 +1119,12 @@ 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])); } @@ -787,9 +1158,10 @@ function redactUrl(value) { function publicCloudApiTarget(apiTarget) { if (!apiTarget || typeof apiTarget !== "object") return null; + const redactedUrl = apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null); return { route: apiTarget.route ?? HWLAB_M3_IO_API_ROUTE, - redactedUrl: apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null), + redactedUrl: redactedUrl && forbiddenDirectTarget.test(redactedUrl) ? null : redactedUrl, source: apiTarget.source ?? null, missingConfig: sanitizeMissingConfig(apiTarget.missingConfig ?? []), cloudApiOnly: apiTarget.cloudApiOnly === true, @@ -799,6 +1171,28 @@ function publicCloudApiTarget(apiTarget) { }; } +function responseSummary(body = {}) { + return { + serviceId: body.serviceId ?? null, + contractVersion: body.contractVersion ?? null, + status: body.status ?? null, + accepted: body.accepted ?? null, + action: body.action ?? null, + operationId: body.operationId ?? null, + traceId: body.traceId ?? null, + auditId: body.auditId ?? body.auditState?.auditId ?? null, + evidenceId: body.evidenceId ?? body.evidenceState?.evidenceId ?? null, + blockerCode: body.blocker?.code ?? body.error?.code ?? body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? null, + controlPath: body.controlPath ? { + cloudApi: body.controlPath.cloudApi ?? null, + gatewaySimu: body.controlPath.gatewaySimu ?? null, + boxSimu: body.controlPath.boxSimu ?? null, + patchPanel: body.controlPath.patchPanel ?? null, + frontendBypass: body.controlPath.frontendBypass ?? null + } : null + }; +} + function parseJsonOrNull(value) { try { return JSON.parse(value); diff --git a/tools/hwlab-cli/lib/cli.mjs b/tools/hwlab-cli/lib/cli.mjs index 3598d878..8c53f925 100644 --- a/tools/hwlab-cli/lib/cli.mjs +++ b/tools/hwlab-cli/lib/cli.mjs @@ -1,4 +1,5 @@ import { DEV_ENDPOINT, loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs"; +import { runM3IoSkillCommand } from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; function formatJson(value) { return JSON.stringify(value, null, 2); @@ -169,6 +170,9 @@ function writeHelp(stdout) { commands: [ "health", "project list", + "m3 status --api-base-url URL", + "m3 io --action do.write --value true --approved --api-base-url URL", + "m3 io --action di.read --api-base-url URL", "test e2e --env dev --mvp", "test e2e --env dev --mvp --dry-run", "test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production" @@ -185,8 +189,25 @@ export async function runCli(argv, io) { const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const repoRoot = io.cwd ?? process.cwd(); - let summary; + const env = io.env ?? process.env; + const [command = "help", subcommand, ...rest] = argv; + const { flags, options } = parseArgs(rest); + + if (command === "m3" && ["io", "status"].includes(subcommand)) { + const result = await runM3IoSkillCommand(["m3", subcommand, ...rest], { + env, + now: io.now, + requestJson: io.requestJson + }); + writeJson(result.ok ? stdout : stderr, { + ...result, + command: `hwlab-cli ${["m3", subcommand, ...redactM3CliArgs(rest)].join(" ")}` + }); + return result.ok ? 0 : 2; + } + + let summary; try { summary = loadMvpGateSummary(repoRoot); } catch (error) { @@ -194,9 +215,6 @@ export async function runCli(argv, io) { return 1; } - const [command = "help", subcommand, ...rest] = argv; - const { flags, options } = parseArgs(rest); - if (command === "health") { writeJson(stdout, mvpGateOverview(summary)); return 0; @@ -264,6 +282,18 @@ export async function runCli(argv, io) { return 0; } +function redactM3CliArgs(args = []) { + const redacted = []; + for (let index = 0; index < args.length; index += 1) { + redacted.push(args[index]); + if (args[index] === "--api-base-url" && index + 1 < args.length) { + redacted.push(""); + index += 1; + } + } + return redacted; +} + export function formatRuntime(runtime) { return formatJson(runtime); } diff --git a/tools/hwlab-cli/lib/cli.test.mjs b/tools/hwlab-cli/lib/cli.test.mjs new file mode 100644 index 00000000..7ba989d7 --- /dev/null +++ b/tools/hwlab-cli/lib/cli.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + HWLAB_M3_IO_API_ROUTE, + HWLAB_M3_STATUS_API_ROUTE +} from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; +import { runCli } from "./cli.mjs"; + +async function captureCli(args, options = {}) { + let stdout = ""; + let stderr = ""; + const exitCode = await runCli(args, { + cwd: options.cwd ?? process.cwd(), + env: options.env ?? { + PATH: process.env.PATH + }, + requestJson: options.requestJson, + stdout: { + write(chunk) { + stdout += chunk; + } + }, + stderr: { + write(chunk) { + stderr += chunk; + } + } + }); + return { exitCode, stdout, stderr }; +} + +test("repo hwlab-cli m3 status uses Skill CLI and calls only HWLAB API /v1/m3/status", async () => { + const calls = []; + const result = await captureCli( + [ + "m3", + "status", + "--api-base-url", + "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + "--trace-id", + "trc_hwlab_cli_status" + ], + { + 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_hwlab_cli_status", + boxes: [{ + id: "boxsimu_2", + resourceId: "res_boxsimu_2", + online: true, + ports: { + DI1: { + value: false + } + } + }] + } + }; + } + } + ); + + assert.equal(result.exitCode, 0, result.stderr); + const body = JSON.parse(result.stdout); + assert.equal(body.route, HWLAB_M3_STATUS_API_ROUTE); + assert.equal(body.method, "GET"); + assert.equal(body.readback.value, false); + assert.equal(body.command.includes("hwlab-cloud-api.hwlab-dev"), false); + assert.equal(calls.length, 1); +}); + +test("repo hwlab-cli m3 io blocks direct hardware targets before request", async () => { + const result = await captureCli( + [ + "m3", + "io", + "--action", + "di.read", + "--api-base-url", + "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301", + "--trace-id", + "trc_hwlab_cli_direct_blocked" + ], + { + requestJson: async () => { + throw new Error("direct hardware target must not be called"); + } + } + ); + + assert.equal(result.exitCode, 2); + const body = JSON.parse(result.stderr); + assert.equal(body.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(body.blocker.code, "direct_hardware_target_blocked"); + assert.equal(body.hwlabApi.redactedUrl, null); + assert.equal(JSON.stringify(body).includes("patch-panel.hwlab-dev"), false); +}); + diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.mjs index 42b4d27b..c11e415e 100644 --- a/web/hwlab-cloud-web/code-agent-facts.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.mjs @@ -349,6 +349,8 @@ function localizedAttributionValue(key, value, message) { 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 / 无工具调用"; } diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 36cc2b1b..f3cecac7 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -262,6 +262,7 @@ function isSkillCliApiControl(payload) { 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);