diff --git a/deploy/deploy.json b/deploy/deploy.json index 1e92f656..7e20bdb3 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -205,6 +205,7 @@ "HWLAB_CODE_AGENT_PROVIDER": "openai", "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", "HWLAB_CODE_AGENT_OPENAI_BASE_URL": "http://172.26.26.227:17680/v1/responses", + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": "1", "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": "repo-owned", "HWLAB_CODE_AGENT_CODEX_WORKSPACE": "/workspace/hwlab", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 92afa260..ef09f74f 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -418,6 +418,10 @@ "type": "string", "const": "http://172.26.26.227:17680/v1/responses" }, + "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL": { + "type": "string", + "const": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + }, "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED": { "type": "string", "const": "1" diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 8cd96602..25439e2f 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -117,6 +117,10 @@ "name": "HWLAB_CODE_AGENT_OPENAI_BASE_URL", "value": "http://172.26.26.227:17680/v1/responses" }, + { + "name": "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", + "value": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + }, { "name": "HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "value": "1" diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index d0d18dea..e661d51c 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -27,8 +27,12 @@ import { import { HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, HWLAB_M3_IO_CAPABILITY_LEVELS, + HWLAB_M3_IO_API_BASE_URL_ENV, + HWLAB_M3_IO_API_BASE_URL_ENVS, HWLAB_M3_IO_API_ROUTE, + HWLAB_M3_IO_DEV_SERVICE_BASE_URL, HWLAB_M3_IO_SKILL_NAME, + configuredCloudApiBaseUrl, runM3IoSkillCommand } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; @@ -608,6 +612,7 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); const codexStdioFeasibility = inspectCodexStdioFeasibility(env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); + const m3IoApiBaseUrl = configuredCloudApiBaseUrl(env); return { kind: READONLY_RUNNER_KIND, backend: READONLY_RUNNER_BACKEND, @@ -638,12 +643,23 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { skillsDirs, skillsDirsPresent, m3IoSkill: { - status: "available", + status: m3IoApiBaseUrl ? "available" : "blocked", service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, + capabilityLevel: m3IoApiBaseUrl + ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready + : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, blockedCapabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + hwlabApi: { + source: m3IoApiBaseUrl ? m3IoSkillApiBaseUrlSource(env) : "missing-config", + baseUrlConfigured: Boolean(m3IoApiBaseUrl), + redactedBaseUrl: m3IoApiBaseUrl ? redactUrl(m3IoApiBaseUrl) : null, + recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, + recommendedDevValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS] + }, + blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(), directGatewayCallsAllowed: false, directBoxCallsAllowed: false, directPatchPanelCallsAllowed: false, @@ -1056,13 +1072,17 @@ async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); + const m3ApiBaseUrl = configuredCloudApiBaseUrl(env); + const sessionCapabilityLevel = m3ApiBaseUrl + ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready + : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; const sessionAcquire = registry.acquire({ conversationId, sessionId, workspace: resolvedWorkspace, sandbox: M3_IO_SKILL_SANDBOX, runnerKind: M3_IO_SKILL_RUNNER_KIND, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready, + capabilityLevel: sessionCapabilityLevel, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, traceId, now @@ -1108,12 +1128,62 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, } let session = sessionAcquire.session; + if (!m3ApiBaseUrl) { + const blocker = m3IoApiBaseUrlMissingBlocker(); + const skillResult = m3IoSkillMissingApiBaseUrlResult({ + traceId, + requestId: `req_${randomUUID()}`, + actorId: "usr_code_agent", + blocker, + startedAt, + finishedAt: nowIso(now) + }); + return m3IoSkillRunnerResult({ + skillResult, + commandArgs: m3IoSkillArgsForIntent(intent, { env, traceId }), + resolvedWorkspace, + registry, + session, + now, + traceId, + conversationId, + codexStdioFeasibility, + startedAt + }); + } + const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); const skillResult = await runM3IoSkillCommand(commandArgs, { env, now, requestJson }); + return m3IoSkillRunnerResult({ + skillResult, + commandArgs, + resolvedWorkspace, + registry, + session, + now, + traceId, + conversationId, + codexStdioFeasibility, + startedAt + }); +} + +function m3IoSkillRunnerResult({ + skillResult, + commandArgs, + resolvedWorkspace, + registry, + session, + now, + traceId, + conversationId, + codexStdioFeasibility, + startedAt +}) { const finishedAt = nowIso(now); const capabilityLevel = skillResult.capabilityLevel ?? ( skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked @@ -1231,6 +1301,101 @@ async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, }; } +function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, startedAt, finishedAt }) { + return { + ok: false, + service: HWLAB_M3_IO_SKILL_NAME, + contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, + route: HWLAB_M3_IO_API_ROUTE, + hwlabApi: { + route: HWLAB_M3_IO_API_ROUTE, + url: null, + redactedUrl: null, + source: "missing-config", + baseUrlConfigured: false, + requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], + recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, + recommendedDevValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + cloudApiOnly: true, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false + }, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + controlReady: false, + action: "m3.io", + accepted: false, + status: "blocked", + traceId, + requestId, + actorId, + operationId: null, + audit: { + auditId: null, + status: "not_written", + durableStatus: null, + summary: "blocked before HWLAB API request" + }, + evidence: { + evidenceId: null, + status: "blocked", + sourceKind: "BLOCKED", + blocker: blocker.code, + writeStatus: "not_written", + summary: "blocked before HWLAB API request" + }, + durable: { + status: "blocked", + durable: false, + blocker: blocker.code, + category: blocker.category, + summary: blocker.message + }, + blocker, + capabilityBlocker: blocker, + trustBlocker: null, + blockers: [blocker], + readiness: { + status: "blocked", + controlReady: false, + capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, + route: HWLAB_M3_IO_API_ROUTE, + blocker, + trustBlocker: null + }, + command: null, + result: { + value: null, + targetReadback: null + }, + controlPath: { + cloudApi: false, + gatewaySimu: false, + boxSimu: false, + patchPanel: false, + frontendBypass: false + }, + safety: { + cloudApiRouteOnly: true, + allowedRoute: HWLAB_M3_IO_API_ROUTE, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false, + fallbackUsed: false, + openAiFallbackUsed: false + }, + httpStatus: 0, + error: { + code: blocker.code, + message: blocker.message + }, + rawStatus: null, + startedAt, + finishedAt, + response: null + }; +} + function detectReadOnlyRunnerIntent(message) { const text = String(message ?? "").trim(); const lower = text.toLowerCase(); @@ -1900,12 +2065,7 @@ function m3IoSkillArgsForIntent(intent, { env, traceId }) { "--actor-id", "usr_code_agent" ]; - const apiBaseUrl = firstNonEmpty( - env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL, - env.HWLAB_API_BASE_URL, - env.HWLAB_CLOUD_API_BASE_URL, - null - ); + const apiBaseUrl = configuredCloudApiBaseUrl(env); if (apiBaseUrl) { args.push("--api-base-url", apiBaseUrl); } @@ -1915,6 +2075,24 @@ function m3IoSkillArgsForIntent(intent, { env, traceId }) { return args; } +function m3IoSkillApiBaseUrlSource(env = process.env) { + for (const name of HWLAB_M3_IO_API_BASE_URL_ENVS) { + if (firstNonEmpty(env[name])) return `env:${name}`; + } + return "missing-config"; +} + +function m3IoApiBaseUrlMissingBlocker() { + return { + code: "hwlab_api_base_url_missing", + category: "hwlab_api_configuration", + source: "code-agent-m3-skill-cli", + summary: `${HWLAB_M3_IO_API_BASE_URL_ENV} is required so the Skill CLI can reach cloud-api from inside the cloud-api runtime container.`, + message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV}=${HWLAB_M3_IO_DEV_SERVICE_BASE_URL} in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`, + zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。` + }; +} + function m3IoSkillReply(skillResult) { const lines = [ `M3 IO Skill CLI result: status=${skillResult.status}; accepted=${skillResult.accepted}; route=${skillResult.route}; traceId=${skillResult.traceId}; operationId=${skillResult.operationId ?? "null"}.` @@ -2794,6 +2972,17 @@ function redactText(value) { .replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***"); } +function redactUrl(value) { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + return url.toString(); + } catch { + return redactText(String(value ?? "")).replace(/\/\/[^/@]+@/u, "//***@"); + } +} + function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index c74a75b6..a8d788b5 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -9,6 +9,7 @@ import { classifyCodeAgentChatReadiness } from "../../scripts/src/code-agent-response-contract.mjs"; import { + HWLAB_M3_IO_API_BASE_URL_ENV, HWLAB_M3_IO_CAPABILITY_LEVELS, HWLAB_M3_IO_API_ROUTE } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; @@ -298,7 +299,7 @@ test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", a env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + 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 () => { @@ -415,7 +416,7 @@ test("Code Agent M3 DI read returns structured blocker from HWLAB API without fa env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + 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 () => { @@ -505,7 +506,7 @@ test("Code Agent blocks direct gateway or patch-panel requests instead of using env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" }, m3IoSkillRequestJson: async () => { throw new Error("direct gateway request must not reach the M3 skill CLI"); @@ -520,6 +521,49 @@ test("Code Agent blocks direct gateway or patch-panel requests instead of using assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); }); +test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_m3_missing_api_base", + traceId: "trc_m3_missing_api_base", + message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。" + }, + { + now: () => "2026-05-23T00:07:30.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + OPENAI_API_KEY: "must-not-be-used" + }, + callProvider: async () => { + throw new Error("OpenAI fallback must not be used for M3 IO"); + }, + m3IoSkillRequestJson: async () => { + throw new Error("missing HWLAB API base URL must block before any HTTP request"); + } + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.toolCalls.length, 1); + assert.equal(payload.toolCalls[0].status, "blocked"); + assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_base_url_missing"); + assert.equal(payload.toolCalls[0].hwlabApi.url, null); + assert.equal(payload.toolCalls[0].hwlabApi.source, "missing-config"); + assert.equal(payload.toolCalls[0].hwlabApi.recommendedEnv, HWLAB_M3_IO_API_BASE_URL_ENV); + assert.equal(payload.toolCalls[0].command.includes("127.0.0.1:6667"), false); + assert.equal(payload.toolCalls[0].accepted, false); + assert.equal(payload.toolCalls[0].operationId, null); + assert.equal(payload.session.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.runnerTrace.blocker.code, "hwlab_api_base_url_missing"); + assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.skills.blockers[0].code, "hwlab_api_base_url_missing"); +}); + test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_blocked" diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 70b8c495..01ce7f20 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1032,7 +1032,7 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", OPENAI_API_KEY: "must-not-be-used" }, callCodeAgentProvider: async () => { diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 7681128b..c1c736df 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -362,7 +362,7 @@ async function runLocalContractSmoke() { env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + 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 () => { diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 557a331f..0f970aea 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -6,6 +6,10 @@ import { DEV_CODE_AGENT_PROVIDER_CONTRACT, codeAgentSecretRefPlaceholder } from "../../internal/cloud/code-agent-contract.mjs"; +import { + HWLAB_M3_IO_API_BASE_URL_ENV, + HWLAB_M3_IO_DEV_SERVICE_BASE_URL +} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const expectedPorts = Object.freeze({ frontend: 16666, api: 16667 }); @@ -377,6 +381,13 @@ function validateCloudApiCodeAgentSource(ctx, env) { "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL", "cloud API Code Agent DEV egress/proxy base URL" ); + expectEqual( + ctx, + env[HWLAB_M3_IO_API_BASE_URL_ENV], + HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + `$.services.hwlab-cloud-api.env.${HWLAB_M3_IO_API_BASE_URL_ENV}`, + "cloud API Code Agent Skill CLI HWLAB API service-local base URL" + ); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API Codex stdio adapter enabled flag"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API Codex stdio supervisor mode"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API Codex stdio workspace mount contract"); @@ -536,6 +547,13 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_OPENAI_BASE_URL", "cloud API workload Code Agent DEV egress/proxy base URL" ); + expectEqual( + ctx, + env.get(HWLAB_M3_IO_API_BASE_URL_ENV)?.value, + HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${HWLAB_M3_IO_API_BASE_URL_ENV}`, + "cloud API workload Code Agent Skill CLI HWLAB API service-local base URL" + ); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED")?.value, "1", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED", "cloud API workload Codex stdio adapter enabled flag"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR")?.value, "repo-owned", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API workload Codex stdio supervisor mode"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API workload Codex stdio workspace mount contract"); diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index e141c3b8..26b839d2 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -11,6 +11,10 @@ import { DEV_CODE_AGENT_PROVIDER_CONTRACT, codeAgentSecretRefPlaceholder } from "../internal/cloud/code-agent-contract.mjs"; +import { + HWLAB_M3_IO_API_BASE_URL_ENV, + HWLAB_M3_IO_DEV_SERVICE_BASE_URL +} from "../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS, @@ -203,6 +207,11 @@ assert.equal( DEV_CODE_AGENT_PROVIDER_CONTRACT.egress.defaultBaseUrl, "cloud-api Code Agent OpenAI base URL must use DEV egress/proxy" ); +assert.equal( + cloudApi.env[HWLAB_M3_IO_API_BASE_URL_ENV], + HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + "cloud-api Code Agent Skill CLI HWLAB API base URL must use service-local cloud-api DNS" +); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "cloud-api Codex stdio adapter enabled flag"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "cloud-api Codex stdio supervisor mode"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "cloud-api Codex stdio workspace mount contract"); @@ -260,6 +269,11 @@ function assertCodeAgentProviderWorkloadContract(env) { contract.egress.defaultBaseUrl, "cloud-api workload Code Agent OpenAI base URL must use DEV egress/proxy" ); + assert.equal( + env[HWLAB_M3_IO_API_BASE_URL_ENV]?.value, + HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + "cloud-api workload Code Agent Skill CLI HWLAB API base URL must use service-local cloud-api DNS" + ); assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED?.value, "1", "cloud-api workload Codex stdio adapter enabled flag"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR?.value, "repo-owned", "cloud-api workload Codex stdio supervisor mode"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Codex stdio workspace mount contract"); diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index c3f84a7c..12b04eea 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -45,10 +45,15 @@ evidence metadata; there is no silent fallback. ## M3 IO Control Contract The M3 IO CLI is the controlled Code Agent -> Skill CLI -> HWLAB API adapter. -`--api-base-url` must be a cloud-api base such as `http://127.0.0.1:6667`, -`http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667`, or the public DEV -API endpoint. The CLI appends `/v1/m3/io` itself and rejects direct -gateway/box/patch-panel targets. +`--api-base-url` must be a cloud-api base such as +`http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667` or the public DEV API +endpoint. In the cloud-api runtime, configure +`HWLAB_CODE_AGENT_HWLAB_API_BASE_URL=http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667` +so the Skill CLI can reach the service-local HWLAB API from inside the +container. The CLI appends `/v1/m3/io` itself and rejects direct +gateway/box/patch-panel targets. If no API base URL is configured, the CLI +returns `hwlab_api_base_url_missing` and does not fall back to `127.0.0.1` or +any direct hardware service. The JSON response includes `route`, `traceId`, `operationId`, `audit`, `evidence`, `accepted`, `status`, `capabilityLevel`, `readiness`, `blocker`, diff --git a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs index e9a58918..cdfe4496 100644 --- a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs +++ b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + HWLAB_M3_IO_API_BASE_URL_ENV, HWLAB_M3_IO_CAPABILITY_LEVELS, + HWLAB_M3_IO_DEV_SERVICE_BASE_URL, HWLAB_M3_IO_API_ROUTE, runM3IoSkillCommand, validateCloudApiTarget @@ -145,3 +147,40 @@ test("M3 Skill CLI validates exact API route contract", () => { }); assert.equal(invalid.code, "invalid_hwlab_api_route"); }); + +test("M3 Skill CLI blocks missing API base URL instead of falling back to loopback", async () => { + const result = await runM3IoSkillCommand( + [ + "m3", + "io", + "--action", + "di.read", + "--trace-id", + "trc_skill_cli_missing_api_base" + ], + { + env: { + PATH: process.env.PATH + }, + requestJson: async () => { + throw new Error("missing HWLAB API base URL must block before network call"); + } + } + ); + + assert.equal(result.ok, false); + assert.equal(result.status, "blocked"); + assert.equal(result.accepted, false); + assert.equal(result.blocker.code, "hwlab_api_base_url_missing"); + assert.equal(result.blocker.category, "hwlab_api_configuration"); + assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(result.hwlabApi.url, null); + assert.equal(result.hwlabApi.source, "missing-config"); + assert.equal(result.hwlabApi.recommendedEnv, HWLAB_M3_IO_API_BASE_URL_ENV); + assert.equal(result.hwlabApi.recommendedDevValue, HWLAB_M3_IO_DEV_SERVICE_BASE_URL); + assert.equal(result.operationId, null); + assert.equal(result.audit.status, "not_written"); + assert.equal(result.safety.directGatewayCalls, false); + assert.equal(result.safety.directBoxCalls, false); + assert.equal(result.safety.directPatchPanelCalls, false); +}); diff --git a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs index c8b717d5..2e21b954 100644 --- a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs +++ b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs @@ -3,6 +3,13 @@ import { randomUUID } from "node:crypto"; export const HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION = "hwlab-agent-runtime-skill-cli-v1"; export const HWLAB_M3_IO_API_ROUTE = "/v1/m3/io"; export const HWLAB_M3_IO_SKILL_NAME = "hwlab-agent-runtime.m3-io"; +export const HWLAB_M3_IO_API_BASE_URL_ENV = "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL"; +export const HWLAB_M3_IO_DEV_SERVICE_BASE_URL = "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667"; +export const HWLAB_M3_IO_API_BASE_URL_ENVS = Object.freeze([ + HWLAB_M3_IO_API_BASE_URL_ENV, + "HWLAB_API_BASE_URL", + "HWLAB_CLOUD_API_BASE_URL" +]); export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({ blocked: "hwlab-api-control-blocked", ready: "hwlab-api-control-ready" @@ -132,32 +139,54 @@ export function parseM3IoArgs(argv = [], env = process.env) { } } - parsed.apiBaseUrl = parsed.apiBaseUrl || firstNonEmpty( - env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL, - env.HWLAB_API_BASE_URL, - env.HWLAB_CLOUD_API_BASE_URL, - loopbackCloudApiBaseUrl(env) - ); + parsed.apiBaseUrl = parsed.apiBaseUrl || configuredCloudApiBaseUrl(env); return parsed; } export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { - const source = apiBaseUrl - ? "explicit" - : firstNonEmpty(env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL, null) - ? "env:HWLAB_CODE_AGENT_HWLAB_API_BASE_URL" - : firstNonEmpty(env.HWLAB_API_BASE_URL, null) - ? "env:HWLAB_API_BASE_URL" - : firstNonEmpty(env.HWLAB_CLOUD_API_BASE_URL, null) - ? "env:HWLAB_CLOUD_API_BASE_URL" - : "loopback-cloud-api-self"; - const base = firstNonEmpty(apiBaseUrl, env.HWLAB_CODE_AGENT_HWLAB_API_BASE_URL, env.HWLAB_API_BASE_URL, env.HWLAB_CLOUD_API_BASE_URL, loopbackCloudApiBaseUrl(env)); - const url = new URL(HWLAB_M3_IO_API_ROUTE, ensureTrailingSlash(base)); - return { - route: HWLAB_M3_IO_API_ROUTE, + const source = cloudApiBaseUrlSource(apiBaseUrl, env); + const base = firstNonEmpty(apiBaseUrl, configuredCloudApiBaseUrl(env)); + if (!base) { + return cloudApiTargetEnvelope({ + source, + url: null, + redactedUrl: null, + baseUrlConfigured: false + }); + } + + let url; + try { + url = new URL(HWLAB_M3_IO_API_ROUTE, ensureTrailingSlash(base)); + } catch { + return cloudApiTargetEnvelope({ + source, + url: String(base), + redactedUrl: redactUrl(base), + baseUrlConfigured: true, + invalidBaseUrl: true + }); + } + + return cloudApiTargetEnvelope({ + source, url: url.toString(), redactedUrl: redactUrl(url.toString()), + baseUrlConfigured: true + }); +} + +function cloudApiTargetEnvelope({ source, url, redactedUrl, baseUrlConfigured, invalidBaseUrl = false }) { + return { + route: HWLAB_M3_IO_API_ROUTE, + url, + redactedUrl, source, + baseUrlConfigured, + requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], + recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, + recommendedDevValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL, + invalidBaseUrl, cloudApiOnly: true, directGatewayCalls: false, directBoxCalls: false, @@ -166,6 +195,20 @@ export function resolveCloudApiTarget(apiBaseUrl, env = process.env) { } export function validateCloudApiTarget(apiTarget) { + if (!apiTarget?.url) { + return { + code: "hwlab_api_base_url_missing", + message: `HWLAB API base URL is missing; set ${HWLAB_M3_IO_API_BASE_URL_ENV}=${HWLAB_M3_IO_DEV_SERVICE_BASE_URL} in the cloud-api runtime or pass --api-base-url explicitly.` + }; + } + + if (apiTarget.invalidBaseUrl === true) { + return { + code: "invalid_hwlab_api_url", + message: "HWLAB API base URL is not a valid URL." + }; + } + let url; try { url = new URL(apiTarget.url); @@ -518,6 +561,7 @@ function dedupeBlockers(blockers) { function blockerCategory(code) { const value = String(code ?? ""); + if (/api_base_url|invalid_hwlab_api_url/u.test(value)) return "hwlab_api_configuration"; if (/readiness|control_disabled|not_ready/u.test(value)) return "readiness_blocked"; if (/gateway/u.test(value)) return "gateway_unavailable"; if (/box|resource/u.test(value)) return "box_unavailable"; @@ -563,10 +607,15 @@ function helpPayload() { service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, usage: [ - "node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url http://127.0.0.1:6667", - "node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url http://127.0.0.1:6667", - "node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url http://127.0.0.1:6667" + `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value true --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`, + `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action do.write --value false --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}`, + `node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs m3 io --action di.read --api-base-url ${HWLAB_M3_IO_DEV_SERVICE_BASE_URL}` ], + env: { + recommended: HWLAB_M3_IO_API_BASE_URL_ENV, + supported: [...HWLAB_M3_IO_API_BASE_URL_ENVS], + devValue: HWLAB_M3_IO_DEV_SERVICE_BASE_URL + }, route: HWLAB_M3_IO_API_ROUTE, safety: { cloudApiRouteOnly: true, @@ -601,9 +650,16 @@ function parseTimeout(value) { return parsed; } -function loopbackCloudApiBaseUrl(env = process.env) { - const port = env.HWLAB_CLOUD_API_PORT || env.PORT || "6667"; - return `http://127.0.0.1:${port}`; +export function configuredCloudApiBaseUrl(env = process.env) { + return firstNonEmpty(...HWLAB_M3_IO_API_BASE_URL_ENVS.map((name) => env[name])); +} + +function cloudApiBaseUrlSource(apiBaseUrl, env = process.env) { + if (apiBaseUrl) return "explicit"; + for (const name of HWLAB_M3_IO_API_BASE_URL_ENVS) { + if (firstNonEmpty(env[name])) return `env:${name}`; + } + return "missing-config"; } function ensureTrailingSlash(value) {