Merge pull request #353 from pikasTech/fix/d601-m3-skill-api-url-334

fix: configure M3 skill CLI cloud API route
This commit is contained in:
Lyon
2026-05-23 22:29:57 +08:00
committed by GitHub
12 changed files with 417 additions and 43 deletions
+1
View File
@@ -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",
+4
View File
@@ -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"
+4
View File
@@ -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"
+198 -9
View File
@@ -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("'", "'\\''")}'`;
}
@@ -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"
+1 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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 () => {
+18
View File
@@ -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");
+14
View File
@@ -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");
+9 -4
View File
@@ -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`,
@@ -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);
});
@@ -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) {