From 9974c259e3847a16d0233ea0c07071ee6eb8c9ed Mon Sep 17 00:00:00 2001 From: unidesk-code-queue-runner Date: Sat, 23 May 2026 16:09:15 +0000 Subject: [PATCH] test: guard M3 IO Code Agent contract --- internal/cloud/code-agent-chat.mjs | 87 +++++++++++++++++++++++++-- internal/cloud/m3-io-control.mjs | 76 +++++++++++++++++++---- internal/cloud/m3-io-control.test.mjs | 20 ++++++ internal/cloud/server.mjs | 45 ++++++++++++++ internal/cloud/server.test.mjs | 52 ++++++++++++++++ scripts/code-agent-chat-smoke.mjs | 20 ++++++ skills/hwlab-agent-runtime/SKILL.md | 7 ++- 7 files changed, 286 insertions(+), 21 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 345add9c..0515df9a 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -64,6 +64,7 @@ const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ ]); const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral"; +const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu; const READONLY_TOOL_OUTPUT_LIMIT = 4000; const READONLY_FILE_READ_LIMIT = 64 * 1024; const READONLY_FILE_ENTRY_LIMIT = 200; @@ -237,7 +238,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { runner: providerRunner, capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only" }); - return { + return finalizeCodeAgentChatPayload({ ...base, status: "completed", updatedAt: completedAt, @@ -281,7 +282,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { usage: providerResult.usage ?? null, providerTrace: providerResult.providerTrace ?? null, ...(providerCompletionBlocker ? { blocker: providerCompletionBlocker, blockers: [providerCompletionBlocker] } : {}) - }; + }); } catch (error) { const failedAt = nowIso(options.now); const payload = { @@ -338,7 +339,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) { runner: runnerResult.runner, capabilityLevel: runnerResult.capabilityLevel }); - return { + return finalizeCodeAgentChatPayload({ ...base, sessionId: runnerResult.session?.sessionId ?? base.sessionId, status: "completed", @@ -369,7 +370,7 @@ function completedRunnerPayload({ base, runnerResult, messageId, now }) { ...(blocker ? { blocker, blockers: [blocker, ...(runnerResult.blockers ?? [])].filter(Boolean) } : {}), usage: null, providerTrace: runnerResult.providerTrace - }; + }); } export function validateCodeAgentChatSchema(payload) { @@ -402,6 +403,56 @@ export function validateCodeAgentChatSchema(payload) { if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) { throw new Error("completed code agent chat response must include non-empty reply.content"); } + validateCodeAgentToolCallContract(payload); +} + +function validateCodeAgentToolCallContract(payload) { + if (!Array.isArray(payload.toolCalls)) return; + for (const [index, toolCall] of payload.toolCalls.entries()) { + if (!toolCall || typeof toolCall !== "object") { + throw new Error(`code agent toolCalls[${index}] must be an object`); + } + const directTarget = findDirectHardwareTarget(toolCall); + if (directTarget) { + throw new Error(`code agent toolCalls[${index}] must not include direct gateway/box/patch-panel target ${directTarget}`); + } + if (!isM3IoToolCall(toolCall, payload)) continue; + const structuredBlocked = toolCall.status === "blocked" && Boolean(toolCall.blocker?.code || toolCall.capabilityBlocker?.code || toolCall.error?.code); + if (toolCall.route !== HWLAB_M3_IO_API_ROUTE && !structuredBlocked) { + throw new Error(`code agent M3 toolCalls[${index}] must show route ${HWLAB_M3_IO_API_ROUTE} or structured blocked`); + } + if (toolCall.route === HWLAB_M3_IO_API_ROUTE && (toolCall.method ?? "POST") !== "POST") { + throw new Error(`code agent M3 toolCalls[${index}] must use POST ${HWLAB_M3_IO_API_ROUTE}`); + } + } +} + +function isM3IoToolCall(toolCall, payload) { + return toolCall.name === HWLAB_M3_IO_SKILL_NAME || + toolCall.route === HWLAB_M3_IO_API_ROUTE || + payload.provider === M3_IO_SKILL_PROVIDER || + payload.runner?.kind === M3_IO_SKILL_RUNNER_KIND || + payload.runnerTrace?.runnerKind === M3_IO_SKILL_RUNNER_KIND; +} + +function findDirectHardwareTarget(value, seen = new Set()) { + if (typeof value === "string") { + const match = value.match(DIRECT_HARDWARE_TARGET_PATTERN); + return match?.[0] ?? null; + } + if (!value || typeof value !== "object") return null; + if (seen.has(value)) return null; + seen.add(value); + for (const child of Object.values(value)) { + const match = findDirectHardwareTarget(child, seen); + if (match) return match; + } + return null; +} + +function finalizeCodeAgentChatPayload(payload) { + validateCodeAgentChatSchema(payload); + return payload; } export function describeCodeAgentAvailability(env = process.env, options = {}) { @@ -1247,7 +1298,7 @@ function m3IoSkillRunnerResult({ 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(" ")}`), + command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${redactM3IoCommandArgs(commandArgs).join(" ")}`), exitCode: skillResult.ok ? 0 : 2, stdout: boundToolOutput(JSON.stringify({ route: skillResult.route, @@ -1270,7 +1321,7 @@ function m3IoSkillRunnerResult({ outputTruncated: false, route: HWLAB_M3_IO_API_ROUTE, method: skillResult.method ?? "POST", - hwlabApi: skillResult.hwlabApi, + hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi), capabilityLevel, controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, @@ -1371,6 +1422,30 @@ function m3IoSkillRunnerResult({ }; } +function redactM3IoCommandArgs(commandArgs = []) { + const redacted = []; + for (let index = 0; index < commandArgs.length; index += 1) { + redacted.push(commandArgs[index]); + if (commandArgs[index] === "--api-base-url" && index + 1 < commandArgs.length) { + redacted.push(""); + index += 1; + } + } + return redacted; +} + +function m3IoToolCallApiTarget(hwlabApi = {}) { + const target = { + ...hwlabApi, + route: hwlabApi?.route ?? HWLAB_M3_IO_API_ROUTE + }; + delete target.url; + if (findDirectHardwareTarget(target.redactedUrl)) { + target.redactedUrl = null; + } + return target; +} + function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, startedAt, finishedAt }) { return { ok: false, diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index b8e0272b..58335a4b 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -1774,9 +1774,21 @@ async function successControlResult(input) { reason: null }); const target = controlTarget(input.command, input.operation.gatewaySessionId); + const audit = auditState(records.auditWrite, records.runtimeAfter); + const evidence = evidenceState(records.runtimeAfter, records); + const readback = input.command.action === "di.read" + ? { + status: "succeeded", + value: readValueFromGatewayResult(input.dispatch.body), + resourceId: input.command.resourceId, + port: input.command.port + } + : input.targetReadback ?? null; return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + route: M3_IO_CONTROL_ROUTE, + method: "POST", status: "succeeded", accepted: true, capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, @@ -1795,11 +1807,19 @@ async function successControlResult(input) { traceId: input.meta.traceId, auditId: records.auditEvent.auditId, evidenceId: records.evidenceRecord.evidenceId, + audit: { + auditId: records.auditEvent.auditId, + ...audit + }, + evidence: { + evidenceId: records.evidenceRecord.evidenceId, + ...evidence + }, operationState: { status: "succeeded", reachable: true }, - auditState: auditState(records.auditWrite, records.runtimeAfter), + auditState: audit, operation: { ...input.operation, status: "succeeded", @@ -1814,6 +1834,7 @@ async function successControlResult(input) { patchPanel: input.patchPanel?.body ?? null, targetReadback: input.targetReadback ?? null }, + readback, controlPath: { status: "succeeded", cloudApi: true, @@ -1824,7 +1845,7 @@ async function successControlResult(input) { }, auditEvent: records.auditEvent, evidenceRecord: records.evidenceRecord, - evidenceState: evidenceState(records.runtimeAfter, records), + evidenceState: evidence, durableStatus: durableStatus(records.runtimeAfter), trustBlocker: trustBlocker(records.runtimeAfter), blockerClassification: redactedBlockerClassification({ @@ -1844,9 +1865,16 @@ async function blockedControlResult(input) { reason: input.reason }); const target = controlTarget(input.command, input.operation.gatewaySessionId); + const audit = auditState(records.auditWrite, records.runtimeAfter); + const evidence = evidenceState(records.runtimeAfter, records); + const readback = input.command.action === "di.read" + ? null + : input.targetReadback ?? null; return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + route: M3_IO_CONTROL_ROUTE, + method: "POST", status: "blocked", accepted: false, capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, @@ -1865,6 +1893,14 @@ async function blockedControlResult(input) { traceId: input.meta.traceId, auditId: records.auditEvent.auditId, evidenceId: records.evidenceRecord.evidenceId, + audit: { + auditId: records.auditEvent.auditId, + ...audit + }, + evidence: { + evidenceId: records.evidenceRecord.evidenceId, + ...evidence + }, blocker: { code: input.code, message: input.reason, @@ -1880,7 +1916,7 @@ async function blockedControlResult(input) { reachable: false, blocker: input.code }, - auditState: auditState(records.auditWrite, records.runtimeAfter), + auditState: audit, operation: { ...input.operation, status: "failed", @@ -1896,6 +1932,7 @@ async function blockedControlResult(input) { patchPanel: input.patchPanel?.body ?? null, targetReadback: input.targetReadback ?? null }, + readback, controlPath: { status: "blocked", cloudApi: true, @@ -1906,7 +1943,7 @@ async function blockedControlResult(input) { }, auditEvent: records.auditEvent, evidenceRecord: records.evidenceRecord, - evidenceState: evidenceState(records.runtimeAfter, records), + evidenceState: evidence, durableStatus: durableStatus(records.runtimeAfter), trustBlocker: trustBlocker(records.runtimeAfter), persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records), @@ -1916,9 +1953,21 @@ async function blockedControlResult(input) { function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) { const target = controlTarget(command, command?.gatewaySessionId); + const audit = { + auditId: null, + status: "not_written", + durableStatus: durableStatus(runtime), + reason: "operation was blocked before audit write" + }; + const evidence = evidenceState(runtime, { + auditWrite: { written: false, reason: "operation was blocked before audit write" }, + evidenceWrite: { written: false, reason: "operation was blocked before evidence write" } + }); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: M3_IO_CONTROL_CONTRACT_VERSION, + route: M3_IO_CONTROL_ROUTE, + method: "POST", status: "blocked", accepted: false, capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, @@ -1937,6 +1986,11 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht traceId: meta.traceId, auditId: null, evidenceId: null, + audit, + evidence: { + evidenceId: null, + ...evidence + }, blocker: { code, message: reason, @@ -1952,11 +2006,12 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht reachable: false, blocker: code }, - auditState: { - status: "not_written", - durableStatus: durableStatus(runtime), - reason: "operation was blocked before audit write" + auditState: audit, + result: { + value: null, + targetReadback: null }, + readback: null, controlPath: { status: "blocked", cloudApi: true, @@ -1965,10 +2020,7 @@ function blockedResult({ action, command = null, meta, runtime, code, reason, ht patchPanel: false, frontendBypass: false }, - evidenceState: evidenceState(runtime, { - auditWrite: { written: false, reason: "operation was blocked before audit write" }, - evidenceWrite: { written: false, reason: "operation was blocked before evidence write" } - }), + evidenceState: evidence, durableStatus: durableStatus(runtime), trustBlocker: trustBlocker(runtime), persistence: { diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index 753241ca..f7953560 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -318,6 +318,8 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, assert.equal(result.status, "succeeded"); assert.equal(result.accepted, true); + assert.equal(result.route, "/v1/m3/io"); + assert.equal(result.method, "POST"); assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); assert.equal(result.controlReady, true); assert.equal(result.action, "do.write"); @@ -341,6 +343,11 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI, assert.equal(result.traceId, "trc_m3_control_write_true"); assert.match(result.auditId, /^aud_m3_do_write_/u); assert.match(result.evidenceId, /^evd_m3_do_write_/u); + assert.equal(result.audit.auditId, result.auditId); + assert.equal(result.audit.status, "written_non_durable"); + assert.equal(result.evidence.evidenceId, result.evidenceId); + assert.equal(result.evidence.status, "blocked"); + assert.equal(result.readback.value, true); assert.equal(result.operationState.status, "succeeded"); assert.equal(result.auditState.status, "written_non_durable"); assert.equal(result.auditState.durableStatus.durable, false); @@ -468,6 +475,8 @@ test("M3 DI read returns structured value through gateway-simu without patch-pan assert.equal(result.status, "succeeded"); assert.equal(result.accepted, true); + assert.equal(result.route, "/v1/m3/io"); + assert.equal(result.method, "POST"); assert.equal(result.action, "di.read"); assert.equal(result.command.port, "DI1"); assert.equal(result.gatewayId, "gwsimu_2"); @@ -479,6 +488,10 @@ test("M3 DI read returns structured value through gateway-simu without patch-pan assert.equal(result.operationState.status, "succeeded"); assert.equal(result.auditState.status, "written_non_durable"); assert.equal(result.result.value, true); + assert.equal(result.readback.status, "succeeded"); + assert.equal(result.readback.value, true); + assert.equal(result.readback.resourceId, "res_boxsimu_2"); + assert.equal(result.readback.port, "DI1"); assert.equal(result.controlPath.patchPanel, false); assert.deepEqual(fixture.calls.map((call) => call.path), ["/status", "/invoke"]); }); @@ -514,10 +527,17 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab assert.equal(result.status, "blocked"); assert.equal(result.accepted, false); + assert.equal(result.route, "/v1/m3/io"); + assert.equal(result.method, "POST"); assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); assert.equal(result.controlReady, false); assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayUnavailable); assert.equal(result.blockerClassification.category, "gateway_session"); + assert.equal(result.audit.auditId, null); + assert.equal(result.audit.status, "not_written"); + assert.equal(result.evidence.evidenceId, null); + assert.equal(result.evidence.status, "blocked"); + assert.equal(result.readback, null); assert.equal(result.auditState.status, "not_written"); assert.equal(result.durableStatus.redacted, true); assert.match(result.blocker.zh, /gateway 未注册\/不可用/u); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index ae8c65b5..b2f3bf8a 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -509,9 +509,24 @@ async function handleM3IoControlHttp(request, response, options) { sendJson(response, 400, { serviceId: CLOUD_API_SERVICE_ID, contractVersion: "m3-io-control-v1", + route: M3_IO_CONTROL_ROUTE, + method: "POST", status: "blocked", accepted: false, traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + operationId: null, + auditId: null, + evidenceId: null, + audit: { + auditId: null, + status: "not_written" + }, + evidence: { + evidenceId: null, + status: "blocked", + sourceKind: "BLOCKED" + }, + readback: null, error: { code: "parse_error", message: "Invalid JSON body", @@ -525,9 +540,24 @@ async function handleM3IoControlHttp(request, response, options) { sendJson(response, 400, { serviceId: CLOUD_API_SERVICE_ID, contractVersion: "m3-io-control-v1", + route: M3_IO_CONTROL_ROUTE, + method: "POST", status: "blocked", accepted: false, traceId: getHeader(request, "x-trace-id") || "trc_unassigned", + operationId: null, + auditId: null, + evidenceId: null, + audit: { + auditId: null, + status: "not_written" + }, + evidence: { + evidenceId: null, + status: "blocked", + sourceKind: "BLOCKED" + }, + readback: null, error: { code: "invalid_params", message: "M3 IO control body must be a JSON object" @@ -559,9 +589,24 @@ async function handleM3IoControlHttp(request, response, options) { sendJson(response, protocolCode === ERROR_CODES.invalidParams ? 400 : 200, { serviceId: CLOUD_API_SERVICE_ID, contractVersion: "m3-io-control-v1", + route: M3_IO_CONTROL_ROUTE, + method: "POST", status: "blocked", accepted: false, traceId: getHeader(request, "x-trace-id") || params.traceId || "trc_unassigned", + operationId: null, + auditId: null, + evidenceId: null, + audit: { + auditId: null, + status: "not_written" + }, + evidence: { + evidenceId: null, + status: "blocked", + sourceKind: "BLOCKED" + }, + readback: null, error: { code: protocolCode, message: error.message, diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 4b3cd033..b812d1ab 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -7,6 +7,7 @@ import path from "node:path"; import test from "node:test"; import { createCloudApiServer } from "./server.mjs"; +import { validateCodeAgentChatSchema } from "./code-agent-chat.mjs"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; import { RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, @@ -1018,6 +1019,7 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence }); assert.equal(response.status, 200); const payload = await response.json(); + validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-readonly-runner"); assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner"); @@ -1137,6 +1139,7 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" }); assert.equal(response.status, 200); const payload = await response.json(); + 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"); @@ -1155,6 +1158,7 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" assert.equal(payload.runner.safety.directGatewayCallsAllowed, false); assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false); assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false); + assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), 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"); @@ -1169,6 +1173,49 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" } }); +test("cloud api /v1/agent/chat blocks direct M3 API base targets before request and without toolCall URL leakage", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-direct-target-")); + const calls = []; + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101" + }, + m3IoSkillRequestJson: async (url, request) => { + calls.push({ url, request }); + throw new Error("direct gateway target must be blocked before requestJson"); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const payload = await postAgent(port, { + conversationId: "cnv_server-test-m3-direct-target", + traceId: "trc_server-test-m3-direct-target", + message: "读取 M3 DI1" + }); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); + assert.equal(payload.toolCalls[0].status, "blocked"); + assert.equal(payload.toolCalls[0].route, "/v1/m3/io"); + assert.equal(payload.toolCalls[0].method, "POST"); + assert.equal(payload.toolCalls[0].blocker.code, "direct_hardware_target_blocked"); + assert.equal(payload.toolCalls[0].hwlabApi.redactedUrl, null); + assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(calls.length, 0); + assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base as structured blocker", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-")); const server = createCloudApiServer({ @@ -2123,10 +2170,15 @@ test("cloud api /v1/m3/io returns structured blocked payload when gateway is una const payload = await response.json(); assert.equal(payload.status, "blocked"); assert.equal(payload.accepted, false); + assert.equal(payload.route, "/v1/m3/io"); + assert.equal(payload.method, "POST"); assert.equal(payload.traceId, "trc_http_m3_gateway_missing"); assert.equal(payload.operationId, null); assert.equal(payload.auditId, null); assert.equal(payload.evidenceId, null); + assert.equal(payload.audit.status, "not_written"); + assert.equal(payload.evidence.status, "blocked"); + assert.equal(payload.readback, null); assert.match(payload.blocker.zh, /gateway 未注册\/不可用/u); assert.equal(payload.controlPath.cloudApi, true); assert.equal(payload.controlPath.frontendBypass, false); diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index b336a49a..fdaa1532 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -435,6 +435,26 @@ async function runLocalContractSmoke() { assert.equal(m3SkillCalls[0].url.includes("patch-panel"), false); logOk("M3 Skill CLI routes through HWLAB API only"); + const directToolCallPayload = JSON.parse(JSON.stringify(m3SkillWrite)); + directToolCallPayload.toolCalls[0].hwlabApi = { + route: "/v1/m3/io", + redactedUrl: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101/v1/m3/io" + }; + assert.throws( + () => validateCodeAgentChatSchema(directToolCallPayload), + /direct gateway\/box\/patch-panel target/u + ); + + const missingRouteToolCallPayload = JSON.parse(JSON.stringify(m3SkillWrite)); + delete missingRouteToolCallPayload.toolCalls[0].route; + missingRouteToolCallPayload.toolCalls[0].blocker = null; + missingRouteToolCallPayload.toolCalls[0].status = "completed"; + assert.throws( + () => validateCodeAgentChatSchema(missingRouteToolCallPayload), + /must show route \/v1\/m3\/io/u + ); + logOk("M3 toolCall schema rejects direct hardware URLs and missing route evidence"); + const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 }); assert.equal(completedHttp200Readiness.status, "blocked"); assert.equal(completedHttp200Readiness.devLiveReplyPass, false); diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index a66d0bdb..1d50fed9 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -55,9 +55,10 @@ gateway/box/patch-panel targets. If no API base URL is configured, the CLI returns `skill_cli_api_base_missing` and does not fall back to `127.0.0.1` or any direct hardware service. -The JSON response includes `route`, `traceId`, `operationId`, `audit`, -`evidence`, `accepted`, `status`, `capabilityLevel`, `readiness`, `blocker`, -`trustBlocker`, and direct-call safety flags. `capabilityLevel` is +The JSON response includes `route`, `method`, `traceId`, `operationId`, +`audit`, `evidence`, `accepted`, `status`, `readback`, `capabilityLevel`, +`readiness`, `blocker`, `trustBlocker`, and direct-call safety flags. +`capabilityLevel` is `hwlab-api-control-ready` only after the HWLAB API accepts the request through `/v1/m3/io`; direct target rejection, gateway unavailability, missing wiring, or other control blockers return `hwlab-api-control-blocked`. Durable runtime