diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 35317276..1dbed385 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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 diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 8987b0fc..ba70c8c9 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -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 }); }); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index debc4edb..7f375c1b 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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 = {}; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 61572adb..81ecf5f8 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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", diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 1780f4be..be3c39ae 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -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;