Merge pull request #349 from pikasTech/feat/d601-m3-skill-control-334
feat: harden M3 skill control capability gates
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user