feat: route code agent m3 io through skill cli
This commit is contained in:
+342
-125
@@ -30,6 +30,7 @@ import {
|
||||
HWLAB_M3_IO_API_BASE_URL_ENV,
|
||||
HWLAB_M3_IO_API_BASE_URL_ENVS,
|
||||
HWLAB_M3_IO_API_ROUTE,
|
||||
HWLAB_M3_STATUS_API_ROUTE,
|
||||
HWLAB_M3_IO_DEV_SERVICE_BASE_URL,
|
||||
HWLAB_M3_IO_SKILL_NAME,
|
||||
configuredCloudApiBaseUrl,
|
||||
@@ -59,7 +60,6 @@ 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";
|
||||
@@ -128,21 +128,6 @@ 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,
|
||||
codexStdioManager: options.codexStdioManager
|
||||
});
|
||||
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
|
||||
}
|
||||
|
||||
const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null;
|
||||
if (securityIntent) {
|
||||
@@ -168,19 +153,50 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
providerPlan.mode === "codex-stdio" ||
|
||||
(providerPlan.mode !== "openai" && codexStdioAvailability.canStartLongLivedCodexStdio === true);
|
||||
if (shouldUseCodexStdio) {
|
||||
const stdioResult = await callCodexStdioRunner({
|
||||
message,
|
||||
const stdioResult = runnerIntent.kind === "m3_io"
|
||||
? await callCodexStdioWithM3IoSkillRunner({
|
||||
message,
|
||||
intent: runnerIntent,
|
||||
conversationId,
|
||||
sessionId: requestedSessionId,
|
||||
traceId,
|
||||
env: options.env ?? process.env,
|
||||
now: options.now,
|
||||
workspace: options.workspace,
|
||||
timeoutMs: options.timeoutMs,
|
||||
model: providerPlan.model,
|
||||
codexStdioManager: options.codexStdioManager,
|
||||
requestJson: options.m3IoSkillRequestJson
|
||||
})
|
||||
: await callCodexStdioRunner({
|
||||
message,
|
||||
conversationId,
|
||||
sessionId: requestedSessionId,
|
||||
traceId,
|
||||
env: options.env ?? process.env,
|
||||
now: options.now,
|
||||
workspace: options.workspace,
|
||||
timeoutMs: options.timeoutMs,
|
||||
model: providerPlan.model,
|
||||
codexStdioManager: options.codexStdioManager
|
||||
});
|
||||
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now });
|
||||
}
|
||||
|
||||
if (runnerIntent.kind === "m3_io") {
|
||||
const runnerResult = await callM3IoSkillRunner({
|
||||
intent: runnerIntent,
|
||||
conversationId,
|
||||
sessionId: requestedSessionId,
|
||||
traceId,
|
||||
sessionId: requestedSessionId,
|
||||
env: options.env ?? process.env,
|
||||
now: options.now,
|
||||
workspace: options.workspace,
|
||||
timeoutMs: options.timeoutMs,
|
||||
model: providerPlan.model,
|
||||
sessionRegistry: options.sessionRegistry,
|
||||
requestJson: options.m3IoSkillRequestJson,
|
||||
codexStdioManager: options.codexStdioManager
|
||||
});
|
||||
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now });
|
||||
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
|
||||
}
|
||||
|
||||
if (runnerIntent.kind !== "none") {
|
||||
@@ -315,14 +331,14 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
|
||||
if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls;
|
||||
if (error.skills !== undefined) payload.skills = error.skills;
|
||||
if (error.runner !== undefined) payload.runner = error.runner;
|
||||
if (error.runnerTrace !== undefined) payload.runnerTrace = error.runnerTrace;
|
||||
if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel;
|
||||
if (error.sessionMode !== undefined) payload.sessionMode = error.sessionMode;
|
||||
if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse;
|
||||
if (error.implementationType !== undefined) payload.implementationType = error.implementationType;
|
||||
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
|
||||
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
|
||||
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
|
||||
if (error.runnerTrace !== undefined) payload.runnerTrace = error.runnerTrace;
|
||||
if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel;
|
||||
if (error.sessionMode !== undefined) payload.sessionMode = error.sessionMode;
|
||||
if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse;
|
||||
if (error.implementationType !== undefined) payload.implementationType = error.implementationType;
|
||||
if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations;
|
||||
if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility;
|
||||
if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate;
|
||||
if (error.availability !== undefined) {
|
||||
payload.availability = error.availability;
|
||||
} else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) {
|
||||
@@ -422,23 +438,31 @@ function validateCodeAgentToolCallContract(payload) {
|
||||
}
|
||||
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 (!m3ToolCallRouteAllowed(toolCall.route) && !structuredBlocked) {
|
||||
throw new Error(`code agent M3 toolCalls[${index}] must show route ${HWLAB_M3_IO_API_ROUTE}/${HWLAB_M3_STATUS_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}`);
|
||||
}
|
||||
if (toolCall.route === HWLAB_M3_STATUS_API_ROUTE && (toolCall.method ?? "GET") !== "GET") {
|
||||
throw new Error(`code agent M3 toolCalls[${index}] must use GET ${HWLAB_M3_STATUS_API_ROUTE}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isM3IoToolCall(toolCall, payload) {
|
||||
return toolCall.name === HWLAB_M3_IO_SKILL_NAME ||
|
||||
toolCall.route === HWLAB_M3_IO_API_ROUTE ||
|
||||
toolCall.route === HWLAB_M3_STATUS_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 m3ToolCallRouteAllowed(route) {
|
||||
return route === HWLAB_M3_IO_API_ROUTE || route === HWLAB_M3_STATUS_API_ROUTE;
|
||||
}
|
||||
|
||||
function findDirectHardwareTarget(value, seen = new Set()) {
|
||||
if (typeof value === "string") {
|
||||
const match = value.match(DIRECT_HARDWARE_TARGET_PATTERN);
|
||||
@@ -571,6 +595,7 @@ export async function describeCodeAgentAvailability(env = process.env, options =
|
||||
blocker: codeAgentReady ? null : primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }),
|
||||
reason: codeAgentReady ? null : primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }),
|
||||
summary: codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }),
|
||||
m3IoSkill: runnerAvailability.m3IoSkill,
|
||||
missingEnv,
|
||||
secretRefs: blocked ? providerContract.secretRefs : [],
|
||||
egress: providerContract.egress,
|
||||
@@ -739,6 +764,7 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
service: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
statusRoute: HWLAB_M3_STATUS_API_ROUTE,
|
||||
capabilityLevel: m3IoApiBaseUrl
|
||||
? HWLAB_M3_IO_CAPABILITY_LEVELS.ready
|
||||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
@@ -751,6 +777,7 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||||
requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS]
|
||||
},
|
||||
blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(),
|
||||
allowedRoutes: [`POST ${HWLAB_M3_IO_API_ROUTE}`, `GET ${HWLAB_M3_STATUS_API_ROUTE}`],
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
@@ -1180,7 +1207,7 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId,
|
||||
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
|
||||
const m3ApiBaseUrl = configuredCloudApiBaseUrl(env);
|
||||
const sessionCapabilityLevel = m3ApiBaseUrl
|
||||
? HWLAB_M3_IO_CAPABILITY_LEVELS.ready
|
||||
? m3SessionCapabilityLevelForIntent(intent)
|
||||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
|
||||
const sessionAcquire = registry.acquire({
|
||||
conversationId,
|
||||
@@ -1238,12 +1265,14 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId,
|
||||
|
||||
let session = sessionAcquire.session;
|
||||
if (!m3ApiBaseUrl) {
|
||||
const blocker = m3IoApiBaseUrlMissingBlocker();
|
||||
const route = intent.action === "status" ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE;
|
||||
const blocker = m3IoApiBaseUrlMissingBlocker({ route });
|
||||
const skillResult = m3IoSkillMissingApiBaseUrlResult({
|
||||
traceId,
|
||||
requestId: `req_${randomUUID()}`,
|
||||
actorId: "usr_code_agent",
|
||||
blocker,
|
||||
route,
|
||||
startedAt,
|
||||
finishedAt: nowIso(now)
|
||||
});
|
||||
@@ -1281,6 +1310,47 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function callCodexStdioWithM3IoSkillRunner({
|
||||
message,
|
||||
intent,
|
||||
conversationId,
|
||||
sessionId,
|
||||
traceId,
|
||||
env,
|
||||
now,
|
||||
workspace,
|
||||
timeoutMs,
|
||||
model,
|
||||
codexStdioManager,
|
||||
requestJson
|
||||
}) {
|
||||
const stdioResult = await callCodexStdioRunner({
|
||||
message,
|
||||
conversationId,
|
||||
sessionId,
|
||||
traceId,
|
||||
env,
|
||||
now,
|
||||
workspace,
|
||||
timeoutMs,
|
||||
model,
|
||||
codexStdioManager
|
||||
});
|
||||
const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId });
|
||||
const skillResult = await runM3IoSkillCommand(commandArgs, {
|
||||
env,
|
||||
now,
|
||||
requestJson
|
||||
});
|
||||
return codexStdioM3IoSkillRunnerResult({
|
||||
stdioResult,
|
||||
skillResult,
|
||||
commandArgs,
|
||||
traceId,
|
||||
now
|
||||
});
|
||||
}
|
||||
|
||||
function m3IoSkillRunnerResult({
|
||||
skillResult,
|
||||
commandArgs,
|
||||
@@ -1297,61 +1367,19 @@ function m3IoSkillRunnerResult({
|
||||
const capabilityLevel = skillResult.capabilityLevel ?? (
|
||||
skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||||
);
|
||||
const toolCall = {
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "skill-cli",
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked",
|
||||
const route = m3SkillRoute(skillResult);
|
||||
const method = m3SkillMethod(skillResult);
|
||||
const toolCall = m3IoSkillToolCall({
|
||||
skillResult,
|
||||
commandArgs,
|
||||
cwd: resolvedWorkspace,
|
||||
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,
|
||||
method: skillResult.method ?? "POST",
|
||||
status: skillResult.status,
|
||||
accepted: skillResult.accepted,
|
||||
traceId: skillResult.traceId,
|
||||
operationId: skillResult.operationId,
|
||||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||||
audit: skillResult.audit,
|
||||
evidence: skillResult.evidence,
|
||||
durable: skillResult.durable,
|
||||
blocker: skillResult.blocker,
|
||||
result: skillResult.result,
|
||||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||||
safety: skillResult.safety
|
||||
})).text,
|
||||
stderrSummary: skillResult.blocker?.code ?? "",
|
||||
outputTruncated: false,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
method: skillResult.method ?? "POST",
|
||||
hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi),
|
||||
capabilityLevel,
|
||||
controlReady: skillResult.controlReady === true,
|
||||
accepted: skillResult.accepted,
|
||||
operationStatus: skillResult.status,
|
||||
apiStatus: skillResult.status,
|
||||
operationId: skillResult.operationId,
|
||||
traceId: skillResult.traceId,
|
||||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||||
audit: skillResult.audit,
|
||||
evidence: skillResult.evidence,
|
||||
durable: skillResult.durable,
|
||||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||||
blocker: skillResult.blocker,
|
||||
capabilityBlocker: skillResult.capabilityBlocker ?? null,
|
||||
trustBlocker: skillResult.trustBlocker ?? null,
|
||||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []),
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false
|
||||
};
|
||||
route,
|
||||
method
|
||||
});
|
||||
|
||||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||||
const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session });
|
||||
const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult });
|
||||
const runnerTracePayload = m3IoSkillRunnerTrace({
|
||||
traceId,
|
||||
workspace: resolvedWorkspace,
|
||||
@@ -1359,7 +1387,7 @@ function m3IoSkillRunnerResult({
|
||||
events: [
|
||||
"intent:m3_io",
|
||||
"tool:skill-cli:started",
|
||||
`route:${HWLAB_M3_IO_API_ROUTE}`,
|
||||
`route:${route}`,
|
||||
`tool:${toolCall.status}`
|
||||
],
|
||||
startedAt,
|
||||
@@ -1392,13 +1420,7 @@ function m3IoSkillRunnerResult({
|
||||
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,
|
||||
capabilityLevel,
|
||||
version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION
|
||||
}],
|
||||
items: [m3IoSkillItem({ capabilityLevel, route })],
|
||||
count: 1,
|
||||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : [])
|
||||
},
|
||||
@@ -1406,24 +1428,186 @@ function m3IoSkillRunnerResult({
|
||||
runnerTrace: runnerTracePayload,
|
||||
capabilityLevel,
|
||||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []),
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
route,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
providerTrace: m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel })
|
||||
};
|
||||
}
|
||||
|
||||
function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }) {
|
||||
const capabilityLevel = skillResult.capabilityLevel ?? (
|
||||
skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||||
);
|
||||
const route = m3SkillRoute(skillResult);
|
||||
const method = m3SkillMethod(skillResult);
|
||||
const toolCall = m3IoSkillToolCall({
|
||||
skillResult,
|
||||
commandArgs,
|
||||
cwd: stdioResult.workspace,
|
||||
capabilityLevel,
|
||||
route,
|
||||
method
|
||||
});
|
||||
const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []);
|
||||
const runnerTrace = {
|
||||
...stdioResult.runnerTrace,
|
||||
events: [
|
||||
...(stdioResult.runnerTrace?.events ?? []),
|
||||
"tool:skill-cli:started",
|
||||
`route:${route}`,
|
||||
`tool:${toolCall.status}`
|
||||
],
|
||||
route,
|
||||
method,
|
||||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||||
capabilityLevel,
|
||||
controlReady: skillResult.controlReady === true,
|
||||
operationId: skillResult.operationId,
|
||||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||||
accepted: skillResult.accepted,
|
||||
status: skillResult.status,
|
||||
blocker: skillResult.blocker ?? null,
|
||||
trustBlocker: skillResult.trustBlocker ?? null,
|
||||
blockers,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false,
|
||||
finishedAt: nowIso(now)
|
||||
};
|
||||
const runner = {
|
||||
...stdioResult.runner,
|
||||
skill: {
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route
|
||||
},
|
||||
toolPolicy: {
|
||||
...(stdioResult.runner?.toolPolicy ?? {}),
|
||||
allowed: [
|
||||
...new Set([
|
||||
...(stdioResult.runner?.toolPolicy?.allowed ?? []),
|
||||
`${method} ${route}`
|
||||
])
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...stdioResult,
|
||||
content: `${stdioResult.content}\n\n${m3IoSkillReply(skillResult)}`,
|
||||
toolCalls: [
|
||||
...(stdioResult.toolCalls ?? []),
|
||||
toolCall
|
||||
],
|
||||
skills: {
|
||||
status: "used",
|
||||
items: [
|
||||
...(stdioResult.skills?.items ?? []),
|
||||
m3IoSkillItem({ capabilityLevel, route })
|
||||
],
|
||||
count: (stdioResult.skills?.count ?? 0) + 1,
|
||||
blockers
|
||||
},
|
||||
runner,
|
||||
runnerTrace,
|
||||
capabilityLevel,
|
||||
blockers,
|
||||
route,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
providerTrace: {
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
method: skillResult.method ?? "POST",
|
||||
...(stdioResult.providerTrace ?? {}),
|
||||
...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }),
|
||||
runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND,
|
||||
codexStdio: true,
|
||||
transport: "stdio+skill-cli"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method }) {
|
||||
return {
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "skill-cli",
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked",
|
||||
cwd,
|
||||
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,
|
||||
method,
|
||||
status: skillResult.status,
|
||||
accepted: skillResult.accepted,
|
||||
traceId: skillResult.traceId,
|
||||
operationId: skillResult.operationId,
|
||||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||||
audit: skillResult.audit,
|
||||
evidence: skillResult.evidence,
|
||||
durable: skillResult.durable,
|
||||
blocker: skillResult.blocker,
|
||||
result: skillResult.result,
|
||||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||||
capabilityLevel,
|
||||
controlReady: skillResult.controlReady === true,
|
||||
accepted: skillResult.accepted,
|
||||
status: skillResult.status,
|
||||
fallbackUsed: false
|
||||
}
|
||||
safety: skillResult.safety
|
||||
})).text,
|
||||
stderrSummary: skillResult.blocker?.code ?? "",
|
||||
outputTruncated: false,
|
||||
route,
|
||||
method,
|
||||
hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi),
|
||||
capabilityLevel,
|
||||
controlReady: skillResult.controlReady === true,
|
||||
accepted: skillResult.accepted,
|
||||
operationStatus: skillResult.status,
|
||||
apiStatus: skillResult.status,
|
||||
operationId: skillResult.operationId,
|
||||
traceId: skillResult.traceId,
|
||||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||||
audit: skillResult.audit,
|
||||
evidence: skillResult.evidence,
|
||||
durable: skillResult.durable,
|
||||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||||
blocker: skillResult.blocker,
|
||||
capabilityBlocker: skillResult.capabilityBlocker ?? null,
|
||||
trustBlocker: skillResult.trustBlocker ?? null,
|
||||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []),
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoSkillItem({ capabilityLevel, route }) {
|
||||
return {
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
summary: `Controlled M3 DO1/DI1 adapter for HWLAB API ${route}.`,
|
||||
route,
|
||||
capabilityLevel,
|
||||
version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION
|
||||
};
|
||||
}
|
||||
|
||||
function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) {
|
||||
return {
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||||
route,
|
||||
method,
|
||||
traceId: skillResult.traceId,
|
||||
operationId: skillResult.operationId,
|
||||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||||
capabilityLevel,
|
||||
controlReady: skillResult.controlReady === true,
|
||||
accepted: skillResult.accepted,
|
||||
status: skillResult.status,
|
||||
fallbackUsed: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1451,15 +1635,29 @@ function m3IoToolCallApiTarget(hwlabApi = {}) {
|
||||
return target;
|
||||
}
|
||||
|
||||
function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, startedAt, finishedAt }) {
|
||||
function m3SkillRoute(skillResult = {}) {
|
||||
return skillResult.route === HWLAB_M3_STATUS_API_ROUTE ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE;
|
||||
}
|
||||
|
||||
function m3SkillMethod(skillResult = {}) {
|
||||
return m3SkillRoute(skillResult) === HWLAB_M3_STATUS_API_ROUTE ? "GET" : skillResult.method ?? "POST";
|
||||
}
|
||||
|
||||
function m3SessionCapabilityLevelForIntent(intent = {}) {
|
||||
if (intent.action === "status") return HWLAB_M3_IO_CAPABILITY_LEVELS.readonly;
|
||||
return HWLAB_M3_IO_CAPABILITY_LEVELS.ready;
|
||||
}
|
||||
|
||||
function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, route = HWLAB_M3_IO_API_ROUTE, startedAt, finishedAt }) {
|
||||
const method = route === HWLAB_M3_STATUS_API_ROUTE ? "GET" : "POST";
|
||||
return {
|
||||
ok: false,
|
||||
service: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
method: "POST",
|
||||
route,
|
||||
method,
|
||||
hwlabApi: {
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
route,
|
||||
redactedUrl: null,
|
||||
source: "missing-config",
|
||||
baseUrlConfigured: false,
|
||||
@@ -1550,7 +1748,7 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker
|
||||
userMessage: blocker.userMessage,
|
||||
message: blocker.message,
|
||||
traceId,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
route,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
missingConfig: blocker.missingConfig
|
||||
},
|
||||
@@ -1611,7 +1809,7 @@ function detectReadOnlyRunnerIntent(message) {
|
||||
|
||||
function detectM3IoIntent(text) {
|
||||
const normalized = String(text ?? "");
|
||||
if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回/u.test(normalized)) {
|
||||
if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
if (/(?:直接|direct|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|\/invoke|\/sync\/tick)/iu.test(normalized)) {
|
||||
@@ -1623,6 +1821,14 @@ function detectM3IoIntent(text) {
|
||||
|
||||
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);
|
||||
const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized);
|
||||
if (wantsStatus && !wantsRead && !wantsWrite) {
|
||||
return {
|
||||
kind: "m3_io",
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
action: "status"
|
||||
};
|
||||
}
|
||||
if (!wantsRead && !wantsWrite) {
|
||||
return null;
|
||||
}
|
||||
@@ -2225,14 +2431,17 @@ function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, r
|
||||
function m3IoSkillArgsForIntent(intent, { env, traceId }) {
|
||||
const args = [
|
||||
"m3",
|
||||
"io",
|
||||
"--action",
|
||||
intent.action,
|
||||
intent.action === "status" ? "status" : "io"
|
||||
];
|
||||
if (intent.action !== "status") {
|
||||
args.push("--action", intent.action);
|
||||
}
|
||||
args.push(
|
||||
"--trace-id",
|
||||
traceId,
|
||||
"--actor-id",
|
||||
"usr_code_agent"
|
||||
];
|
||||
);
|
||||
const apiBaseUrl = configuredCloudApiBaseUrl(env);
|
||||
if (apiBaseUrl) {
|
||||
args.push("--api-base-url", apiBaseUrl);
|
||||
@@ -2260,7 +2469,7 @@ function m3IoSkillApiBaseUrlSource(env = process.env) {
|
||||
return "missing-config";
|
||||
}
|
||||
|
||||
function m3IoApiBaseUrlMissingBlocker() {
|
||||
function m3IoApiBaseUrlMissingBlocker({ route = HWLAB_M3_IO_API_ROUTE } = {}) {
|
||||
return {
|
||||
code: "skill_cli_api_base_missing",
|
||||
layer: "skill-cli-config",
|
||||
@@ -2272,7 +2481,7 @@ function m3IoApiBaseUrlMissingBlocker() {
|
||||
zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。`,
|
||||
userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。",
|
||||
traceId: null,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
route,
|
||||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||||
missingConfig: [
|
||||
...HWLAB_M3_IO_API_BASE_URL_ENVS,
|
||||
@@ -2298,12 +2507,17 @@ function m3IoSkillReply(skillResult) {
|
||||
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")}.`);
|
||||
} else if (skillResult.readonly === true || skillResult.route === HWLAB_M3_STATUS_API_ROUTE) {
|
||||
lines.push(`M3 status readback DI1=${String(skillResult.readback?.value ?? "unknown")}; readiness=${skillResult.readiness?.status ?? "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.");
|
||||
lines.push(`Boundary: Code Agent -> Skill CLI -> HWLAB API ${skillResult.route}; 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 } = {}) {
|
||||
function m3IoSkillRunnerDescriptor({ workspace, session = null, skillResult = null } = {}) {
|
||||
const route = m3SkillRoute(skillResult);
|
||||
const method = m3SkillMethod(skillResult);
|
||||
const capabilityLevel = skillResult?.capabilityLevel ?? HWLAB_M3_IO_CAPABILITY_LEVELS.ready;
|
||||
return {
|
||||
kind: M3_IO_SKILL_RUNNER_KIND,
|
||||
provider: M3_IO_SKILL_PROVIDER,
|
||||
@@ -2321,14 +2535,14 @@ function m3IoSkillRunnerDescriptor({ workspace, session = null } = {}) {
|
||||
durableSession: false,
|
||||
writeCapable: true,
|
||||
readOnly: false,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready,
|
||||
capabilityLevel,
|
||||
skill: {
|
||||
name: HWLAB_M3_IO_SKILL_NAME,
|
||||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||||
route: HWLAB_M3_IO_API_ROUTE
|
||||
route
|
||||
},
|
||||
toolPolicy: {
|
||||
allowed: [`POST ${HWLAB_M3_IO_API_ROUTE}`],
|
||||
allowed: [`${method} ${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],
|
||||
@@ -2337,7 +2551,7 @@ function m3IoSkillRunnerDescriptor({ workspace, session = null } = {}) {
|
||||
secretValuesPrinted: false,
|
||||
kubeconfigRead: false,
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||||
allowedRoute: route,
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
@@ -2352,6 +2566,8 @@ function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = started
|
||||
const capabilityLevel = skillResult?.capabilityLevel ?? (
|
||||
skillResult?.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||||
);
|
||||
const route = m3SkillRoute(skillResult);
|
||||
const method = m3SkillMethod(skillResult);
|
||||
return {
|
||||
traceId,
|
||||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||||
@@ -2369,8 +2585,8 @@ function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = started
|
||||
startedAt,
|
||||
finishedAt,
|
||||
events,
|
||||
route: HWLAB_M3_IO_API_ROUTE,
|
||||
method: skillResult?.method ?? "POST",
|
||||
route,
|
||||
method,
|
||||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||||
capabilityLevel,
|
||||
controlReady: skillResult?.controlReady === true,
|
||||
@@ -2389,7 +2605,7 @@ function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = started
|
||||
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.`
|
||||
note: `Controlled M3 IO uses Skill CLI -> HWLAB API ${route}; it is not OpenAI fallback and does not directly call gateway, box, or patch-panel.`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2553,6 +2769,7 @@ function runnerSafetyContract() {
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
m3IoSkillCliAllowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||||
m3StatusSkillCliAllowedRoute: HWLAB_M3_STATUS_API_ROUTE,
|
||||
openAiHardwareFallbackAllowed: false,
|
||||
m3m4m5AcceptanceClaimsAllowed: false,
|
||||
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
import {
|
||||
HWLAB_M3_IO_API_BASE_URL_ENV,
|
||||
HWLAB_M3_IO_CAPABILITY_LEVELS,
|
||||
HWLAB_M3_IO_API_ROUTE
|
||||
HWLAB_M3_IO_API_ROUTE,
|
||||
HWLAB_M3_STATUS_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", () => {
|
||||
@@ -911,6 +912,232 @@ test("Code Agent M3 DO false write exposes readback and identifier contract", as
|
||||
assert.equal(calls[0].url.includes("patch-panel"), false);
|
||||
});
|
||||
|
||||
test("Code Agent M3 DO write uses Codex stdio session and appends Skill CLI toolCall", async () => {
|
||||
const calls = [];
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_stdio_m3_skill",
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
async callTool(name, args) {
|
||||
calls.push({ kind: "codex", name, args });
|
||||
return {
|
||||
structuredContent: {
|
||||
threadId: "thread_stdio_m3_skill",
|
||||
content: "准备通过受控 Skill CLI 执行 M3 IO。"
|
||||
}
|
||||
};
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_stdio_m3_skill",
|
||||
traceId: "trc_stdio_m3_skill",
|
||||
message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:08:40.000Z",
|
||||
codexStdioManager: manager,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"
|
||||
},
|
||||
callProvider: async () => {
|
||||
throw new Error("OpenAI fallback must not be used for M3 IO control");
|
||||
},
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ kind: "skill", url, request });
|
||||
assert.equal(new URL(url).pathname, 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.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_stdio_m3_skill",
|
||||
operationId: "op_m3_do_write_stdio_skill",
|
||||
auditId: "aud_m3_do_write_stdio_skill_succeeded",
|
||||
evidenceId: "evd_m3_do_write_stdio_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: 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, "codex-stdio");
|
||||
assert.equal(payload.runner.kind, "codex-mcp-stdio-runner");
|
||||
assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived");
|
||||
assert.equal(payload.implementationType, "repo-owned-codex-mcp-stdio-session");
|
||||
assert.equal(payload.session.longLivedSession, true);
|
||||
assert.equal(payload.session.codexStdio, true);
|
||||
assert.equal(payload.longLivedSessionGate.status, "pass");
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
||||
assert.equal(payload.toolCalls.length, 2);
|
||||
assert.equal(payload.toolCalls[0].type, "codex-stdio");
|
||||
const skillTool = payload.toolCalls[1];
|
||||
assert.equal(skillTool.type, "skill-cli");
|
||||
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(skillTool.status, "completed");
|
||||
assert.equal(skillTool.route, HWLAB_M3_IO_API_ROUTE);
|
||||
assert.equal(skillTool.method, "POST");
|
||||
assert.equal(skillTool.accepted, true);
|
||||
assert.equal(skillTool.operationId, "op_m3_do_write_stdio_skill");
|
||||
assert.equal(skillTool.auditId, "aud_m3_do_write_stdio_skill_succeeded");
|
||||
assert.equal(skillTool.evidenceId, "evd_m3_do_write_stdio_skill_succeeded");
|
||||
assert.equal(skillTool.readback.value, true);
|
||||
assert.equal(payload.runnerTrace.runnerKind, "codex-mcp-stdio-runner");
|
||||
assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE);
|
||||
assert.equal(payload.runnerTrace.method, "POST");
|
||||
assert.equal(payload.providerTrace.codexStdio, true);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(payload.providerTrace.readback.value, true);
|
||||
assert.ok(payload.runner.toolPolicy.allowed.includes(`POST ${HWLAB_M3_IO_API_ROUTE}`));
|
||||
assert.equal(JSON.stringify(payload.toolCalls).includes("gateway-simu"), false);
|
||||
assert.equal(JSON.stringify(payload.toolCalls).includes("box-simu"), false);
|
||||
assert.equal(JSON.stringify(payload.toolCalls).includes("patch-panel"), false);
|
||||
assert.equal(calls.filter((call) => call.kind === "codex").length, 1);
|
||||
assert.equal(calls.filter((call) => call.kind === "skill").length, 1);
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Code Agent M3 status uses Skill CLI GET /v1/m3/status and keeps route evidence", async () => {
|
||||
const calls = [];
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_m3_status_skill",
|
||||
traceId: "trc_m3_status_skill",
|
||||
message: "读取 M3 status 聚合状态"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:09:10.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_CODE_AGENT_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 status");
|
||||
},
|
||||
m3IoSkillRequestJson: async (url, request) => {
|
||||
calls.push({ url, request });
|
||||
assert.equal(new URL(url).pathname, HWLAB_M3_STATUS_API_ROUTE);
|
||||
assert.equal(request.method, "GET");
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
status: "live",
|
||||
sourceKind: "DEV-LIVE",
|
||||
traceId: "trc_m3_status_skill",
|
||||
boxes: [{
|
||||
id: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
online: true,
|
||||
ports: {
|
||||
DI1: {
|
||||
value: false
|
||||
}
|
||||
}
|
||||
}],
|
||||
patchPanel: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
observable: true,
|
||||
connectionActive: true
|
||||
},
|
||||
trust: {
|
||||
operationId: "op_m3_status_skill",
|
||||
traceId: "trc_m3_status_skill",
|
||||
auditId: "aud_m3_status_skill",
|
||||
evidenceId: "evd_m3_status_skill",
|
||||
durableStatus: "green",
|
||||
blocker: null,
|
||||
readStatus: {
|
||||
audit: "read",
|
||||
evidence: "read"
|
||||
},
|
||||
runtime: {
|
||||
durable: true
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "hwlab-skill-cli");
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.readonly);
|
||||
assert.equal(payload.toolCalls[0].route, HWLAB_M3_STATUS_API_ROUTE);
|
||||
assert.equal(payload.toolCalls[0].method, "GET");
|
||||
assert.equal(payload.toolCalls[0].accepted, true);
|
||||
assert.equal(payload.toolCalls[0].operationId, "op_m3_status_skill");
|
||||
assert.equal(payload.toolCalls[0].readback.value, false);
|
||||
assert.equal(payload.runnerTrace.route, HWLAB_M3_STATUS_API_ROUTE);
|
||||
assert.equal(payload.providerTrace.method, "GET");
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => {
|
||||
const direct = await handleCodeAgentChat(
|
||||
{
|
||||
@@ -946,6 +1173,21 @@ function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function createFakeCodexCommand() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
|
||||
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
|
||||
const command = path.join(packageRoot, "bin", "codex.js");
|
||||
const native = path.join(root, "node_modules", "@openai", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex");
|
||||
await mkdir(path.dirname(command), { recursive: true });
|
||||
await mkdir(path.dirname(native), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.128.0" }), "utf8");
|
||||
await writeFile(command, "#!/usr/bin/env node\nconsole.log('codex 0.128.0');\n", "utf8");
|
||||
await writeFile(native, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await chmod(command, 0o755);
|
||||
await chmod(native, 0o755);
|
||||
return { root, command };
|
||||
}
|
||||
|
||||
test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => {
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
@@ -1040,16 +1282,15 @@ test("Codex stdio manager reports concrete blockers without falling back to read
|
||||
test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and version-probed command", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-stdio-workspace-"));
|
||||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-codex-home-"));
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
try {
|
||||
const manager = createCodexStdioSessionManager();
|
||||
const localCodexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex");
|
||||
const codexCommand = existsSync(localCodexCommand) ? localCodexCommand : "codex";
|
||||
const availability = manager.describe({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: codexCommand,
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
@@ -1058,7 +1299,7 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(availability.command, codexCommand);
|
||||
assert.equal(availability.command, fakeCodex.command);
|
||||
assert.equal(availability.binary.present, true);
|
||||
assert.equal(availability.binary.versionDetected, true);
|
||||
assert.equal(availability.workspace, workspace);
|
||||
@@ -1082,11 +1323,13 @@ test("Codex stdio feasibility verifies writable workspace, CODEX_HOME, and versi
|
||||
} finally {
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("repo-owned Codex stdio manager creates and reuses long-lived sessions with trace evidence", async () => {
|
||||
const calls = [];
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_stdio_ready",
|
||||
createRpcClient: async () => ({
|
||||
@@ -1114,6 +1357,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
@@ -1167,9 +1411,11 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
|
||||
assert.equal(calls[0].name, "codex");
|
||||
assert.equal(calls[1].name, "codex-reply");
|
||||
assert.equal(calls[1].args.threadId, "thread_stdio_ready");
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Codex stdio runner startup failure marks session failed and blocks long-lived gate", async () => {
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_stdio_start_failed",
|
||||
createRpcClient: async () => ({
|
||||
@@ -1185,6 +1431,7 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
@@ -1221,9 +1468,11 @@ test("Codex stdio runner startup failure marks session failed and blocks long-li
|
||||
assert.equal(serialized.includes("sk-test-secret"), false);
|
||||
assert.equal(serialized.includes("TOKEN=abc123"), false);
|
||||
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false);
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Codex stdio protocol missing tools is a structured blocker, not a passed session", async () => {
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_stdio_missing_tool",
|
||||
createRpcClient: async () => ({
|
||||
@@ -1242,6 +1491,7 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s
|
||||
CODEX_HOME: process.cwd(),
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
@@ -1271,4 +1521,5 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s
|
||||
assert.ok(payload.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired"));
|
||||
assert.deepEqual(payload.codexStdioFeasibility.protocol.toolsObserved, ["codex"]);
|
||||
assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false);
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -1067,7 +1067,7 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
skillsDirsExact: options.skillsDirsExact,
|
||||
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
||||
sessionRegistry: options.sessionRegistry,
|
||||
m3IoSkillRequestJson: options.m3IoSkillRequestJson,
|
||||
m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options),
|
||||
codexStdioManager: options.codexStdioManager
|
||||
}
|
||||
);
|
||||
@@ -1075,6 +1075,43 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
|
||||
}
|
||||
|
||||
function createCodeAgentM3HwlabApiRequestJson(options = {}) {
|
||||
return async (targetUrl, request = {}) => {
|
||||
const url = new URL(targetUrl);
|
||||
const route = url.pathname;
|
||||
if (route === M3_IO_CONTROL_ROUTE && request.method === "POST") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: await handleM3IoControl(request.body ?? {}, {
|
||||
runtimeStore: options.runtimeStore,
|
||||
now: options.now,
|
||||
env: options.env,
|
||||
requestJson: options.m3IoRequestJson
|
||||
})
|
||||
};
|
||||
}
|
||||
if (route === M3_STATUS_ROUTE && request.method === "GET") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: await describeM3StatusLive(options)
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
body: {
|
||||
error: {
|
||||
code: "not_found",
|
||||
message: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
||||
}
|
||||
},
|
||||
error: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function handleM3IoControlHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { createServer as createTcpServer } from "node:net";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
import { validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
|
||||
import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import {
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
@@ -990,8 +991,9 @@ function layerForBlockedCase(blockedLayer) {
|
||||
return blockedLayer;
|
||||
}
|
||||
|
||||
async function m3ReadinessRequestJson(url) {
|
||||
async function m3ReadinessRequestJson(url, request = {}) {
|
||||
const parsed = new URL(url);
|
||||
const body = request.body ?? {};
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
@@ -1041,6 +1043,9 @@ async function m3ReadinessRequestJson(url) {
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") {
|
||||
const value = request.body?.operationId?.includes("do_write") || request.body?.operationId?.includes("do-write")
|
||||
? true
|
||||
: false;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -1051,11 +1056,11 @@ async function m3ReadinessRequestJson(url) {
|
||||
boxId: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: false,
|
||||
value,
|
||||
state: {
|
||||
port: "DI1",
|
||||
direction: "input",
|
||||
value: false,
|
||||
value,
|
||||
source: "patch-panel",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
@@ -1108,6 +1113,34 @@ async function m3ReadinessRequestJson(url) {
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
propagatedBy: "hwlab-patch-panel",
|
||||
routeCount: 1,
|
||||
deliveryCount: 1,
|
||||
routes: [
|
||||
{
|
||||
accepted: true,
|
||||
deliveryCount: 1,
|
||||
deliveries: [
|
||||
{
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: body.signals?.[0]?.value ?? false,
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
deliveryStatus: "applied"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected M3 readiness request ${url}`);
|
||||
}
|
||||
|
||||
@@ -1454,12 +1487,33 @@ test("cloud api /v1 describes Code Agent egress blocker without leaking API key"
|
||||
});
|
||||
|
||||
test("cloud api health reports provider, durable DB, and codex stdio ready when runtime contract passes", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-health-codex-stdio-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
const manager = createCodexStdioSessionManager({
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:17680/v1/responses",
|
||||
HWLAB_CLOUD_DB_URL: "postgres://hwlab_test:password@db.internal.local:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "disable"
|
||||
@@ -1484,7 +1538,8 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when
|
||||
async readiness() {
|
||||
return durableReadyRuntime();
|
||||
}
|
||||
}
|
||||
},
|
||||
codexStdioManager: manager
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
@@ -1528,6 +1583,8 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1815,6 +1872,103 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only"
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat routes Codex stdio M3 request through in-process HWLAB API handler", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-"));
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
const gatewayCalls = [];
|
||||
const manager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_server_stdio_m3_skill",
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
async callTool() {
|
||||
return {
|
||||
structuredContent: {
|
||||
threadId: "thread_server_stdio_m3_skill",
|
||||
content: "stdio session ready for M3 skill."
|
||||
}
|
||||
};
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: workspace,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667",
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
codexStdioManager: manager,
|
||||
m3IoRequestJson: async (url, request) => {
|
||||
gatewayCalls.push({ url, request });
|
||||
return m3ReadinessRequestJson(url, request);
|
||||
}
|
||||
});
|
||||
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_stdio_m3_skill",
|
||||
traceId: "trc_server_stdio_m3_skill",
|
||||
message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。"
|
||||
});
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.runner.kind, "codex-mcp-stdio-runner");
|
||||
assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived");
|
||||
assert.equal(payload.longLivedSessionGate.status, "pass");
|
||||
assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready);
|
||||
assert.equal(payload.toolCalls.length, 2);
|
||||
assert.equal(payload.toolCalls[0].type, "codex-stdio");
|
||||
const skillTool = payload.toolCalls[1];
|
||||
assert.equal(skillTool.type, "skill-cli");
|
||||
assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io");
|
||||
assert.equal(skillTool.route, "/v1/m3/io");
|
||||
assert.equal(skillTool.method, "POST");
|
||||
assert.equal(skillTool.accepted, true);
|
||||
assert.equal(skillTool.readback.value, true);
|
||||
assert.equal(skillTool.controlReady, true);
|
||||
assert.equal(skillTool.operationId.startsWith("op_m3_do_write_"), true);
|
||||
assert.match(skillTool.auditId, /^aud_m3_do_write_/u);
|
||||
assert.match(skillTool.evidenceId, /^evd_m3_do_write_/u);
|
||||
assert.equal(payload.runnerTrace.route, "/v1/m3/io");
|
||||
assert.equal(payload.providerTrace.codexStdio, true);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.ok(payload.runner.toolPolicy.allowed.includes("POST /v1/m3/io"));
|
||||
assert.deepEqual(gatewayCalls.map((call) => new URL(call.url).pathname), [
|
||||
"/status",
|
||||
"/invoke",
|
||||
"/sync/tick",
|
||||
"/status",
|
||||
"/invoke"
|
||||
]);
|
||||
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()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
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 = [];
|
||||
@@ -2857,6 +3011,21 @@ async function postAgent(port, body) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function createFakeCodexCommand() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
|
||||
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
|
||||
const command = path.join(packageRoot, "bin", "codex.js");
|
||||
const native = path.join(root, "node_modules", "@openai", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex");
|
||||
await mkdir(path.dirname(command), { recursive: true });
|
||||
await mkdir(path.dirname(native), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.128.0" }), "utf8");
|
||||
await writeFile(command, "#!/usr/bin/env node\nconsole.log('codex 0.128.0');\n", "utf8");
|
||||
await writeFile(native, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await chmod(command, 0o755);
|
||||
await chmod(native, 0o755);
|
||||
return { root, command };
|
||||
}
|
||||
|
||||
async function postAgentRaw(port, body, headers = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import os from "node:os";
|
||||
@@ -240,7 +240,9 @@ async function runLocalContractSmoke() {
|
||||
"",
|
||||
"# Stdio Smoke"
|
||||
].join("\n"));
|
||||
let fakeCodex = null;
|
||||
try {
|
||||
fakeCodex = await createFakeCodexCommand();
|
||||
const stdioManager = createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_code_agent_chat_smoke_stdio",
|
||||
createRpcClient: async () => ({
|
||||
@@ -267,6 +269,7 @@ async function runLocalContractSmoke() {
|
||||
CODEX_HOME: stdioCodexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: stdioWorkspace,
|
||||
@@ -352,6 +355,9 @@ async function runLocalContractSmoke() {
|
||||
logOk("codex stdio runner passes long-lived workspace-write session capability");
|
||||
} finally {
|
||||
await rm(stdioWorkspaceRoot, { recursive: true, force: true });
|
||||
if (fakeCodex) {
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const runnerSecondTurn = await handleCodeAgentChat(
|
||||
@@ -752,6 +758,21 @@ function usage() {
|
||||
};
|
||||
}
|
||||
|
||||
async function createFakeCodexCommand() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
|
||||
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
|
||||
const command = path.join(packageRoot, "bin", "codex.js");
|
||||
const native = path.join(root, "node_modules", "@openai", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex");
|
||||
await mkdir(path.dirname(command), { recursive: true });
|
||||
await mkdir(path.dirname(native), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.128.0" }), "utf8");
|
||||
await writeFile(command, "#!/usr/bin/env node\nconsole.log('codex 0.128.0');\n", "utf8");
|
||||
await writeFile(native, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await chmod(command, 0o755);
|
||||
await chmod(native, 0o755);
|
||||
return { root, command };
|
||||
}
|
||||
|
||||
function agentChatEndpoint(value) {
|
||||
const url = new URL(value);
|
||||
if (url.pathname.endsWith("/v1/agent/chat")) return url;
|
||||
|
||||
Reference in New Issue
Block a user