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:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user