From 8b2c569ec48314fb34aabe1fe42a90148a069a7d Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 20:55:33 +0800 Subject: [PATCH] fix: remove code agent routing gates --- docs/reference/code-agent-chat-readiness.md | 10 +- internal/cloud/code-agent-chat.mjs | 22 +- .../code-agent-session-registry.test.mjs | 2 +- internal/cloud/codex-stdio-session.mjs | 672 +----------------- internal/cloud/m3-io-control.mjs | 33 +- internal/cloud/m3-io-control.test.mjs | 102 --- internal/cloud/server.test.mjs | 12 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 6 +- .../scripts/src/m3-io-skill-client.mjs | 98 +-- web/hwlab-cloud-web/code-agent-facts.mjs | 82 +-- web/hwlab-cloud-web/code-agent-facts.test.mjs | 131 +--- .../code-agent-m3-evidence.test.mjs | 325 --------- web/hwlab-cloud-web/code-agent-status.mjs | 32 - .../code-agent-status.test.mjs | 33 - web/hwlab-cloud-web/scripts/check.mjs | 94 +-- 15 files changed, 64 insertions(+), 1590 deletions(-) delete mode 100644 web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index ad89a188..ec22d5a2 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -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。 diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 28c4faa4..6529200c 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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 diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 3b6fc22b..0631ce14 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -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); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 9544278e..27a7ef2c 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -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 }; } diff --git a/internal/cloud/m3-io-control.mjs b/internal/cloud/m3-io-control.mjs index 8bb8b78b..c34bc6d1 100644 --- a/internal/cloud/m3-io-control.mjs +++ b/internal/cloud/m3-io-control.mjs @@ -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, diff --git a/internal/cloud/m3-io-control.test.mjs b/internal/cloud/m3-io-control.test.mjs index 60a078e6..4657690f 100644 --- a/internal/cloud/m3-io-control.test.mjs +++ b/internal/cloud/m3-io-control.test.mjs @@ -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: { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 71b288da..ebbd9e2a 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 4cb8688c..273b81df 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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, diff --git a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs index f40bc50c..459a4474 100644 --- a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs +++ b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs @@ -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 }; } diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.mjs index 381bbdca..8a1af9e4 100644 --- a/web/hwlab-cloud-web/code-agent-facts.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.mjs @@ -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); } diff --git a/web/hwlab-cloud-web/code-agent-facts.test.mjs b/web/hwlab-cloud-web/code-agent-facts.test.mjs index 5cd093bc..fea5ea7a 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.test.mjs @@ -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", diff --git a/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs b/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs deleted file mode 100644 index f28b8d81..00000000 --- a/web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs +++ /dev/null @@ -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." - } - }; -} diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index 901066e3..0af840d1 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -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 ( ( diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 3f2bdce0..76671114 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -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: { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index e03753e7..38ce0094 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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/);