diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index cf22329c..afdf7b61 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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 }; diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 68fe28b1..9aa2e4f0 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -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); +}); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index b733a40a..c24c51a7 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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 } ); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index e2fe28fc..13c81cad 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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"); diff --git a/package.json b/package.json index 78664d84..6a4f8470 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/wiring-status.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index b1d90ea2..bb5d65f3 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -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); diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index 847cc187..28fb4a4b 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -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. diff --git a/skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs b/skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs new file mode 100644 index 00000000..83f3168a --- /dev/null +++ b/skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs @@ -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; +} diff --git a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs new file mode 100644 index 00000000..d42a98cc --- /dev/null +++ b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs @@ -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"); +}); diff --git a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs new file mode 100644 index 00000000..feffb07f --- /dev/null +++ b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs @@ -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(); +}