test: guard M3 IO Code Agent contract
This commit is contained in:
@@ -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("<hwlab-api-base-url>");
|
||||
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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user