feat: add code agent M3 skill control path
Adds the Code Agent -> Skill CLI -> HWLAB API /v1/m3/io controlled M3 IO path with route/audit/evidence/blocker payloads and tests proving no direct gateway/box/patch-panel or OpenAI fallback hardware control.
This commit is contained in:
@@ -15,6 +15,12 @@ import {
|
||||
DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
|
||||
createCodeAgentSessionRegistry
|
||||
} from "./code-agent-session-registry.mjs";
|
||||
import {
|
||||
HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
HWLAB_M3_IO_API_ROUTE,
|
||||
HWLAB_M3_IO_SKILL_NAME,
|
||||
runM3IoSkillCommand
|
||||
} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
|
||||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000;
|
||||
const DEFAULT_CODEX_COMMAND = "codex";
|
||||
@@ -28,6 +34,21 @@ const READONLY_RUNNER_SANDBOX = "read-only";
|
||||
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
|
||||
const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry";
|
||||
const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools";
|
||||
const M3_IO_SKILL_PROVIDER = "hwlab-skill-cli";
|
||||
const M3_IO_SKILL_BACKEND = "hwlab-cloud-api/hwlab-agent-runtime-skill-cli";
|
||||
const M3_IO_SKILL_MODEL = "controlled-m3-io";
|
||||
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",
|
||||
"not-generic-hardware-control",
|
||||
"not-codex-stdio",
|
||||
"not-durable-session"
|
||||
]);
|
||||
const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback";
|
||||
const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral";
|
||||
const READONLY_TOOL_OUTPUT_LIMIT = 4000;
|
||||
@@ -91,6 +112,51 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
try {
|
||||
const message = normalizeUserMessage(params.message);
|
||||
const runnerIntent = detectReadOnlyRunnerIntent(message);
|
||||
if (runnerIntent.kind === "m3_io") {
|
||||
const runnerResult = await callM3IoSkillRunner({
|
||||
intent: runnerIntent,
|
||||
conversationId,
|
||||
traceId,
|
||||
sessionId: requestedSessionId,
|
||||
env: options.env ?? process.env,
|
||||
now: options.now,
|
||||
workspace: options.workspace,
|
||||
sessionRegistry: options.sessionRegistry,
|
||||
requestJson: options.m3IoSkillRequestJson
|
||||
});
|
||||
const completedAt = nowIso(options.now);
|
||||
return {
|
||||
...base,
|
||||
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
|
||||
status: "completed",
|
||||
updatedAt: completedAt,
|
||||
provider: runnerResult.provider,
|
||||
model: runnerResult.model,
|
||||
backend: runnerResult.backend,
|
||||
workspace: runnerResult.workspace,
|
||||
sandbox: runnerResult.sandbox,
|
||||
session: runnerResult.session,
|
||||
sessionMode: runnerResult.sessionMode,
|
||||
sessionReuse: runnerResult.sessionReuse,
|
||||
implementationType: runnerResult.implementationType,
|
||||
runnerLimitations: runnerResult.runnerLimitations,
|
||||
codexStdioFeasibility: runnerResult.codexStdioFeasibility,
|
||||
longLivedSessionGate: runnerResult.longLivedSessionGate,
|
||||
toolCalls: runnerResult.toolCalls,
|
||||
skills: runnerResult.skills,
|
||||
runner: runnerResult.runner,
|
||||
runnerTrace: runnerResult.runnerTrace,
|
||||
capabilityLevel: runnerResult.capabilityLevel,
|
||||
reply: {
|
||||
messageId,
|
||||
role: "assistant",
|
||||
content: runnerResult.content,
|
||||
createdAt: completedAt
|
||||
},
|
||||
usage: null,
|
||||
providerTrace: runnerResult.providerTrace
|
||||
};
|
||||
}
|
||||
if (runnerIntent.kind !== "none") {
|
||||
const runnerResult = await callReadOnlyRunner({
|
||||
intent: runnerIntent,
|
||||
@@ -334,8 +400,8 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||||
blocker: blocked && !runnerAvailability.ready ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null,
|
||||
reason: blocked && !runnerAvailability.ready ? "provider_unavailable" : null,
|
||||
summary: blocked
|
||||
? `受控只读 runner 可用于 pwd/skills 等工作区能力;OpenAI Responses fallback 当前受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 影响。`
|
||||
: "真实后端已接入;pwd/skills 走受控只读 runner,普通聊天可走 OpenAI Responses fallback。OpenAI fallback 不满足 Codex runner capability gate。",
|
||||
? `受控只读 runner 可用于 pwd/skills 等工作区能力,M3 IO 只允许 Skill CLI 调用 ${HWLAB_M3_IO_API_ROUTE};OpenAI Responses fallback 当前受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 影响。`
|
||||
: `真实后端已接入;pwd/skills 走受控只读 runner,M3 IO 走 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE},普通聊天可走 OpenAI Responses fallback。OpenAI fallback 不满足 Codex runner capability gate。`,
|
||||
missingEnv,
|
||||
secretRefs: blocked ? providerContract.secretRefs : [],
|
||||
egress: providerContract.egress,
|
||||
@@ -441,6 +507,17 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
sessionRegistry,
|
||||
skillsDirs,
|
||||
skillsDirsPresent,
|
||||
m3IoSkill: {
|
||||
status: "available",
|
||||
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,
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
fallbackAllowed: false
|
||||
},
|
||||
safety: runnerSafetyContract()
|
||||
};
|
||||
}
|
||||
@@ -772,6 +849,172 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson }) {
|
||||
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
|
||||
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
|
||||
const sessionAcquire = registry.acquire({
|
||||
conversationId,
|
||||
sessionId,
|
||||
workspace: resolvedWorkspace,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
traceId,
|
||||
now
|
||||
});
|
||||
const startedAt = nowIso(now);
|
||||
const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env));
|
||||
if (!sessionAcquire.ok) {
|
||||
const blockedSession = sessionAcquire.session;
|
||||
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
model: M3_IO_SKILL_MODEL,
|
||||
backend: M3_IO_SKILL_BACKEND,
|
||||
workspace: resolvedWorkspace ?? null,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
session: blockedSession,
|
||||
toolCalls: [],
|
||||
skills: notRequestedSkills(),
|
||||
runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
|
||||
runnerTrace: m3IoSkillRunnerTrace({
|
||||
traceId,
|
||||
workspace: resolvedWorkspace ?? repoRoot,
|
||||
session: blockedSession,
|
||||
events: [`blocked:${sessionAcquire.code}`],
|
||||
startedAt,
|
||||
outputTruncated: false
|
||||
}),
|
||||
capabilityLevel: "blocked",
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(blockedSession),
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
session: blockedSession,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
}),
|
||||
blockers: [sessionAcquire.blocker]
|
||||
});
|
||||
}
|
||||
|
||||
let session = sessionAcquire.session;
|
||||
const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId });
|
||||
const skillResult = await runM3IoSkillCommand(commandArgs, {
|
||||
env,
|
||||
now,
|
||||
requestJson
|
||||
});
|
||||
const finishedAt = nowIso(now);
|
||||
const toolCall = {
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "skill-cli",
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked",
|
||||
cwd: resolvedWorkspace,
|
||||
command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${commandArgs.join(" ")}`),
|
||||
exitCode: skillResult.ok ? 0 : 2,
|
||||
stdout: boundToolOutput(JSON.stringify({
|
||||
route: skillResult.route,
|
||||
status: skillResult.status,
|
||||
accepted: skillResult.accepted,
|
||||
traceId: skillResult.traceId,
|
||||
operationId: skillResult.operationId,
|
||||
audit: skillResult.audit,
|
||||
evidence: skillResult.evidence,
|
||||
durable: skillResult.durable,
|
||||
blocker: skillResult.blocker,
|
||||
result: skillResult.result,
|
||||
safety: skillResult.safety
|
||||
})).text,
|
||||
stderrSummary: skillResult.blocker?.code ?? "",
|
||||
outputTruncated: false,
|
||||
traceId,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
hwlabApi: skillResult.hwlabApi,
|
||||
accepted: skillResult.accepted,
|
||||
operationId: skillResult.operationId,
|
||||
audit: skillResult.audit,
|
||||
evidence: skillResult.evidence,
|
||||
blocker: skillResult.blocker,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false
|
||||
};
|
||||
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session });
|
||||
const runnerTracePayload = m3IoSkillRunnerTrace({
|
||||
traceId,
|
||||
workspace: resolvedWorkspace,
|
||||
session,
|
||||
events: [
|
||||
"intent:m3_io",
|
||||
"tool:skill-cli:started",
|
||||
`route:${HWLAB_M3_IO_API_ROUTE}`,
|
||||
`tool:${toolCall.status}`
|
||||
],
|
||||
startedAt,
|
||||
finishedAt,
|
||||
outputTruncated: toolCall.outputTruncated,
|
||||
skillResult
|
||||
});
|
||||
|
||||
return {
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
model: M3_IO_SKILL_MODEL,
|
||||
backend: M3_IO_SKILL_BACKEND,
|
||||
content: m3IoSkillReply(skillResult),
|
||||
workspace: resolvedWorkspace,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
session,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(session),
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||||
codexStdioFeasibility,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
session,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility
|
||||
}),
|
||||
toolCalls: [toolCall],
|
||||
skills: {
|
||||
status: "used",
|
||||
items: [{
|
||||
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,
|
||||
version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION
|
||||
}],
|
||||
count: 1,
|
||||
blockers: skillResult.blocker ? [skillResult.blocker] : []
|
||||
},
|
||||
runner,
|
||||
runnerTrace: runnerTracePayload,
|
||||
capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL,
|
||||
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,
|
||||
accepted: skillResult.accepted,
|
||||
status: skillResult.status,
|
||||
fallbackUsed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function detectReadOnlyRunnerIntent(message) {
|
||||
const text = String(message ?? "").trim();
|
||||
const lower = text.toLowerCase();
|
||||
@@ -783,11 +1026,15 @@ function detectReadOnlyRunnerIntent(message) {
|
||||
reason: "只读 runner 不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。"
|
||||
};
|
||||
}
|
||||
const m3IoIntent = detectM3IoIntent(text);
|
||||
if (m3IoIntent) {
|
||||
return m3IoIntent;
|
||||
}
|
||||
if (isHardwareWriteOrAcceptanceRequest(text)) {
|
||||
return {
|
||||
kind: "security",
|
||||
toolName: "security.hardware-boundary",
|
||||
reason: "只读 runner 不直接调用 gateway/box-simu/patch-panel、硬件写接口或宣称 M3/M4/M5 验收通过。"
|
||||
reason: `只读 runner 不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。`
|
||||
};
|
||||
}
|
||||
if (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) {
|
||||
@@ -816,6 +1063,54 @@ function detectReadOnlyRunnerIntent(message) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
function detectM3IoIntent(text) {
|
||||
const normalized = String(text ?? "");
|
||||
if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回/u.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:直接|direct|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|\/invoke|\/sync\/tick)/iu.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wantsRead = /(?:DI1|数字输入).{0,16}(?:read|readback|读取|回读|读回|查询)|(?:read|readback|读取|回读|读回|查询).{0,16}(?:DI1|数字输入)/iu.test(normalized);
|
||||
const wantsWrite = /(?:DO1|数字输出).{0,20}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,20}(?:DO1|数字输出)/iu.test(normalized);
|
||||
if (!wantsRead && !wantsWrite) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (wantsRead && !wantsWrite) {
|
||||
return {
|
||||
kind: "m3_io",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
action: "di.read"
|
||||
};
|
||||
}
|
||||
|
||||
const value = parseM3WriteValue(normalized);
|
||||
if (typeof value !== "boolean") {
|
||||
return {
|
||||
kind: "unsupported",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
reason: "M3 DO1 write requires an explicit true/false value."
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "m3_io",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
action: "do.write",
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
function parseM3WriteValue(text) {
|
||||
if (/\btrue\b|\b1\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true;
|
||||
if (/\bfalse\b|\b0\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractReadOnlyTarget(text, { defaultTarget = null, preferFile = false } = {}) {
|
||||
const value = String(text ?? "");
|
||||
const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1];
|
||||
@@ -845,7 +1140,7 @@ function isSecretReadRequest(text) {
|
||||
}
|
||||
|
||||
function isHardwareWriteOrAcceptanceRequest(text) {
|
||||
const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用)/iu.test(text);
|
||||
const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用|\/invoke|\/sync\/tick)/iu.test(text);
|
||||
const mutationVerb = /(?:call|invoke|write|mutate|apply|rollout|accept|pass|验收|通过|写入|调用|变更|操作)/iu.test(text);
|
||||
const acceptanceClaim = /(?:M3|M4|M5).{0,20}(?:pass|accept|green|通过|验收|完成)/iu.test(text);
|
||||
return (hardwareTarget && mutationVerb) || acceptanceClaim;
|
||||
@@ -1378,6 +1673,133 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoSkillArgsForIntent(intent, { env, traceId }) {
|
||||
const args = [
|
||||
"m3",
|
||||
"io",
|
||||
"--action",
|
||||
intent.action,
|
||||
"--trace-id",
|
||||
traceId,
|
||||
"--actor-id",
|
||||
"usr_code_agent"
|
||||
];
|
||||
const apiBaseUrl = firstNonEmpty(
|
||||
env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL,
|
||||
env.HWLAB_API_BASE_URL,
|
||||
env.HWLAB_CLOUD_API_BASE_URL,
|
||||
null
|
||||
);
|
||||
if (apiBaseUrl) {
|
||||
args.push("--api-base-url", apiBaseUrl);
|
||||
}
|
||||
if (intent.action === "do.write") {
|
||||
args.push("--value", String(intent.value));
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function m3IoSkillReply(skillResult) {
|
||||
const lines = [
|
||||
`M3 IO Skill CLI result: status=${skillResult.status}; accepted=${skillResult.accepted}; route=${skillResult.route}; traceId=${skillResult.traceId}; operationId=${skillResult.operationId ?? "null"}.`
|
||||
];
|
||||
if (skillResult.audit?.summary) {
|
||||
lines.push(`Audit: ${skillResult.audit.summary}.`);
|
||||
}
|
||||
if (skillResult.evidence?.summary) {
|
||||
lines.push(`Evidence: ${skillResult.evidence.summary}.`);
|
||||
}
|
||||
if (skillResult.blocker) {
|
||||
lines.push(`Blocker: ${skillResult.blocker.code}; ${skillResult.blocker.zh ?? skillResult.blocker.message}.`);
|
||||
}
|
||||
if (skillResult.action === "do.write") {
|
||||
lines.push(`DO1 requested value=${String(skillResult.command?.value)}; DI1 readback=${String(skillResult.result?.targetReadback?.value ?? "unknown")}.`);
|
||||
} else if (skillResult.action === "di.read") {
|
||||
lines.push(`DI1 value=${String(skillResult.result?.value ?? "unknown")}.`);
|
||||
}
|
||||
lines.push("Boundary: Code Agent -> Skill CLI -> HWLAB API /v1/m3/io; no direct gateway/box/patch-panel call; no OpenAI fallback used. This is source/artifact control capability, not DEV-LIVE verification.");
|
||||
return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text;
|
||||
}
|
||||
|
||||
function m3IoSkillRunnerDescriptor({ workspace, session = null } = {}) {
|
||||
return {
|
||||
kind: M3_IO_SKILL_RUNNER_KIND,
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
backend: M3_IO_SKILL_BACKEND,
|
||||
workspace: workspace ?? repoRoot,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
session: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionId: session?.sessionId ?? null,
|
||||
turn: session?.turn ?? null,
|
||||
sessionReused: session?.reused ?? false,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
codexStdio: false,
|
||||
longLivedSession: false,
|
||||
durableSession: false,
|
||||
writeCapable: true,
|
||||
readOnly: false,
|
||||
capabilityLevel: M3_IO_SKILL_CAPABILITY_LEVEL,
|
||||
skill: {
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route: HWLAB_M3_IO_API_ROUTE
|
||||
},
|
||||
toolPolicy: {
|
||||
allowed: [`POST ${HWLAB_M3_IO_API_ROUTE}`],
|
||||
blocked: ["gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "generic-hardware-rpc", "OpenAI-hardware-fallback", "M3/M4/M5-acceptance-claim"]
|
||||
},
|
||||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||||
safety: {
|
||||
secretsRead: false,
|
||||
secretValuesPrinted: false,
|
||||
kubeconfigRead: false,
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
openAiFallbackAllowed: false,
|
||||
m3m4m5AcceptanceClaimsAllowed: false,
|
||||
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = startedAt, outputTruncated, workspace = repoRoot, session = null, skillResult = null }) {
|
||||
return {
|
||||
traceId,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
workspace,
|
||||
sandbox: M3_IO_SKILL_SANDBOX,
|
||||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||||
sessionId: session?.sessionId ?? null,
|
||||
sessionStatus: session?.status ?? null,
|
||||
idleTimeoutMs: session?.idleTimeoutMs ?? null,
|
||||
lastTraceId: session?.lastTraceId ?? null,
|
||||
turn: session?.turn ?? null,
|
||||
sessionReused: session?.reused ?? false,
|
||||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||||
limitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||||
startedAt,
|
||||
finishedAt,
|
||||
events,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||||
operationId: skillResult?.operationId ?? null,
|
||||
accepted: skillResult?.accepted ?? false,
|
||||
status: skillResult?.status ?? "blocked",
|
||||
blocker: skillResult?.blocker ?? null,
|
||||
outputTruncated: Boolean(outputTruncated),
|
||||
valuesPrinted: false,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false,
|
||||
note: `Controlled M3 IO uses Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}; it is not OpenAI fallback and does not directly call gateway, box, or patch-panel.`
|
||||
};
|
||||
}
|
||||
|
||||
function sessionReuseEvidence(session) {
|
||||
return {
|
||||
conversationId: session.conversationId,
|
||||
@@ -1424,6 +1846,9 @@ function resolveCodeAgentSessionRegistry(options = {}) {
|
||||
}
|
||||
|
||||
function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) {
|
||||
const allowed = kind === READONLY_RUNNER_KIND
|
||||
? ["pwd", "skills.discover", "ls", "rg --files", "cat", `m3.io.skill-cli:${HWLAB_M3_IO_API_ROUTE}`]
|
||||
: ["pwd", "skills.discover", "ls", "rg --files", "cat"];
|
||||
return {
|
||||
kind,
|
||||
provider: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_PROVIDER : "codex-cli",
|
||||
@@ -1443,7 +1868,7 @@ function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = nu
|
||||
readOnly: true,
|
||||
capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only",
|
||||
toolPolicy: {
|
||||
allowed: ["pwd", "skills.discover", "ls", "rg --files", "cat"],
|
||||
allowed,
|
||||
blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"]
|
||||
},
|
||||
runnerLimitations: kind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"],
|
||||
@@ -1486,6 +1911,8 @@ function runnerSafetyContract() {
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
m3IoSkillCliAllowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||||
openAiHardwareFallbackAllowed: false,
|
||||
m3m4m5AcceptanceClaimsAllowed: false,
|
||||
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
|
||||
test("code agent session registry creates, reuses, and expires sessions without storing secrets", () => {
|
||||
const registry = createCodeAgentSessionRegistry({
|
||||
@@ -279,3 +280,224 @@ test("OpenAI fallback and codex one-shot do not pass the long-lived session gate
|
||||
assert.equal(classifyCodexRunnerCapability(oneShot, { httpStatus: 200 }).capabilityPass, false);
|
||||
assert.equal(classifyCodeAgentChatReadiness(oneShot, { realDevLive: true, httpStatus: 200 }).devLiveReplyPass, true);
|
||||
});
|
||||
|
||||
test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => {
|
||||
const calls = [];
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_m3_skill_write",
|
||||
traceId: "trc_m3_skill_write",
|
||||
message: "请通过 M3 DO1 写入 true,并回读 DI1"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:05:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
OPENAI_API_KEY: "must-not-be-used"
|
||||
},
|
||||
callProvider: async () => {
|
||||
throw new Error("OpenAI fallback must not be used for M3 IO control");
|
||||
},
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
const parsed = new URL(url);
|
||||
assert.equal(parsed.pathname, HWLAB_M3_IO_API_ROUTE);
|
||||
assert.equal(parsed.hostname, "hwlab-cloud-api.hwlab-dev.svc.cluster.local");
|
||||
assert.equal(request.method, "POST");
|
||||
assert.equal(request.body.action, "do.write");
|
||||
assert.equal(request.body.resourceId, "res_boxsimu_1");
|
||||
assert.equal(request.body.port, "DO1");
|
||||
assert.equal(request.body.value, true);
|
||||
assert.equal(request.body.source, "hwlab-agent-runtime.m3-io");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
action: "do.write",
|
||||
traceId: "trc_m3_skill_write",
|
||||
operationId: "op_m3_do_write_skill",
|
||||
auditId: "aud_m3_do_write_skill_succeeded",
|
||||
evidenceId: "evd_m3_do_write_skill_succeeded",
|
||||
auditState: {
|
||||
status: "written_non_durable",
|
||||
durableStatus: {
|
||||
status: "degraded"
|
||||
}
|
||||
},
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: "runtime_durable_not_green",
|
||||
writeStatus: "written_non_durable"
|
||||
},
|
||||
durableStatus: {
|
||||
status: "degraded",
|
||||
durable: false,
|
||||
blocker: "runtime_durable_not_green"
|
||||
},
|
||||
result: {
|
||||
value: true,
|
||||
targetReadback: {
|
||||
status: "succeeded",
|
||||
value: true,
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
}
|
||||
},
|
||||
controlPath: {
|
||||
status: "succeeded",
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
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.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].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.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.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}`);
|
||||
assert.equal(calls[0].url.includes("gateway"), false);
|
||||
assert.equal(calls[0].url.includes("box-simu"), false);
|
||||
assert.equal(calls[0].url.includes("patch-panel"), false);
|
||||
});
|
||||
|
||||
test("Code Agent M3 DI read returns structured blocker from HWLAB API without fallback", async () => {
|
||||
const calls = [];
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_m3_skill_read_blocked",
|
||||
traceId: "trc_m3_skill_read_blocked",
|
||||
message: "读取 M3 DI1 readback"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:06:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
OPENAI_API_KEY: "must-not-be-used"
|
||||
},
|
||||
callProvider: async () => {
|
||||
throw new Error("OpenAI fallback must not be used for M3 IO read");
|
||||
},
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
action: "di.read",
|
||||
traceId: "trc_m3_skill_read_blocked",
|
||||
operationId: "op_m3_di_read_blocked",
|
||||
auditId: "aud_m3_di_read_blocked_failed",
|
||||
evidenceId: "evd_m3_di_read_blocked_failed",
|
||||
blocker: {
|
||||
code: "m3_gateway_session_unavailable",
|
||||
message: "gateway unavailable",
|
||||
zh: "gateway 未注册/不可用"
|
||||
},
|
||||
blockerClassification: {
|
||||
category: "gateway_session"
|
||||
},
|
||||
auditState: {
|
||||
status: "written_non_durable"
|
||||
},
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: "runtime_durable_not_green",
|
||||
writeStatus: "written_non_durable"
|
||||
},
|
||||
durableStatus: {
|
||||
status: "degraded",
|
||||
durable: false,
|
||||
blocker: "runtime_durable_not_green"
|
||||
},
|
||||
controlPath: {
|
||||
cloudApi: true,
|
||||
gatewaySimu: false,
|
||||
boxSimu: false,
|
||||
patchPanel: false,
|
||||
frontendBypass: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.toolCalls[0].status, "blocked");
|
||||
assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE);
|
||||
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.providerTrace.fallbackUsed, 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");
|
||||
assert.equal(calls[0].request.body.resourceId, "res_boxsimu_2");
|
||||
assert.equal(calls[0].request.body.port, "DI1");
|
||||
});
|
||||
|
||||
test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => {
|
||||
const direct = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_m3_direct_blocked",
|
||||
traceId: "trc_m3_direct_blocked",
|
||||
message: "请直接调用 gateway-simu /invoke 写入 M3 DO1 true"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:07:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||||
},
|
||||
m3IoSkillRequestJson: async () => {
|
||||
throw new Error("direct gateway request must not reach the M3 skill CLI");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(direct);
|
||||
assert.equal(direct.status, "failed");
|
||||
assert.equal(direct.error.code, "security_blocked");
|
||||
assert.equal(direct.toolCalls[0].name, "security.hardware-boundary");
|
||||
assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u);
|
||||
});
|
||||
|
||||
@@ -344,7 +344,8 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
workspace: options.workspace,
|
||||
skillsDirs: options.skillsDirs,
|
||||
skillsDirsExact: options.skillsDirsExact,
|
||||
sessionRegistry: options.sessionRegistry
|
||||
sessionRegistry: options.sessionRegistry,
|
||||
m3IoSkillRequestJson: options.m3IoSkillRequestJson
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1021,6 +1021,108 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-"));
|
||||
const calls = [];
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
OPENAI_API_KEY: "must-not-be-used"
|
||||
},
|
||||
callCodeAgentProvider: async () => {
|
||||
throw new Error("OpenAI fallback must not handle M3 IO");
|
||||
},
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
contractVersion: "m3-io-control-v1",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
action: "do.write",
|
||||
traceId: "trc_server-test-m3-skill",
|
||||
operationId: "op_m3_do_write_server_skill",
|
||||
auditId: "aud_m3_do_write_server_skill_succeeded",
|
||||
evidenceId: "evd_m3_do_write_server_skill_succeeded",
|
||||
auditState: {
|
||||
status: "written_non_durable"
|
||||
},
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: "runtime_durable_not_green",
|
||||
writeStatus: "written_non_durable"
|
||||
},
|
||||
durableStatus: {
|
||||
status: "degraded",
|
||||
durable: false,
|
||||
blocker: "runtime_durable_not_green"
|
||||
},
|
||||
result: {
|
||||
value: false,
|
||||
targetReadback: {
|
||||
status: "succeeded",
|
||||
value: false
|
||||
}
|
||||
},
|
||||
controlPath: {
|
||||
status: "succeeded",
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_server-test-m3-skill"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-m3-skill",
|
||||
message: "请通过 M3 DO1 写入 false,并回读 DI1"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
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.toolCalls[0].name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(payload.toolCalls[0].route, "/v1/m3/io");
|
||||
assert.equal(payload.toolCalls[0].accepted, true);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(payload.runner.safety.directGatewayCallsAllowed, false);
|
||||
assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false);
|
||||
assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io");
|
||||
assert.equal(calls[0].request.body.action, "do.write");
|
||||
assert.equal(calls[0].request.body.value, false);
|
||||
assert.equal(calls[0].url.includes("gateway-simu"), false);
|
||||
assert.equal(calls[0].url.includes("box-simu"), false);
|
||||
assert.equal(calls[0].url.includes("patch-panel"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat reuses controlled read-only session and exposes bounded file tools", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-tools-"));
|
||||
await writeFile(path.join(workspace, "alpha.txt"), "alpha\nbeta\n", "utf8");
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -350,6 +350,84 @@ async function runLocalContractSmoke() {
|
||||
assert.equal(classifyCodexRunnerCapability(securityBlocked, { httpStatus: 200 }).capabilityPass, false);
|
||||
logOk("security blocked path is structured");
|
||||
|
||||
const m3SkillCalls = [];
|
||||
const m3SkillWrite = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_code-agent-chat-m3-skill",
|
||||
traceId: "trc_code-agent-chat-m3-skill",
|
||||
message: "请通过 M3 DO1 写入 true,并回读 DI1"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-22T00:04:55.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
OPENAI_API_KEY: "must-not-be-used"
|
||||
},
|
||||
callProvider: async () => {
|
||||
throw new Error("OpenAI fallback must not be used for M3 IO");
|
||||
},
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
m3SkillCalls.push({ url, request });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
action: "do.write",
|
||||
traceId: "trc_code-agent-chat-m3-skill",
|
||||
operationId: "op_m3_do_write_smoke",
|
||||
auditId: "aud_m3_do_write_smoke_succeeded",
|
||||
evidenceId: "evd_m3_do_write_smoke_succeeded",
|
||||
auditState: {
|
||||
status: "written_non_durable"
|
||||
},
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: "runtime_durable_not_green",
|
||||
writeStatus: "written_non_durable"
|
||||
},
|
||||
durableStatus: {
|
||||
status: "degraded",
|
||||
durable: false,
|
||||
blocker: "runtime_durable_not_green"
|
||||
},
|
||||
result: {
|
||||
value: true,
|
||||
targetReadback: {
|
||||
status: "succeeded",
|
||||
value: true
|
||||
}
|
||||
},
|
||||
controlPath: {
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
validateCodeAgentChatSchema(m3SkillWrite);
|
||||
assert.equal(m3SkillWrite.status, "completed");
|
||||
assert.equal(m3SkillWrite.provider, "hwlab-skill-cli");
|
||||
assert.equal(m3SkillWrite.toolCalls[0].name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(m3SkillWrite.toolCalls[0].route, "/v1/m3/io");
|
||||
assert.equal(m3SkillWrite.providerTrace.fallbackUsed, false);
|
||||
assert.equal(m3SkillWrite.runner.safety.directGatewayCallsAllowed, false);
|
||||
assert.equal(m3SkillCalls.length, 1);
|
||||
assert.equal(m3SkillCalls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io");
|
||||
assert.equal(m3SkillCalls[0].url.includes("gateway-simu"), false);
|
||||
assert.equal(m3SkillCalls[0].url.includes("box-simu"), false);
|
||||
assert.equal(m3SkillCalls[0].url.includes("patch-panel"), false);
|
||||
logOk("M3 Skill CLI routes through HWLAB API only");
|
||||
|
||||
const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 });
|
||||
assert.equal(completedHttp200Readiness.status, "pass");
|
||||
assert.equal(completedHttp200Readiness.devLiveReplyPass, true);
|
||||
|
||||
@@ -5,6 +5,8 @@ description: Build and validate the HWLAB agent-mgr and agent-worker runtime ske
|
||||
|
||||
# HWLAB Agent Runtime
|
||||
|
||||
Skill(cli-spec)
|
||||
|
||||
Use this skill when working on HWLAB agent runtime skeletons.
|
||||
|
||||
- Keep the control plane long-lived and worker execution session-scoped.
|
||||
@@ -13,6 +15,10 @@ Use this skill when working on HWLAB agent runtime skeletons.
|
||||
- Use repo-local skills artifacts only when `commitId` and `version` are
|
||||
supplied explicitly.
|
||||
- Do not infer or fall back to external skill sources.
|
||||
- For M3 hardware control, use only the repo-owned Skill CLI below. It calls
|
||||
HWLAB cloud-api `POST /v1/m3/io`; it must not call gateway, box, or
|
||||
patch-panel URLs directly, and it must not use an OpenAI/text fallback as
|
||||
hardware capability.
|
||||
|
||||
## Local CLI Contract
|
||||
|
||||
@@ -25,6 +31,9 @@ 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 io --action di.read --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.
|
||||
@@ -32,3 +41,16 @@ Use `--state-dir DIR` to isolate local state. The default state directory is
|
||||
Provide both `--skill-commit-id COMMIT` and `--skill-version VERSION` for a
|
||||
ready health result. Missing fields must produce `degraded` health and degraded
|
||||
evidence metadata; there is no silent fallback.
|
||||
|
||||
## M3 IO Control Contract
|
||||
|
||||
The M3 IO CLI is the controlled Code Agent -> Skill CLI -> HWLAB API adapter.
|
||||
`--api-base-url` must be a cloud-api base such as `http://127.0.0.1:6667`,
|
||||
`http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667`, or the public DEV
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { runM3IoSkillCommand } from "./src/m3-io-skill-client.mjs";
|
||||
|
||||
try {
|
||||
const result = await runM3IoSkillCommand(process.argv.slice(2));
|
||||
const pretty = process.argv.includes("--pretty");
|
||||
process.stdout.write(`${JSON.stringify(result, null, pretty ? 2 : 0)}\n`);
|
||||
process.exitCode = result.ok ? 0 : 2;
|
||||
} catch (error) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
status: "failed",
|
||||
code: "skill_cli_failed",
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
HWLAB_M3_IO_API_ROUTE,
|
||||
runM3IoSkillCommand,
|
||||
validateCloudApiTarget
|
||||
} from "./src/m3-io-skill-client.mjs";
|
||||
|
||||
test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () => {
|
||||
const calls = [];
|
||||
const result = await runM3IoSkillCommand(
|
||||
[
|
||||
"m3",
|
||||
"io",
|
||||
"--action",
|
||||
"do.write",
|
||||
"--value",
|
||||
"false",
|
||||
"--api-base-url",
|
||||
"http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
"--trace-id",
|
||||
"trc_skill_cli_write_false",
|
||||
"--request-id",
|
||||
"req_skill_cli_write_false"
|
||||
],
|
||||
{
|
||||
now: () => "2026-05-23T00:08:00.000Z",
|
||||
requestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
assert.equal(url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`);
|
||||
assert.equal(request.method, "POST");
|
||||
assert.equal(request.body.action, "do.write");
|
||||
assert.equal(request.body.resourceId, "res_boxsimu_1");
|
||||
assert.equal(request.body.boxId, "boxsimu_1");
|
||||
assert.equal(request.body.port, "DO1");
|
||||
assert.equal(request.body.value, false);
|
||||
assert.equal(request.body.source, "hwlab-agent-runtime.m3-io");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
traceId: "trc_skill_cli_write_false",
|
||||
operationId: "op_m3_do_write_cli",
|
||||
auditId: "aud_m3_do_write_cli_succeeded",
|
||||
evidenceId: "evd_m3_do_write_cli_succeeded",
|
||||
auditState: {
|
||||
status: "written_non_durable"
|
||||
},
|
||||
evidenceState: {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: "runtime_durable_not_green",
|
||||
writeStatus: "written_non_durable"
|
||||
},
|
||||
durableStatus: {
|
||||
status: "degraded",
|
||||
durable: false,
|
||||
blocker: "runtime_durable_not_green"
|
||||
},
|
||||
result: {
|
||||
value: false,
|
||||
targetReadback: {
|
||||
value: false
|
||||
}
|
||||
},
|
||||
controlPath: {
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: false
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.route, HWLAB_M3_IO_API_ROUTE);
|
||||
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.safety.directGatewayCalls, false);
|
||||
assert.equal(result.safety.directBoxCalls, false);
|
||||
assert.equal(result.safety.directPatchPanelCalls, false);
|
||||
assert.equal(result.safety.fallbackUsed, false);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("M3 Skill CLI rejects direct gateway, box, and patch-panel targets before request", async () => {
|
||||
for (const url of [
|
||||
"http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101",
|
||||
"http://hwlab-box-simu-1.hwlab-dev.svc.cluster.local:7201",
|
||||
"http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301"
|
||||
]) {
|
||||
const result = await runM3IoSkillCommand(
|
||||
[
|
||||
"m3",
|
||||
"io",
|
||||
"--action",
|
||||
"di.read",
|
||||
"--api-base-url",
|
||||
url,
|
||||
"--trace-id",
|
||||
"trc_skill_cli_direct_blocked"
|
||||
],
|
||||
{
|
||||
requestJson: async () => {
|
||||
throw new Error("direct hardware target must be blocked before network call");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, "direct_hardware_target_blocked");
|
||||
assert.equal(result.operationId, null);
|
||||
assert.equal(result.safety.directGatewayCalls, false);
|
||||
assert.equal(result.safety.directBoxCalls, false);
|
||||
assert.equal(result.safety.directPatchPanelCalls, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("M3 Skill CLI validates exact API route contract", () => {
|
||||
const invalid = validateCloudApiTarget({
|
||||
url: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/rpc/m3.io.di.read"
|
||||
});
|
||||
assert.equal(invalid.code, "invalid_hwlab_api_route");
|
||||
});
|
||||
@@ -0,0 +1,564 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
const defaultTimeoutMs = 30000;
|
||||
|
||||
export async function runM3IoSkillCommand(argv = [], options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const parsed = parseM3IoArgs(argv, env);
|
||||
if (parsed.help) {
|
||||
return helpPayload();
|
||||
}
|
||||
|
||||
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 validation = validateCloudApiTarget(apiTarget);
|
||||
if (validation) {
|
||||
return blockedPayload({
|
||||
traceId,
|
||||
requestId,
|
||||
apiTarget,
|
||||
code: validation.code,
|
||||
message: validation.message
|
||||
});
|
||||
}
|
||||
|
||||
const command = normalizeCommand(parsed);
|
||||
if (!allowedActions.has(command.action)) {
|
||||
return blockedPayload({
|
||||
traceId,
|
||||
requestId,
|
||||
apiTarget,
|
||||
code: "invalid_action",
|
||||
message: "M3 Skill CLI only allows do.write or di.read."
|
||||
});
|
||||
}
|
||||
if (command.action === "do.write" && typeof command.value !== "boolean") {
|
||||
return blockedPayload({
|
||||
traceId,
|
||||
requestId,
|
||||
apiTarget,
|
||||
code: "invalid_boolean_value",
|
||||
message: "M3 DO1 write requires --value true or --value false."
|
||||
});
|
||||
}
|
||||
|
||||
const payload = requestPayload(command, { traceId, requestId, actorId });
|
||||
const startedAt = nowIso(options.now);
|
||||
const response = await postJson(apiTarget.url, payload, {
|
||||
requestJson: options.requestJson,
|
||||
timeoutMs: parsed.timeoutMs,
|
||||
headers: {
|
||||
"x-trace-id": traceId,
|
||||
"x-request-id": requestId,
|
||||
"x-actor-id": actorId
|
||||
}
|
||||
});
|
||||
const finishedAt = nowIso(options.now);
|
||||
|
||||
return normalizeSkillResponse({
|
||||
response,
|
||||
command,
|
||||
payload,
|
||||
apiTarget,
|
||||
traceId,
|
||||
requestId,
|
||||
actorId,
|
||||
startedAt,
|
||||
finishedAt
|
||||
});
|
||||
}
|
||||
|
||||
export function parseM3IoArgs(argv = [], env = process.env) {
|
||||
const args = [...argv];
|
||||
const command = args.shift() ?? "help";
|
||||
const subcommand = args.shift() ?? "";
|
||||
const parsed = {
|
||||
command,
|
||||
subcommand,
|
||||
action: null,
|
||||
value: undefined,
|
||||
apiBaseUrl: null,
|
||||
traceId: null,
|
||||
requestId: null,
|
||||
actorId: null,
|
||||
timeoutMs: defaultTimeoutMs,
|
||||
pretty: false,
|
||||
help: command === "help" || command === "--help" || command === "-h"
|
||||
};
|
||||
|
||||
if (command !== "m3" || subcommand !== "io") {
|
||||
parsed.help = true;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
parsed.help = true;
|
||||
} else if (arg === "--pretty") {
|
||||
parsed.pretty = true;
|
||||
} else if (arg === "--action") {
|
||||
parsed.action = readOption(args, ++index, arg);
|
||||
} else if (arg === "--value") {
|
||||
parsed.value = parseBoolean(readOption(args, ++index, arg));
|
||||
} else if (arg === "--api-base-url") {
|
||||
parsed.apiBaseUrl = readOption(args, ++index, arg);
|
||||
} else if (arg === "--trace-id") {
|
||||
parsed.traceId = readOption(args, ++index, arg);
|
||||
} else if (arg === "--request-id") {
|
||||
parsed.requestId = readOption(args, ++index, arg);
|
||||
} else if (arg === "--actor-id") {
|
||||
parsed.actorId = readOption(args, ++index, arg);
|
||||
} else if (arg === "--timeout-ms") {
|
||||
parsed.timeoutMs = parseTimeout(readOption(args, ++index, arg));
|
||||
} else if (arg === "write") {
|
||||
parsed.action = "do.write";
|
||||
} else if (arg === "read") {
|
||||
parsed.action = "di.read";
|
||||
} else {
|
||||
throw new Error(`unknown argument ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
parsed.apiBaseUrl = parsed.apiBaseUrl || firstNonEmpty(
|
||||
env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL,
|
||||
env.HWLAB_API_BASE_URL,
|
||||
env.HWLAB_CLOUD_API_BASE_URL,
|
||||
loopbackCloudApiBaseUrl(env)
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveCloudApiTarget(apiBaseUrl, env = process.env) {
|
||||
const source = apiBaseUrl
|
||||
? "explicit"
|
||||
: firstNonEmpty(env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL, null)
|
||||
? "env:HWLAB_CODE_AGENT_HWLAB_API_BASE_URL"
|
||||
: firstNonEmpty(env.HWLAB_API_BASE_URL, null)
|
||||
? "env:HWLAB_API_BASE_URL"
|
||||
: firstNonEmpty(env.HWLAB_CLOUD_API_BASE_URL, null)
|
||||
? "env:HWLAB_CLOUD_API_BASE_URL"
|
||||
: "loopback-cloud-api-self";
|
||||
const base = firstNonEmpty(apiBaseUrl, env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL, env.HWLAB_API_BASE_URL, env.HWLAB_CLOUD_API_BASE_URL, loopbackCloudApiBaseUrl(env));
|
||||
const url = new URL(HWLAB_M3_IO_API_ROUTE, ensureTrailingSlash(base));
|
||||
return {
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
url: url.toString(),
|
||||
redactedUrl: redactUrl(url.toString()),
|
||||
source,
|
||||
cloudApiOnly: true,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCloudApiTarget(apiTarget) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(apiTarget.url);
|
||||
} catch {
|
||||
return {
|
||||
code: "invalid_hwlab_api_url",
|
||||
message: "HWLAB API base URL is not a valid URL."
|
||||
};
|
||||
}
|
||||
|
||||
if (url.pathname !== HWLAB_M3_IO_API_ROUTE) {
|
||||
return {
|
||||
code: "invalid_hwlab_api_route",
|
||||
message: `Skill CLI must call exactly ${HWLAB_M3_IO_API_ROUTE}.`
|
||||
};
|
||||
}
|
||||
|
||||
const targetText = `${url.hostname}${url.pathname}${url.port ? `:${url.port}` : ""}`;
|
||||
if (forbiddenDirectTarget.test(targetText)) {
|
||||
return {
|
||||
code: "direct_hardware_target_blocked",
|
||||
message: "Skill CLI target must be HWLAB cloud-api, not gateway/box/patch-panel."
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCommand(parsed) {
|
||||
const action = String(parsed.action ?? "").trim().toLowerCase();
|
||||
if (action === "do.write") {
|
||||
return {
|
||||
action,
|
||||
gatewayId: "gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
port: "DO1",
|
||||
value: parsed.value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
gatewayId: "gwsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
boxId: "boxsimu_2",
|
||||
port: "DI1"
|
||||
};
|
||||
}
|
||||
|
||||
function requestPayload(command, meta) {
|
||||
return {
|
||||
action: command.action,
|
||||
gatewayId: command.gatewayId,
|
||||
resourceId: command.resourceId,
|
||||
boxId: command.boxId,
|
||||
port: command.port,
|
||||
...(Object.hasOwn(command, "value") ? { value: command.value } : {}),
|
||||
traceId: meta.traceId,
|
||||
requestId: meta.requestId,
|
||||
actorId: meta.actorId,
|
||||
source: HWLAB_M3_IO_SKILL_NAME
|
||||
};
|
||||
}
|
||||
|
||||
async function postJson(url, body, { requestJson, timeoutMs, headers }) {
|
||||
if (typeof requestJson === "function") {
|
||||
try {
|
||||
const result = await requestJson(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
...headers
|
||||
},
|
||||
body,
|
||||
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: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json",
|
||||
...headers
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
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 {
|
||||
ok: result.ok ?? (Number(result.status ?? 200) >= 200 && Number(result.status ?? 200) < 300),
|
||||
status: result.status ?? 200,
|
||||
body: result.body ?? result.json ?? null,
|
||||
error: result.error ?? result.body?.error?.message ?? null
|
||||
};
|
||||
}
|
||||
const status = result.status ?? 200;
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body: result.json ?? result,
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillResponse({ response, command, payload, apiTarget, traceId, requestId, actorId, startedAt, finishedAt }) {
|
||||
const body = response.body ?? {};
|
||||
const blocker = normalizeBlocker(body, response);
|
||||
const accepted = body.accepted === true;
|
||||
const status = body.status ?? (response.ok && accepted ? "succeeded" : "blocked");
|
||||
const durable = durableSummary(body);
|
||||
|
||||
return {
|
||||
ok: response.ok && accepted && status !== "blocked",
|
||||
service: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
hwlabApi: apiTarget,
|
||||
action: command.action,
|
||||
accepted,
|
||||
status,
|
||||
traceId: body.traceId ?? traceId,
|
||||
requestId,
|
||||
actorId,
|
||||
operationId: body.operationId ?? null,
|
||||
audit: {
|
||||
auditId: body.auditId ?? body.auditState?.auditId ?? null,
|
||||
status: body.auditState?.status ?? null,
|
||||
durableStatus: body.auditState?.durableStatus?.status ?? body.durableStatus?.status ?? null,
|
||||
summary: auditSummary(body)
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: body.evidenceId ?? body.evidenceState?.evidenceId ?? null,
|
||||
status: body.evidenceState?.status ?? null,
|
||||
sourceKind: body.evidenceState?.sourceKind ?? null,
|
||||
blocker: body.evidenceState?.blocker ?? null,
|
||||
writeStatus: body.evidenceState?.writeStatus ?? null,
|
||||
summary: evidenceSummary(body)
|
||||
},
|
||||
durable,
|
||||
blocker,
|
||||
command,
|
||||
result: {
|
||||
value: body.result?.value ?? body.value ?? null,
|
||||
targetReadback: body.result?.targetReadback ?? null
|
||||
},
|
||||
controlPath: body.controlPath ?? {
|
||||
cloudApi: true,
|
||||
gatewaySimu: false,
|
||||
boxSimu: false,
|
||||
patchPanel: false,
|
||||
frontendBypass: false
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: HWLAB_M3_IO_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",
|
||||
message: response.error ?? "HWLAB API request failed"
|
||||
},
|
||||
rawStatus: body.status ?? null,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
response: body
|
||||
};
|
||||
}
|
||||
|
||||
function blockedPayload({ traceId, requestId, apiTarget, code, message }) {
|
||||
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,
|
||||
accepted: false,
|
||||
status: "blocked",
|
||||
traceId,
|
||||
requestId,
|
||||
operationId: null,
|
||||
audit: {
|
||||
auditId: null,
|
||||
status: "not_written",
|
||||
durableStatus: null,
|
||||
summary: "blocked before HWLAB API request"
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: code,
|
||||
writeStatus: "not_written",
|
||||
summary: "blocked before HWLAB API request"
|
||||
},
|
||||
durable: {
|
||||
status: "blocked",
|
||||
blocker: code,
|
||||
summary: message
|
||||
},
|
||||
blocker: {
|
||||
code,
|
||||
message,
|
||||
zh: message,
|
||||
category: blockerCategory(code)
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false,
|
||||
openAiFallbackUsed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBlocker(body, response) {
|
||||
const raw = body.blocker ?? body.error ?? null;
|
||||
if (raw) {
|
||||
const code = raw.code ?? body.blockerClassification?.code ?? "m3_io_blocked";
|
||||
return {
|
||||
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.",
|
||||
category: body.blockerClassification?.category ?? blockerCategory(code)
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
return {
|
||||
code: "hwlab_api_unavailable",
|
||||
message: response.error ?? `HWLAB API HTTP ${response.status}`,
|
||||
zh: response.error ?? `HWLAB API HTTP ${response.status}`,
|
||||
category: "hwlab_api"
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function blockerCategory(code) {
|
||||
const value = String(code ?? "");
|
||||
if (/readiness|control_disabled|not_ready/u.test(value)) return "readiness_blocked";
|
||||
if (/gateway/u.test(value)) return "gateway_unavailable";
|
||||
if (/box|resource/u.test(value)) return "box_unavailable";
|
||||
if (/wiring|patch_panel|patch-panel/u.test(value)) return "patch_panel_wiring_missing";
|
||||
if (/durable|runtime/u.test(value)) return "durable_blocked";
|
||||
if (/direct_hardware/u.test(value)) return "direct_hardware_blocked";
|
||||
return "m3_io_blocked";
|
||||
}
|
||||
|
||||
function durableSummary(body) {
|
||||
const status = body.durableStatus?.status ?? body.evidenceState?.status ?? null;
|
||||
const blocker = body.durableStatus?.blocker ?? body.evidenceState?.blocker ?? null;
|
||||
return {
|
||||
status,
|
||||
durable: body.durableStatus?.durable ?? false,
|
||||
blocker,
|
||||
category: blocker ? blockerCategory(blocker) : null,
|
||||
summary: blocker
|
||||
? `durable evidence blocked: ${blocker}`
|
||||
: status
|
||||
? `durable status: ${status}`
|
||||
: "durable status not reported"
|
||||
};
|
||||
}
|
||||
|
||||
function auditSummary(body) {
|
||||
if (body.auditId) {
|
||||
return `${body.auditId}; auditState=${body.auditState?.status ?? "unknown"}`;
|
||||
}
|
||||
return body.auditState?.reason ?? "audit not written";
|
||||
}
|
||||
|
||||
function evidenceSummary(body) {
|
||||
if (body.evidenceId) {
|
||||
return `${body.evidenceId}; evidenceState=${body.evidenceState?.status ?? "unknown"}`;
|
||||
}
|
||||
return body.evidenceState?.reason ?? "evidence not written";
|
||||
}
|
||||
|
||||
function helpPayload() {
|
||||
return {
|
||||
ok: true,
|
||||
service: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
usage: [
|
||||
"node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url http://127.0.0.1:6667",
|
||||
"node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url http://127.0.0.1:6667",
|
||||
"node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url http://127.0.0.1:6667"
|
||||
],
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function readOption(args, index, name) {
|
||||
const value = args[index];
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`${name} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseBoolean(value) {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (["true", "1", "on", "high"].includes(normalized)) return true;
|
||||
if (["false", "0", "off", "low"].includes(normalized)) return false;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTimeout(value) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 60000) {
|
||||
throw new Error("--timeout-ms must be an integer between 1 and 60000");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function loopbackCloudApiBaseUrl(env = process.env) {
|
||||
const port = env.HWLAB_CLOUD_API_PORT || env.PORT || "6667";
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value) {
|
||||
return String(value ?? "").endsWith("/") ? String(value) : `${value}/`;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values) {
|
||||
return values.find((value) => typeof value === "string" && value.trim())?.trim() ?? null;
|
||||
}
|
||||
|
||||
function redactUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return String(value ?? "").replace(/\/\/[^/@]+@/u, "//***@");
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonOrNull(value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function nowIso(now) {
|
||||
return typeof now === "function" ? now() : new Date().toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user