From 4576c5bbf0e81c83b8ae28b4a0a449f4a4e37e9e Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 14:06:03 +0000 Subject: [PATCH] feat: harden M3 skill control capability gates --- internal/cloud/code-agent-chat.mjs | 34 ++++++-- .../code-agent-session-registry.test.mjs | 21 ++++- internal/cloud/m3-io-control.mjs | 83 +++++++++++++++++- internal/cloud/m3-io-control.test.mjs | 69 +++++++++++++++ skills/hwlab-agent-runtime/SKILL.md | 11 ++- .../scripts/m3-io-skill-client.test.mjs | 10 +++ .../scripts/src/m3-io-skill-client.mjs | 84 +++++++++++++++++-- 7 files changed, 294 insertions(+), 18 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 22bc09cb..d0d18dea 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -26,6 +26,7 @@ import { } from "./code-agent-session-registry.mjs"; import { HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, + HWLAB_M3_IO_CAPABILITY_LEVELS, HWLAB_M3_IO_API_ROUTE, HWLAB_M3_IO_SKILL_NAME, runM3IoSkillCommand @@ -50,7 +51,6 @@ 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_CAPABILITY_LEVEL = "controlled-m3-io-skill-cli"; const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ "m3-io-only", "hwlab-api-route-only", @@ -642,7 +642,8 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, - capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, + blockedCapabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, directGatewayCallsAllowed: false, directBoxCallsAllowed: false, directPatchPanelCallsAllowed: false, @@ -1061,7 +1062,7 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, workspace: resolvedWorkspace, sandbox: M3_IO_SKILL_SANDBOX, runnerKind: M3_IO_SKILL_RUNNER_KIND, - capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, traceId, now @@ -1114,6 +1115,9 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, requestJson }); const finishedAt = nowIso(now); + const capabilityLevel = skillResult.capabilityLevel ?? ( + skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked + ); const toolCall = { id: `tool_${randomUUID()}`, type: "skill-cli", @@ -1137,14 +1141,20 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, })).text, stderrSummary: skillResult.blocker?.code ?? "", outputTruncated: false, - traceId, route: HWLAB_M3_IO_API_ROUTE, hwlabApi: skillResult.hwlabApi, + capabilityLevel, + controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, operationId: skillResult.operationId, + traceId: skillResult.traceId, audit: skillResult.audit, evidence: skillResult.evidence, + durable: skillResult.durable, blocker: skillResult.blocker, + capabilityBlocker: skillResult.capabilityBlocker ?? null, + trustBlocker: skillResult.trustBlocker ?? null, + blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, @@ -1197,20 +1207,23 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, name: HWLAB_M3_IO_SKILL_NAME, summary: `Controlled M3 DO1/DI1 adapter for HWLAB API ${HWLAB_M3_IO_API_ROUTE}.`, route: HWLAB_M3_IO_API_ROUTE, + capabilityLevel, version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION }], count: 1, - blockers: skillResult.blocker ? [skillResult.blocker] : [] + blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []) }, runner, runnerTrace: runnerTracePayload, - capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL, + capabilityLevel, providerTrace: { runnerKind: M3_IO_SKILL_RUNNER_KIND, skill: HWLAB_M3_IO_SKILL_NAME, route: HWLAB_M3_IO_API_ROUTE, traceId: skillResult.traceId, operationId: skillResult.operationId, + capabilityLevel, + controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, status: skillResult.status, fallbackUsed: false @@ -1942,7 +1955,7 @@ function m3IoSkillRunnerDescriptor({ workspace, session = null } = {}) { durableSession: false, writeCapable: true, readOnly: false, - capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, skill: { name: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, @@ -1970,6 +1983,9 @@ function m3IoSkillRunnerDescriptor({ workspace, session = null } = {}) { } 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 + ); return { traceId, runnerKind: M3_IO_SKILL_RUNNER_KIND, @@ -1989,10 +2005,14 @@ function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = started events, route: HWLAB_M3_IO_API_ROUTE, skill: HWLAB_M3_IO_SKILL_NAME, + capabilityLevel, + controlReady: skillResult?.controlReady === true, operationId: skillResult?.operationId ?? 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, diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 5fdd9321..c74a75b6 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -8,7 +8,10 @@ import { classifyCodexRunnerCapability, classifyCodeAgentChatReadiness } from "../../scripts/src/code-agent-response-contract.mjs"; -import { HWLAB_M3_IO_API_ROUTE } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; +import { + HWLAB_M3_IO_CAPABILITY_LEVELS, + HWLAB_M3_IO_API_ROUTE +} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; test("code agent session registry creates, reuses, and expires sessions without storing secrets", () => { const registry = createCodeAgentSessionRegistry({ @@ -369,21 +372,28 @@ test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", a 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, "controlled-m3-io-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); 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].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.runnerTrace.route, HWLAB_M3_IO_API_ROUTE); assert.equal(payload.runnerTrace.accepted, 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.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}`); @@ -464,11 +474,18 @@ test("Code Agent M3 DI read returns structured blocker from HWLAB API without fa 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[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"); diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index e4e68782..b8e0272b 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -10,6 +10,7 @@ import { CLOUD_API_SERVICE_ID, createProtocolAuditEvent } from "../audit/index.mjs"; +import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1"; export const M3_IO_CONTROL_ROUTE = "/v1/m3/io"; @@ -62,6 +63,12 @@ export function describeM3IoControl(options = {}) { contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, route: M3_IO_CONTROL_ROUTE, status: config.enabled ? "available" : "blocked", + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + readinessStatus: "not_checked", + capabilityLevels: [ + HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + HWLAB_M3_IO_CAPABILITY_LEVELS.ready + ], sourceKind: "SOURCE", chain: M3_IO_CHAIN, actions: [ @@ -104,18 +111,33 @@ export async function describeM3IoControlLive(options = {}) { const contract = describeM3IoControl(options); const env = options.env ?? process.env; const config = buildM3IoConfig(env); + const runtime = await safeRuntimeSummary(options.runtimeStore); const readiness = await buildM3IoReadiness({ config, requestJson: options.m3IoRequestJson ?? options.requestJson, now: options.now }); const controlReady = readiness.status === "ready" && readiness.controlReady === true; + const capabilityLevel = controlReady + ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready + : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; + const trustReadiness = buildM3IoTrustReadiness(runtime, { + observedAt: readiness.observedAt, + controlReady + }); return { ...contract, status: controlReady ? "available" : "blocked", sourceKind: controlReady ? "DEV-LIVE" : "BLOCKED", - readiness, + capabilityLevel, + readinessStatus: readiness.status, + readiness: { + ...readiness, + capabilityLevel, + trust: trustReadiness + }, + trustReadiness, controlReady, blockedReason: controlReady ? null @@ -604,6 +626,7 @@ export async function buildM3IoReadiness({ config, requestJson, now } = {}) { return { status: "ready", controlReady: true, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, sourceKind: "DEV-LIVE", evidenceLevel: "DEV-LIVE", observedAt, @@ -648,6 +671,7 @@ function blockedReadiness({ checks, code, layer, reason, observedAt }) { return { status: "blocked", controlReady: false, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, sourceKind: "BLOCKED", evidenceLevel: "BLOCKED", observedAt, @@ -658,10 +682,45 @@ function blockedReadiness({ checks, code, layer, reason, observedAt }) { layer, zh: reason }, + capabilityBlocker: { + code, + layer, + zh: reason, + category: blockerCategory(code) + }, checks }; } +function buildM3IoTrustReadiness(runtime, { observedAt, controlReady } = {}) { + const durable = durableStatus(runtime); + const ready = isDurableRuntimeReady(runtime); + const blocker = ready + ? null + : { + code: durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked, + layer: "runtime-durable", + zh: `runtime durable 未 green:${durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked}`, + category: "runtime_durable" + }; + return { + status: ready ? "ready" : "blocked", + controlReady: Boolean(controlReady), + trustedEvidenceReady: ready, + capabilityLevel: controlReady + ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready + : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + evidenceLevel: ready ? "DEV-LIVE" : "BLOCKED", + sourceKind: ready ? "DEV-LIVE" : "BLOCKED", + observedAt, + durableStatus: durable, + blocker, + summary: ready + ? "runtime durable 已 green;operation/audit/evidence 可作为可信记录写入。" + : "受控硬件路径 readiness 与可信持久化分离;runtime durable 未 green 时不得宣称完整硬件控制验收。" + }; +} + async function buildM3StatusTrust({ runtimeStore, runtime }) { const durable = durableStatus(runtime); const base = { @@ -1720,6 +1779,8 @@ async function successControlResult(input) { contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, status: "succeeded", accepted: true, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, + controlReady: true, action: input.action, chain: M3_IO_CHAIN, command: input.command, @@ -1765,6 +1826,7 @@ async function successControlResult(input) { evidenceRecord: records.evidenceRecord, evidenceState: evidenceState(records.runtimeAfter, records), durableStatus: durableStatus(records.runtimeAfter), + trustBlocker: trustBlocker(records.runtimeAfter), blockerClassification: redactedBlockerClassification({ code: records.runtimeAfter?.blocker, reason: records.runtimeAfter?.reason, @@ -1787,6 +1849,8 @@ async function blockedControlResult(input) { contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, status: "blocked", accepted: false, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + controlReady: false, action: input.action, chain: M3_IO_CHAIN, command: input.command, @@ -1844,6 +1908,7 @@ async function blockedControlResult(input) { evidenceRecord: records.evidenceRecord, evidenceState: evidenceState(records.runtimeAfter, records), durableStatus: durableStatus(records.runtimeAfter), + trustBlocker: trustBlocker(records.runtimeAfter), persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records), observedAt: input.now }; @@ -1856,6 +1921,8 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, status: "blocked", accepted: false, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + controlReady: false, action: action || "unknown", chain: M3_IO_CHAIN, command, @@ -1903,6 +1970,7 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht evidenceWrite: { written: false, reason: "operation was blocked before evidence write" } }), durableStatus: durableStatus(runtime), + trustBlocker: trustBlocker(runtime), persistence: { runtime, writes: [] @@ -2083,6 +2151,18 @@ function durableStatus(runtime) { }; } +function trustBlocker(runtime) { + if (isDurableRuntimeReady(runtime)) return null; + const durable = durableStatus(runtime); + return { + code: durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked, + layer: "runtime-durable", + zh: `runtime durable 未 green:${durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked}`, + category: "runtime_durable", + durableStatus: durable + }; +} + function redactedBlockerClassification({ code, reason, runtime } = {}) { const blockerCode = code ?? runtime?.blocker ?? null; if (!blockerCode) { @@ -2100,6 +2180,7 @@ function redactedBlockerClassification({ code, reason, runtime } = {}) { function blockerCategory(code) { if (String(code).startsWith("runtime_durable")) return "runtime_durable"; if (String(code).includes("gateway")) return "gateway_session"; + if (String(code).includes("patch_panel")) return "patch_panel"; if (String(code).includes("wiring")) return "patch_panel_wiring"; if (String(code).includes("box")) return "box_resource"; if (String(code).includes("port")) return "port_direction"; diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index dc8277e5..753241ca 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -6,6 +6,7 @@ import { createBoxState, createGatewayState } from "../sim/model.mjs"; import { writeBoxPort } from "../sim/l2-runtime.mjs"; import { validateResponse } from "../protocol/index.mjs"; import { handleJsonRpcRequest, SUPPORTED_RPC_METHODS } from "./json-rpc.mjs"; +import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; import { M3_IO_BLOCKER_CODES, M3_IO_CHAIN, @@ -50,8 +51,12 @@ test("M3 IO live descriptor enables controls only after read-only backend readin assert.equal(contract.status, "available"); assert.equal(contract.controlReady, true); + assert.equal(contract.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); + assert.equal(contract.readiness.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); assert.equal(contract.readiness.status, "ready"); assert.equal(contract.readiness.sourceKind, "DEV-LIVE"); + assert.equal(contract.trustReadiness.status, "blocked"); + assert.equal(contract.trustReadiness.blocker.code, "runtime_store_missing"); assert.equal(contract.blockedReason, null); assert.deepEqual(fixture.calls.map((call) => call.path), [ "/status", @@ -86,10 +91,67 @@ test("M3 IO live descriptor blocks controls with Chinese reason when readiness i assert.equal(contract.status, "blocked"); assert.equal(contract.controlReady, false); + assert.equal(contract.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(contract.readiness.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); assert.equal(contract.readiness.status, "blocked"); assert.equal(contract.readiness.sourceKind, "BLOCKED"); assert.match(contract.blockedReason, /未确认 active|控制面板保持阻塞/u); assert.equal(contract.readiness.blocker.code, M3_IO_BLOCKER_CODES.wiringMissing); + assert.equal(contract.readiness.capabilityBlocker.category, "patch_panel_wiring"); +}); + +test("M3 IO live descriptor classifies gateway unavailable and patch-panel unavailable blockers", async () => { + const gatewayBlocked = await describeM3IoControlLive({ + 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" + }, + now: () => fixedNow, + m3IoRequestJson: async (url) => { + if (url.startsWith("http://gateway-1")) { + return { + ok: false, + status: 503, + body: { error: { message: "gateway unavailable" } } + }; + } + throw new Error("readiness must stop at the first gateway blocker"); + } + }); + + assert.equal(gatewayBlocked.status, "blocked"); + assert.equal(gatewayBlocked.controlReady, false); + assert.equal(gatewayBlocked.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(gatewayBlocked.readiness.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable); + assert.equal(gatewayBlocked.readiness.capabilityBlocker.category, "gateway_session"); + assert.match(gatewayBlocked.blockedReason, /gateway 未注册\/不可用/u); + + const fixture = createM3ControlFixture(); + const patchPanelBlocked = await describeM3IoControlLive({ + 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" + }, + now: () => fixedNow, + m3IoRequestJson: async (url, request) => { + if (url === "http://patch-panel/status") { + return { + ok: false, + status: 503, + body: { error: { message: "patch-panel unavailable" } } + }; + } + return fixture.requestJson(url, request); + } + }); + + assert.equal(patchPanelBlocked.status, "blocked"); + assert.equal(patchPanelBlocked.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(patchPanelBlocked.readiness.blocker.code, M3_IO_BLOCKER_CODES.patchPanelUnavailable); + assert.equal(patchPanelBlocked.readiness.capabilityBlocker.category, "patch_panel"); + assert.match(patchPanelBlocked.blockedReason, /hwlab-patch-panel 不可用/u); }); test("M3 status live descriptor aggregates gateways, boxes, patch-panel, IO values, and blocked trust", async () => { @@ -256,6 +318,8 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, assert.equal(result.status, "succeeded"); assert.equal(result.accepted, true); + assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); + assert.equal(result.controlReady, true); assert.equal(result.action, "do.write"); assert.equal(result.command.port, "DO1"); assert.equal(result.command.value, true); @@ -282,6 +346,7 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, assert.equal(result.auditState.durableStatus.durable, false); assert.equal(result.durableStatus.status, "degraded"); assert.equal(result.durableStatus.redacted, true); + assert.equal(result.trustBlocker.code, result.durableStatus.blocker); assert.equal(result.blockerClassification.category, "runtime_durable"); assert.equal(result.blockerClassification.redacted, true); assert.equal(result.controlPath.cloudApi, true); @@ -350,6 +415,8 @@ test("M3 DO write can prove control reachability while durable trusted persisten assert.equal(result.status, "succeeded"); assert.equal(result.accepted, true); + assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); + assert.equal(result.trustBlocker.code, "runtime_durable_adapter_query_blocked"); assert.equal(result.result.value, true); assert.equal(result.result.targetReadback.value, true); assert.equal(result.controlPath.status, "succeeded"); @@ -447,6 +514,8 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab assert.equal(result.status, "blocked"); assert.equal(result.accepted, false); + assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(result.controlReady, false); assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable); assert.equal(result.blockerClassification.category, "gateway_session"); assert.equal(result.auditState.status, "not_written"); diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index 28fb4a4b..c3f84a7c 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -51,6 +51,11 @@ API endpoint. The CLI appends `/v1/m3/io` itself and rejects direct gateway/box/patch-panel targets. The JSON response includes `route`, `traceId`, `operationId`, `audit`, -`evidence`, `accepted`, `status`, `blocker`, and direct-call safety flags. -Durable runtime blockers are preserved as structured `durable` and `evidence` -state; a local/source result must not be called DEV-LIVE. +`evidence`, `accepted`, `status`, `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. 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 d42a98cc..e9a58918 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 @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + HWLAB_M3_IO_CAPABILITY_LEVELS, HWLAB_M3_IO_API_ROUTE, runM3IoSkillCommand, validateCloudApiTarget @@ -81,12 +82,17 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () = assert.equal(result.ok, true); assert.equal(result.route, HWLAB_M3_IO_API_ROUTE); + 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.audit.auditId, "aud_m3_do_write_cli_succeeded"); assert.equal(result.evidence.evidenceId, "evd_m3_do_write_cli_succeeded"); 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); @@ -120,8 +126,12 @@ test("M3 Skill CLI rejects direct gateway, box, and patch-panel targets before r assert.equal(result.ok, false); assert.equal(result.status, "blocked"); + 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); 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 feffb07f..c8b717d5 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 @@ -3,6 +3,10 @@ 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_IO_SKILL_NAME = "hwlab-agent-runtime.m3-io"; +export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({ + blocked: "hwlab-api-control-blocked", + ready: "hwlab-api-control-ready" +}); 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; @@ -308,6 +312,13 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId const accepted = body.accepted === true; const status = body.status ?? (response.ok && accepted ? "succeeded" : "blocked"); const durable = durableSummary(body); + const controlReady = response.ok && accepted && status !== "blocked" && body.controlPath?.cloudApi !== false && body.controlPath?.frontendBypass !== true; + const capabilityLevel = controlReady + ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready + : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; + const capabilityBlocker = controlReady ? null : normalizeCapabilityBlocker(blocker, response); + const trustBlocker = normalizeTrustBlocker(body); + const blockers = dedupeBlockers([capabilityBlocker, trustBlocker]); return { ok: response.ok && accepted && status !== "blocked", @@ -315,6 +326,8 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, hwlabApi: apiTarget, + capabilityLevel, + controlReady, action: command.action, accepted, status, @@ -338,6 +351,17 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId }, durable, blocker, + capabilityBlocker, + trustBlocker, + blockers, + readiness: { + status: controlReady ? "ready" : "blocked", + controlReady, + capabilityLevel, + route: HWLAB_M3_IO_API_ROUTE, + blocker: capabilityBlocker, + trustBlocker + }, command, result: { value: body.result?.value ?? body.value ?? null, @@ -372,12 +396,20 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId } function blockedPayload({ traceId, requestId, apiTarget, code, message }) { + const capabilityBlocker = { + code, + message, + zh: message, + category: blockerCategory(code) + }; return { ok: false, service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, hwlabApi: apiTarget ?? null, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + controlReady: false, accepted: false, status: "blocked", traceId, @@ -402,11 +434,17 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message }) { blocker: code, summary: message }, - blocker: { - code, - message, - zh: message, - category: blockerCategory(code) + blocker: capabilityBlocker, + capabilityBlocker, + trustBlocker: null, + blockers: [capabilityBlocker], + readiness: { + status: "blocked", + controlReady: false, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + route: HWLAB_M3_IO_API_ROUTE, + blocker: capabilityBlocker, + trustBlocker: null }, safety: { cloudApiRouteOnly: true, @@ -442,6 +480,42 @@ function normalizeBlocker(body, response) { return null; } +function normalizeCapabilityBlocker(blocker, response) { + if (blocker) return blocker; + return { + code: response.ok ? "hwlab_api_control_blocked" : "hwlab_api_unavailable", + message: response.ok ? "HWLAB API did not accept the M3 IO control request." : response.error ?? "HWLAB API request failed", + zh: response.ok ? "HWLAB API 未接受 M3 IO 控制请求。" : response.error ?? "HWLAB API 请求失败", + category: response.ok ? "readiness_blocked" : "hwlab_api" + }; +} + +function normalizeTrustBlocker(body) { + const code = body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? null; + if (!code) return null; + return { + code, + message: `Durable trust is blocked: ${code}`, + zh: `可信持久化仍 blocked:${code}`, + category: blockerCategory(code), + layer: "runtime-durable", + source: body.evidenceState?.blocker ? "evidenceState.blocker" : "durableStatus.blocker" + }; +} + +function dedupeBlockers(blockers) { + const seen = new Set(); + const result = []; + for (const blocker of blockers) { + if (!blocker?.code) continue; + const key = `${blocker.code}:${blocker.layer ?? ""}:${blocker.category ?? ""}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(blocker); + } + return result; +} + function blockerCategory(code) { const value = String(code ?? ""); if (/readiness|control_disabled|not_ready/u.test(value)) return "readiness_blocked";