feat: route code agent m3 io through hwlab api skill cli

This commit is contained in:
Code Queue Review
2026-05-23 18:38:47 +00:00
parent f19c3d1c85
commit fc7ff9b022
9 changed files with 791 additions and 52 deletions
+23 -8
View File
@@ -31,9 +31,12 @@ All commands emit JSON and stay local:
- `node cmd/hwlab-agent-mgr/main.mjs evidence --agent-session-id ID`
- `node cmd/hwlab-agent-mgr/main.mjs cleanup --agent-session-id ID`
- `node cmd/hwlab-agent-worker/main.mjs dry-run --agent-session-id ID`
- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url URL`
- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url URL`
- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 status --api-base-url URL`
- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --approved --api-base-url URL`
- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --approved --api-base-url URL`
- `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url URL`
- `node tools/hwlab-cli/bin/hwlab-cli.mjs m3 status --api-base-url URL`
- `node tools/hwlab-cli/bin/hwlab-cli.mjs m3 io --action do.write --value true --approved --api-base-url URL`
Use `--state-dir DIR` to isolate local state. The default state directory is
`.state/agent-runtime`, which is intended for local runtime state only.
@@ -55,13 +58,25 @@ gateway/box/patch-panel targets. If no API base URL is configured, the CLI
returns `skill_cli_api_base_missing` and does not fall back to `127.0.0.1` or
any direct hardware service.
Use `m3 status` for read-only status aggregation; it performs `GET
/v1/m3/status` through the HWLAB API base and never calls gateway, box, or
patch-panel URLs. Use `m3 io --action di.read` for a controlled DI read through
`POST /v1/m3/io`. DO writes must include `--approved` or `--confirm-control`;
without that explicit approval flag the CLI returns `approval_required` before
any HTTP request. The Code Agent runner adds this flag only after the user's
message explicitly asks to write `res_boxsimu_1` / `DO1` through the HWLAB API,
and the request body carries an `approval` object with policy
`hwlab-api-control-with-approval`.
The JSON response includes `route`, `method`, `traceId`, `operationId`,
`audit`, `evidence`, `accepted`, `status`, `readback`, `capabilityLevel`,
`readiness`, `blocker`, `trustBlocker`, and direct-call safety flags.
`capabilityLevel` is
`hwlab-api-control-ready` only after the HWLAB API accepts the request through
`/v1/m3/io`; direct target rejection, gateway unavailability, missing wiring, or
other control blockers return `hwlab-api-control-blocked`. Durable runtime
blockers are preserved as structured `durable`, `trustBlocker`, and `evidence`
state; a reachable control path with blocked durable persistence must not be
called DEV-LIVE or trusted green.
`hwlab-api-control-with-approval` only after the HWLAB API accepts an approved
write request through `/v1/m3/io`; read-only status uses
`hwlab-api-readonly`. Direct target rejection, gateway unavailability, missing
wiring, approval absence, or other control blockers return
`hwlab-api-control-blocked`. Durable runtime blockers are preserved as
structured `durable`, `trustBlocker`, and `evidence` state; a reachable control
path with blocked durable persistence must not be called DEV-LIVE or trusted
green.
@@ -6,6 +6,7 @@ import {
HWLAB_M3_IO_CAPABILITY_LEVELS,
HWLAB_M3_IO_DEV_SERVICE_BASE_URL,
HWLAB_M3_IO_API_ROUTE,
HWLAB_M3_STATUS_API_ROUTE,
runM3IoSkillCommand,
validateCloudApiTarget
} from "./src/m3-io-skill-client.mjs";
@@ -22,6 +23,7 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () =
"false",
"--api-base-url",
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"--approved",
"--trace-id",
"trc_skill_cli_write_false",
"--request-id",
@@ -38,6 +40,8 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () =
assert.equal(request.body.boxId, "boxsimu_1");
assert.equal(request.body.port, "DO1");
assert.equal(request.body.value, false);
assert.equal(request.body.approval.approved, true);
assert.equal(request.body.approval.policy, "hwlab-api-control-with-approval");
assert.equal(request.body.source, "hwlab-agent-runtime.m3-io");
return {
ok: true,
@@ -91,6 +95,8 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () =
assert.equal(result.accepted, true);
assert.equal(result.status, "succeeded");
assert.equal(result.operationId, "op_m3_do_write_cli");
assert.equal(result.approval.approved, true);
assert.equal(result.approval.policy, "hwlab-api-control-with-approval");
assert.equal(result.auditId, "aud_m3_do_write_cli_succeeded");
assert.equal(result.evidenceId, "evd_m3_do_write_cli_succeeded");
assert.equal(result.audit.auditId, "aud_m3_do_write_cli_succeeded");
@@ -153,6 +159,179 @@ test("M3 Skill CLI validates exact API route contract", () => {
assert.equal(invalid.code, "invalid_hwlab_api_route");
});
test("M3 Skill CLI enforces DEV/MVP HWLAB API base allowlist", async () => {
const result = await runM3IoSkillCommand(
[
"m3",
"status",
"--api-base-url",
"https://example.com",
"--trace-id",
"trc_skill_cli_unallowlisted"
],
{
requestJson: async () => {
throw new Error("unallowlisted API base must be blocked before network call");
}
}
);
assert.equal(result.ok, false);
assert.equal(result.status, "blocked");
assert.equal(result.route, HWLAB_M3_STATUS_API_ROUTE);
assert.equal(result.method, "GET");
assert.equal(result.blocker.code, "hwlab_api_target_not_allowlisted");
assert.equal(result.hwlabApi.redactedUrl, "https://example.com/v1/m3/status");
});
test("M3 Skill CLI requires explicit approval for DO writes before request", async () => {
const result = await runM3IoSkillCommand(
[
"m3",
"io",
"--action",
"do.write",
"--value",
"true",
"--api-base-url",
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"--trace-id",
"trc_skill_cli_write_unapproved"
],
{
requestJson: async () => {
throw new Error("unapproved DO write must be blocked before network call");
}
}
);
assert.equal(result.ok, false);
assert.equal(result.status, "blocked");
assert.equal(result.blocker.code, "approval_required");
assert.equal(result.approval.required, true);
assert.equal(result.approval.approved, false);
assert.equal(result.approval.policy, "hwlab-api-control-with-approval");
assert.equal(result.accepted, false);
assert.equal(result.operationId, null);
assert.equal(result.safety.directGatewayCalls, false);
assert.equal(result.safety.fallbackUsed, false);
});
test("M3 Skill CLI reads status only through HWLAB API /v1/m3/status", async () => {
const calls = [];
const result = await runM3IoSkillCommand(
[
"m3",
"status",
"--api-base-url",
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"--trace-id",
"trc_skill_cli_status"
],
{
now: () => "2026-05-23T00:08:30.000Z",
requestJson: async (url, request) => {
calls.push({ url, request });
assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_STATUS_API_ROUTE}`);
assert.equal(request.method, "GET");
return {
ok: true,
status: 200,
body: {
status: "live",
sourceKind: "DEV-LIVE",
traceId: "trc_skill_cli_status",
gateways: [
{ id: "gwsimu_1", online: true },
{ id: "gwsimu_2", online: true }
],
boxes: [
{
id: "boxsimu_2",
resourceId: "res_boxsimu_2",
online: true,
ports: {
DI1: {
value: true
}
}
}
],
patchPanel: {
serviceId: "hwlab-patch-panel",
observable: true,
connectionActive: true
},
trust: {
operationId: "op_m3_status_latest",
traceId: "trc_skill_cli_status",
auditId: "aud_m3_status_latest",
evidenceId: "evd_m3_status_latest",
durableStatus: "green",
blocker: null,
readStatus: {
audit: "read",
evidence: "read"
},
runtime: {
durable: true
}
}
}
};
}
}
);
assert.equal(result.ok, true);
assert.equal(result.route, HWLAB_M3_STATUS_API_ROUTE);
assert.equal(result.method, "GET");
assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.readonly);
assert.equal(result.readonly, true);
assert.equal(result.readback.value, true);
assert.equal(result.operationId, "op_m3_status_latest");
assert.equal(result.auditId, "aud_m3_status_latest");
assert.equal(result.evidenceId, "evd_m3_status_latest");
assert.equal(result.safety.allowedRoute, HWLAB_M3_STATUS_API_ROUTE);
assert.equal(result.safety.directPatchPanelCalls, false);
assert.equal(calls.length, 1);
});
test("M3 Skill CLI reports status HWLAB API failures on /v1/m3/status", async () => {
const result = await runM3IoSkillCommand(
[
"m3",
"status",
"--api-base-url",
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"--trace-id",
"trc_skill_cli_status_unavailable"
],
{
requestJson: async (url, request) => {
assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_STATUS_API_ROUTE}`);
assert.equal(request.method, "GET");
return {
ok: false,
status: 503,
body: null,
error: "service unavailable"
};
}
}
);
assert.equal(result.ok, false);
assert.equal(result.status, "blocked");
assert.equal(result.route, HWLAB_M3_STATUS_API_ROUTE);
assert.equal(result.method, "GET");
assert.equal(result.blocker.code, "hwlab_api_unavailable");
assert.equal(result.blocker.route, HWLAB_M3_STATUS_API_ROUTE);
assert.equal(result.error.code, "hwlab_api_unavailable");
assert.equal(result.error.route, HWLAB_M3_STATUS_API_ROUTE);
assert.match(result.error.userMessage, /状态未读取/u);
});
test("M3 Skill CLI reports missing API base as safe structured config blocker", async () => {
const result = await runM3IoSkillCommand(
[
@@ -237,6 +416,7 @@ test("M3 Skill CLI preserves slow structured blocker and timeout budget", async
"di.read",
"--api-base-url",
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
"--approved",
"--trace-id",
"trc_skill_cli_slow_blocker",
"--request-id",
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
export const HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION = "hwlab-agent-runtime-skill-cli-v1";
export const HWLAB_M3_IO_API_ROUTE = "/v1/m3/io";
export const HWLAB_M3_STATUS_API_ROUTE = "/v1/m3/status";
export const HWLAB_M3_IO_SKILL_NAME = "hwlab-agent-runtime.m3-io";
export const HWLAB_M3_IO_API_BASE_URL_ENV = "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL";
export const HWLAB_M3_IO_DEV_SERVICE_BASE_URL = "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667";
@@ -12,7 +13,8 @@ export const HWLAB_M3_IO_API_BASE_URL_ENVS = Object.freeze([
]);
export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({
blocked: "hwlab-api-control-blocked",
ready: "hwlab-api-control-ready"
readonly: "hwlab-api-readonly",
ready: "hwlab-api-control-with-approval"
});
const allowedActions = new Set(["do.write", "di.read"]);
@@ -23,6 +25,15 @@ const safeApiBaseEnvNames = Object.freeze([
"HWLAB_API_BASE_URL",
"HWLAB_CLOUD_API_BASE_URL"
]);
const allowedCloudApiTargets = Object.freeze([
{ hostname: "hwlab-cloud-api", ports: new Set(["", "6667"]) },
{ hostname: "hwlab-cloud-api.hwlab-dev", ports: new Set(["", "6667"]) },
{ hostname: "hwlab-cloud-api.hwlab-dev.svc", ports: new Set(["", "6667"]) },
{ hostname: "hwlab-cloud-api.hwlab-dev.svc.cluster.local", ports: new Set(["", "6667"]) },
{ hostname: "74.48.78.17", ports: new Set(["16667"]) },
{ hostname: "127.0.0.1", ports: new Set(["6667"]) },
{ hostname: "localhost", ports: new Set(["6667"]) }
]);
export async function runM3IoSkillCommand(argv = [], options = {}) {
const env = options.env ?? process.env;
@@ -34,12 +45,15 @@ export async function runM3IoSkillCommand(argv = [], options = {}) {
const traceId = parsed.traceId || `trc_${randomUUID()}`;
const requestId = parsed.requestId || `req_${randomUUID()}`;
const actorId = parsed.actorId || "usr_code_agent";
const apiTarget = resolveCloudApiTarget(parsed.apiBaseUrl, env);
const apiTarget = resolveCloudApiTarget(parsed.apiBaseUrl, env, {
route: parsed.mode === "status" ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE
});
const validation = validateCloudApiTarget(apiTarget);
if (validation) {
return blockedPayload({
traceId,
requestId,
actorId,
apiTarget,
code: validation.code,
message: validation.message,
@@ -47,11 +61,24 @@ export async function runM3IoSkillCommand(argv = [], options = {}) {
});
}
if (parsed.mode === "status") {
return runM3StatusSkillCommand({
apiTarget,
parsed,
traceId,
requestId,
actorId,
requestJson: options.requestJson,
now: options.now
});
}
const command = normalizeCommand(parsed);
if (!allowedActions.has(command.action)) {
return blockedPayload({
traceId,
requestId,
actorId,
apiTarget,
code: "invalid_action",
message: "M3 Skill CLI only allows do.write or di.read."
@@ -61,13 +88,26 @@ export async function runM3IoSkillCommand(argv = [], options = {}) {
return blockedPayload({
traceId,
requestId,
actorId,
apiTarget,
code: "invalid_boolean_value",
message: "M3 DO1 write requires --value true or --value false."
});
}
const approval = approvalForParsed(parsed, command);
if (approval.blocker) {
return blockedPayload({
traceId,
requestId,
actorId,
apiTarget,
code: approval.blocker.code,
message: approval.blocker.message,
approval
});
}
const payload = requestPayload(command, { traceId, requestId, actorId });
const payload = requestPayload(command, { traceId, requestId, actorId, approval });
const startedAt = nowIso(options.now);
const response = await postJson(apiTarget.url, payload, {
requestJson: options.requestJson,
@@ -100,18 +140,22 @@ export function parseM3IoArgs(argv = [], env = process.env) {
const parsed = {
command,
subcommand,
mode: command === "m3" && subcommand === "status" ? "status" : "io",
action: null,
value: undefined,
apiBaseUrl: null,
traceId: null,
requestId: null,
actorId: null,
approved: false,
approvalReason: null,
policy: null,
timeoutMs: defaultTimeoutMs,
pretty: false,
help: command === "help" || command === "--help" || command === "-h"
};
if (command !== "m3" || subcommand !== "io") {
if (command !== "m3" || !["io", "status"].includes(subcommand)) {
parsed.help = true;
return parsed;
}
@@ -134,11 +178,17 @@ export function parseM3IoArgs(argv = [], env = process.env) {
parsed.requestId = readOption(args, ++index, arg);
} else if (arg === "--actor-id") {
parsed.actorId = readOption(args, ++index, arg);
} else if (arg === "--approved" || arg === "--confirm-control") {
parsed.approved = true;
} else if (arg === "--approval-reason") {
parsed.approvalReason = readOption(args, ++index, arg);
} else if (arg === "--policy") {
parsed.policy = readOption(args, ++index, arg);
} else if (arg === "--timeout-ms") {
parsed.timeoutMs = parseTimeout(readOption(args, ++index, arg));
} else if (arg === "write") {
} else if (subcommand === "io" && arg === "write") {
parsed.action = "do.write";
} else if (arg === "read") {
} else if (subcommand === "io" && arg === "read") {
parsed.action = "di.read";
} else {
throw new Error(`unknown argument ${arg}`);
@@ -149,11 +199,13 @@ export function parseM3IoArgs(argv = [], env = process.env) {
return parsed;
}
export function resolveCloudApiTarget(apiBaseUrl, env = process.env) {
export function resolveCloudApiTarget(apiBaseUrl, env = process.env, options = {}) {
const route = options.route ?? HWLAB_M3_IO_API_ROUTE;
const source = cloudApiBaseUrlSource(apiBaseUrl, env);
const base = firstNonEmpty(apiBaseUrl, configuredCloudApiBaseUrl(env));
if (!base) {
return cloudApiTargetEnvelope({
route,
source,
url: null,
redactedUrl: null,
@@ -164,9 +216,10 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) {
let url;
try {
url = new URL(HWLAB_M3_IO_API_ROUTE, ensureTrailingSlash(base));
url = new URL(route, ensureTrailingSlash(base));
} catch {
return cloudApiTargetEnvelope({
route,
source,
url: String(base),
redactedUrl: redactUrl(base),
@@ -176,6 +229,7 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) {
}
return cloudApiTargetEnvelope({
route,
source,
url: url.toString(),
redactedUrl: redactUrl(url.toString()),
@@ -183,9 +237,9 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) {
});
}
function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false, missingConfig = [] }) {
function cloudApiTargetEnvelope({ route = HWLAB_M3_IO_API_ROUTE, source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false, missingConfig = [] }) {
return {
route: HWLAB_M3_IO_API_ROUTE,
route,
url,
redactedUrl,
source,
@@ -227,10 +281,10 @@ export function validateCloudApiTarget(apiTarget) {
};
}
if (url.pathname !== HWLAB_M3_IO_API_ROUTE) {
if (![HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE].includes(url.pathname)) {
return {
code: "invalid_hwlab_api_route",
message: `Skill CLI must call exactly ${HWLAB_M3_IO_API_ROUTE}.`
message: `Skill CLI must call exactly ${HWLAB_M3_IO_API_ROUTE} or ${HWLAB_M3_STATUS_API_ROUTE}.`
};
}
@@ -242,6 +296,13 @@ export function validateCloudApiTarget(apiTarget) {
};
}
if (!isAllowlistedCloudApiUrl(url)) {
return {
code: "hwlab_api_target_not_allowlisted",
message: "Skill CLI API base must be a DEV/MVP HWLAB cloud-api endpoint."
};
}
return null;
}
@@ -278,10 +339,41 @@ function requestPayload(command, meta) {
traceId: meta.traceId,
requestId: meta.requestId,
actorId: meta.actorId,
approval: meta.approval
? {
approved: meta.approval.approved,
policy: meta.approval.policy,
reason: meta.approval.reason,
required: meta.approval.required
}
: undefined,
source: HWLAB_M3_IO_SKILL_NAME
};
}
async function runM3StatusSkillCommand({ apiTarget, parsed, traceId, requestId, actorId, requestJson, now }) {
const startedAt = nowIso(now);
const response = await getJson(apiTarget.url, {
requestJson,
timeoutMs: parsed.timeoutMs,
headers: {
"x-trace-id": traceId,
"x-request-id": requestId,
"x-actor-id": actorId
}
});
const finishedAt = nowIso(now);
return normalizeStatusResponse({
response,
apiTarget,
traceId,
requestId,
actorId,
startedAt,
finishedAt
});
}
async function postJson(url, body, { requestJson, timeoutMs, headers }) {
if (typeof requestJson === "function") {
try {
@@ -339,6 +431,59 @@ async function postJson(url, body, { requestJson, timeoutMs, headers }) {
}
}
async function getJson(url, { requestJson, timeoutMs, headers }) {
if (typeof requestJson === "function") {
try {
const result = await requestJson(url, {
method: "GET",
headers: {
accept: "application/json",
...headers
},
timeoutMs
});
return normalizeJsonResponse(result);
} catch (error) {
return {
ok: false,
status: 0,
body: null,
error: error instanceof Error ? error.message : String(error)
};
}
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
...headers
},
signal: controller.signal
});
const text = await response.text();
const parsed = parseJsonOrNull(text);
return {
ok: response.ok,
status: response.status,
body: parsed,
error: response.ok ? null : parsed?.error?.message ?? parsed?.message ?? response.statusText
};
} catch (error) {
return {
ok: false,
status: 0,
body: null,
error: error.name === "AbortError" ? `HWLAB API request timed out after ${timeoutMs}ms` : error.message
};
} finally {
clearTimeout(timer);
}
}
function normalizeJsonResponse(result = {}) {
if (Object.hasOwn(result, "ok") || Object.hasOwn(result, "body")) {
return {
@@ -368,7 +513,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId
? HWLAB_M3_IO_CAPABILITY_LEVELS.ready
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
const capabilityBlocker = controlReady ? null : normalizeCapabilityBlocker(blocker, response);
const trustBlocker = normalizeTrustBlocker(body);
const trustBlocker = normalizeTrustBlocker(body, HWLAB_M3_IO_API_ROUTE);
const blockers = dedupeBlockers([capabilityBlocker, trustBlocker]);
return {
@@ -386,6 +531,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId
traceId: body.traceId ?? traceId,
requestId,
actorId,
approval: payload.approval ?? approvalForAction(command.action),
operationId: body.operationId ?? null,
auditId: body.auditId ?? body.auditState?.auditId ?? null,
evidenceId: body.evidenceId ?? body.evidenceState?.evidenceId ?? null,
@@ -453,11 +599,142 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId
rawStatus: body.status ?? null,
startedAt,
finishedAt,
response: body
responseSummary: responseSummary(body)
};
}
function blockedPayload({ traceId, requestId, apiTarget, code, message, missingConfig = [] }) {
function normalizeStatusResponse({ response, apiTarget, traceId, requestId, actorId, startedAt, finishedAt }) {
const body = response.body ?? {};
const blocker = response.ok
? normalizeStatusBlocker(body, response)
: normalizeBlocker(body, response, {
route: HWLAB_M3_STATUS_API_ROUTE,
unavailableUserMessage: "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。",
defaultMessage: "M3 status request was blocked."
});
const ready = response.ok && !blocker && !["blocked", "error", "failed"].includes(String(body.status ?? "").toLowerCase());
const trustBlocker = normalizeTrustBlocker(body, HWLAB_M3_STATUS_API_ROUTE);
const blockers = dedupeBlockers([blocker, trustBlocker]);
return {
ok: response.ok && !blocker,
service: HWLAB_M3_IO_SKILL_NAME,
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
route: HWLAB_M3_STATUS_API_ROUTE,
method: "GET",
hwlabApi: publicCloudApiTarget(apiTarget),
capabilityLevel: ready ? HWLAB_M3_IO_CAPABILITY_LEVELS.readonly : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
controlReady: ready,
readonly: true,
accepted: response.ok && !blocker,
status: body.status ?? (response.ok ? "succeeded" : "blocked"),
traceId: body.traceId ?? body.trust?.traceId ?? traceId,
requestId,
actorId,
operationId: body.trust?.operationId ?? body.operationId ?? null,
auditId: body.trust?.auditId ?? body.auditId ?? null,
evidenceId: body.trust?.evidenceId ?? body.evidenceId ?? null,
audit: {
auditId: body.trust?.auditId ?? body.auditId ?? null,
status: body.trust?.readStatus?.audit ?? null,
durableStatus: body.trust?.durableStatus ?? null,
summary: body.trust?.auditId ? `${body.trust.auditId}; status=${body.trust.readStatus?.audit ?? "unknown"}` : "audit not reported"
},
evidence: {
evidenceId: body.trust?.evidenceId ?? body.evidenceId ?? null,
status: body.trust?.readStatus?.evidence ?? null,
sourceKind: body.sourceKind ?? null,
blocker: body.trust?.blocker ?? null,
writeStatus: null,
summary: body.trust?.evidenceId ? `${body.trust.evidenceId}; status=${body.trust.readStatus?.evidence ?? "unknown"}` : "evidence not reported"
},
durable: {
status: body.trust?.durableStatus ?? body.trust?.runtime?.status ?? null,
durable: body.trust?.runtime?.durable === true,
blocker: body.trust?.blocker ?? body.trust?.runtime?.blocker ?? null,
category: body.trust?.blocker ? blockerCategory(body.trust.blocker) : null,
summary: body.trust?.blocker ? `durable evidence blocked: ${body.trust.blocker}` : "status read completed"
},
blocker,
capabilityBlocker: blocker,
trustBlocker,
blockers,
readiness: {
status: ready ? "ready" : "blocked",
controlReady: ready,
capabilityLevel: ready ? HWLAB_M3_IO_CAPABILITY_LEVELS.readonly : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
route: HWLAB_M3_STATUS_API_ROUTE,
blocker,
trustBlocker
},
result: {
value: body.boxes?.find?.((box) => box?.resourceId === "res_boxsimu_2")?.ports?.DI1?.value ?? null,
targetReadback: null
},
readback: {
status: body.status ?? null,
value: body.boxes?.find?.((box) => box?.resourceId === "res_boxsimu_2")?.ports?.DI1?.value ?? null,
resourceId: "res_boxsimu_2",
port: "DI1"
},
statusSummary: {
sourceKind: body.sourceKind ?? null,
readinessStatus: body.readinessStatus ?? null,
gateways: Array.isArray(body.gateways) ? body.gateways.map((gateway) => ({
id: gateway.id,
online: gateway.online,
blocker: gateway.blocker ?? null
})) : [],
boxes: Array.isArray(body.boxes) ? body.boxes.map((box) => ({
id: box.id,
resourceId: box.resourceId,
online: box.online,
ports: box.ports
})) : [],
patchPanel: body.patchPanel ? {
serviceId: body.patchPanel.serviceId,
observable: body.patchPanel.observable,
connectionActive: body.patchPanel.connectionActive,
blocker: body.patchPanel.blocker ?? null,
lastError: body.patchPanel.lastError ?? null
} : null
},
controlPath: {
cloudApi: true,
gatewaySimu: false,
boxSimu: false,
patchPanel: false,
frontendBypass: false
},
safety: {
cloudApiRouteOnly: true,
allowedRoute: HWLAB_M3_STATUS_API_ROUTE,
directGatewayCalls: false,
directBoxCalls: false,
directPatchPanelCalls: false,
fallbackUsed: false,
openAiFallbackUsed: false
},
httpStatus: response.status,
error: response.ok ? body.error ?? null : {
code: "hwlab_api_unavailable",
layer: "hwlab-api",
blocker,
retryable: true,
userMessage: "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。",
message: response.error ?? "HWLAB API request failed",
traceId,
route: HWLAB_M3_STATUS_API_ROUTE,
toolName: HWLAB_M3_IO_SKILL_NAME
},
rawStatus: body.status ?? null,
startedAt,
finishedAt
};
}
function blockedPayload({ traceId, requestId, actorId = "usr_code_agent", apiTarget, code, message, missingConfig = [], approval = null }) {
const route = apiTarget?.route ?? HWLAB_M3_IO_API_ROUTE;
const method = route === HWLAB_M3_STATUS_API_ROUTE ? "GET" : "POST";
const capabilityBlocker = {
code,
layer: blockerLayer(code),
@@ -467,7 +744,7 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC
retryable: retryableBlocker(code),
userMessage: userMessageForBlocker(code, message),
traceId,
route: HWLAB_M3_IO_API_ROUTE,
route,
toolName: HWLAB_M3_IO_SKILL_NAME,
missingConfig: sanitizeMissingConfig(missingConfig)
};
@@ -475,8 +752,8 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC
ok: false,
service: HWLAB_M3_IO_SKILL_NAME,
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
route: HWLAB_M3_IO_API_ROUTE,
method: "POST",
route,
method,
hwlabApi: publicCloudApiTarget(apiTarget),
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
controlReady: false,
@@ -484,6 +761,8 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC
status: "blocked",
traceId,
requestId,
actorId,
approval: approvalPayload(approval) ?? approvalForAction(null),
operationId: null,
auditId: null,
evidenceId: null,
@@ -514,13 +793,13 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC
status: "blocked",
controlReady: false,
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
route: HWLAB_M3_IO_API_ROUTE,
route,
blocker: capabilityBlocker,
trustBlocker: null
},
safety: {
cloudApiRouteOnly: true,
allowedRoute: HWLAB_M3_IO_API_ROUTE,
allowedRoute: route,
directGatewayCalls: false,
directBoxCalls: false,
directPatchPanelCalls: false,
@@ -535,27 +814,58 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC
userMessage: capabilityBlocker.userMessage,
message,
traceId,
route: HWLAB_M3_IO_API_ROUTE,
route,
toolName: HWLAB_M3_IO_SKILL_NAME,
missingConfig: capabilityBlocker.missingConfig
}
};
}
function normalizeBlocker(body, response) {
function normalizeStatusBlocker(body, response) {
if (!response.ok) {
return normalizeBlocker(body, response, {
route: HWLAB_M3_STATUS_API_ROUTE,
unavailableUserMessage: "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。",
defaultMessage: "M3 status request was blocked."
});
}
const raw = body.blocker ?? body.error ?? null;
const trustBlockerCode = body.trust?.blocker ?? null;
if (!raw && !trustBlockerCode && !["blocked", "error", "failed"].includes(String(body.status ?? "").toLowerCase())) {
return null;
}
const code = raw?.code ?? trustBlockerCode ?? "m3_status_blocked";
return {
code,
layer: blockerLayer(code),
message: raw?.message ?? raw?.reason ?? raw?.zh ?? body.summary ?? "M3 status request was blocked.",
zh: raw?.zh ?? raw?.message ?? raw?.reason ?? body.summary ?? "M3 status request was blocked.",
category: blockerCategory(code),
retryable: retryableBlocker(code),
userMessage: raw?.userMessage ?? raw?.zh ?? userMessageForBlocker(code, raw?.message ?? raw?.reason),
traceId: body.traceId ?? body.trust?.traceId ?? null,
route: HWLAB_M3_STATUS_API_ROUTE,
toolName: HWLAB_M3_IO_SKILL_NAME
};
}
function normalizeBlocker(body, response, options = {}) {
const route = options.route ?? HWLAB_M3_IO_API_ROUTE;
const unavailableUserMessage = options.unavailableUserMessage ?? "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。";
const defaultMessage = options.defaultMessage ?? "M3 IO request was blocked.";
const raw = body.blocker ?? body.error ?? null;
if (raw) {
const code = raw.code ?? body.blockerClassification?.code ?? "m3_io_blocked";
return {
code,
layer: blockerLayer(code),
message: raw.message ?? raw.reason ?? raw.zh ?? body.blockerClassification?.reason ?? "M3 IO request was blocked.",
zh: raw.zh ?? raw.message ?? raw.reason ?? "M3 IO request was blocked.",
message: raw.message ?? raw.reason ?? raw.zh ?? body.blockerClassification?.reason ?? defaultMessage,
zh: raw.zh ?? raw.message ?? raw.reason ?? defaultMessage,
category: body.blockerClassification?.category ?? blockerCategory(code),
retryable: retryableBlocker(code),
userMessage: raw.userMessage ?? raw.zh ?? userMessageForBlocker(code, raw.message ?? raw.reason),
userMessage: raw.userMessage ?? raw.zh ?? userMessageForBlocker(code, raw.message ?? raw.reason, { route }),
traceId: body.traceId ?? null,
route: HWLAB_M3_IO_API_ROUTE,
route,
toolName: HWLAB_M3_IO_SKILL_NAME
};
}
@@ -567,8 +877,8 @@ function normalizeBlocker(body, response) {
zh: response.error ?? `HWLAB API HTTP ${response.status}`,
category: "hwlab_api",
retryable: true,
userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。",
route: HWLAB_M3_IO_API_ROUTE,
userMessage: unavailableUserMessage,
route,
toolName: HWLAB_M3_IO_SKILL_NAME
};
}
@@ -590,8 +900,8 @@ function normalizeCapabilityBlocker(blocker, response) {
};
}
function normalizeTrustBlocker(body) {
const code = body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? null;
function normalizeTrustBlocker(body, route = HWLAB_M3_IO_API_ROUTE) {
const code = body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? body.trust?.blocker ?? null;
if (!code) return null;
return {
code,
@@ -601,7 +911,7 @@ function normalizeTrustBlocker(body) {
layer: "runtime-durable",
retryable: false,
userMessage: "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。",
route: HWLAB_M3_IO_API_ROUTE,
route,
toolName: HWLAB_M3_IO_SKILL_NAME,
source: body.evidenceState?.blocker ? "evidenceState.blocker" : "durableStatus.blocker"
};
@@ -646,9 +956,13 @@ function retryableBlocker(code) {
return ["hwlab_api_unavailable"].includes(String(code ?? ""));
}
function userMessageForBlocker(code, message) {
function userMessageForBlocker(code, message, options = {}) {
if (code === "skill_cli_api_base_missing") return "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。";
if (code === "hwlab_api_unavailable") return "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。";
if (code === "hwlab_api_unavailable") {
return options.route === HWLAB_M3_STATUS_API_ROUTE
? "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。"
: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。";
}
if (code === "direct_hardware_target_blocked") return "该请求被安全边界阻断,不能绕过 cloud-api/HWLAB API 直接调用硬件服务。";
if (/durable|runtime/u.test(String(code ?? ""))) return "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。";
return message || "M3 控制链路仍受阻,前端应显示为能力未就绪。";
@@ -713,21 +1027,72 @@ function helpPayload() {
usage: [
`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`,
`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`,
`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`
`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`,
`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 status --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`
],
env: {
recommended: HWLAB_M3_IO_API_BASE_URL_ENV,
supported: [...HWLAB_M3_IO_API_BASE_URL_ENVS],
devValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL
},
route: HWLAB_M3_IO_API_ROUTE,
routes: [HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE],
safety: {
cloudApiRouteOnly: true,
directGatewayCalls: false,
directBoxCalls: false,
directPatchPanelCalls: false,
fallbackUsed: false
}
fallbackUsed: false,
writeApprovalRequired: true
},
allowlist: allowedCloudApiTargets.map((target) => ({
hostname: target.hostname,
ports: [...target.ports]
}))
};
}
function approvalForParsed(parsed, command) {
const required = command.action === "do.write";
const approved = required ? parsed.approved === true : true;
const policy = parsed.policy || (required ? "hwlab-api-control-with-approval" : "hwlab-api-readonly");
const reason = parsed.approvalReason || (required ? "explicit user requested DO1 write through HWLAB API" : "readonly DI1 read");
if (required && !approved) {
return {
required,
approved: false,
policy,
reason,
blocker: {
code: "approval_required",
message: "M3 DO write requires --approved or --confirm-control so the Skill CLI records explicit controlled approval semantics."
}
};
}
return {
required,
approved,
policy,
reason
};
}
function approvalForAction(action) {
const required = action === "do.write";
return {
required,
approved: !required,
policy: required ? "hwlab-api-control-with-approval" : "hwlab-api-readonly",
reason: required ? null : "readonly or blocked before command normalization"
};
}
function approvalPayload(approval) {
if (!approval || typeof approval !== "object") return null;
return {
required: approval.required === true,
approved: approval.approved === true,
policy: approval.policy ?? null,
reason: approval.reason ?? null
};
}
@@ -754,6 +1119,12 @@ function parseTimeout(value) {
return parsed;
}
function isAllowlistedCloudApiUrl(url) {
return allowedCloudApiTargets.some((target) =>
url.hostname === target.hostname && target.ports.has(url.port || "")
);
}
export function configuredCloudApiBaseUrl(env = process.env) {
return firstNonEmpty(...HWLAB_M3_IO_API_BASE_URL_ENVS.map((name) => env[name]));
}
@@ -787,9 +1158,10 @@ function redactUrl(value) {
function publicCloudApiTarget(apiTarget) {
if (!apiTarget || typeof apiTarget !== "object") return null;
const redactedUrl = apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null);
return {
route: apiTarget.route ?? HWLAB_M3_IO_API_ROUTE,
redactedUrl: apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null),
redactedUrl: redactedUrl && forbiddenDirectTarget.test(redactedUrl) ? null : redactedUrl,
source: apiTarget.source ?? null,
missingConfig: sanitizeMissingConfig(apiTarget.missingConfig ?? []),
cloudApiOnly: apiTarget.cloudApiOnly === true,
@@ -799,6 +1171,28 @@ function publicCloudApiTarget(apiTarget) {
};
}
function responseSummary(body = {}) {
return {
serviceId: body.serviceId ?? null,
contractVersion: body.contractVersion ?? null,
status: body.status ?? null,
accepted: body.accepted ?? null,
action: body.action ?? null,
operationId: body.operationId ?? null,
traceId: body.traceId ?? null,
auditId: body.auditId ?? body.auditState?.auditId ?? null,
evidenceId: body.evidenceId ?? body.evidenceState?.evidenceId ?? null,
blockerCode: body.blocker?.code ?? body.error?.code ?? body.evidenceState?.blocker ?? body.durableStatus?.blocker ?? null,
controlPath: body.controlPath ? {
cloudApi: body.controlPath.cloudApi ?? null,
gatewaySimu: body.controlPath.gatewaySimu ?? null,
boxSimu: body.controlPath.boxSimu ?? null,
patchPanel: body.controlPath.patchPanel ?? null,
frontendBypass: body.controlPath.frontendBypass ?? null
} : null
};
}
function parseJsonOrNull(value) {
try {
return JSON.parse(value);