fix: remove code agent routing gates
This commit is contained in:
@@ -19,10 +19,10 @@ Secret 或 token。
|
||||
## Provider 前置条件
|
||||
|
||||
当前 DEV 部署合同中 `hwlab-cloud-api` 的 Code Agent provider 是
|
||||
`HWLAB_CODE_AGENT_PROVIDER=codex-stdio`。运行时必须具备 repo-owned Codex MCP
|
||||
`HWLAB_CODE_AGENT_PROVIDER=codex-stdio`。运行时必须具备 repo-owned Codex app-server
|
||||
stdio session supervisor,并证明 `/workspace/hwlab` 可读写、`CODEX_HOME=/codex-home`
|
||||
可写、`/app/node_modules/.bin/codex --version` 可执行、`codex mcp-server`
|
||||
可通过 MCP initialize/tools/list 观测到 `codex` 与 `codex-reply`。
|
||||
可写、`/app/node_modules/.bin/codex --version` 可执行、`codex app-server --listen stdio://`
|
||||
可创建和复用同一个 Codex thread/session。
|
||||
|
||||
Codex token boundary 仍由授权路径把 `OPENAI_API_KEY` 注入到 DEV runtime。DEV Pod
|
||||
必须使用 `HWLAB_CODE_AGENT_OPENAI_BASE_URL` 指向受控 DEV egress/proxy 路径
|
||||
@@ -66,7 +66,9 @@ result 轮询和 schema 化返回。
|
||||
如果 Codex stdio 不具备运行条件,`/v1/agent/chat` 只能返回 Codex stdio readiness
|
||||
blocker,不能降级到 M3 Skill CLI、受控硬件 shortcut、外网专用检查或普通 OpenAI 文本回复。
|
||||
显式 `/v1/m3/io` 控制面可以作为独立 API 或 UI 控制面继续存在,但聊天自然语言不得自动路由
|
||||
到该 API,也不得保留要求自然语言先满足 M3 白名单的源码检查或测试。
|
||||
到该 API,也不得保留要求自然语言先满足 M3 白名单的源码检查或测试。该显式控制面也不得在
|
||||
进入 gateway 前保留固定 `DO1/DI1` 或固定 gateway 身份白名单预拦截;真实下游执行失败可以
|
||||
返回执行失败,但不能由 cloud-api 用旧白名单提前拒绝。
|
||||
|
||||
持久 session 是默认合同:同一个 `conversationId/sessionId` 必须映射到 repo-owned Codex
|
||||
thread 和固定 workspace,刷新前端、重新打开页面或短连接 result 轮询不得创建新的短期 runner。
|
||||
|
||||
@@ -55,10 +55,6 @@ const READONLY_LIMITATION_FLAGS = Object.freeze([
|
||||
"process-local-session-registry"
|
||||
]);
|
||||
const CODEX_STDIO_LIMITATION_FLAGS = Object.freeze([
|
||||
"hardware-control-via-cloud-api-only",
|
||||
"no-direct-gateway-link",
|
||||
"no-direct-box-simu-link",
|
||||
"no-direct-patch-panel-link",
|
||||
"secret-values-redacted"
|
||||
]);
|
||||
const CODE_AGENT_SYSTEM_PROMPT = [
|
||||
@@ -629,10 +625,10 @@ function codexStdioRunnerAvailability(codexStdio = {}) {
|
||||
secretsRead: false,
|
||||
secretValuesPrinted: false,
|
||||
kubeconfigRead: false,
|
||||
hardwareControlViaCloudApiOnly: true,
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
hardwareControlViaCloudApiOnly: false,
|
||||
directGatewayCallsAllowed: true,
|
||||
directBoxSimuCallsAllowed: true,
|
||||
directPatchPanelCallsAllowed: true,
|
||||
m3m4m5AcceptanceClaimsAllowed: false
|
||||
}
|
||||
};
|
||||
@@ -674,10 +670,10 @@ function codexStdioBlockedRunnerAvailability(codexStdio = {}) {
|
||||
secretsRead: false,
|
||||
secretValuesPrinted: false,
|
||||
kubeconfigRead: false,
|
||||
hardwareControlViaCloudApiOnly: true,
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
hardwareControlViaCloudApiOnly: false,
|
||||
directGatewayCallsAllowed: true,
|
||||
directBoxSimuCallsAllowed: true,
|
||||
directPatchPanelCallsAllowed: true,
|
||||
m3m4m5AcceptanceClaimsAllowed: false
|
||||
}
|
||||
};
|
||||
@@ -720,7 +716,7 @@ function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) {
|
||||
|
||||
function codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }) {
|
||||
if (codexStdio.ready) {
|
||||
return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat can create/reuse/cancel/reap sessions with trace capture. Hardware control still must use cloud-api/HWLAB API/skill CLI.";
|
||||
return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat sends natural-language requests to the persistent Codex session with full workspace and tool access.";
|
||||
}
|
||||
if (runnerAvailability.ready) return "Codex stdio long-lived session is blocked; /v1/agent/chat will not use controlled-readonly-session-registry, local skills discovery, shell/file shortcuts, or text fallback.";
|
||||
return blocked
|
||||
|
||||
@@ -1288,7 +1288,7 @@ test("repo-owned Codex stdio manager creates and reuses long-lived sessions with
|
||||
assert.equal(first.capabilityLevel, "long-lived-codex-stdio-session");
|
||||
assert.equal(first.longLivedSessionGate.status, "pass");
|
||||
assert.equal(first.longLivedSessionGate.pass, true);
|
||||
assert.deepEqual(first.runnerLimitations, ["hardware-control-via-cloud-api-only", "secret-values-redacted"]);
|
||||
assert.deepEqual(first.runnerLimitations, ["secret-values-redacted"]);
|
||||
assert.equal(first.runnerLimitations.includes("not-codex-stdio"), false);
|
||||
assert.equal(first.runnerLimitations.includes("not-write-capable"), false);
|
||||
assert.equal(first.runnerLimitations.includes("process-local-session-registry"), false);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import { accessSync, constants as fsConstants, existsSync, realpathSync, statSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -58,10 +56,6 @@ const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000;
|
||||
const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe";
|
||||
const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe";
|
||||
const CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS = 15 * 1000;
|
||||
const EXTERNAL_NETWORK_TOOL_NAME = "external.network.check";
|
||||
const EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS = 8000;
|
||||
const EXTERNAL_NETWORK_DNS_TIMEOUT_MS = 1500;
|
||||
const EXTERNAL_NETWORK_HTTP_METHOD = "HEAD";
|
||||
const CODEX_CHILD_STRIPPED_ENV_KEYS = Object.freeze([
|
||||
"OPENAI_API_KEY",
|
||||
"CODEX_API_KEY",
|
||||
@@ -424,23 +418,6 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
turn: session.turn,
|
||||
waitingFor: "prompt-send"
|
||||
});
|
||||
const externalNetworkIntent = normalizeExternalNetworkIntent(params.externalNetworkIntent, params.message);
|
||||
if (externalNetworkIntent) {
|
||||
return await runExternalNetworkTurn({
|
||||
params,
|
||||
env,
|
||||
traceRecorder,
|
||||
session,
|
||||
availability,
|
||||
workspace,
|
||||
sandbox,
|
||||
startedAt,
|
||||
intent: externalNetworkIntent,
|
||||
fetchImpl: options.fetchImpl,
|
||||
lookupImpl: options.lookupImpl,
|
||||
releaseSession
|
||||
});
|
||||
}
|
||||
const sidecar = await collectWorkspaceSidecarEvidence({
|
||||
message: params.message,
|
||||
workspace,
|
||||
@@ -632,7 +609,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(session),
|
||||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||||
runnerLimitations: ["secret-values-redacted"],
|
||||
codexStdioFeasibility: availability,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: CODEX_STDIO_PROVIDER,
|
||||
@@ -719,10 +696,7 @@ export function createCodexStdioSessionManager(options = {}) {
|
||||
});
|
||||
}
|
||||
if (error.code && (error.code.startsWith("codex_stdio") || [
|
||||
"skills_unavailable",
|
||||
"external_network_blocked",
|
||||
"network_tool_unavailable",
|
||||
"network_timeout"
|
||||
"skills_unavailable"
|
||||
].includes(error.code))) {
|
||||
error.session = session;
|
||||
error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox });
|
||||
@@ -2229,10 +2203,10 @@ function runnerDescriptor({ workspace, sandbox, session }) {
|
||||
readOnly: false,
|
||||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||||
toolPolicy: {
|
||||
allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start", "workspace-read", "workspace-write", EXTERNAL_NETWORK_TOOL_NAME],
|
||||
allowed: ["codex-app-server.thread/start", "codex-app-server.thread/resume", "codex-app-server.turn/start", "workspace-read", "workspace-write"],
|
||||
blocked: ["secret-read", "kubeconfig-read", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim-without-evidence"]
|
||||
},
|
||||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||||
runnerLimitations: ["secret-values-redacted"],
|
||||
safety: codexStdioSafety()
|
||||
};
|
||||
}
|
||||
@@ -2269,7 +2243,7 @@ function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, star
|
||||
turn: session?.turn ?? null,
|
||||
sessionReused: session?.reused ?? false,
|
||||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||||
limitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||||
limitations: ["secret-values-redacted"],
|
||||
startedAt,
|
||||
outputTruncated: Boolean(outputTruncated),
|
||||
note: "Repo-owned Codex app-server stdio supervisor manages create/reuse/cancel/reap/idle timeout and real-time trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries."
|
||||
@@ -2352,632 +2326,6 @@ function summarizeToolResult(toolResult) {
|
||||
return "tool result captured";
|
||||
}
|
||||
|
||||
async function runExternalNetworkTurn({
|
||||
params,
|
||||
env,
|
||||
traceRecorder,
|
||||
session,
|
||||
availability,
|
||||
workspace,
|
||||
sandbox,
|
||||
startedAt,
|
||||
intent,
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
releaseSession: releaseSessionFn
|
||||
} = {}) {
|
||||
const traceId = optionalId(params?.traceId) ?? traceRecorder?.traceId;
|
||||
const now = params?.now;
|
||||
const toolName = EXTERNAL_NETWORK_TOOL_NAME;
|
||||
traceRecorder.append({
|
||||
type: "prompt",
|
||||
status: "sent",
|
||||
label: "prompt:sent",
|
||||
toolName,
|
||||
promptSummary: summarizePrompt(params?.message),
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn,
|
||||
waitingFor: "network-policy"
|
||||
});
|
||||
traceRecorder.append({
|
||||
type: "tool_call",
|
||||
status: "started",
|
||||
label: `tool:${toolName}:started`,
|
||||
toolName,
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn,
|
||||
waitingFor: "network:policy"
|
||||
});
|
||||
traceRecorder.append({
|
||||
type: "network",
|
||||
stage: "policy",
|
||||
status: "started",
|
||||
label: "network:started",
|
||||
toolName,
|
||||
outputSummary: `target=${safeNetworkUrl(intent.url)}`,
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn,
|
||||
waitingFor: "network:http"
|
||||
});
|
||||
|
||||
const check = await controlledExternalNetworkCheck({
|
||||
intent,
|
||||
env,
|
||||
timeoutMs: params?.networkTimeoutMs,
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
now
|
||||
});
|
||||
const toolCall = externalNetworkToolCall({ check, workspace, traceId });
|
||||
if (check.ok !== true) {
|
||||
traceRecorder.append({
|
||||
type: "network",
|
||||
stage: check.stage ?? "blocked",
|
||||
status: check.code === "network_timeout" ? "timeout" : "blocked",
|
||||
label: check.code === "network_timeout" ? "network:timeout" : "network:blocked",
|
||||
toolName,
|
||||
errorCode: check.code,
|
||||
message: check.userMessage,
|
||||
outputSummary: check.summary,
|
||||
timeoutMs: check.timeoutMs,
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn,
|
||||
waitingFor: check.code === "network_timeout" ? "external-network-response" : "network-policy"
|
||||
});
|
||||
traceRecorder.append({
|
||||
type: "tool_call",
|
||||
status: "blocked",
|
||||
label: `tool:${toolName}:blocked`,
|
||||
toolName,
|
||||
errorCode: check.code,
|
||||
outputSummary: check.summary,
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn
|
||||
});
|
||||
const traceSnapshot = traceRecorder.snapshot({
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: session.status,
|
||||
turn: session.turn
|
||||
});
|
||||
const blocker = externalNetworkBlocker(check, {
|
||||
traceId,
|
||||
session,
|
||||
conversationId: params?.conversationId,
|
||||
runnerTrace: traceSnapshot
|
||||
});
|
||||
throw codexStdioError(check.code, check.message, {
|
||||
availability,
|
||||
session,
|
||||
toolCalls: [toolCall],
|
||||
skills: notRequestedSkills(),
|
||||
route: "/v1/agent/chat",
|
||||
toolName,
|
||||
blockers: [blocker],
|
||||
blocker,
|
||||
userMessage: check.userMessage,
|
||||
retryable: check.retryable,
|
||||
stage: check.stage,
|
||||
targetUrl: check.url,
|
||||
timeoutMs: check.timeoutMs
|
||||
});
|
||||
}
|
||||
|
||||
const released = releaseSessionFn(session.sessionId, {
|
||||
now,
|
||||
traceId,
|
||||
conversationId: params.conversationId,
|
||||
reused: session.reused,
|
||||
threadId: session.threadId,
|
||||
status: "idle"
|
||||
}) ?? session;
|
||||
traceRecorder.append({
|
||||
type: "network",
|
||||
stage: "http",
|
||||
status: "completed",
|
||||
label: "network:completed",
|
||||
toolName,
|
||||
outputSummary: `HTTP ${check.httpStatus} ${check.statusText || ""}; elapsedMs=${check.elapsedMs}`,
|
||||
sessionId: released.sessionId,
|
||||
sessionStatus: released.status,
|
||||
turn: released.turn
|
||||
});
|
||||
traceRecorder.append({
|
||||
type: "tool_call",
|
||||
status: "completed",
|
||||
label: `tool:${toolName}:completed`,
|
||||
toolName,
|
||||
outputSummary: `HTTP ${check.httpStatus}; target=${safeNetworkUrl(check.url)}`,
|
||||
sessionId: released.sessionId,
|
||||
sessionStatus: released.status,
|
||||
turn: released.turn
|
||||
});
|
||||
const content = externalNetworkReply(check);
|
||||
traceRecorder.append({
|
||||
type: "assistant_message",
|
||||
status: "chunk",
|
||||
label: "assistant:chunk",
|
||||
chunk: content.slice(0, 400),
|
||||
sessionId: released.sessionId,
|
||||
sessionStatus: released.status,
|
||||
turn: released.turn,
|
||||
waitingFor: "assistant-message-complete"
|
||||
});
|
||||
traceRecorder.append({
|
||||
type: "assistant_message",
|
||||
status: "completed",
|
||||
label: "assistant:completed",
|
||||
sessionId: released.sessionId,
|
||||
sessionStatus: released.status,
|
||||
turn: released.turn,
|
||||
terminal: true
|
||||
});
|
||||
|
||||
return {
|
||||
provider: CODEX_STDIO_PROVIDER,
|
||||
model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"),
|
||||
backend: CODEX_STDIO_BACKEND,
|
||||
content,
|
||||
workspace,
|
||||
sandbox,
|
||||
session: released,
|
||||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||||
sessionReuse: sessionReuseEvidence(released),
|
||||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted", "external-network-http-check-only"],
|
||||
codexStdioFeasibility: availability,
|
||||
longLivedSessionGate: longLivedSessionGate({
|
||||
provider: CODEX_STDIO_PROVIDER,
|
||||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||||
session: released,
|
||||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||||
codexStdioFeasibility: availability
|
||||
}),
|
||||
toolCalls: [toolCall],
|
||||
skills: notRequestedSkills(),
|
||||
runner: runnerDescriptor({ workspace, sandbox, session: released }),
|
||||
runnerTrace: runnerTrace({
|
||||
traceRecorder,
|
||||
traceId,
|
||||
workspace,
|
||||
sandbox,
|
||||
session: released,
|
||||
startedAt,
|
||||
outputTruncated: false
|
||||
}),
|
||||
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
|
||||
providerTrace: {
|
||||
transport: "stdio+controlled-network",
|
||||
protocol: `${CODEX_APP_SERVER_PROTOCOL}+http-head`,
|
||||
command: `${availability.command} app-server --listen stdio:// + ${toolName}`,
|
||||
toolName,
|
||||
targetUrl: safeNetworkUrl(check.url),
|
||||
method: check.method,
|
||||
httpStatus: check.httpStatus,
|
||||
elapsedMs: check.elapsedMs,
|
||||
valuesPrinted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeExternalNetworkIntent(intent, message) {
|
||||
if (!intent || intent.kind !== "external_network") return null;
|
||||
const url = normalizeNetworkUrl(intent.targetUrl ?? intent.url ?? extractNetworkTargetFromText(message));
|
||||
if (!url) return null;
|
||||
return {
|
||||
kind: "external_network",
|
||||
url,
|
||||
method: EXTERNAL_NETWORK_HTTP_METHOD,
|
||||
originalText: String(intent.originalText ?? message ?? "").slice(0, 240)
|
||||
};
|
||||
}
|
||||
|
||||
async function controlledExternalNetworkCheck({
|
||||
intent,
|
||||
env = process.env,
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
lookupImpl,
|
||||
now
|
||||
} = {}) {
|
||||
const startedAt = timestampFor(now);
|
||||
const startedMs = Date.now();
|
||||
const url = normalizeNetworkUrl(intent?.url);
|
||||
const method = EXTERNAL_NETWORK_HTTP_METHOD;
|
||||
const configuredTimeoutMs = Number.parseInt(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK_TIMEOUT_MS ?? "", 10);
|
||||
const effectiveTimeoutMs = positiveInteger(timeoutMs, positiveInteger(configuredTimeoutMs, EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS));
|
||||
const policy = await externalNetworkPolicy({ url, env, lookupImpl });
|
||||
if (!policy.ok) {
|
||||
return {
|
||||
...policy,
|
||||
ok: false,
|
||||
url: safeNetworkUrl(url),
|
||||
method,
|
||||
startedAt,
|
||||
finishedAt: timestampFor(now),
|
||||
elapsedMs: Date.now() - startedMs,
|
||||
timeoutMs: effectiveTimeoutMs
|
||||
};
|
||||
}
|
||||
const effectiveFetch = fetchImpl === undefined ? globalThis.fetch : fetchImpl;
|
||||
if (typeof effectiveFetch !== "function") {
|
||||
return externalNetworkFailure({
|
||||
code: "network_tool_unavailable",
|
||||
stage: "tool",
|
||||
retryable: false,
|
||||
url,
|
||||
method,
|
||||
startedAt,
|
||||
elapsedMs: Date.now() - startedMs,
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
message: "No fetch-compatible HTTP client is available for the controlled external network check.",
|
||||
userMessage: "当前运行环境没有可用的受控 HTTP 网络检查工具;未访问外网,也不会伪造成功。"
|
||||
});
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs);
|
||||
try {
|
||||
const response = await effectiveFetch(url, {
|
||||
method,
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"user-agent": "HWLAB-Code-Agent-Network-Check/1.0",
|
||||
accept: "text/html,application/json;q=0.5,*/*;q=0.1"
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
const elapsedMs = Date.now() - startedMs;
|
||||
return {
|
||||
ok: true,
|
||||
code: "network_completed",
|
||||
stage: "http",
|
||||
retryable: false,
|
||||
url: safeNetworkUrl(url),
|
||||
method,
|
||||
host: new URL(url).hostname,
|
||||
httpStatus: Number(response?.status ?? 0),
|
||||
statusText: safeNetworkStatusText(response?.statusText),
|
||||
reachable: Number(response?.status ?? 0) > 0,
|
||||
redirected: response?.url ? safeNetworkUrl(response.url) !== safeNetworkUrl(url) : false,
|
||||
finalUrl: safeNetworkUrl(response?.url || url),
|
||||
startedAt,
|
||||
finishedAt: timestampFor(now),
|
||||
elapsedMs,
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
summary: `HTTP ${Number(response?.status ?? 0)} ${safeNetworkStatusText(response?.statusText)}`.trim(),
|
||||
userMessage: "外部网络检查已完成。"
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsedMs = Date.now() - startedMs;
|
||||
const aborted = error?.name === "AbortError" || /abort|timeout|timed out/iu.test(String(error?.message ?? ""));
|
||||
return externalNetworkFailure({
|
||||
code: aborted ? "network_timeout" : "external_network_blocked",
|
||||
stage: aborted ? "http-timeout" : "http",
|
||||
retryable: true,
|
||||
url,
|
||||
method,
|
||||
startedAt,
|
||||
elapsedMs,
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
message: aborted
|
||||
? `Controlled external network check timed out after ${effectiveTimeoutMs}ms.`
|
||||
: `Controlled external network check failed: ${redactText(error?.message ?? "network error")}`,
|
||||
userMessage: aborted
|
||||
? `访问 ${safeNetworkUrl(url)} 的受控外网检查在 ${effectiveTimeoutMs}ms 内未完成;输入已保留,可稍后重试。`
|
||||
: `访问 ${safeNetworkUrl(url)} 的受控外网检查失败;当前不会回退成文本成功。`
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function externalNetworkPolicy({ url, env = process.env, lookupImpl } = {}) {
|
||||
if (!url) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: "No external HTTP(S) target was detected.",
|
||||
userMessage: "没有识别到可访问的公网 HTTP(S) 目标;请提供明确 URL,例如 https://github.com。"
|
||||
});
|
||||
}
|
||||
if (/^(?:0|false|deny|denied|disabled|off)$/iu.test(String(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK ?? env.HWLAB_CODE_AGENT_NETWORK ?? "").trim())) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: "External network checks are disabled by runtime policy.",
|
||||
userMessage: "当前 Code Agent 运行策略禁止外部网络访问;未访问外网,也不会伪造成功。"
|
||||
});
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: "External network target URL is invalid.",
|
||||
userMessage: "外部网络目标 URL 格式不正确;请改用明确的 http(s) URL。"
|
||||
});
|
||||
}
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: "External network checks only allow HTTP(S).",
|
||||
userMessage: "当前只允许受控 HTTP(S) 外网检查;未访问该目标。"
|
||||
});
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (isForbiddenNetworkHost(host)) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: "External network target is local, private, or a forbidden HWLAB runtime host.",
|
||||
userMessage: "当前策略禁止访问 localhost、内网地址或 HWLAB gateway/box/patch-panel 直连目标;未访问该目标。"
|
||||
});
|
||||
}
|
||||
const allowlist = parseNetworkAllowlist(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK_ALLOWLIST);
|
||||
if (allowlist.length > 0 && !allowlist.some((pattern) => networkHostMatches(host, pattern))) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: `External network target ${host} is not in the configured allowlist.`,
|
||||
userMessage: `当前外网策略不允许访问 ${host};请让维护者确认 allowlist 或换用允许的公网目标。`
|
||||
});
|
||||
}
|
||||
const lookup = lookupImpl === undefined ? dnsLookup : lookupImpl;
|
||||
if (typeof lookup === "function") {
|
||||
try {
|
||||
const records = await withTimeout(
|
||||
Promise.resolve(lookup(host, { all: true })),
|
||||
EXTERNAL_NETWORK_DNS_TIMEOUT_MS,
|
||||
"dns lookup timed out"
|
||||
);
|
||||
const addresses = Array.isArray(records)
|
||||
? records.map((record) => typeof record === "string" ? record : record?.address).filter(Boolean)
|
||||
: [typeof records === "string" ? records : records?.address].filter(Boolean);
|
||||
if (addresses.some((address) => isPrivateNetworkAddress(address))) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: false,
|
||||
url,
|
||||
message: "External network target resolved to a private/local address.",
|
||||
userMessage: "目标解析到内网或本地地址;当前策略已阻断,未访问该目标。"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return externalNetworkFailure({
|
||||
code: "external_network_blocked",
|
||||
stage: "policy",
|
||||
retryable: true,
|
||||
url,
|
||||
message: `DNS policy check failed: ${redactText(error?.message ?? "lookup failed")}`,
|
||||
userMessage: "外网目标 DNS/策略检查未通过;未访问该目标,可稍后重试或让维护者确认网络策略。"
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function externalNetworkFailure({ code, stage, retryable, url, method = EXTERNAL_NETWORK_HTTP_METHOD, startedAt, elapsedMs = 0, timeoutMs = null, message, userMessage }) {
|
||||
return {
|
||||
ok: false,
|
||||
code,
|
||||
stage,
|
||||
retryable,
|
||||
url: safeNetworkUrl(url),
|
||||
method,
|
||||
message: redactText(message),
|
||||
summary: redactText(message),
|
||||
userMessage,
|
||||
startedAt: startedAt ?? timestampFor(),
|
||||
finishedAt: timestampFor(),
|
||||
elapsedMs,
|
||||
timeoutMs,
|
||||
valuesPrinted: false
|
||||
};
|
||||
}
|
||||
|
||||
function externalNetworkToolCall({ check, workspace, traceId }) {
|
||||
const stdout = check.ok === true
|
||||
? {
|
||||
targetUrl: safeNetworkUrl(check.url),
|
||||
method: check.method,
|
||||
httpStatus: check.httpStatus,
|
||||
statusText: check.statusText,
|
||||
reachable: check.reachable,
|
||||
elapsedMs: check.elapsedMs,
|
||||
finalUrl: safeNetworkUrl(check.finalUrl)
|
||||
}
|
||||
: {
|
||||
targetUrl: safeNetworkUrl(check.url),
|
||||
method: check.method,
|
||||
blocker: check.code,
|
||||
stage: check.stage,
|
||||
elapsedMs: check.elapsedMs,
|
||||
timeoutMs: check.timeoutMs
|
||||
};
|
||||
const bounded = boundToolOutput(JSON.stringify(stdout));
|
||||
return {
|
||||
id: `tool_${randomUUID()}`,
|
||||
type: "network-check",
|
||||
name: EXTERNAL_NETWORK_TOOL_NAME,
|
||||
status: check.ok === true ? "completed" : "blocked",
|
||||
cwd: workspace,
|
||||
command: `${check.method ?? EXTERNAL_NETWORK_HTTP_METHOD} ${safeNetworkUrl(check.url)}`,
|
||||
exitCode: check.ok === true ? 0 : 2,
|
||||
stdout: bounded.text,
|
||||
stderrSummary: check.ok === true ? "" : check.code,
|
||||
outputTruncated: bounded.truncated,
|
||||
traceId,
|
||||
route: "/v1/agent/chat",
|
||||
method: check.method ?? EXTERNAL_NETWORK_HTTP_METHOD,
|
||||
targetUrl: safeNetworkUrl(check.url),
|
||||
httpStatus: check.httpStatus ?? null,
|
||||
elapsedMs: check.elapsedMs ?? null,
|
||||
blocker: check.ok === true ? null : {
|
||||
code: check.code,
|
||||
stage: check.stage,
|
||||
retryable: check.retryable,
|
||||
userMessage: check.userMessage
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function externalNetworkBlocker(check, { traceId, session, conversationId, runnerTrace } = {}) {
|
||||
return {
|
||||
code: check.code,
|
||||
layer: check.code === "network_tool_unavailable" ? "network-tool" : "network",
|
||||
category: check.code === "network_timeout" ? "timeout" : check.code === "network_tool_unavailable" ? "needs_config" : "capability_unavailable",
|
||||
retryable: Boolean(check.retryable),
|
||||
summary: check.summary,
|
||||
userMessage: check.userMessage,
|
||||
traceId,
|
||||
sessionId: session?.sessionId ?? null,
|
||||
conversationId: conversationId ?? session?.conversationId ?? null,
|
||||
stage: check.stage,
|
||||
lastEvent: runnerTrace?.lastEvent ?? null,
|
||||
elapsedMs: runnerTrace?.elapsedMs ?? check.elapsedMs ?? null,
|
||||
targetUrl: safeNetworkUrl(check.url),
|
||||
toolName: EXTERNAL_NETWORK_TOOL_NAME,
|
||||
sourceIssue: "pikasTech/HWLAB#412"
|
||||
};
|
||||
}
|
||||
|
||||
function externalNetworkReply(check) {
|
||||
const target = safeNetworkUrl(check.url);
|
||||
const status = `HTTP ${check.httpStatus}${check.statusText ? ` ${check.statusText}` : ""}`;
|
||||
const reachability = check.httpStatus >= 500
|
||||
? "网络已到达目标,但目标返回服务端错误。"
|
||||
: "网络已到达目标。";
|
||||
return [
|
||||
`${target.includes("github.com") ? "GitHub" : "目标站点"}可以访问:受控检查 ${check.method} ${target} 返回 ${status},用时 ${check.elapsedMs}ms。`,
|
||||
reachability,
|
||||
"本次走 Codex stdio/session 的受控网络检查,未使用 OpenAI text-only fallback,未读取 secret/token/kubeconfig。"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function extractNetworkTargetFromText(text) {
|
||||
const value = String(text ?? "");
|
||||
const url = value.match(/\bhttps?:\/\/[^\s"'<>,。!?))]+/iu)?.[0];
|
||||
if (url) return url;
|
||||
if (/\bgithub(?:\.com)?\b|github\.com|GitHub/u.test(value)) return "https://github.com/";
|
||||
const host = value.match(/\b([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?:\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]*)?/u)?.[0];
|
||||
if (host) return `https://${host}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeNetworkUrl(value) {
|
||||
const raw = String(value ?? "").trim().replace(/[,。!?))]+$/u, "");
|
||||
if (!raw) return null;
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//iu.test(raw)
|
||||
? raw
|
||||
: raw.toLowerCase() === "github" ? "https://github.com/" : `https://${raw}`;
|
||||
try {
|
||||
const url = new URL(withScheme);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
if (!url.pathname) url.pathname = "/";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeNetworkUrl(value) {
|
||||
const normalized = normalizeNetworkUrl(value);
|
||||
if (!normalized) return "unknown";
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return redactText(url.toString());
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function safeNetworkStatusText(value) {
|
||||
return redactText(String(value ?? "").replace(/\s+/gu, " ").trim()).slice(0, 80);
|
||||
}
|
||||
|
||||
function parseNetworkAllowlist(value) {
|
||||
return String(value ?? "")
|
||||
.split(/[,;\s]+/u)
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function networkHostMatches(host, pattern) {
|
||||
if (!pattern) return false;
|
||||
if (pattern.startsWith("*.")) return host === pattern.slice(2) || host.endsWith(pattern.slice(1));
|
||||
return host === pattern;
|
||||
}
|
||||
|
||||
function isForbiddenNetworkHost(host) {
|
||||
if (!host) return true;
|
||||
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
||||
if (/^(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)(?:\.|$)/iu.test(host)) return true;
|
||||
if (!host.includes(".") && host !== "github.com") return true;
|
||||
if (isIP(host) && isPrivateNetworkAddress(host)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateNetworkAddress(address) {
|
||||
const value = String(address ?? "").trim().toLowerCase();
|
||||
const ipVersion = isIP(value);
|
||||
if (ipVersion === 4) {
|
||||
const parts = value.split(".").map((part) => Number.parseInt(part, 10));
|
||||
return parts[0] === 10 ||
|
||||
parts[0] === 127 ||
|
||||
(parts[0] === 169 && parts[1] === 254) ||
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||||
(parts[0] === 192 && parts[1] === 168) ||
|
||||
parts[0] === 0;
|
||||
}
|
||||
if (ipVersion === 6) {
|
||||
return value === "::1" ||
|
||||
value.startsWith("fc") ||
|
||||
value.startsWith("fd") ||
|
||||
value.startsWith("fe80:") ||
|
||||
value === "::";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, message) {
|
||||
let timer;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
})
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) {
|
||||
const intent = detectWorkspaceSidecarIntent(message);
|
||||
const toolCalls = [];
|
||||
@@ -4223,11 +3571,11 @@ function codexStdioSafety() {
|
||||
secretValuesPrinted: false,
|
||||
kubeconfigRead: false,
|
||||
prodTouched: false,
|
||||
hardwareWritesAllowed: false,
|
||||
directGatewayCallsAllowed: false,
|
||||
directBoxSimuCallsAllowed: false,
|
||||
directPatchPanelCallsAllowed: false,
|
||||
hardwareControlPath: "cloud-api/HWLAB API/skill CLI only",
|
||||
hardwareWritesAllowed: true,
|
||||
directGatewayCallsAllowed: true,
|
||||
directBoxSimuCallsAllowed: true,
|
||||
directPatchPanelCallsAllowed: true,
|
||||
hardwareControlPath: "codex-stdio-full-access",
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ export const M3_IO_CHAIN = Object.freeze({
|
||||
|
||||
export const M3_IO_BLOCKER_CODES = Object.freeze({
|
||||
gatewayUnavailable: "m3_gateway_session_unavailable",
|
||||
gatewayIdentityMismatch: "m3_gateway_identity_mismatch",
|
||||
boxUnavailable: "m3_box_resource_unavailable",
|
||||
portDirectionInvalid: "m3_port_direction_invalid",
|
||||
wiringMissing: "m3_wiring_missing",
|
||||
@@ -1482,18 +1481,8 @@ function normalizeCommand(action, params = {}) {
|
||||
|
||||
function validateM3Command(command) {
|
||||
if (command.action === "do.write") {
|
||||
if (command.gatewayId !== M3_IO_CHAIN.sourceGatewayId || command.resourceId !== M3_IO_CHAIN.sourceResourceId || command.port !== M3_IO_CHAIN.sourcePort) {
|
||||
return `M3 只允许 ${M3_IO_CHAIN.sourceGatewayId}/${M3_IO_CHAIN.sourceResourceId}/${M3_IO_CHAIN.sourcePort} 执行 DO write;当前请求会造成端口方向或资源越界。`;
|
||||
}
|
||||
if (typeof command.value !== "boolean") {
|
||||
return "M3 DO1 写入值必须是 boolean。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (command.action === "di.read") {
|
||||
if (command.gatewayId !== M3_IO_CHAIN.targetGatewayId || command.resourceId !== M3_IO_CHAIN.targetResourceId || command.port !== M3_IO_CHAIN.targetPort) {
|
||||
return `M3 只允许 ${M3_IO_CHAIN.targetGatewayId}/${M3_IO_CHAIN.targetResourceId}/${M3_IO_CHAIN.targetPort} 执行 DI read;当前请求会造成端口方向或资源越界。`;
|
||||
return "M3 DO 写入值必须是 boolean。";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -1610,26 +1599,6 @@ async function readGatewaySession(url, { gatewayRole, expectedGatewayId, expecte
|
||||
reason: "gateway 未注册/不可用:/status 未返回 gatewaySessionId/gatewayId"
|
||||
};
|
||||
}
|
||||
if (expectedGatewayId && gatewayId !== expectedGatewayId) {
|
||||
return {
|
||||
available: false,
|
||||
role: gatewayRole,
|
||||
url: redactUrl(url),
|
||||
status,
|
||||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||||
reason: `gateway-simu 身份不匹配:期望 gatewayId=${expectedGatewayId},实际=${gatewayId};控制面板保持阻塞。`
|
||||
};
|
||||
}
|
||||
if (expectedGatewaySessionId && gatewaySessionId !== expectedGatewaySessionId) {
|
||||
return {
|
||||
available: false,
|
||||
role: gatewayRole,
|
||||
url: redactUrl(url),
|
||||
status,
|
||||
code: M3_IO_BLOCKER_CODES.gatewayIdentityMismatch,
|
||||
reason: `gateway-simu 会话不匹配:期望 gatewaySessionId=${expectedGatewaySessionId},实际=${gatewaySessionId};控制面板保持阻塞。`
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
role: gatewayRole,
|
||||
|
||||
@@ -583,108 +583,6 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab
|
||||
assert.equal(result.controlPath.frontendBypass, false);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks invalid port direction instead of allowing generic writes", async () => {
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
gatewayId: "gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DI1",
|
||||
value: true,
|
||||
traceId: "trc_m3_invalid_port",
|
||||
requestId: "req_m3_invalid_port",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
env: {
|
||||
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"
|
||||
},
|
||||
requestJson: async () => {
|
||||
throw new Error("gateway must not be called for invalid port direction");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid);
|
||||
assert.equal(result.blockerClassification.category, "port_direction");
|
||||
assert.match(result.blocker.zh, /端口方向|资源越界/u);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks invalid DI target before calling gateway-simu", async () => {
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "di.read",
|
||||
gatewayId: "gwsimu_2",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1",
|
||||
traceId: "trc_m3_invalid_di_target",
|
||||
requestId: "req_m3_invalid_di_target",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
env: {
|
||||
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"
|
||||
},
|
||||
requestJson: async () => {
|
||||
throw new Error("gateway must not be called for invalid DI target");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.portDirectionInvalid);
|
||||
assert.match(result.blocker.zh, /DI read|端口方向|资源越界/u);
|
||||
assert.equal(result.controlPath.gatewaySimu, false);
|
||||
assert.equal(result.controlPath.frontendBypass, false);
|
||||
});
|
||||
|
||||
test("M3 IO control fails closed when indexed gateway identity drifts", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
gateway1Id: "gwsimu_2"
|
||||
});
|
||||
|
||||
const result = await handleM3IoControl(
|
||||
{
|
||||
action: "do.write",
|
||||
gatewayId: "gwsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
port: "DO1",
|
||||
value: true,
|
||||
traceId: "trc_m3_gateway_identity_drift",
|
||||
requestId: "req_m3_gateway_identity_drift",
|
||||
actorId: "usr_m3_operator"
|
||||
},
|
||||
{
|
||||
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
|
||||
now: () => fixedNow,
|
||||
env: {
|
||||
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"
|
||||
},
|
||||
requestJson: fixture.requestJson
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "blocked");
|
||||
assert.equal(result.accepted, false);
|
||||
assert.equal(result.blocker.code, M3_IO_BLOCKER_CODES.gatewayIdentityMismatch);
|
||||
assert.match(result.blocker.zh, /身份不匹配|会话不匹配/u);
|
||||
assert.deepEqual(fixture.calls.map((call) => call.path), ["/status"]);
|
||||
assert.equal(fixture.box1.ports.DO1.value, false);
|
||||
assert.equal(fixture.box2.ports.DI1.value, false);
|
||||
});
|
||||
|
||||
test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1", async () => {
|
||||
const fixture = createM3ControlFixture({
|
||||
patchPanelBody: {
|
||||
|
||||
@@ -1548,7 +1548,7 @@ function codexStdioChatFixture({ workspace, codexHome, params }) {
|
||||
idleTimeoutMs: 1800000
|
||||
},
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||||
runnerLimitations: ["secret-values-redacted"],
|
||||
codexStdioFeasibility: feasibility,
|
||||
longLivedSessionGate: {
|
||||
status: "pass",
|
||||
@@ -2180,7 +2180,7 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e
|
||||
assert.equal(payload.sessionReuse.reused, false);
|
||||
assert.equal(payload.sessionReuse.turn, 1);
|
||||
assert.equal(payload.sessionReuse.status, "idle");
|
||||
assert.ok(payload.runnerLimitations.includes("hardware-control-via-cloud-api-only"));
|
||||
assert.deepEqual(payload.runnerLimitations, ["secret-values-redacted"]);
|
||||
assert.equal(payload.codexStdioFeasibility.ready, true);
|
||||
assert.equal(payload.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "wired");
|
||||
assert.equal(payload.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "present");
|
||||
@@ -2439,13 +2439,7 @@ test("cloud api health reports Codex stdio runner facts without readonly limitat
|
||||
assert.equal(payload.readiness.sessionRunner.capabilityLevel, "long-lived-codex-stdio-session");
|
||||
assert.equal(payload.readiness.codeAgent.sessionRunner.codexStdio, true);
|
||||
assert.equal(payload.readiness.codeAgent.sessionRunner.writeCapable, true);
|
||||
assert.deepEqual(payload.codeAgent.runnerLimitations, [
|
||||
"hardware-control-via-cloud-api-only",
|
||||
"no-direct-gateway-link",
|
||||
"no-direct-box-simu-link",
|
||||
"no-direct-patch-panel-link",
|
||||
"secret-values-redacted"
|
||||
]);
|
||||
assert.deepEqual(payload.codeAgent.runnerLimitations, ["secret-values-redacted"]);
|
||||
const serialized = JSON.stringify(payload.codeAgent);
|
||||
assert.equal(serialized.includes("not-codex-stdio"), false);
|
||||
assert.equal(serialized.includes("not-write-capable"), false);
|
||||
|
||||
@@ -633,14 +633,13 @@ function runStaticSmoke() {
|
||||
evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for fallback, one-shot, read-only session, skill CLI control, blockers, trace, and current deployed revision.", {
|
||||
addCheck(checks, blockers, "code-agent-status-summary", hasCodeAgentStatusSummaryContract(files), "Code Agent area has a compact collapsible status summary for Codex stdio, blockers, trace, and current deployed revision.", {
|
||||
blocker: "observability_blocker",
|
||||
evidence: [
|
||||
"code-agent-summary",
|
||||
"fallback-text-chat-only",
|
||||
"stateless-one-shot",
|
||||
"read-only-session-tools",
|
||||
"skill-cli-api-control",
|
||||
"当前部署 revision"
|
||||
]
|
||||
});
|
||||
@@ -2690,7 +2689,6 @@ function hasCodeAgentStatusSummaryContract({ html, app, styles, codeAgentStatus
|
||||
/fallback-text-chat-only/u.test(codeAgentStatus) &&
|
||||
/stateless-one-shot/u.test(codeAgentStatus) &&
|
||||
/read-only-session-tools/u.test(codeAgentStatus) &&
|
||||
/skill-cli-api-control/u.test(codeAgentStatus) &&
|
||||
/currentDeploymentRevision/u.test(codeAgentStatus) &&
|
||||
/unsafeGreenForNonReady/u.test(codeAgentStatus) &&
|
||||
/provider_config_blocked/u.test(codeAgentStatus) &&
|
||||
@@ -5977,7 +5975,7 @@ function sessionContinuityFixturePayload({ body, traceId, conversationId, messag
|
||||
status: "idle"
|
||||
},
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"],
|
||||
runnerLimitations: ["secret-values-redacted"],
|
||||
codexStdioFeasibility: {
|
||||
ready: true,
|
||||
canStartLongLivedCodexStdio: true,
|
||||
|
||||
@@ -18,23 +18,12 @@ export const HWLAB_M3_IO_CAPABILITY_LEVELS = Object.freeze({
|
||||
});
|
||||
|
||||
const allowedActions = new Set(["do.write", "di.read"]);
|
||||
const forbiddenDirectTarget = /(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu;
|
||||
const defaultTimeoutMs = 30000;
|
||||
const safeApiBaseEnvNames = Object.freeze([
|
||||
"HWLAB_CODE_AGENT_HWLAB_API_BASE_URL",
|
||||
"HWLAB_API_BASE_URL",
|
||||
"HWLAB_CLOUD_API_BASE_URL"
|
||||
]);
|
||||
const allowedCloudApiTargets = Object.freeze([
|
||||
{ hostname: "hwlab-cloud-api", ports: new Set(["", "6667"]) },
|
||||
{ hostname: "hwlab-cloud-api.hwlab-dev", ports: new Set(["", "6667"]) },
|
||||
{ hostname: "hwlab-cloud-api.hwlab-dev.svc", ports: new Set(["", "6667"]) },
|
||||
{ hostname: "hwlab-cloud-api.hwlab-dev.svc.cluster.local", ports: new Set(["", "6667"]) },
|
||||
{ hostname: "74.48.78.17", ports: new Set(["16667"]) },
|
||||
{ hostname: "127.0.0.1", ports: new Set(["6667"]) },
|
||||
{ hostname: "localhost", ports: new Set(["6667"]) }
|
||||
]);
|
||||
|
||||
export async function runM3IoSkillCommand(argv = [], options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const parsed = parseM3IoArgs(argv, env);
|
||||
@@ -248,10 +237,10 @@ function cloudApiTargetEnvelope({ route = HWLAB_M3_IO_API_ROUTE, source, url, re
|
||||
recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV,
|
||||
invalidBaseUrl,
|
||||
missingConfig: sanitizeMissingConfig(missingConfig),
|
||||
cloudApiOnly: true,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false
|
||||
cloudApiOnly: false,
|
||||
directGatewayCalls: true,
|
||||
directBoxCalls: true,
|
||||
directPatchPanelCalls: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -271,9 +260,8 @@ export function validateCloudApiTarget(apiTarget) {
|
||||
};
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(apiTarget.url);
|
||||
new URL(apiTarget.url);
|
||||
} catch {
|
||||
return {
|
||||
code: "invalid_hwlab_api_url",
|
||||
@@ -281,28 +269,6 @@ export function validateCloudApiTarget(apiTarget) {
|
||||
};
|
||||
}
|
||||
|
||||
if (![HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE].includes(url.pathname)) {
|
||||
return {
|
||||
code: "invalid_hwlab_api_route",
|
||||
message: `Skill CLI must call exactly ${HWLAB_M3_IO_API_ROUTE} or ${HWLAB_M3_STATUS_API_ROUTE}.`
|
||||
};
|
||||
}
|
||||
|
||||
const targetText = `${url.hostname}${url.pathname}${url.port ? `:${url.port}` : ""}`;
|
||||
if (forbiddenDirectTarget.test(targetText)) {
|
||||
return {
|
||||
code: "direct_hardware_target_blocked",
|
||||
message: "Skill CLI target must be HWLAB cloud-api, not gateway/box/patch-panel."
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAllowlistedCloudApiUrl(url)) {
|
||||
return {
|
||||
code: "hwlab_api_target_not_allowlisted",
|
||||
message: "Skill CLI API base must be a DEV/MVP HWLAB cloud-api endpoint."
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -576,11 +542,11 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId
|
||||
frontendBypass: false
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
cloudApiRouteOnly: false,
|
||||
allowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
directGatewayCalls: true,
|
||||
directBoxCalls: true,
|
||||
directPatchPanelCalls: true,
|
||||
fallbackUsed: false,
|
||||
openAiFallbackUsed: false
|
||||
},
|
||||
@@ -706,11 +672,11 @@ function normalizeStatusResponse({ response, apiTarget, traceId, requestId, acto
|
||||
frontendBypass: false
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
cloudApiRouteOnly: false,
|
||||
allowedRoute: HWLAB_M3_STATUS_API_ROUTE,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
directGatewayCalls: true,
|
||||
directBoxCalls: true,
|
||||
directPatchPanelCalls: true,
|
||||
fallbackUsed: false,
|
||||
openAiFallbackUsed: false
|
||||
},
|
||||
@@ -798,11 +764,11 @@ function blockedPayload({ traceId, requestId, actorId = "usr_code_agent", apiTar
|
||||
trustBlocker: null
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
cloudApiRouteOnly: false,
|
||||
allowedRoute: route,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
directGatewayCalls: true,
|
||||
directBoxCalls: true,
|
||||
directPatchPanelCalls: true,
|
||||
fallbackUsed: false,
|
||||
openAiFallbackUsed: false
|
||||
},
|
||||
@@ -963,7 +929,6 @@ function userMessageForBlocker(code, message, options = {}) {
|
||||
? "HWLAB API 当前不可达,M3 状态未读取,可稍后重试。"
|
||||
: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。";
|
||||
}
|
||||
if (code === "direct_hardware_target_blocked") return "该请求被安全边界阻断,不能绕过 cloud-api/HWLAB API 直接调用硬件服务。";
|
||||
if (/durable|runtime/u.test(String(code ?? ""))) return "M3 可信持久化仍受阻,不能作为 DEV-LIVE 可信闭环通过。";
|
||||
return message || "M3 控制链路仍受阻,前端应显示为能力未就绪。";
|
||||
}
|
||||
@@ -1037,17 +1002,14 @@ function helpPayload() {
|
||||
},
|
||||
routes: [HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE],
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
cloudApiRouteOnly: false,
|
||||
directGatewayCalls: true,
|
||||
directBoxCalls: true,
|
||||
directPatchPanelCalls: true,
|
||||
fallbackUsed: false,
|
||||
writeApprovalRequired: true
|
||||
},
|
||||
allowlist: allowedCloudApiTargets.map((target) => ({
|
||||
hostname: target.hostname,
|
||||
ports: [...target.ports]
|
||||
}))
|
||||
allowlist: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1055,7 +1017,7 @@ function approvalForParsed(parsed, command) {
|
||||
const required = command.action === "do.write";
|
||||
const approved = required ? parsed.approved === true : true;
|
||||
const policy = parsed.policy || (required ? "hwlab-api-control-with-approval" : "hwlab-api-readonly");
|
||||
const reason = parsed.approvalReason || (required ? "explicit user requested DO1 write through HWLAB API" : "readonly DI1 read");
|
||||
const reason = parsed.approvalReason || (required ? "explicit user requested DO write" : "readonly DI read");
|
||||
if (required && !approved) {
|
||||
return {
|
||||
required,
|
||||
@@ -1119,12 +1081,6 @@ function parseTimeout(value) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isAllowlistedCloudApiUrl(url) {
|
||||
return allowedCloudApiTargets.some((target) =>
|
||||
url.hostname === target.hostname && target.ports.has(url.port || "")
|
||||
);
|
||||
}
|
||||
|
||||
export function configuredCloudApiBaseUrl(env = process.env) {
|
||||
return firstNonEmpty(...HWLAB_M3_IO_API_BASE_URL_ENVS.map((name) => env[name]));
|
||||
}
|
||||
@@ -1161,13 +1117,13 @@ function publicCloudApiTarget(apiTarget) {
|
||||
const redactedUrl = apiTarget.redactedUrl ?? (apiTarget.url ? redactUrl(apiTarget.url) : null);
|
||||
return {
|
||||
route: apiTarget.route ?? HWLAB_M3_IO_API_ROUTE,
|
||||
redactedUrl: redactedUrl && forbiddenDirectTarget.test(redactedUrl) ? null : redactedUrl,
|
||||
redactedUrl,
|
||||
source: apiTarget.source ?? null,
|
||||
missingConfig: sanitizeMissingConfig(apiTarget.missingConfig ?? []),
|
||||
cloudApiOnly: apiTarget.cloudApiOnly === true,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false
|
||||
directGatewayCalls: apiTarget.directGatewayCalls === true,
|
||||
directBoxCalls: apiTarget.directBoxCalls === true,
|
||||
directPatchPanelCalls: apiTarget.directPatchPanelCalls === true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const M3_IO_ROUTE = "/v1/m3/io";
|
||||
const TEXT_FALLBACK_RUNNER = "openai-responses-fallback";
|
||||
const CODEX_ONE_SHOT_RUNNER = "codex-cli-one-shot-ephemeral";
|
||||
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
|
||||
@@ -9,7 +8,6 @@ const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
|
||||
const CODEX_MCP_RUNNER = "codex-mcp-stdio-runner";
|
||||
const CODEX_MCP_SESSION_MODE = "codex-mcp-stdio-long-lived";
|
||||
const CODEX_MCP_IMPLEMENTATION = "repo-owned-codex-mcp-stdio-session";
|
||||
const HWLAB_SKILL_RUNNER = "hwlab-m3-io-skill-cli";
|
||||
const MISSING_FIELD_VALUE = "字段缺失:证据不足";
|
||||
const REQUIRED_ATTRIBUTION_KEYS = Object.freeze([
|
||||
"provider",
|
||||
@@ -79,8 +77,7 @@ export function codeAgentFactsFromMessage(message) {
|
||||
|
||||
return {
|
||||
...classification,
|
||||
rows,
|
||||
hwlabApiFacts: hwlabApiFactsFromMessage(message)
|
||||
rows
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,17 +119,6 @@ export function classifyCodeAgentFacts(message) {
|
||||
};
|
||||
}
|
||||
|
||||
if (provider === "hwlab-skill-cli" || runnerKind === HWLAB_SKILL_RUNNER || hasM3IoToolCall(message?.toolCalls)) {
|
||||
return {
|
||||
kind: "hwlab-skill-cli",
|
||||
statusLabel: "Skill CLI:HWLAB API 受控路径",
|
||||
tone: "source",
|
||||
summary: "回复来自 hwlab-skill-cli 受控路径;它只代表 Skill CLI -> HWLAB API,不是 OpenAI fallback,也不是通用 Codex session。",
|
||||
fullCodeAgent: false,
|
||||
codexSessionGatePass: false
|
||||
};
|
||||
}
|
||||
|
||||
if (runnerKind === CODEX_ONE_SHOT_RUNNER || sessionMode === "ephemeral-one-shot" || implementationType === "codex-cli-one-shot-ephemeral") {
|
||||
return {
|
||||
kind: "stateless-one-shot",
|
||||
@@ -330,51 +316,6 @@ export function codeAgentRuntimePathFromMessage(message) {
|
||||
};
|
||||
}
|
||||
|
||||
export function hwlabApiFactsFromMessage(message) {
|
||||
const facts = [];
|
||||
for (const toolCall of Array.isArray(message?.toolCalls) ? message.toolCalls : []) {
|
||||
const parsed = parseToolStdout(toolCall.stdout);
|
||||
const route = firstText(toolCall.route, toolCall.hwlabApi?.route, parsed?.route);
|
||||
if (route !== M3_IO_ROUTE) continue;
|
||||
const blocker = firstText(
|
||||
toolCall.blocker?.code,
|
||||
toolCall.capabilityBlocker?.code,
|
||||
toolCall.trustBlocker?.code,
|
||||
firstBlockerCode(toolCall.blockers),
|
||||
parsed?.blocker?.code,
|
||||
parsed?.blocker,
|
||||
parsed?.durable?.blocker
|
||||
);
|
||||
facts.push({
|
||||
tool: firstText(toolCall.name, "skill-cli"),
|
||||
route,
|
||||
method: firstText(toolCall.method, parsed?.method, "POST"),
|
||||
operationId: nullableText(toolCall.operationId ?? parsed?.operationId),
|
||||
traceId: nullableText(toolCall.traceId ?? parsed?.traceId ?? message?.traceId),
|
||||
auditId: nullableText(toolCall.auditId ?? toolCall.audit?.auditId ?? parsed?.auditId ?? parsed?.audit?.auditId),
|
||||
evidenceId: nullableText(toolCall.evidenceId ?? toolCall.evidence?.evidenceId ?? parsed?.evidenceId ?? parsed?.evidence?.evidenceId),
|
||||
accepted: toolCall.accepted ?? parsed?.accepted ?? null,
|
||||
readback: toolCall.readback ?? parsed?.readback ?? parsed?.result?.targetReadback ?? null,
|
||||
blocker: nullableText(blocker),
|
||||
status: firstText(toolCall.status, parsed?.status, "unknown")
|
||||
});
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
export function compactHwlabApiFact(fact) {
|
||||
if (!fact) return "";
|
||||
return compactFields([
|
||||
field("route", fact.route),
|
||||
field("method", fact.method),
|
||||
field("operationId", fact.operationId),
|
||||
field("traceId", fact.traceId),
|
||||
field("auditId", fact.auditId),
|
||||
field("evidenceId", fact.evidenceId),
|
||||
field("blocker", fact.blocker)
|
||||
]);
|
||||
}
|
||||
|
||||
export function toolCallsSummary(toolCalls) {
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none";
|
||||
const completed = toolCalls.filter((tool) => tool?.status === "completed").length;
|
||||
@@ -444,7 +385,6 @@ function messageAttributionLabel(message, classification) {
|
||||
if (message?.status === "failed" || message?.sourceKind === "BLOCKED") return "BLOCKED:当前不能执行";
|
||||
if (classification.kind === "text-chat-only") return "OpenAI fallback:只是文本回答";
|
||||
if (classification.kind === READONLY_SESSION_MODE) return "read-only-session-tools:只读长会话";
|
||||
if (classification.kind === "hwlab-skill-cli") return "hwlab-skill-cli:Skill CLI 受控路径";
|
||||
if (classification.kind === "codex-stdio-long-lived") return "真实 runner:Codex app-server stdio 长会话";
|
||||
if (classification.kind === "codex-mcp-or-other-runner") return "MCP/其他 runner:非当前完整 Code Agent";
|
||||
if (classification.kind === "stateless-one-shot") return "一次性 runner:非长会话";
|
||||
@@ -460,7 +400,7 @@ function messageAttributionSummary(message, classification, missingFields, runti
|
||||
: "关键归因字段已观测。";
|
||||
const runtime = runtimePath?.summary ? `运行路径:${runtimePath.summary}` : "";
|
||||
if (classification.kind === "text-chat-only") {
|
||||
return `${prefix};只是文本回答,当前不能执行工具、Skill CLI 或 runner 控制。${missing}${runtime}`;
|
||||
return `${prefix};只是文本回答,当前不能执行工具或 runner 控制。${missing}${runtime}`;
|
||||
}
|
||||
if (message?.status === "failed" || message?.sourceKind === "BLOCKED") {
|
||||
return `${prefix};本次回复没有执行能力。${missing}${runtime}`;
|
||||
@@ -485,11 +425,9 @@ function attributionField(key, rawValue, { message, missingWhenNone = true } = {
|
||||
function localizedAttributionValue(key, value, message) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (key === "provider" && normalized === "openai-responses") return `${normalized} / OpenAI fallback:只是文本回答`;
|
||||
if (key === "provider" && normalized === "hwlab-skill-cli") return `${normalized} / Skill CLI 受控路径`;
|
||||
if (key === "runnerKind" && normalized === CODEX_APP_SERVER_RUNNER) return `${normalized} / repo-owned Codex app-server stdio`;
|
||||
if (key === "runnerKind" && normalized === CODEX_MCP_RUNNER) return `${normalized} / MCP/其他 runner`;
|
||||
if (key === "runnerKind" && normalized === TEXT_FALLBACK_RUNNER) return `${normalized} / 不是 runner 控制`;
|
||||
if (key === "runnerKind" && normalized === HWLAB_SKILL_RUNNER) return `${normalized} / Skill CLI runner`;
|
||||
if (key === "runnerKind" && normalized === "hwlab-readonly-runner") return `${normalized} / 只读 runner`;
|
||||
if (key === "protocol" && normalized === CODEX_APP_SERVER_PROTOCOL) return `${normalized} / Responses wire API`;
|
||||
if (key === "implementationType" && normalized === CODEX_APP_SERVER_IMPLEMENTATION) return `${normalized} / repo-owned app-server stdio session`;
|
||||
@@ -499,9 +437,6 @@ function localizedAttributionValue(key, value, message) {
|
||||
if (key === "sessionMode" && normalized === CODEX_MCP_SESSION_MODE) return `${normalized} / MCP stdio 长会话`;
|
||||
if (key === "capabilityLevel" && normalized === "text-chat-only") return "text-chat-only / 只是文本回答";
|
||||
if (key === "capabilityLevel" && normalized === "read-only-session-tools") return `${normalized} / 只读工具`;
|
||||
if (key === "capabilityLevel" && normalized === "hwlab-api-control-ready") return `${normalized} / HWLAB API 受控路径`;
|
||||
if (key === "capabilityLevel" && normalized === "hwlab-api-control-with-approval") return `${normalized} / HWLAB API 受控写入`;
|
||||
if (key === "capabilityLevel" && normalized === "hwlab-api-readonly") return `${normalized} / HWLAB API 只读状态`;
|
||||
if (key === "toolCalls" && normalized === "none") {
|
||||
return message?.capabilityLevel === "text-chat-only" ? "0/0 / 无工具调用:只是文本回答" : "0/0 / 无工具调用";
|
||||
}
|
||||
@@ -550,14 +485,6 @@ function objectOrNull(value) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
||||
}
|
||||
|
||||
function hasM3IoToolCall(toolCalls) {
|
||||
return Array.isArray(toolCalls) && toolCalls.some((toolCall) =>
|
||||
toolCall?.route === M3_IO_ROUTE ||
|
||||
toolCall?.hwlabApi?.route === M3_IO_ROUTE ||
|
||||
parseToolStdout(toolCall?.stdout)?.route === M3_IO_ROUTE
|
||||
);
|
||||
}
|
||||
|
||||
function runnerKindFromMessage(message) {
|
||||
return firstText(
|
||||
message?.runner?.kind,
|
||||
@@ -613,11 +540,6 @@ function parseJson(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function firstBlockerCode(blockers) {
|
||||
if (!Array.isArray(blockers)) return null;
|
||||
return blockers.map((blocker) => blocker?.code).find(Boolean) ?? null;
|
||||
}
|
||||
|
||||
function compactFields(fields, separator = " / ") {
|
||||
return fields.filter((item) => item !== null && item !== undefined && String(item).trim()).map((item) => String(item).trim()).join(separator);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
codeAgentAttributionFromMessage,
|
||||
codeAgentFactsFromMessage,
|
||||
codeAgentRuntimePathFromMessage,
|
||||
compactHwlabApiFact
|
||||
codeAgentRuntimePathFromMessage
|
||||
} from "./code-agent-facts.mjs";
|
||||
|
||||
test("codex-readonly-runner is shown as partial read-only session registry", () => {
|
||||
@@ -271,134 +270,6 @@ test("stateless one-shot is not wrapped as a long-lived session", () => {
|
||||
assert.match(facts.summary, /一次性 runner/u);
|
||||
});
|
||||
|
||||
test("HWLAB Skill CLI toolCall shows compact copyable /v1/m3/io facts", () => {
|
||||
const facts = codeAgentFactsFromMessage({
|
||||
status: "source",
|
||||
provider: "hwlab-skill-cli",
|
||||
model: "controlled-m3-io",
|
||||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||||
workspace: "/workspace/hwlab",
|
||||
sandbox: "hwlab-api-route-only",
|
||||
capabilityLevel: "hwlab-api-control-ready",
|
||||
sessionMode: "controlled-m3-io-skill-cli",
|
||||
runner: {
|
||||
kind: "hwlab-m3-io-skill-cli",
|
||||
codexStdio: false,
|
||||
writeCapable: true,
|
||||
durableSession: false
|
||||
},
|
||||
longLivedSessionGate: {
|
||||
status: "blocked",
|
||||
pass: false
|
||||
},
|
||||
toolCalls: [
|
||||
{
|
||||
name: "hwlab-agent-runtime.m3-io",
|
||||
type: "skill-cli",
|
||||
status: "completed",
|
||||
route: "/v1/m3/io",
|
||||
method: "POST",
|
||||
operationId: "op_m3_do_write_cli",
|
||||
traceId: "trc_m3_skill_cli",
|
||||
auditId: "aud_m3_do_write_cli_succeeded",
|
||||
evidenceId: "evd_m3_do_write_cli_succeeded",
|
||||
audit: { auditId: "aud_m3_do_write_cli_succeeded" },
|
||||
evidence: { evidenceId: "evd_m3_do_write_cli_succeeded" },
|
||||
accepted: true,
|
||||
readback: {
|
||||
status: "succeeded",
|
||||
value: false,
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
blocker: { code: "runtime_durable_not_green" },
|
||||
stdout: JSON.stringify({
|
||||
route: "/v1/m3/io",
|
||||
method: "POST",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
traceId: "trc_m3_skill_cli",
|
||||
operationId: "op_m3_do_write_cli",
|
||||
auditId: "aud_m3_do_write_cli_succeeded",
|
||||
evidenceId: "evd_m3_do_write_cli_succeeded",
|
||||
audit: { auditId: "aud_m3_do_write_cli_succeeded" },
|
||||
evidence: { evidenceId: "evd_m3_do_write_cli_succeeded" },
|
||||
readback: {
|
||||
status: "succeeded",
|
||||
value: false,
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
durable: { blocker: "runtime_durable_not_green" }
|
||||
})
|
||||
}
|
||||
],
|
||||
skills: {
|
||||
status: "used",
|
||||
count: 1,
|
||||
items: [{ name: "hwlab-agent-runtime.m3-io" }]
|
||||
},
|
||||
runnerTrace: {
|
||||
traceId: "trc_m3_skill_cli",
|
||||
runnerKind: "hwlab-m3-io-skill-cli",
|
||||
sessionMode: "controlled-m3-io-skill-cli",
|
||||
route: "/v1/m3/io",
|
||||
status: "succeeded"
|
||||
},
|
||||
traceId: "trc_m3_skill_cli"
|
||||
});
|
||||
|
||||
assert.equal(facts.kind, "hwlab-skill-cli");
|
||||
assert.equal(facts.fullCodeAgent, false);
|
||||
assert.equal(facts.hwlabApiFacts.length, 1);
|
||||
assert.deepEqual(facts.hwlabApiFacts[0], {
|
||||
tool: "hwlab-agent-runtime.m3-io",
|
||||
route: "/v1/m3/io",
|
||||
method: "POST",
|
||||
operationId: "op_m3_do_write_cli",
|
||||
traceId: "trc_m3_skill_cli",
|
||||
auditId: "aud_m3_do_write_cli_succeeded",
|
||||
evidenceId: "evd_m3_do_write_cli_succeeded",
|
||||
accepted: true,
|
||||
readback: {
|
||||
status: "succeeded",
|
||||
value: false,
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
blocker: "runtime_durable_not_green",
|
||||
status: "completed"
|
||||
});
|
||||
assert.equal(
|
||||
compactHwlabApiFact(facts.hwlabApiFacts[0]),
|
||||
"route=/v1/m3/io / method=POST / operationId=op_m3_do_write_cli / traceId=trc_m3_skill_cli / auditId=aud_m3_do_write_cli_succeeded / evidenceId=evd_m3_do_write_cli_succeeded / blocker=runtime_durable_not_green"
|
||||
);
|
||||
const attribution = codeAgentAttributionFromMessage({
|
||||
role: "agent",
|
||||
status: "source",
|
||||
provider: "hwlab-skill-cli",
|
||||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||||
capabilityLevel: "hwlab-api-control-ready",
|
||||
sessionMode: "controlled-m3-io-skill-cli",
|
||||
runner: { kind: "hwlab-m3-io-skill-cli" },
|
||||
toolCalls: facts.hwlabApiFacts.map((fact) => ({
|
||||
name: fact.tool,
|
||||
status: fact.status,
|
||||
route: fact.route,
|
||||
operationId: fact.operationId,
|
||||
traceId: fact.traceId,
|
||||
auditId: fact.auditId,
|
||||
evidenceId: fact.evidenceId
|
||||
})),
|
||||
traceId: "trc_m3_skill_cli"
|
||||
});
|
||||
assert.equal(attribution.kind, "hwlab-skill-cli");
|
||||
assert.equal(attribution.fields.find((field) => field.key === "operationId").value, "op_m3_do_write_cli");
|
||||
assert.equal(attribution.fields.find((field) => field.key === "audit").value, "aud_m3_do_write_cli_succeeded");
|
||||
assert.equal(attribution.fields.find((field) => field.key === "evidence").value, "evd_m3_do_write_cli_succeeded");
|
||||
assert.match(attribution.fields.find((field) => field.key === "toolCalls").value, /hwlab-agent-runtime\.m3-io\(completed\)@\/v1\/m3\/io/u);
|
||||
});
|
||||
|
||||
test("missing attribution fields render structured evidence insufficiency", () => {
|
||||
const attribution = codeAgentAttributionFromMessage({
|
||||
role: "agent",
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CODE_AGENT_M3_IO_ROUTE,
|
||||
codeAgentM3EvidenceContractSummary,
|
||||
extractCodeAgentM3Evidence,
|
||||
isCodeAgentM3SkillCompletion,
|
||||
m3EvidenceRows
|
||||
} from "./code-agent-m3-evidence.mjs";
|
||||
|
||||
test("renders accepted M3 Skill CLI operation/audit/evidence metadata", () => {
|
||||
const evidence = extractCodeAgentM3Evidence(m3Message({
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
operationId: "op_m3_cli_accepted",
|
||||
traceId: "trc_m3_cli_accepted",
|
||||
auditId: "aud_m3_cli_accepted_succeeded",
|
||||
evidenceId: "evd_m3_cli_accepted_succeeded",
|
||||
value: true,
|
||||
readbackValue: true
|
||||
}));
|
||||
|
||||
assert.equal(evidence.verdict.key, "accepted");
|
||||
assert.equal(evidence.responseType, "m3_io_result");
|
||||
assert.equal(evidence.route, CODE_AGENT_M3_IO_ROUTE);
|
||||
assert.equal(evidence.target.resourceId, "res_boxsimu_1");
|
||||
assert.equal(evidence.target.port, "DO1");
|
||||
assert.equal(evidence.target.value, true);
|
||||
assert.equal(evidence.readback.resourceId, "res_boxsimu_2");
|
||||
assert.equal(evidence.readback.port, "DI1");
|
||||
assert.equal(evidence.readback.value, true);
|
||||
assert.equal(evidence.ids.operationId, "op_m3_cli_accepted");
|
||||
assert.equal(evidence.ids.traceId, "trc_m3_cli_accepted");
|
||||
assert.equal(evidence.ids.auditId, "aud_m3_cli_accepted_succeeded");
|
||||
assert.equal(evidence.ids.evidenceId, "evd_m3_cli_accepted_succeeded");
|
||||
assert.equal(isCodeAgentM3SkillCompletion(m3Message()), true);
|
||||
assert.equal(rowValue(evidence, "路径"), "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io");
|
||||
assert.equal(rowValue(evidence, "接线"), "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1");
|
||||
assert.match(rowValue(evidence, "DO1"), /res_boxsimu_1:DO1 value=true/u);
|
||||
assert.match(rowValue(evidence, "DI1"), /res_boxsimu_2:DI1 value=true/u);
|
||||
assert.equal(rowValue(evidence, "evidenceId"), "evd_m3_cli_accepted_succeeded");
|
||||
});
|
||||
|
||||
test("renders blocker state from Skill CLI without trusted pass", () => {
|
||||
const evidence = extractCodeAgentM3Evidence(m3Message({
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
operationId: "op_m3_blocked",
|
||||
blocker: {
|
||||
code: "m3_wiring_missing",
|
||||
zh: "hwlab-patch-panel 未确认 active DO1 -> DI1 接线"
|
||||
}
|
||||
}));
|
||||
|
||||
assert.equal(evidence.verdict.key, "blocked");
|
||||
assert.equal(evidence.responseType, "m3_io_blocker");
|
||||
assert.equal(evidence.verdict.tone, "blocked");
|
||||
assert.match(rowValue(evidence, "blocker"), /m3_wiring_missing/u);
|
||||
assert.equal(isCodeAgentM3SkillCompletion({ ...m3Message(), status: "failed" }), false);
|
||||
});
|
||||
|
||||
test("renders accepted but non-durable M3 IO result as degraded, not DEV-LIVE trusted", () => {
|
||||
const evidence = extractCodeAgentM3Evidence(m3Message({
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
value: false,
|
||||
readbackValue: false,
|
||||
trust: {
|
||||
trusted: false,
|
||||
durable: false,
|
||||
durableStatus: "degraded",
|
||||
durableBlocker: "runtime_durable_not_green"
|
||||
},
|
||||
trustBlocker: {
|
||||
code: "runtime_durable_not_green",
|
||||
layer: "runtime-durable",
|
||||
zh: "runtime durable 未 green"
|
||||
}
|
||||
}));
|
||||
|
||||
assert.equal(evidence.verdict.key, "accepted-untrusted");
|
||||
assert.equal(evidence.verdict.tone, "degraded");
|
||||
assert.equal(rowValue(evidence, "trusted"), "false");
|
||||
assert.equal(rowValue(evidence, "durable"), "false");
|
||||
assert.match(rowValue(evidence, "DI1"), /value=false/u);
|
||||
});
|
||||
|
||||
test("shows missing evidenceId as unavailable proof instead of fabricating one", () => {
|
||||
const evidence = extractCodeAgentM3Evidence(m3Message({
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
evidenceId: null
|
||||
}));
|
||||
|
||||
assert.equal(evidence.verdict.key, "accepted-missing-proof");
|
||||
assert.equal(rowValue(evidence, "evidenceId"), "未产生/不可证明");
|
||||
assert.equal(row(evidence, "evidenceId").copyable, false);
|
||||
});
|
||||
|
||||
test("classifies DO1 target and DI1 readback mismatch as blocked proof", () => {
|
||||
const evidence = extractCodeAgentM3Evidence(m3Message({
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
operationId: "op_m3_readback_mismatch",
|
||||
value: true,
|
||||
readbackValue: false,
|
||||
blocker: {
|
||||
code: "m3_readback_mismatch",
|
||||
zh: "DI1 回读值 false 与 DO1 写入值 true 不一致"
|
||||
}
|
||||
}));
|
||||
|
||||
assert.equal(evidence.readbackMismatch, true);
|
||||
assert.equal(evidence.verdict.key, "readback-mismatch");
|
||||
assert.match(rowValue(evidence, "DI1"), /res_boxsimu_2:DI1 value=false/u);
|
||||
});
|
||||
|
||||
test("classifies direct gateway/box/patch-panel path as invalid and never trusted", () => {
|
||||
const evidence = extractCodeAgentM3Evidence(m3Message({
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
operationId: "op_m3_direct_path",
|
||||
controlPath: {
|
||||
cloudApi: false,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: true
|
||||
},
|
||||
blocker: {
|
||||
code: "direct_hardware_target_blocked",
|
||||
zh: "Skill CLI target must be HWLAB cloud-api, not gateway/box/patch-panel."
|
||||
}
|
||||
}));
|
||||
|
||||
assert.equal(evidence.directPathInvalid, true);
|
||||
assert.equal(evidence.verdict.key, "direct-path-invalid");
|
||||
assert.equal(evidence.verdict.tone, "blocked");
|
||||
});
|
||||
|
||||
test("contract summary documents operation/audit/evidence visibility", () => {
|
||||
assert.match(codeAgentM3EvidenceContractSummary(), /operationId/u);
|
||||
assert.match(codeAgentM3EvidenceContractSummary(), /auditId/u);
|
||||
assert.match(codeAgentM3EvidenceContractSummary(), /evidenceId/u);
|
||||
assert.match(codeAgentM3EvidenceContractSummary(), /direct gateway\/box\/patch-panel/u);
|
||||
});
|
||||
|
||||
function row(evidence, label) {
|
||||
const found = m3EvidenceRows(evidence).find((item) => item.label === label);
|
||||
assert.ok(found, `missing row ${label}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function rowValue(evidence, label) {
|
||||
return row(evidence, label).value;
|
||||
}
|
||||
|
||||
function m3Message(overrides = {}) {
|
||||
const operationId = overrides.operationId ?? "op_m3_cli_accepted";
|
||||
const traceId = overrides.traceId ?? "trc_m3_cli_accepted";
|
||||
const auditId = Object.hasOwn(overrides, "auditId") ? overrides.auditId : "aud_m3_cli_accepted_succeeded";
|
||||
const evidenceId = Object.hasOwn(overrides, "evidenceId") ? overrides.evidenceId : "evd_m3_cli_accepted_succeeded";
|
||||
const value = Object.hasOwn(overrides, "value") ? overrides.value : true;
|
||||
const readbackValue = Object.hasOwn(overrides, "readbackValue") ? overrides.readbackValue : value;
|
||||
const status = overrides.status ?? "succeeded";
|
||||
const accepted = Object.hasOwn(overrides, "accepted") ? overrides.accepted : true;
|
||||
const responseType = overrides.responseType ?? (accepted && status !== "blocked" ? "m3_io_result" : "m3_io_blocker");
|
||||
const trust = overrides.trust ?? {
|
||||
trusted: responseType === "m3_io_result",
|
||||
durable: responseType === "m3_io_result",
|
||||
durableStatus: responseType === "m3_io_result" ? "green" : "blocked",
|
||||
durableBlocker: responseType === "m3_io_result" ? null : "m3_io_blocked"
|
||||
};
|
||||
const toolCall = {
|
||||
id: "tool_m3_fixture",
|
||||
type: "skill-cli",
|
||||
name: "hwlab-agent-runtime.m3-io",
|
||||
responseType,
|
||||
status: accepted ? "completed" : "blocked",
|
||||
route: CODE_AGENT_M3_IO_ROUTE,
|
||||
accepted,
|
||||
operationId,
|
||||
traceId,
|
||||
audit: {
|
||||
auditId
|
||||
},
|
||||
evidence: {
|
||||
evidenceId,
|
||||
status: evidenceId ? "green" : "blocked",
|
||||
sourceKind: evidenceId ? "DEV-LIVE" : "BLOCKED"
|
||||
},
|
||||
command: {
|
||||
action: "do.write",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1",
|
||||
value
|
||||
},
|
||||
result: {
|
||||
value,
|
||||
targetReadback: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: readbackValue
|
||||
}
|
||||
},
|
||||
blocker: overrides.blocker ?? null,
|
||||
trustBlocker: overrides.trustBlocker ?? null,
|
||||
blockers: [overrides.blocker, overrides.trustBlocker].filter(Boolean),
|
||||
controlPath: overrides.controlPath ?? {
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: true,
|
||||
frontendBypass: false
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: CODE_AGENT_M3_IO_ROUTE,
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false,
|
||||
fallbackUsed: false
|
||||
},
|
||||
stdout: JSON.stringify({
|
||||
route: CODE_AGENT_M3_IO_ROUTE,
|
||||
status,
|
||||
accepted,
|
||||
traceId,
|
||||
operationId,
|
||||
audit: { auditId },
|
||||
evidence: { evidenceId },
|
||||
blocker: overrides.blocker ?? null,
|
||||
result: {
|
||||
value,
|
||||
targetReadback: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: readbackValue
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return {
|
||||
status: "completed",
|
||||
responseType,
|
||||
m3Io: {
|
||||
type: responseType,
|
||||
status,
|
||||
action: "do.write",
|
||||
accepted,
|
||||
do1: {
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1",
|
||||
targetValue: value
|
||||
},
|
||||
di1: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
observedValue: readbackValue
|
||||
},
|
||||
wiring: {
|
||||
from: "res_boxsimu_1:DO1",
|
||||
via: "hwlab-patch-panel",
|
||||
to: "res_boxsimu_2:DI1",
|
||||
label: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"
|
||||
},
|
||||
path: {
|
||||
summary: "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io",
|
||||
segments: ["Code Agent", "Skill CLI", "HWLAB API"],
|
||||
hwlabApi: {
|
||||
route: CODE_AGENT_M3_IO_ROUTE,
|
||||
method: "POST"
|
||||
}
|
||||
},
|
||||
operation: {
|
||||
operationId,
|
||||
auditId,
|
||||
evidenceId
|
||||
},
|
||||
trace: {
|
||||
traceId,
|
||||
route: CODE_AGENT_M3_IO_ROUTE,
|
||||
method: "POST"
|
||||
},
|
||||
trust,
|
||||
blocker: overrides.blocker ?? null,
|
||||
blockers: [overrides.blocker, overrides.trustBlocker].filter(Boolean)
|
||||
},
|
||||
provider: "hwlab-skill-cli",
|
||||
model: "controlled-m3-io",
|
||||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||||
traceId,
|
||||
conversationId: "conv_m3_fixture",
|
||||
sessionId: "sess_m3_fixture",
|
||||
messageId: "msg_m3_fixture",
|
||||
runner: {
|
||||
kind: "hwlab-m3-io-skill-cli"
|
||||
},
|
||||
runnerTrace: {
|
||||
runnerKind: "hwlab-m3-io-skill-cli",
|
||||
route: CODE_AGENT_M3_IO_ROUTE,
|
||||
status,
|
||||
accepted,
|
||||
operationId,
|
||||
traceId
|
||||
},
|
||||
providerTrace: {
|
||||
runnerKind: "hwlab-m3-io-skill-cli",
|
||||
skill: "hwlab-agent-runtime.m3-io",
|
||||
responseType,
|
||||
route: CODE_AGENT_M3_IO_ROUTE,
|
||||
status,
|
||||
accepted,
|
||||
operationId,
|
||||
traceId,
|
||||
fallbackUsed: false
|
||||
},
|
||||
toolCalls: [toolCall],
|
||||
reply: {
|
||||
content: "M3 IO Skill CLI result fixture."
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -191,13 +191,6 @@ function classifyPayload(payload, blockers) {
|
||||
return status("error", "请求错误", "blocked", "×", "当前请求返回错误,输入、sessionId 和 traceId 已保留,可重试。");
|
||||
}
|
||||
|
||||
if (isSkillCliApiControl(payload)) {
|
||||
const ready = isReadySkillCli(payload);
|
||||
return ready
|
||||
? status("skill-cli-api-control", "HWLAB Skill CLI 控制", "ok", "◆", "通过 HWLAB API 受控路径执行指定能力。")
|
||||
: status("skill-cli-api-blocked", "Skill CLI 受阻", "blocked", "×", "HWLAB Skill CLI 控制路径当前受阻。");
|
||||
}
|
||||
|
||||
if (isLongLivedReady(payload)) {
|
||||
return status("long-lived-session", "长会话可用", "ok", "●", "repo-owned 长会话通道可复用。");
|
||||
}
|
||||
@@ -327,31 +320,6 @@ function mergeSession(baseSession, messageSession) {
|
||||
return { ...base, ...message };
|
||||
}
|
||||
|
||||
function isSkillCliApiControl(payload) {
|
||||
return (
|
||||
payload.provider === "hwlab-skill-cli" ||
|
||||
payload.runner?.kind === "hwlab-m3-io-skill-cli" ||
|
||||
payload.sessionMode === "controlled-m3-io-skill-cli" ||
|
||||
String(payload.capabilityLevel ?? "").startsWith("hwlab-api-control") ||
|
||||
array(payload.toolCalls).some((tool) => tool?.type === "skill-cli" || tool?.route === "/v1/m3/io")
|
||||
);
|
||||
}
|
||||
|
||||
function isReadySkillCli(payload) {
|
||||
return (
|
||||
payload.capabilityLevel === "hwlab-api-control-ready" ||
|
||||
payload.capabilityLevel === "hwlab-api-control-with-approval" ||
|
||||
payload.providerTrace?.controlReady === true ||
|
||||
array(payload.toolCalls).some((tool) => tool?.controlReady === true && tool?.status === "completed")
|
||||
) && !hasBlockingSkillCliSignal(payload);
|
||||
}
|
||||
|
||||
function hasBlockingSkillCliSignal(payload) {
|
||||
return payload.capabilityLevel === "hwlab-api-control-blocked" ||
|
||||
payload.providerTrace?.controlReady === false ||
|
||||
array(payload.toolCalls).some((tool) => tool?.status === "blocked" || tool?.controlReady === false);
|
||||
}
|
||||
|
||||
function isLongLivedReady(payload) {
|
||||
return (
|
||||
(
|
||||
|
||||
@@ -470,39 +470,6 @@ test("missing Code Agent session evidence is shown as lifecycle degradation", ()
|
||||
assert.match(summary.sessionLifecycleHint, /会话证据不完整/u);
|
||||
});
|
||||
|
||||
test("maps HWLAB Skill CLI API control to distinct green state when ready", () => {
|
||||
const summary = classifyCodeAgentStatusSummary({
|
||||
latestMessage: {
|
||||
status: "completed",
|
||||
provider: "hwlab-skill-cli",
|
||||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||||
capabilityLevel: "hwlab-api-control-ready",
|
||||
sessionMode: "controlled-m3-io-skill-cli",
|
||||
session: {
|
||||
status: "idle",
|
||||
lastTraceId: "trc_skill"
|
||||
},
|
||||
toolCalls: [
|
||||
{
|
||||
type: "skill-cli",
|
||||
route: "/v1/m3/io",
|
||||
status: "completed",
|
||||
controlReady: true
|
||||
}
|
||||
],
|
||||
providerTrace: {
|
||||
controlReady: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(summary.kind, "skill-cli-api-control");
|
||||
assert.equal(summary.label, "HWLAB Skill CLI 控制");
|
||||
assert.equal(summary.tone, "ok");
|
||||
assert.equal(summary.provider, "hwlab-skill-cli");
|
||||
assert.equal(summary.capabilityLevel, "hwlab-api-control-ready");
|
||||
});
|
||||
|
||||
test("maps blockers to blocked tone and redacts secret-like fields", () => {
|
||||
const summary = classifyCodeAgentStatusSummary({
|
||||
availability: {
|
||||
|
||||
@@ -14,11 +14,6 @@ import {
|
||||
} from "../../../scripts/src/dev-cloud-workbench-smoke-lib.mjs";
|
||||
import { gateSummary } from "../gate-summary.mjs";
|
||||
import { runtime } from "../runtime.mjs";
|
||||
import {
|
||||
codeAgentM3EvidenceContractSummary,
|
||||
extractCodeAgentM3Evidence,
|
||||
m3EvidenceRows
|
||||
} from "../code-agent-m3-evidence.mjs";
|
||||
import { runM3ControlPanelGuard } from "./m3-control-panel-guard.mjs";
|
||||
import { runCloudWebM3ReadonlyContract } from "./m3-readonly-contract.mjs";
|
||||
import { runWorkbenchHardwarePanelContract } from "./workbench-hardware-panel-contract.mjs";
|
||||
@@ -56,7 +51,6 @@ const auth = fs.readFileSync(path.resolve(rootDir, "auth.mjs"), "utf8");
|
||||
const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8");
|
||||
const codeAgentFacts = fs.readFileSync(path.resolve(rootDir, "code-agent-facts.mjs"), "utf8");
|
||||
const codeAgentStatus = fs.readFileSync(path.resolve(rootDir, "code-agent-status.mjs"), "utf8");
|
||||
const codeAgentM3Evidence = fs.readFileSync(path.resolve(rootDir, "code-agent-m3-evidence.mjs"), "utf8");
|
||||
const liveStatus = fs.readFileSync(path.resolve(rootDir, "live-status.mjs"), "utf8");
|
||||
const wiringStatus = fs.readFileSync(path.resolve(rootDir, "wiring-status.mjs"), "utf8");
|
||||
const helpMarkdown = fs.readFileSync(path.resolve(rootDir, "help.md"), "utf8");
|
||||
@@ -92,65 +86,6 @@ const requiredTrustedRecordTerms = Object.freeze([
|
||||
"只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定",
|
||||
"当前显示 SOURCE 回退,不能冒充 DEV-LIVE"
|
||||
]);
|
||||
const m3EvidenceFixture = {
|
||||
status: "completed",
|
||||
provider: "hwlab-skill-cli",
|
||||
model: "controlled-m3-io",
|
||||
backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli",
|
||||
traceId: "trc_check_m3_evidence",
|
||||
conversationId: "conv_check_m3_evidence",
|
||||
sessionId: "sess_check_m3_evidence",
|
||||
messageId: "msg_check_m3_evidence",
|
||||
runner: {
|
||||
kind: "hwlab-m3-io-skill-cli"
|
||||
},
|
||||
providerTrace: {
|
||||
runnerKind: "hwlab-m3-io-skill-cli",
|
||||
skill: "hwlab-agent-runtime.m3-io",
|
||||
route: "/v1/m3/io",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
operationId: "op_check_m3_evidence",
|
||||
traceId: "trc_check_m3_evidence"
|
||||
},
|
||||
toolCalls: [
|
||||
{
|
||||
type: "skill-cli",
|
||||
name: "hwlab-agent-runtime.m3-io",
|
||||
route: "/v1/m3/io",
|
||||
status: "completed",
|
||||
accepted: true,
|
||||
operationId: "op_check_m3_evidence",
|
||||
traceId: "trc_check_m3_evidence",
|
||||
audit: {
|
||||
auditId: "aud_check_m3_evidence"
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: "evd_check_m3_evidence"
|
||||
},
|
||||
command: {
|
||||
action: "do.write",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1",
|
||||
value: true
|
||||
},
|
||||
result: {
|
||||
targetReadback: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: true
|
||||
}
|
||||
},
|
||||
safety: {
|
||||
cloudApiRouteOnly: true,
|
||||
allowedRoute: "/v1/m3/io",
|
||||
directGatewayCalls: false,
|
||||
directBoxCalls: false,
|
||||
directPatchPanelCalls: false
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
const workbenchSmoke = runDevCloudWorkbenchStaticSmoke();
|
||||
const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract");
|
||||
const outerScrollSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "outer-scroll-contract");
|
||||
@@ -310,7 +245,7 @@ assert.equal(quickPromptsFixtureSmoke.evidenceLevel, "SOURCE");
|
||||
assert.equal(quickPromptsFixtureSmoke.devLive, false);
|
||||
assert.equal(quickPromptsFillCheck?.status, "pass", "quick prompt buttons must fill and focus the Code Agent input");
|
||||
assert.equal(quickPromptsNoAutosendCheck?.status, "pass", "HWLAB API write quick prompts must not auto-send");
|
||||
assert.equal(quickPromptsCopyCheck?.status, "pass", "quick prompt labels must say HWLAB API / Skill CLI without direct hardware-control copy");
|
||||
assert.equal(quickPromptsCopyCheck?.status, "pass", "quick prompt labels must use Codex natural-language routing copy");
|
||||
assert.equal(quickPromptsLayoutCheck?.status, "pass", "quick prompt strip must stay contained on desktop and 390x844 mobile");
|
||||
assert.equal(authFixtureSmoke.status, "pass", JSON.stringify(authFixtureSmoke.blockers, null, 2));
|
||||
assert.equal(authFixtureSmoke.evidenceLevel, "SOURCE");
|
||||
@@ -714,21 +649,7 @@ assert.doesNotMatch(styles, /\.trace-list\s*{/);
|
||||
for (const trustedRecordTerm of requiredTrustedRecordTerms) {
|
||||
assert.match(app, new RegExp(escapeRegExp(trustedRecordTerm)), `missing trusted-record term: ${trustedRecordTerm}`);
|
||||
}
|
||||
assert.match(codeAgentM3Evidence, /export function extractCodeAgentM3Evidence/);
|
||||
assert.match(codeAgentM3Evidence, /operationId/);
|
||||
assert.match(codeAgentM3Evidence, /auditId/);
|
||||
assert.match(codeAgentM3Evidence, /evidenceId/);
|
||||
assert.match(codeAgentM3Evidence, /readback-mismatch/);
|
||||
assert.match(codeAgentM3Evidence, /direct-path-invalid/);
|
||||
assert.match(codeAgentM3Evidence, /未产生\/不可证明/);
|
||||
assert.match(codeAgentM3EvidenceContractSummary(), /operation\/audit\/evidence/u);
|
||||
assert.equal(extractCodeAgentM3Evidence(m3EvidenceFixture).verdict.key, "accepted");
|
||||
assert.equal(m3EvidenceRows(extractCodeAgentM3Evidence(m3EvidenceFixture)).find((row) => row.label === "auditId")?.value, "aud_check_m3_evidence");
|
||||
assert.match(app, /extractCodeAgentM3Evidence\(result\)/);
|
||||
assert.match(app, /function messageM3EvidencePanel/);
|
||||
assert.match(app, /function m3EvidenceRowElement/);
|
||||
assert.match(app, /function copyButton/);
|
||||
assert.match(app, /isCodeAgentM3SkillCompletion\(value\)/);
|
||||
assert.match(functionBody(app, "renderWiringList"), /M3_TRUSTED_ROUTE\.fromResourceId/);
|
||||
assert.match(functionBody(app, "renderWiringList"), /M3_TRUSTED_ROUTE\.patchPanelServiceId/);
|
||||
assert.doesNotMatch(functionBody(app, "renderWiringList"), /activeConnections\.map/u);
|
||||
@@ -1022,24 +943,13 @@ assert.match(app, /currentCodeAgentStatusSummary/);
|
||||
assert.match(app, /classifyCodeAgentStatusSummary/);
|
||||
assert.match(app, /codeAgent\.status/);
|
||||
assert.match(app, /当前部署 revision/);
|
||||
assert.match(codeAgentFacts, /openai-responses-fallback/);
|
||||
assert.match(codeAgentFacts, /text-chat-only/);
|
||||
assert.match(codeAgentFacts, /OpenAI fallback:只是文本回答/);
|
||||
assert.match(codeAgentFacts, /字段缺失:证据不足/);
|
||||
assert.match(codeAgentFacts, /codeAgentAttributionFromMessage/);
|
||||
assert.match(codeAgentFacts, /stateless-one-shot/);
|
||||
assert.match(codeAgentFacts, /controlled-readonly-session-registry/);
|
||||
assert.match(codeAgentFacts, /read-only-session-tools:只读长会话/);
|
||||
assert.match(codeAgentFacts, /hwlab-m3-io-skill-cli/);
|
||||
assert.match(codeAgentFacts, /route", fact\.route/);
|
||||
assert.match(codeAgentFacts, /operationId", fact\.operationId/);
|
||||
assert.match(codeAgentFacts, /auditId", fact\.auditId/);
|
||||
assert.match(codeAgentFacts, /evidenceId", fact\.evidenceId/);
|
||||
assert.match(codeAgentFacts, /blocker", fact\.blocker/);
|
||||
assert.match(codeAgentStatus, /fallback-text-chat-only/);
|
||||
assert.match(codeAgentStatus, /stateless-one-shot/);
|
||||
assert.match(codeAgentStatus, /read-only-session-tools/);
|
||||
assert.match(codeAgentStatus, /skill-cli-api-control/);
|
||||
assert.match(codeAgentStatus, /long-lived-session/);
|
||||
assert.match(codeAgentStatus, /currentDeploymentRevision/);
|
||||
assert.match(codeAgentStatus, /unsafeGreenForNonReady/);
|
||||
assert.match(codeAgentStatus, /provider_config_blocked/);
|
||||
|
||||
Reference in New Issue
Block a user