Merge pull request #383 from pikasTech/feat/hwlab-334-skill-cli
HWLAB#334: route Code Agent M3 IO through repo-owned Skill CLI and HWLAB API.
This commit is contained in:
@@ -2233,7 +2233,17 @@ function m3IoSkillArgsForIntent(intent, { env, traceId }) {
|
||||
args.push("--api-base-url", apiBaseUrl);
|
||||
}
|
||||
if (intent.action === "do.write") {
|
||||
args.push("--value", String(intent.value));
|
||||
args.push(
|
||||
"--value",
|
||||
String(intent.value),
|
||||
"--approved",
|
||||
"--policy",
|
||||
"hwlab-api-control-with-approval",
|
||||
"--approval-reason",
|
||||
"user explicitly requested res_boxsimu_1 DO1 write through HWLAB API"
|
||||
);
|
||||
} else {
|
||||
args.push("--policy", "hwlab-api-readonly");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DEV_ENDPOINT, loadMvpGateSummary } from "../../../internal/mvp-gate/summary.mjs";
|
||||
import { runM3IoSkillCommand } from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
|
||||
function formatJson(value) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
@@ -169,6 +170,9 @@ function writeHelp(stdout) {
|
||||
commands: [
|
||||
"health",
|
||||
"project list",
|
||||
"m3 status --api-base-url URL",
|
||||
"m3 io --action do.write --value true --approved --api-base-url URL",
|
||||
"m3 io --action di.read --api-base-url URL",
|
||||
"test e2e --env dev --mvp",
|
||||
"test e2e --env dev --mvp --dry-run",
|
||||
"test e2e --env dev --mvp --live --confirm-dev --confirmed-non-production"
|
||||
@@ -185,8 +189,25 @@ export async function runCli(argv, io) {
|
||||
const stdout = io.stdout ?? process.stdout;
|
||||
const stderr = io.stderr ?? process.stderr;
|
||||
const repoRoot = io.cwd ?? process.cwd();
|
||||
let summary;
|
||||
const env = io.env ?? process.env;
|
||||
|
||||
const [command = "help", subcommand, ...rest] = argv;
|
||||
const { flags, options } = parseArgs(rest);
|
||||
|
||||
if (command === "m3" && ["io", "status"].includes(subcommand)) {
|
||||
const result = await runM3IoSkillCommand(["m3", subcommand, ...rest], {
|
||||
env,
|
||||
now: io.now,
|
||||
requestJson: io.requestJson
|
||||
});
|
||||
writeJson(result.ok ? stdout : stderr, {
|
||||
...result,
|
||||
command: `hwlab-cli ${["m3", subcommand, ...redactM3CliArgs(rest)].join(" ")}`
|
||||
});
|
||||
return result.ok ? 0 : 2;
|
||||
}
|
||||
|
||||
let summary;
|
||||
try {
|
||||
summary = loadMvpGateSummary(repoRoot);
|
||||
} catch (error) {
|
||||
@@ -194,9 +215,6 @@ export async function runCli(argv, io) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const [command = "help", subcommand, ...rest] = argv;
|
||||
const { flags, options } = parseArgs(rest);
|
||||
|
||||
if (command === "health") {
|
||||
writeJson(stdout, mvpGateOverview(summary));
|
||||
return 0;
|
||||
@@ -264,6 +282,18 @@ export async function runCli(argv, io) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function redactM3CliArgs(args = []) {
|
||||
const redacted = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
redacted.push(args[index]);
|
||||
if (args[index] === "--api-base-url" && index + 1 < args.length) {
|
||||
redacted.push("<hwlab-api-base-url>");
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function formatRuntime(runtime) {
|
||||
return formatJson(runtime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
HWLAB_M3_IO_API_ROUTE,
|
||||
HWLAB_M3_STATUS_API_ROUTE
|
||||
} from "../../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
import { runCli } from "./cli.mjs";
|
||||
|
||||
async function captureCli(args, options = {}) {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const exitCode = await runCli(args, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
env: options.env ?? {
|
||||
PATH: process.env.PATH
|
||||
},
|
||||
requestJson: options.requestJson,
|
||||
stdout: {
|
||||
write(chunk) {
|
||||
stdout += chunk;
|
||||
}
|
||||
},
|
||||
stderr: {
|
||||
write(chunk) {
|
||||
stderr += chunk;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { exitCode, stdout, stderr };
|
||||
}
|
||||
|
||||
test("repo hwlab-cli m3 status uses Skill CLI and calls only HWLAB API /v1/m3/status", async () => {
|
||||
const calls = [];
|
||||
const result = await captureCli(
|
||||
[
|
||||
"m3",
|
||||
"status",
|
||||
"--api-base-url",
|
||||
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
"--trace-id",
|
||||
"trc_hwlab_cli_status"
|
||||
],
|
||||
{
|
||||
requestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_STATUS_API_ROUTE}`);
|
||||
assert.equal(request.method, "GET");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
status: "live",
|
||||
sourceKind: "DEV-LIVE",
|
||||
traceId: "trc_hwlab_cli_status",
|
||||
boxes: [{
|
||||
id: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
online: true,
|
||||
ports: {
|
||||
DI1: {
|
||||
value: false
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
const body = JSON.parse(result.stdout);
|
||||
assert.equal(body.route, HWLAB_M3_STATUS_API_ROUTE);
|
||||
assert.equal(body.method, "GET");
|
||||
assert.equal(body.readback.value, false);
|
||||
assert.equal(body.command.includes("hwlab-cloud-api.hwlab-dev"), false);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("repo hwlab-cli m3 io blocks direct hardware targets before request", async () => {
|
||||
const result = await captureCli(
|
||||
[
|
||||
"m3",
|
||||
"io",
|
||||
"--action",
|
||||
"di.read",
|
||||
"--api-base-url",
|
||||
"http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301",
|
||||
"--trace-id",
|
||||
"trc_hwlab_cli_direct_blocked"
|
||||
],
|
||||
{
|
||||
requestJson: async () => {
|
||||
throw new Error("direct hardware target must not be called");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.exitCode, 2);
|
||||
const body = JSON.parse(result.stderr);
|
||||
assert.equal(body.route, HWLAB_M3_IO_API_ROUTE);
|
||||
assert.equal(body.blocker.code, "direct_hardware_target_blocked");
|
||||
assert.equal(body.hwlabApi.redactedUrl, null);
|
||||
assert.equal(JSON.stringify(body).includes("patch-panel.hwlab-dev"), false);
|
||||
});
|
||||
|
||||
@@ -349,6 +349,8 @@ function localizedAttributionValue(key, value, message) {
|
||||
if (key === "capabilityLevel" && normalized === "text-chat-only") return "text-chat-only / 只是文本回答";
|
||||
if (key === "capabilityLevel" && normalized === "read-only-session-tools") return `${normalized} / 只读工具`;
|
||||
if (key === "capabilityLevel" && normalized === "hwlab-api-control-ready") return `${normalized} / HWLAB API 受控路径`;
|
||||
if (key === "capabilityLevel" && normalized === "hwlab-api-control-with-approval") return `${normalized} / HWLAB API 受控写入`;
|
||||
if (key === "capabilityLevel" && normalized === "hwlab-api-readonly") return `${normalized} / HWLAB API 只读状态`;
|
||||
if (key === "toolCalls" && normalized === "none") {
|
||||
return message?.capabilityLevel === "text-chat-only" ? "0/0 / 无工具调用:只是文本回答" : "0/0 / 无工具调用";
|
||||
}
|
||||
|
||||
@@ -262,6 +262,7 @@ function isSkillCliApiControl(payload) {
|
||||
function isReadySkillCli(payload) {
|
||||
return (
|
||||
payload.capabilityLevel === "hwlab-api-control-ready" ||
|
||||
payload.capabilityLevel === "hwlab-api-control-with-approval" ||
|
||||
payload.providerTrace?.controlReady === true ||
|
||||
array(payload.toolCalls).some((tool) => tool?.controlReady === true && tool?.status === "completed")
|
||||
) && !hasBlockingSkillCliSignal(payload);
|
||||
|
||||
Reference in New Issue
Block a user