Merge pull request #349 from pikasTech/feat/d601-m3-skill-control-334

feat: harden M3 skill control capability gates
This commit is contained in:
Lyon
2026-05-23 22:10:08 +08:00
committed by GitHub
7 changed files with 294 additions and 18 deletions
+27 -7
View File
@@ -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,
@@ -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");
+82 -1
View File
@@ -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 已 greenoperation/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";
+69
View File
@@ -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");
+8 -3
View File
@@ -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.
@@ -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);
@@ -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";