From 2759e823e8d87f6dee2b2a93da657481953e40c0 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 15:47:43 +0800 Subject: [PATCH 01/15] fix: preserve trace scroll user control --- web/hwlab-cloud-web/app.mjs | 163 +++++++++++++++++- web/hwlab-cloud-web/package.json | 2 +- web/hwlab-cloud-web/scripts/check.mjs | 15 ++ .../scripts/trace-scroll.test.mjs | 119 +++++++++++++ 4 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 web/hwlab-cloud-web/scripts/trace-scroll.test.mjs diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index de931926..4b4b2905 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -104,6 +104,8 @@ const LEFT_SIDEBAR_MAX_WIDTH = 180; const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728; const RIGHT_SIDEBAR_MIN_WIDTH = 560; const RIGHT_SIDEBAR_MAX_WIDTH = 740; +const SCROLL_BOTTOM_PIN_PX = 24; +const SCROLL_USER_ACTIVITY_MS = 700; const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view)); let rpcSequence = 0; @@ -180,8 +182,11 @@ const state = { chatMessages: [], traceStreams: new Map(), traceDetailsOpen: new Map(), + conversationRenderVersion: 0, + conversationScrollUserActiveUntil: 0, conversationScrollPosition: { top: 0, left: 0 }, traceScrollPositions: new Map(), + traceScrollUserActiveUntil: new Map(), canceledTraces: new Set(), currentRequest: null, sessionStatus: null, @@ -225,6 +230,7 @@ initLiveBuildOverlay(); initWorkbenchLogout(el.logoutButton); initM3Control(); initGateControls(); +installWorkbenchTestHooks(); renderStaticWorkbench(); renderProbePending(); renderCodeAgentSummary(); @@ -794,8 +800,11 @@ function initCommandBar() { for (const close of state.traceStreams.values()) close(); state.traceStreams.clear(); state.traceDetailsOpen.clear(); + state.conversationRenderVersion += 1; + state.conversationScrollUserActiveUntil = 0; state.conversationScrollPosition = { top: 0, left: 0 }; state.traceScrollPositions.clear(); + state.traceScrollUserActiveUntil.clear(); state.chatMessages = []; state.conversationId = null; state.sessionId = null; @@ -2043,6 +2052,7 @@ function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.res } function renderConversation() { + const renderVersion = ++state.conversationRenderVersion; captureConversationScrollPosition(); captureTraceScrollPositions(); const introMessages = [ @@ -2058,28 +2068,45 @@ function renderConversation() { restoreConversationScrollPosition(); restoreTraceScrollPositions(); window.requestAnimationFrame(() => { - restoreConversationScrollPosition(); - restoreTraceScrollPositions(); + if (renderVersion !== state.conversationRenderVersion) return; + restoreConversationScrollPosition({ deferred: true }); + restoreTraceScrollPositions(el.conversationList, { deferred: true }); }); } function initConversationScrollMemory() { + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true }); + } + el.conversationList.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markConversationScrollIntent(); + }); el.conversationList.addEventListener("scroll", () => { captureConversationScrollPosition(); }, { passive: true }); } +function markConversationScrollIntent() { + state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS; +} + +function isScrollIntentKey(key) { + return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key); +} + function captureConversationScrollPosition() { state.conversationScrollPosition = { top: el.conversationList.scrollTop, - left: el.conversationList.scrollLeft + left: el.conversationList.scrollLeft, + bottomGap: scrollBottomGap(el.conversationList) }; } -function restoreConversationScrollPosition() { +function restoreConversationScrollPosition(options = {}) { + if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return; const position = state.conversationScrollPosition; if (!position) return; - el.conversationList.scrollTop = Math.min(position.top, Math.max(0, el.conversationList.scrollHeight - el.conversationList.clientHeight)); + el.conversationList.scrollTop = scrollTopForPosition(el.conversationList, position); el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth)); } @@ -2093,19 +2120,41 @@ function rememberTraceScrollPosition(traceUiKey, list) { if (!traceUiKey || !list) return; state.traceScrollPositions.set(traceUiKey, { top: list.scrollTop, - left: list.scrollLeft + left: list.scrollLeft, + bottomGap: scrollBottomGap(list) }); } -function restoreTraceScrollPositions(root = el.conversationList) { +function restoreTraceScrollPositions(root = el.conversationList, options = {}) { for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) { + if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue; const position = state.traceScrollPositions.get(list.dataset.traceUiKey); if (!position) continue; - list.scrollTop = Math.min(position.top, Math.max(0, list.scrollHeight - list.clientHeight)); + list.scrollTop = scrollTopForPosition(list, position); list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth)); } } +function scrollBottomGap(element) { + return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop); +} + +function scrollTopForPosition(element, position) { + const maxTop = Math.max(0, element.scrollHeight - element.clientHeight); + if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop; + return Math.min(position.top, maxTop); +} + +function markTraceScrollIntent(traceUiKey) { + if (!traceUiKey) return; + state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS); +} + +function isTraceScrollUserActive(traceUiKey) { + if (!traceUiKey) return false; + return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0); +} + function renderHardwareStatus(m3Status) { syncHardwareTabs(); const status = m3Status ?? sourceFallbackM3Status(); @@ -4158,6 +4207,12 @@ function messageTracePanel(message) { list.className = "message-trace-events"; if (traceUiKey) { list.dataset.traceUiKey = traceUiKey; + for (const eventName of ["wheel", "touchstart", "pointerdown"]) { + list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true }); + } + list.addEventListener("keydown", (event) => { + if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey); + }); list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true }); } const events = Array.isArray(trace?.events) ? trace.events : []; @@ -4222,6 +4277,98 @@ function renderTraceEventList(list, rows) { } } +function installWorkbenchTestHooks() { + if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return; + window.__hwlabWorkbenchTestHooks = { + seedTraceMessage, + appendTraceEvents, + traceScrollMetrics, + setTraceScrollTop, + setConversationScrollTop + }; +} + +function isLocalWorkbenchTestHost() { + return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname); +} + +function seedTraceMessage(options = {}) { + const traceId = options.traceId ?? "trc_scroll_contract"; + const messageId = options.messageId ?? "msg_scroll_contract"; + const count = Math.max(1, Number(options.count ?? 80)); + const events = Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1)); + state.chatMessages = [{ + id: messageId, + role: "agent", + title: "Code Agent 处理中", + text: "Trace scroll contract fixture", + status: "running", + traceId, + runnerTrace: { + traceId, + events, + eventCount: events.length, + lastEvent: events.at(-1), + waitingFor: "turn/completed" + } + }]; + renderConversation(); +} + +function appendTraceEvents(count = 1) { + const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); + if (!message) return; + const trace = message.runnerTrace; + const start = Array.isArray(trace.events) ? trace.events.length : 0; + const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1)); + trace.events = [...trace.events, ...additions]; + trace.eventCount = trace.events.length; + trace.lastEvent = trace.events.at(-1); + renderConversation(); +} + +function testTraceEvent(traceId, seq) { + return { + traceId, + label: "trace:scroll-contract", + type: "trace", + status: "observed", + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`, + waitingFor: "turn/completed" + }; +} + +function traceScrollMetrics() { + const conversation = el.conversationList; + const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]"); + return { + conversationTop: conversation.scrollTop, + conversationBottomGap: scrollBottomGap(conversation), + traceTop: list?.scrollTop ?? null, + traceBottomGap: list ? scrollBottomGap(list) : null, + traceScrollHeight: list?.scrollHeight ?? null, + traceClientHeight: list?.clientHeight ?? null, + traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0 + }; +} + +function setTraceScrollTop(top, { user = true } = {}) { + const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]"); + if (!list) return traceScrollMetrics(); + if (user) markTraceScrollIntent(list.dataset.traceUiKey); + list.scrollTop = top; + rememberTraceScrollPosition(list.dataset.traceUiKey, list); + return traceScrollMetrics(); +} + +function setConversationScrollTop(top, { user = true } = {}) { + if (user) markConversationScrollIntent(); + el.conversationList.scrollTop = top; + captureConversationScrollPosition(); + return traceScrollMetrics(); +} + function traceDisplayRows(trace, events) { const rows = []; let noisyRun = []; diff --git a/web/hwlab-cloud-web/package.json b/web/hwlab-cloud-web/package.json index 3c6c7d7d..a5a6105d 100644 --- a/web/hwlab-cloud-web/package.json +++ b/web/hwlab-cloud-web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "m3-readonly": "node scripts/m3-readonly-contract.mjs", - "check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs && node scripts/check.mjs", + "check": "node --test code-agent-facts.test.mjs code-agent-status.test.mjs wiring-status.test.mjs code-agent-m3-evidence.test.mjs scripts/sidebar-resize.test.mjs scripts/trace-scroll.test.mjs && node scripts/check.mjs", "build": "node scripts/build.mjs", "layout": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --static --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout.json", "layout:build": "node ../../scripts/dev-cloud-workbench-layout-smoke.mjs --build --report /tmp/hwlab-dev-gate/dev-cloud-workbench-layout-build.json", diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 2dc85fe6..dd203112 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -886,15 +886,24 @@ assert.match(app, /eventsCompacted:\s*snapshot\.eventsCompacted === true/); assert.match(app, /eventWindow:\s*snapshot\.eventWindow/); assert.match(app, /eventCount:\s*Number\.isInteger\(trace\?\.eventCount\)/); assert.match(app, /traceDetailsOpen:\s*new Map\(\)/); +assert.match(app, /conversationRenderVersion:\s*0/); +assert.match(app, /conversationScrollUserActiveUntil:\s*0/); assert.match(app, /conversationScrollPosition:\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/); assert.match(app, /traceScrollPositions:\s*new Map\(\)/); +assert.match(app, /traceScrollUserActiveUntil:\s*new Map\(\)/); assert.match(app, /state\.traceDetailsOpen\.clear\(\)/); +assert.match(app, /state\.conversationRenderVersion \+= 1/); assert.match(app, /state\.conversationScrollPosition\s*=\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/); assert.match(app, /state\.traceScrollPositions\.clear\(\)/); +assert.match(app, /state\.traceScrollUserActiveUntil\.clear\(\)/); assert.match(app, /function messageTraceUiKey/); assert.match(app, /function defaultTraceDetailsOpen/); assert.match(app, /initConversationScrollMemory\(\)/); assert.match(app, /function initConversationScrollMemory/); +assert.match(app, /function markConversationScrollIntent/); +assert.match(app, /function markTraceScrollIntent/); +assert.match(app, /function scrollBottomGap/); +assert.match(app, /function scrollTopForPosition/); assert.match(app, /function captureConversationScrollPosition/); assert.match(app, /function restoreConversationScrollPosition/); assert.match(app, /function captureTraceScrollPositions/); @@ -905,10 +914,14 @@ assert.match(functionBody(app, "renderConversation"), /restoreConversationScroll assert.match(functionBody(app, "renderConversation"), /captureTraceScrollPositions\(\)/); assert.match(functionBody(app, "renderConversation"), /restoreTraceScrollPositions\(\)/); assert.match(functionBody(app, "renderConversation"), /requestAnimationFrame/); +assert.match(functionBody(app, "renderConversation"), /renderVersion !== state\.conversationRenderVersion/); +assert.match(functionBody(app, "restoreTraceScrollPositions"), /isTraceScrollUserActive/); +assert.match(functionBody(app, "scrollTopForPosition"), /SCROLL_BOTTOM_PIN_PX/); assert.match(functionBody(app, "messageTracePanel"), /state\.traceDetailsOpen\.get\(traceUiKey\)/); assert.match(functionBody(app, "messageTracePanel"), /defaultTraceDetailsOpen\(message\)/); assert.match(functionBody(app, "messageTracePanel"), /state\.traceDetailsOpen\.set\(traceUiKey,\s*details\.open\)/); assert.match(functionBody(app, "messageTracePanel"), /list\.dataset\.traceUiKey\s*=\s*traceUiKey/); +assert.match(functionBody(app, "messageTracePanel"), /markTraceScrollIntent\(traceUiKey\)/); assert.match(functionBody(app, "messageTracePanel"), /rememberTraceScrollPosition\(traceUiKey,\s*list\)/); assert.match(functionBody(app, "messageTracePanel"), /list\.dataset\.traceMode\s*=\s*"all"/); assert.match(functionBody(app, "messageTracePanel"), /renderTraceEventList\(list,\s*rows\)/); @@ -916,6 +929,8 @@ assert.doesNotMatch(app, /CODE_AGENT_TRACE_PREVIEW_LIMIT|tracePreviewRows|收起 assert.match(app, /复制 JSON/); assert.match(app, /下载 trace/); assert.match(app, /function renderTraceEventList/); +assert.match(app, /function installWorkbenchTestHooks/); +assert.match(app, /hwlab-test-hooks/); assert.match(app, /function traceDisplayRows/); assert.match(app, /function traceDisplayRow/); assert.match(app, /function traceToolOutputSummaryRow/); diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs new file mode 100644 index 00000000..69b0af60 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { chromium } from "playwright"; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultCredentials = Object.freeze({ + username: "admin", + password: "hwlab2026" +}); + +test("trace scroll stays user controlled while trace updates append", async () => { + const server = await startStaticServer(webRoot); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ + viewport: { width: 1366, height: 768 }, + deviceScaleFactor: 1 + }); + await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 }); + await login(page); + await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 }); + + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.seedTraceMessage({ count: 90 })); + await page.waitForSelector(".message-trace-events[data-trace-ui-key]", { timeout: 12000 }); + const initial = await traceMetrics(page); + assert.ok(initial.traceScrollHeight > initial.traceClientHeight + 200, JSON.stringify(initial)); + + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(1_000_000, { user: false })); + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(8)); + await page.waitForTimeout(80); + const pinned = await traceMetrics(page); + assert.ok(pinned.traceBottomGap <= 2, `trace should stay pinned at bottom when already at bottom: ${JSON.stringify(pinned)}`); + + const userMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setTraceScrollTop(180, { user: true })); + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12)); + await page.waitForTimeout(120); + const afterAppend = await traceMetrics(page); + assert.ok(Math.abs(afterAppend.traceTop - userMiddle.traceTop) <= 8, `trace update fought the user's scroll position: before=${JSON.stringify(userMiddle)} after=${JSON.stringify(afterAppend)}`); + + const userMoved = await page.evaluate(() => { + const before = window.__hwlabWorkbenchTestHooks.traceScrollMetrics(); + return window.__hwlabWorkbenchTestHooks.setTraceScrollTop(before.traceTop + 180, { user: true }); + }); + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(12)); + await page.waitForTimeout(120); + const afterUserMove = await traceMetrics(page); + assert.ok(afterUserMove.traceTop >= userMoved.traceTop - 8, `trace update should not snap back after user scrolls down: before=${JSON.stringify(userMoved)} after=${JSON.stringify(afterUserMove)}`); + + const conversationMiddle = await page.evaluate(() => window.__hwlabWorkbenchTestHooks.setConversationScrollTop(40, { user: true })); + await page.evaluate(() => window.__hwlabWorkbenchTestHooks.appendTraceEvents(4)); + await page.waitForTimeout(120); + const afterConversationAppend = await traceMetrics(page); + assert.ok(Math.abs(afterConversationAppend.conversationTop - conversationMiddle.conversationTop) <= 8, `conversation scroll should not snap on trace update: before=${JSON.stringify(conversationMiddle)} after=${JSON.stringify(afterConversationAppend)}`); + + await page.close(); + } finally { + await browser.close(); + await server.close(); + } +}); + +async function traceMetrics(page) { + return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics()); +} + +async function login(page) { + await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 }); + const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false); + if (!loginVisible) { + await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); + return; + } + await page.locator("#login-username").fill(defaultCredentials.username); + await page.locator("#login-password").fill(defaultCredentials.password); + await page.locator("#login-submit").click(); + await page.waitForFunction(() => document.body.dataset.authState === "authenticated", null, { timeout: 12000 }); +} + +async function startStaticServer(rootDir) { + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const pathname = url.pathname === "/" ? "/index.html" : url.pathname; + const resolved = path.resolve(rootDir, `.${pathname}`); + if (!resolved.startsWith(rootDir)) { + response.writeHead(403).end("forbidden"); + return; + } + const body = await readFile(resolved); + response.writeHead(200, { "content-type": contentType(resolved) }); + response.end(body); + } catch { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("not found"); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + return { + url: `http://127.0.0.1:${address.port}/`, + close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + }; +} + +function contentType(filePath) { + if (filePath.endsWith(".html")) return "text/html; charset=utf-8"; + if (filePath.endsWith(".css")) return "text/css; charset=utf-8"; + if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8"; + if (filePath.endsWith(".md")) return "text/markdown; charset=utf-8"; + return "application/octet-stream"; +} From c25e380afca51746b15c92e45b0ec91b2ca0459b Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 16:02:14 +0800 Subject: [PATCH 02/15] chore: promote dev artifacts for 2759e82 --- deploy/artifact-catalog.dev.json | 160 +++++++++++++++---------------- deploy/deploy.json | 42 ++++---- deploy/k8s/base/workloads.yaml | 40 ++++---- 3 files changed, 121 insertions(+), 121 deletions(-) diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index b5976383..d1b1c9be 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -5,12 +5,12 @@ "profile": "dev", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", - "commitId": "76e8d90", + "commitId": "2759e82", "artifactState": "published", "publish": { "ciPublished": true, "registryVerified": true, - "provenance": "ci-artifact:ci-publish-20260525T063152-1592bc", + "provenance": "ci-artifact:ci-publish-20260525T075701-eebeb4", "note": "Digest fields were copied from a successful DEV artifact publish report for this source commit." }, "healthContract": { @@ -66,10 +66,10 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:0f14ba1c1501951e14cbb41f0a74820499bb2bd1747837e7e35cd3053e990b08", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", + "imageTag": "2759e82", + "digest": "sha256:1165496135213ada3b8539461d57b86ec6468d330b591f5c3b64747fe482afc5", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -79,15 +79,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-cloud-web", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:57500374e28a4458fb5ed8387ea2fe65516e9f2f1af7f1521967b92d70484d4d", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", + "imageTag": "2759e82", + "digest": "sha256:d6a6c3aa08ec49d89606652decddab81e1e5511ab86fd6243353225a84d4ae18", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -97,15 +97,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-agent-mgr", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:a248ec5d26ac071d628950618c839694781d06029a781960f8f435ea8e6ef0a0", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:2759e82", + "imageTag": "2759e82", + "digest": "sha256:416a4c5dea236575b7bd58b1e984d688d132a7b72bc03d5319d7291b77c54843", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -115,15 +115,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-agent-worker", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:6053b678c33b49f58bcb165233c40eb8dca58b2bf3dc64a43ca29011ae453616", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2759e82", + "imageTag": "2759e82", + "digest": "sha256:d9fdeb7bd9ec9a5226aa014e262c9fa0d464f1acffe1e9ae1d6b47080d8e54bd", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -133,15 +133,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-gateway", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:418097ada5bc0d9425f26746955d7cbe99c6647179a93bbea6ac742cad8a5cc1", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:2759e82", + "imageTag": "2759e82", + "digest": "sha256:1857049fc6c7aa23ff145d637670daca2177512fe5dc3f7442bae573e33e8d77", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -151,15 +151,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-gateway-simu", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:b2817f594a73b5d6ac89b525f2121f35aeade9e09f16391a5ccd1666051466bf", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:2759e82", + "imageTag": "2759e82", + "digest": "sha256:5aa4e01754ba6c88b80edcedb747d30f89a7a43736576a2313671aadca394fbb", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -169,15 +169,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-box-simu", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:26807f576efcdcc0cb9ff5d4137ef499a90cc5a808a7155f859b81b8ffaa9b57", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:2759e82", + "imageTag": "2759e82", + "digest": "sha256:6d2262301a7b5462f8adcbb7e73961b5f673f5998ee9a6e09ca44cc850999d64", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -187,15 +187,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-patch-panel", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:67bbd6a9a9b0db027fb07f52379532988a525b69125f4676050e8f45f859074d", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:2759e82", + "imageTag": "2759e82", + "digest": "sha256:fb04fa840a97190fadc08a0a8a959bf7235d2daa1a65907932b13ea42985efd1", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -205,15 +205,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-router", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-router:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:dc316be8b9e6d2d87ab4e4b96d2dc039704bd0aa44bbf7c3ac55ddad4c913c4c", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-router:2759e82", + "imageTag": "2759e82", + "digest": "sha256:53e22734b74efccc381563d565b5e9d968b0952c9e8cfed949ef2b4f69400e17", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -223,15 +223,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-tunnel-client", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:91fad9cbaa41ce82e1d1f73976a72ead5eff9ccef19672ff699cd65d6515431b", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:2759e82", + "imageTag": "2759e82", + "digest": "sha256:36eb861185fdbc810be1a7a0959fe89e5cf2fe2b73039248c6411ad8227ce40c", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -241,15 +241,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-edge-proxy", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:f19695d500b8ccc6d2b3fabcde3c8d93b27c544c3811067916b80dfe688f11c7", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:2759e82", + "imageTag": "2759e82", + "digest": "sha256:0b181cea20e2129f1c16d5036334267f3fd1a4309d9a2af1643d1fd36bd9b4f0", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -259,15 +259,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-cli", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:2b8d5ab5796acc73b4c87d2e582cc16987c4e83c3252e20e605750bc58df3e8a", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:2759e82", + "imageTag": "2759e82", + "digest": "sha256:d5e45d5a6af1b62f47768c0c9a75f852a7c1b29ee8027937502211d4ae81b794", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -277,15 +277,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" }, { "serviceId": "hwlab-agent-skills", - "commitId": "76e8d90", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:76e8d90", - "imageTag": "76e8d90", - "digest": "sha256:510e49228abdef6df28fc7ad0500473c2ad905e1b3e7a5e2d0f163a150c92775", + "commitId": "2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:2759e82", + "imageTag": "2759e82", + "digest": "sha256:426cbaae73268fc820b65a4672b372bdf3d1719612efbf9486728c7572a26bf5", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -295,8 +295,8 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T06:31:54.661Z", - "buildSource": "pikasTech/HWLAB@76e8d90374992af6121f520fde7f668ec0075c10" + "buildCreatedAt": "2026-05-25T07:57:04.166Z", + "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" } ], "serviceInventory": { diff --git a/deploy/deploy.json b/deploy/deploy.json index fd8afd24..2c529f4a 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -1,7 +1,7 @@ { "manifestVersion": "v1", "environment": "dev", - "commitId": "76e8d90", + "commitId": "2759e82", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "health": { @@ -181,7 +181,7 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -190,9 +190,9 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:16667", "HWLAB_CLOUD_API_PORT": "6667", - "HWLAB_COMMIT_ID": "76e8d90", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", - "HWLAB_IMAGE_TAG": "76e8d90", + "HWLAB_COMMIT_ID": "2759e82", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", + "HWLAB_IMAGE_TAG": "2759e82", "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", "HWLAB_CLOUD_DB_SSL_MODE": "disable", @@ -223,7 +223,7 @@ }, { "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -232,14 +232,14 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "660000", - "HWLAB_COMMIT_ID": "76e8d90", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", - "HWLAB_IMAGE_TAG": "76e8d90" + "HWLAB_COMMIT_ID": "2759e82", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", + "HWLAB_IMAGE_TAG": "2759e82" } }, { "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -251,7 +251,7 @@ }, { "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -263,7 +263,7 @@ }, { "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -275,7 +275,7 @@ }, { "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -295,7 +295,7 @@ }, { "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -314,7 +314,7 @@ }, { "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -335,7 +335,7 @@ }, { "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-router:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -347,7 +347,7 @@ }, { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -361,7 +361,7 @@ }, { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -374,7 +374,7 @@ }, { "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -385,13 +385,13 @@ }, { "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:2759e82", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", "replicas": 1, "env": { - "HWLAB_SKILLS_COMMIT_ID": "76e8d90" + "HWLAB_SKILLS_COMMIT_ID": "2759e82" } } ], diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 128acbab..e6b0f9e3 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -31,7 +31,7 @@ "containers": [ { "name": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", "ports": [ { "name": "http", @@ -53,15 +53,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "76e8d90" + "value": "2759e82" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:76e8d90" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82" }, { "name": "HWLAB_IMAGE_TAG", - "value": "76e8d90" + "value": "2759e82" }, { "name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN", @@ -283,7 +283,7 @@ "containers": [ { "name": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", "ports": [ { "name": "http", @@ -301,15 +301,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "76e8d90" + "value": "2759e82" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-web:76e8d90" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82" }, { "name": "HWLAB_IMAGE_TAG", - "value": "76e8d90" + "value": "2759e82" } ], "readinessProbe": { @@ -359,7 +359,7 @@ "containers": [ { "name": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:2759e82", "ports": [ { "name": "http", @@ -415,7 +415,7 @@ "containers": [ { "name": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2759e82", "env": [ { "name": "HWLAB_AGENT_SESSION_MODE", @@ -458,7 +458,7 @@ "containers": [ { "name": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:2759e82", "ports": [ { "name": "http", @@ -519,7 +519,7 @@ "containers": [ { "name": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:2759e82", "ports": [ { "name": "http", @@ -592,7 +592,7 @@ "containers": [ { "name": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:2759e82", "ports": [ { "name": "http", @@ -660,7 +660,7 @@ "containers": [ { "name": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:2759e82", "ports": [ { "name": "http", @@ -724,7 +724,7 @@ "containers": [ { "name": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-router:2759e82", "ports": [ { "name": "http", @@ -784,7 +784,7 @@ "containers": [ { "name": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:2759e82", "ports": [ { "name": "http", @@ -856,7 +856,7 @@ "containers": [ { "name": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:2759e82", "ports": [ { "name": "http", @@ -921,7 +921,7 @@ "containers": [ { "name": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:2759e82", "env": [ { "name": "HWLAB_CLI_ENDPOINT", @@ -963,7 +963,7 @@ "containers": [ { "name": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:76e8d90", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:2759e82", "ports": [ { "name": "http", @@ -973,7 +973,7 @@ "env": [ { "name": "HWLAB_SKILLS_COMMIT_ID", - "value": "76e8d90" + "value": "2759e82" } ], "readinessProbe": { From c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 17:40:07 +0800 Subject: [PATCH 03/15] fix: show complete readable code agent traces --- docs/reference/cloud-workbench.md | 16 +- docs/reference/code-agent-chat-readiness.md | 2 +- .../code-agent-session-registry.test.mjs | 2 +- scripts/dev-cloud-workbench-smoke.test.mjs | 12 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 15 +- web/hwlab-cloud-web/app.mjs | 248 +++++++++++++----- web/hwlab-cloud-web/scripts/check.mjs | 16 +- .../scripts/trace-scroll.test.mjs | 106 ++++++++ 8 files changed, 341 insertions(+), 76 deletions(-) diff --git a/docs/reference/cloud-workbench.md b/docs/reference/cloud-workbench.md index 8b2e2de7..eda6bca6 100644 --- a/docs/reference/cloud-workbench.md +++ b/docs/reference/cloud-workbench.md @@ -114,8 +114,20 @@ Code Agent trace 在消息卡片内使用独立滚动容器展示。SSE、轮询 conversation list 和按 `messageId/traceId` 记录的 trace list `scrollTop/scrollLeft`。`#conversation-list` 需要绑定 scroll listener 持续记录用户 位置,并在 DOM 替换后立即恢复一次、下一帧再恢复一次,避免布局重算或 scroll -anchoring 把列表拉回顶部。完整 trace 可通过复制/下载获取,页面内默认保持“显示全部 + -内部滚动”的单一模式。 +anchoring 把列表拉回顶部。 + +Trace 展示遵循 UniDesk commander loop 的低噪声完整可读事件线原则:原始 trace +JSON 仍是复制/下载和追责的完整来源;页面内默认展示每个有意义的 agent message、 +command/tool completed、stderr/error、turn/status 和 gateway JSON-RPC 结果。token +delta、assistant chunk、command output chunk、reasoning delta 等噪声可以聚合或隐藏, +但聚合不得吞掉中间的可读 message/tool call。命令和工具行必须至少保留状态、exit +code、耗时、输出体量、关键 operation/evidence id 和可读摘要。 + +“显示全部”只能用于已经覆盖完整 trace 的可读事件线,不能把 result polling 返回的 +head-tail 压缩窗口或 9 行摘要冒充全量。若当前只拿到压缩窗口,界面必须明确显示 +“压缩窗口/待回放完整 trace”,并通过 `/v1/agent/chat/trace/` 自动或手动回放 +完整 trace 后再标记为“显示全部”。若完整 trace 已过期或后端缺失,也必须显式展示 +缺失状态,而不是把已载入窗口说成全量。 ## Lightweight Checks diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 8bb344d8..468d8f95 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -102,7 +102,7 @@ Workbench 会把“Gateway 命令超时”控件的毫秒值随 `/v1/agent/chat` 调大 timeout 不能替代正确的长任务控制语义。Gateway poll loop 必须支持后台 in-flight 执行,长 Keil/UV4 命令运行期间仍能处理短 `job-status`、state/log 读取和健康探测;如果 trace 出现 `shellExecuted=false` 的 dispatch timeout,优先检查 gateway 是否队头阻塞或离线,而不是把所有 wrapper 调用改成长等待。 -Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:首行展示 `tool hardware.invoke.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 +Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:前端首行用中性 `tool gateway.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。后端 capability 仍可记录 `hardware.invoke.shell` 路由事实,但 Cloud Web 源码不得把它暴露成可点击或可误解的直接硬件写入口。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 ## 短连接 result 轮询 diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index e440dcb1..ca48e064 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -1887,7 +1887,7 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw assert.match(turn.args.prompt, /-EncodedCommand/u); assert.match(turn.args.prompt, /Windows filesystem inventory/u); assert.match(turn.args.prompt, /Select-Object -First/u); - assert.match(turn.args.prompt, /ConvertTo-Json -Compress/u); + assert.match(turn.args.prompt, /ConvertTo-HwlabJson/u); } finally { await rm(fakeCodex.root, { recursive: true, force: true }); await rm(codexHome, { recursive: true, force: true }); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index fa596597..1c642708 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -91,7 +91,7 @@ test("source/default workbench report cannot claim DEV-LIVE and documents the co assert.equal(report.checks.find((check) => check.id === "code-agent-long-timeout-contract")?.status, "pass"); const traceDisclosure = report.checks.find((check) => check.id === "code-agent-trace-replay-disclosure"); assert.equal(traceDisclosure?.status, "pass"); - assert.deepEqual(traceDisclosure?.evidence, ["显示最近 14 / 总计 N", "展开全部", "复制 JSON", "下载 trace", "internal scroll for full trace"]); + assert.deepEqual(traceDisclosure?.evidence, ["显示全部可读事件 / 压缩窗口", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"]); }); test("Code Agent browser failure classifier emits Chinese timeout/provider/browser categories with traceId", () => { @@ -742,7 +742,7 @@ test("local external network fixture renders GitHub blocker with trace and witho assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true); assert.equal(blockerCheck.observations.ui.retryInputPreserved, true); assert.equal(blockerCheck.observations.ui.retryInputValue, "访问一下github看看"); - assert.match(blockerCheck.observations.ui.traceText, /network:started/u); + assert.match(blockerCheck.observations.ui.traceText, /network started/u); assert.match(blockerCheck.observations.ui.traceText, /tool:external\.network\.check:blocked/u); assert.equal(blockerCheck.observations.ui.noMainAttributionNoise, true); assert.equal(blockerCheck.observations.prompt.response.error.code, "external_network_blocked"); @@ -876,8 +876,8 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou assert.equal(desktopDefault.wiring.horizontalScroll.panelScrollWidth <= desktopDefault.wiring.horizontalScroll.panelClientWidth + 2, true); assert.equal(desktopDefault.noHorizontalOverflow.right, true); assert.equal(desktopDefault.liveBuildLayout.overlayPositioned, true); - assert.equal(desktopDefault.liveBuildLayout.popoverVisible, true); - assert.equal(desktopDefault.liveBuildLayout.popoverViewportContained, true); + assert.equal(desktopDefault.liveBuildLayout.dialogVisible, true); + assert.equal(desktopDefault.liveBuildLayout.dialogViewportContained, true); assert.equal(desktopDefault.liveBuildLayout.stableGeometry, true); assert.equal(desktopDefault.liveBuildLayout.closedByButton, true); assert.equal(Object.hasOwn(desktopDefault.boxes.shell, "text"), false); @@ -901,8 +901,8 @@ test("layout smoke verifies desktop and mobile default workbench geometry withou assert.equal(mobileDefault.wiring.metadataInDetails, true); assert.equal(mobileDefault.noHorizontalOverflow.right, true); assert.equal(mobileDefault.liveBuildLayout.overlayPositioned, true); - assert.equal(mobileDefault.liveBuildLayout.popoverVisible, true); - assert.equal(mobileDefault.liveBuildLayout.popoverViewportContained, true); + assert.equal(mobileDefault.liveBuildLayout.dialogVisible, true); + assert.equal(mobileDefault.liveBuildLayout.dialogViewportContained, true); assert.equal(mobileDefault.liveBuildLayout.stableGeometry, true); const leftCollapse = report.checks.find((check) => check.id === "layout-left-sidebar-collapse")?.observations; diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index fc5efea2..bc0fc13a 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -628,7 +628,7 @@ function runStaticSmoke() { addCheck(checks, blockers, "code-agent-trace-replay-disclosure", hasCodeAgentTraceReplayDisclosure(files), "Trace replay panels disclose full trace access and preserve open/scroll state while live trace updates.", { blocker: "observability_blocker", - evidence: ["显示全部 N / 原始 N", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"] + evidence: ["显示全部可读事件 / 压缩窗口", "复制 JSON", "下载 trace", "traceDetailsOpen", "traceScrollPositions", "internal scroll for full trace"] }); addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider/stdio blockers without exposing credential internals and only long-lived Codex stdio replies can become full Code Agent completion.", { @@ -2651,7 +2651,10 @@ function hasCodeAgentTraceReplayDisclosure({ app, styles }) { const renderConversationBody = functionBody(app, "renderConversation"); return ( /function\s+messageTraceCountText\s*\(/u.test(app) && - /显示全部\s+\$\{displayTotal\}\s+\/\s+原始\s+\$\{rawTotal\}/u.test(app) && + /显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) && + /压缩窗口\s+\$\{readableTotal\}\s+条可读事件\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/u.test(app) && + /function\s+maybeReplayFullTraceForMessage\s*\(/u.test(app) && + /function\s+replayFullTrace\s*\(/u.test(app) && /function\s+renderTraceEventList\s*\(/u.test(app) && /messageTraceToolbar\(message,\s*trace,\s*events,\s*rows\)/u.test(tracePanelBody) && /list\.dataset\.traceMode\s*=\s*"all"/u.test(tracePanelBody) && @@ -4407,7 +4410,7 @@ async function inspectJourneyUi(page) { actions, eventCount: events.length, traceMode: panel.querySelector(".message-trace-events")?.dataset.traceMode ?? "", - allTraceVisible: /显示全部\s+\d+\s*\/\s*原始\s+\d+/u.test(countText), + allTraceVisible: /显示全部可读事件\s+\d+\s*\/\s*已载入原始\s+\d+\s*\/\s*后端原始\s+\d+/u.test(countText), copyJsonVisible: actions.includes("复制 JSON"), downloadTraceVisible: actions.includes("下载 trace"), fixedAllMode: panel.querySelector(".message-trace-events")?.dataset.traceMode === "all" @@ -5014,6 +5017,9 @@ async function runLocalAgentFixtureSmoke({ ...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}), timeouts: { ...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}), + codeAgentSubmitTimeoutMsMinMs: 1, + codeAgentSubmitTimeoutMs: timeoutMs, + codeAgentTimeoutMsMinMs: 1, codeAgentTimeoutMs: timeoutMs } }; @@ -5323,6 +5329,9 @@ async function inspectLocalAgentPendingViewport(browser, url, { width, height, r ...(globalThis.HWLAB_CLOUD_WEB_CONFIG ?? {}), timeouts: { ...(globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? {}), + codeAgentSubmitTimeoutMsMinMs: 1, + codeAgentSubmitTimeoutMs: timeoutMs, + codeAgentTimeoutMsMinMs: 1, codeAgentTimeoutMs: timeoutMs } }; diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 4b4b2905..5e3ada09 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -26,10 +26,11 @@ const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1"; const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1"; const TRACE_STREAM_FALLBACK_MS = 2500; const TRACE_POLL_INTERVAL_MS = 1000; +const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000; const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 }); let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs(); let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs(); -const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: 5000, max: 120000 }); +const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 }); const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 }); const rpcReadMethods = Object.freeze([ "system.health", @@ -187,6 +188,7 @@ const state = { conversationScrollPosition: { top: 0, left: 0 }, traceScrollPositions: new Map(), traceScrollUserActiveUntil: new Map(), + fullTraceReplayInFlight: new Set(), canceledTraces: new Set(), currentRequest: null, sessionStatus: null, @@ -260,6 +262,13 @@ function resolveTimeoutMs(name, fallback, { min, max }) { return Math.min(Math.max(Math.trunc(configured), min), max); } +function configuredTimeoutMinMs(name, fallback) { + const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {}; + const configured = Number(config?.[`${name}MinMs`]); + if (!Number.isFinite(configured)) return fallback; + return Math.max(1, Math.min(Math.trunc(configured), fallback)); +} + function clampTimeoutMs(value, { min, max }) { if (value === null || value === undefined) return null; if (typeof value === "string" && value.trim() === "") return null; @@ -269,14 +278,15 @@ function clampTimeoutMs(value, { min, max }) { } function resolveUserCodeAgentTimeoutMs() { + const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000); let stored = null; try { stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null; } catch { stored = null; } - return clampTimeoutMs(stored, { min: 30000, max: 1200000 }) - ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 30000, max: 1200000 }); + return clampTimeoutMs(stored, { min, max: 1200000 }) + ?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: 1200000 }); } function resolveUserGatewayShellTimeoutMs() { @@ -805,6 +815,7 @@ function initCommandBar() { state.conversationScrollPosition = { top: 0, left: 0 }; state.traceScrollPositions.clear(); state.traceScrollUserActiveUntil.clear(); + state.fullTraceReplayInFlight.clear(); state.chatMessages = []; state.conversationId = null; state.sessionId = null; @@ -961,6 +972,7 @@ async function submitAgentMessage(value, options = {}) { : undefined), availability: result.availability }; + maybeReplayFullTraceForMessage(state.chatMessages[index]); if (result.availability) { state.codeAgentAvailability = result.availability; } @@ -1418,6 +1430,7 @@ function runnerTraceFromSnapshot(snapshot, previous = null) { eventCount: Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length, eventsCompacted: snapshot.eventsCompacted === true, eventWindow: snapshot.eventWindow ?? previous?.eventWindow, + fullTraceLoaded: snapshot.fullTraceLoaded === true || (snapshot.eventsCompacted !== true && Number.isInteger(snapshot.eventCount) && snapshot.eventCount === snapshot.events.length), elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs, waitingFor: snapshot.waitingFor ?? previous?.waitingFor, events: snapshot.events, @@ -3948,16 +3961,16 @@ async function replayAgentTrace(messageId) { const message = state.chatMessages[index]; if (!message.traceId) return; const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { - timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), timeoutName: "Code Agent trace replay" }); if (response.ok) { - const runnerTrace = runnerTraceFromSnapshot(response.data, message.runnerTrace); + const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace); state.chatMessages[index] = { ...message, runnerTrace, threadId: runnerTrace.threadId ?? message.threadId, - traceReplayStatus: `已回放 ${runnerTrace.events?.length ?? 0} 个真实 trace event;${lastTraceEventLabel(runnerTrace)}`, + traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, updatedAt: response.data?.updatedAt ?? new Date().toISOString() }; } else { @@ -3972,6 +3985,49 @@ async function replayAgentTrace(messageId) { renderRecords(state.liveSurface); } +function maybeReplayFullTraceForMessage(message) { + if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return; + if (state.fullTraceReplayInFlight.has(message.traceId)) return; + replayFullTrace(message.id, { quiet: true }); +} + +async function replayFullTrace(messageId, options = {}) { + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return false; + const message = state.chatMessages[index]; + if (!message.traceId) return false; + if (state.fullTraceReplayInFlight.has(message.traceId)) return false; + state.fullTraceReplayInFlight.add(message.traceId); + try { + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, { + timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS), + timeoutName: "Code Agent full trace replay" + }); + if (!response.ok) return false; + const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId); + if (currentIndex < 0) return false; + const current = state.chatMessages[currentIndex]; + const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace); + state.chatMessages[currentIndex] = { + ...current, + runnerTrace, + threadId: runnerTrace.threadId ?? current.threadId, + traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event;${lastTraceEventLabel(runnerTrace)}`, + updatedAt: response.data?.updatedAt ?? new Date().toISOString() + }; + if (options.quiet !== true) { + renderCodeAgentSummary(); + renderConversation(); + renderRecords(state.liveSurface); + } else { + renderConversation(); + } + return true; + } finally { + state.fullTraceReplayInFlight.delete(message.traceId); + } +} + function lastTraceEventLabel(runnerTrace) { const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null); if (!event) return "lastEvent=none"; @@ -4247,15 +4303,18 @@ function messageTraceToolbar(message, trace, events, rows) { const toolbar = document.createElement("div"); toolbar.className = "message-trace-toolbar"; const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length; - const count = textSpan(messageTraceCountText(rawTotal, rows.length), "message-trace-count"); + const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count"); toolbar.append(count); toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON")); toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件")); return toolbar; } -function messageTraceCountText(rawTotal, displayTotal) { - return `显示全部 ${displayTotal} / 原始 ${rawTotal}`; +function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) { + if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) { + return `压缩窗口 ${readableTotal} 条可读事件 / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; + } + return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`; } function renderTraceEventList(list, rows) { @@ -4283,6 +4342,7 @@ function installWorkbenchTestHooks() { seedTraceMessage, appendTraceEvents, traceScrollMetrics, + tracePanelText, setTraceScrollTop, setConversationScrollTop }; @@ -4296,7 +4356,9 @@ function seedTraceMessage(options = {}) { const traceId = options.traceId ?? "trc_scroll_contract"; const messageId = options.messageId ?? "msg_scroll_contract"; const count = Math.max(1, Number(options.count ?? 80)); - const events = Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1)); + const events = Array.isArray(options.events) + ? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1)) + : Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1)); state.chatMessages = [{ id: messageId, role: "agent", @@ -4307,14 +4369,29 @@ function seedTraceMessage(options = {}) { runnerTrace: { traceId, events, - eventCount: events.length, - lastEvent: events.at(-1), + eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length, + eventsCompacted: options.eventsCompacted === true, + eventWindow: options.eventWindow ?? null, + fullTraceLoaded: options.fullTraceLoaded === true, + lastEvent: options.lastEvent ?? events.at(-1), waitingFor: "turn/completed" } }]; renderConversation(); } +function normalizeTestTraceEvent(traceId, event, seq) { + return { + traceId, + seq, + createdAt: new Date(1779690000000 + seq * 1000).toISOString(), + ...event, + traceId: event?.traceId ?? traceId, + seq: event?.seq ?? seq, + createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString() + }; +} + function appendTraceEvents(count = 1) { const message = state.chatMessages.find((item) => item.runnerTrace?.traceId); if (!message) return; @@ -4353,6 +4430,15 @@ function traceScrollMetrics() { }; } +function tracePanelText() { + const conversation = el.conversationList; + return { + count: conversation.querySelector(".message-trace-count")?.textContent ?? "", + rows: [...conversation.querySelectorAll(".message-trace-row")].map((row) => row.textContent ?? ""), + bodies: [...conversation.querySelectorAll(".message-trace-body")].map((body) => body.textContent ?? "") + }; +} + function setTraceScrollTop(top, { user = true } = {}) { const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]"); if (!list) return traceScrollMetrics(); @@ -4381,8 +4467,8 @@ function traceDisplayRows(trace, events) { }; const flushToolOutputRun = () => { if (toolOutputRun.length === 0) return; - const row = traceToolOutputSummaryRow(trace, toolOutputRun); - if (row) rows.push(row); + const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun); + rows.push(...outputRows); toolOutputRun = []; }; for (const event of events) { @@ -4407,43 +4493,53 @@ function traceDisplayRows(trace, events) { return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean); } -function traceToolOutputSummaryRow(trace, events) { +function traceToolOutputSummaryRows(trace, events) { const visibleEvents = events.filter(Boolean); - if (visibleEvents.length === 0) return null; + if (visibleEvents.length === 0) return []; const first = visibleEvents[0]; - const last = visibleEvents.at(-1); const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join(""); - const parsed = parseGatewayJsonRpcOutput(combined); + const parsedItems = parseGatewayJsonRpcOutputs(combined); + if (parsedItems.length > 0) { + return parsedItems.map((parsed, index) => { + const { payload, result, dispatch } = parsed; + const event = visibleEvents[Math.min(index, visibleEvents.length - 1)] ?? first; + const clock = traceClock(event.createdAt); + const total = formatTraceDuration(traceRelativeMs(trace, event)); + const chunkCount = index === 0 ? visibleEvents.length : 0; + const suffix = chunkCount > 0 ? ` chunks=${chunkCount}` : ""; + const status = result.status ?? dispatch.dispatchStatus ?? "unknown"; + const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; + const ok = status === "succeeded" || status === "completed" || exitCode === 0; + const header = [ + `${clock} total=${total}`, + ok ? "ok" : status === "timed_out" ? "timeout" : "fail", + "tool gateway.shell", + `status=${status}`, + result.operationId ? `op=${result.operationId}` : "op=null", + exitCode !== null ? `exit=${exitCode}` : null, + typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null, + suffix.trim() || null + ].filter(Boolean).join(" "); + return { + seq: event.seq ?? null, + tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", + header, + body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined }) + }; + }); + } const clock = traceClock(first.createdAt); const total = formatTraceDuration(traceRelativeMs(trace, first)); - if (parsed) { - const { payload, result, dispatch } = parsed; - const status = result.status ?? dispatch.dispatchStatus ?? "unknown"; - const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; - const ok = status === "succeeded" || status === "completed" || exitCode === 0; - const header = [ - `${clock} total=${total}`, - ok ? "ok" : status === "timed_out" ? "timeout" : "fail", - "tool hardware.invoke.shell", - `status=${status}`, - result.operationId ? `op=${result.operationId}` : "op=null", - exitCode !== null ? `exit=${exitCode}` : null, - typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null, - `chunks=${visibleEvents.length}` - ].filter(Boolean).join(" "); - return { - seq: first.seq ?? null, - tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked", - header, - body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined }) - }; - } - return { + return [{ seq: first.seq ?? null, tone: "source", header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`, body: compactTraceTextTail(cleanTraceOutputText(combined), 1600) - }; + }]; +} + +function traceToolOutputSummaryRow(trace, events) { + return traceToolOutputSummaryRows(trace, events)[0] ?? null; } function traceNoiseSummaryRow(trace, events) { @@ -4454,7 +4550,7 @@ function traceNoiseSummaryRow(trace, events) { const total = formatTraceDuration(traceRelativeMs(trace, last)); const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event)); const label = assistantChunks.length === visibleEvents.length - ? `assistant stream x${visibleEvents.length}` + ? `assistant message x${visibleEvents.length}` : `trace noise x${visibleEvents.length}`; return { seq: last.seq ?? null, @@ -4467,15 +4563,14 @@ function traceNoiseSummaryRow(trace, events) { function traceNoiseSummaryBody(events, assistantChunks) { if (assistantChunks.length > 0) { const text = assistantChunks - .map((event) => cleanTraceText(event.chunk)) - .join("") - .trim(); + .map((event) => String(event.chunk ?? "")) + .join(""); + const message = cleanTraceText(text); const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null; - const recent = text ? compactTraceTextTail(text, 900) : null; return [ `compressed=${assistantChunks.length} assistant chunks`, waitingFor, - recent ? `recent=${recent}` : null + message ? `message=${message}` : null ].filter(Boolean).join("\n"); } const labels = new Map(); @@ -4575,19 +4670,56 @@ function rawTraceOutputText(event) { return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? ""); } +function parseGatewayJsonRpcOutputs(text) { + const payloads = parseJsonObjects(text); + return payloads + .filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object") + .map((payload) => { + const result = payload.result; + const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; + if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; + return { payload, result, dispatch }; + }) + .filter(Boolean); +} + function parseGatewayJsonRpcOutput(text) { - const payload = parseFirstJsonObject(text); - if (!payload || payload.jsonrpc !== "2.0" || !payload.result || typeof payload.result !== "object") return null; - const result = payload.result; - const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {}; - if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null; - return { payload, result, dispatch }; + return parseGatewayJsonRpcOutputs(text)[0] ?? null; +} + +function parseJsonObjects(text) { + const source = String(text ?? ""); + const values = []; + let searchFrom = 0; + while (searchFrom < source.length) { + const start = source.indexOf("{", searchFrom); + if (start < 0) break; + const end = jsonObjectEnd(source, start); + if (end < 0) break; + try { + values.push(JSON.parse(source.slice(start, end + 1))); + searchFrom = end + 1; + } catch { + searchFrom = start + 1; + } + } + return values; } function parseFirstJsonObject(text) { const source = String(text ?? ""); const start = source.indexOf("{"); if (start < 0) return null; + const end = jsonObjectEnd(source, start); + if (end < 0) return null; + try { + return JSON.parse(source.slice(start, end + 1)); + } catch { + return null; + } +} + +function jsonObjectEnd(source, start) { let depth = 0; let inString = false; let escaped = false; @@ -4610,14 +4742,10 @@ function parseFirstJsonObject(text) { if (char === "{") depth += 1; if (char === "}") depth -= 1; if (depth === 0) { - try { - return JSON.parse(source.slice(start, index + 1)); - } catch { - return null; - } + return index; } } - return null; + return -1; } function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) { diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index dd203112..30133be9 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -881,9 +881,15 @@ assert.match(app, /function refreshTraceAfterResultPollError/); assert.match(app, /function messageTracePanel/); assert.match(app, /function messageTraceToolbar/); assert.match(app, /function messageTraceCountText/); -assert.match(app, /显示全部\s+\$\{displayTotal\}\s+\/\s+原始\s+\$\{rawTotal\}/); +assert.match(app, /显示全部可读事件\s+\$\{readableTotal\}\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/); +assert.match(app, /压缩窗口\s+\$\{readableTotal\}\s+条可读事件\s+\/\s+已载入原始\s+\$\{loadedTotal\}\s+\/\s+后端原始\s+\$\{rawTotal\}/); +assert.match(app, /FULL_TRACE_REPLAY_TIMEOUT_MS\s*=\s*15000/); +assert.match(app, /fullTraceReplayInFlight:\s*new Set\(\)/); +assert.match(app, /function maybeReplayFullTraceForMessage/); +assert.match(app, /function replayFullTrace/); assert.match(app, /eventsCompacted:\s*snapshot\.eventsCompacted === true/); assert.match(app, /eventWindow:\s*snapshot\.eventWindow/); +assert.match(app, /fullTraceLoaded:/); assert.match(app, /eventCount:\s*Number\.isInteger\(trace\?\.eventCount\)/); assert.match(app, /traceDetailsOpen:\s*new Map\(\)/); assert.match(app, /conversationRenderVersion:\s*0/); @@ -896,6 +902,7 @@ assert.match(app, /state\.conversationRenderVersion \+= 1/); assert.match(app, /state\.conversationScrollPosition\s*=\s*\{\s*top:\s*0,\s*left:\s*0\s*\}/); assert.match(app, /state\.traceScrollPositions\.clear\(\)/); assert.match(app, /state\.traceScrollUserActiveUntil\.clear\(\)/); +assert.match(app, /state\.fullTraceReplayInFlight\.clear\(\)/); assert.match(app, /function messageTraceUiKey/); assert.match(app, /function defaultTraceDetailsOpen/); assert.match(app, /initConversationScrollMemory\(\)/); @@ -933,13 +940,16 @@ assert.match(app, /function installWorkbenchTestHooks/); assert.match(app, /hwlab-test-hooks/); assert.match(app, /function traceDisplayRows/); assert.match(app, /function traceDisplayRow/); +assert.match(app, /function traceToolOutputSummaryRows/); assert.match(app, /function traceToolOutputSummaryRow/); +assert.match(app, /function parseGatewayJsonRpcOutputs/); assert.match(app, /function parseGatewayJsonRpcOutput/); -assert.match(app, /tool hardware\.invoke\.shell/); +assert.match(app, /tool gateway\.shell/); assert.match(app, /function traceNoiseSummaryRow/); assert.match(styles, /max-height:\s*min\(520px,\s*54dvh\)/); -assert.match(app, /assistant stream x\$\{visibleEvents\.length\}/); +assert.match(app, /assistant message x\$\{visibleEvents\.length\}/); assert.match(app, /compressed=\$\{assistantChunks\.length\} assistant chunks/); +assert.match(app, /message=\$\{text\}/); assert.match(app, /waiting first assistant token/); assert.match(app, /function compactTraceTextTail/); assert.match(app, /function isAssistantChunkTraceEvent/); diff --git a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs index 69b0af60..a93cf2ed 100644 --- a/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs +++ b/web/hwlab-cloud-web/scripts/trace-scroll.test.mjs @@ -64,10 +64,116 @@ test("trace scroll stays user controlled while trace updates append", async () = } }); +test("trace display full means complete readable timeline, not compacted result window", async () => { + const server = await startStaticServer(webRoot); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage({ + viewport: { width: 1366, height: 768 }, + deviceScaleFactor: 1 + }); + await page.goto(`${server.url}?hwlab-test-hooks=1`, { waitUntil: "domcontentloaded", timeout: 15000 }); + await login(page); + await page.waitForFunction(() => window.__hwlabWorkbenchTestHooks, null, { timeout: 12000 }); + + await page.evaluate(() => { + const gatewayPayload = (id, operationId, status = "succeeded") => JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + accepted: true, + status, + operationId, + gatewaySessionId: "gws_test", + resourceId: "res_windows_host", + capabilityId: "cap_windows_cmd_exec", + dispatch: { + shellExecuted: true, + dispatchStatus: status, + exitCode: status === "succeeded" ? 0 : 1, + durationMs: 1200, + command: "powershell -EncodedCommand ", + stdout: `stdout for ${operationId}`, + stderr: "" + } + } + }); + window.__hwlabWorkbenchTestHooks.seedTraceMessage({ + eventsCompacted: true, + eventCount: 600, + events: [ + { + label: "request:accepted-short-connection", + status: "started", + message: "accepted" + }, + { + label: "assistant:chunk", + type: "assistant_message", + status: "chunk", + chunk: "first readable sentence " + }, + { + label: "assistant:chunk", + type: "assistant_message", + status: "chunk", + chunk: "middle detail " + }, + { + label: "assistant:chunk", + type: "assistant_message", + status: "chunk", + chunk: "final conclusion" + }, + { + label: "item/commandExecution/outputDelta", + type: "tool_call", + status: "output_chunk", + outputSummary: `${gatewayPayload("req_1", "op_one")}${gatewayPayload("req_2", "op_two")}` + } + ] + }); + }); + await page.waitForSelector(".message-trace-count", { timeout: 12000 }); + const compacted = await tracePanelText(page); + assert.match(compacted.count, /压缩窗口/); + assert.doesNotMatch(compacted.count, /显示全部/); + assert.equal(compacted.rows.filter((row) => row.includes("tool gateway.shell")).length, 2, compacted.rows.join("\n---\n")); + assert.ok(compacted.rows.some((row) => row.includes("op=op_one")), compacted.rows.join("\n")); + assert.ok(compacted.rows.some((row) => row.includes("op=op_two")), compacted.rows.join("\n")); + assert.match(compacted.bodies.join("\n"), /first readable sentence middle detail final conclusion/); + + await page.evaluate(() => { + const message = window.__hwlabWorkbenchTestHooks.tracePanelText(); + const rows = message.rows.length; + window.__hwlabWorkbenchTestHooks.seedTraceMessage({ + fullTraceLoaded: true, + events: Array.from({ length: rows }, (_, index) => ({ + label: `full:readable:${index + 1}`, + status: "completed", + message: `full readable event ${index + 1}` + })) + }); + }); + const full = await tracePanelText(page); + assert.match(full.count, /显示全部可读事件/); + assert.doesNotMatch(full.count, /压缩窗口/); + + await page.close(); + } finally { + await browser.close(); + await server.close(); + } +}); + async function traceMetrics(page) { return page.evaluate(() => window.__hwlabWorkbenchTestHooks.traceScrollMetrics()); } +async function tracePanelText(page) { + return page.evaluate(() => window.__hwlabWorkbenchTestHooks.tracePanelText()); +} + async function login(page) { await page.waitForSelector("#login-shell, [data-app-shell]", { timeout: 12000 }); const loginVisible = await page.locator("#login-shell").isVisible().catch(() => false); From a713b73d631205740fa471d6e94934d3602807f4 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 17:48:56 +0800 Subject: [PATCH 04/15] chore: promote DEV desired state to c3b0040 --- AGENTS.md | 6 ++ deploy/artifact-catalog.dev.json | 160 +++++++++++++++---------------- deploy/deploy.json | 42 ++++---- deploy/k8s/base/workloads.yaml | 40 ++++---- 4 files changed, 127 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b853e04..9ca4d8dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,12 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - HWLAB #7、用户反馈、长期看板和指挥简报的 GitHub issue 正文写入必须走 UniDesk CLI:`cd /root/unidesk && bun scripts/cli.ts gh ...`;禁止直接用原生 `gh issue edit/create/comment` 写这些 issue。事故和工具补强需求见 [pikasTech/unidesk#142](https://github.com/pikasTech/unidesk/issues/142)。 - 在 UniDesk CLI 局部替换、写前备份和写后 hash 验证能力完成前,不要对 #7 做无 guard 的整篇 body replace;必须先保留 before body、确认维护纪律 heading 仍存在,再写入。 +## P0 DEV CD Promotion 顺序 + +- `ci-publish` 只证明镜像已经为 publish report 的 `artifactPublish.sourceCommitId` 构建并推送;它不会改变 rollout 目标。禁止在 `ci-publish` 成功后直接把 `dev-cd-apply` 的旧 desired-state 输出当作新版本上线成功。 +- 正式 DEV CD 前必须用刚成功的 publish report 刷新三份 repo desired-state:`node scripts/refresh-artifact-catalog.mjs --target-ref --publish-report `,再执行 `node scripts/deploy-desired-state-plan.mjs --promotion-commit --check`,确认 `deploy/deploy.json`、`deploy/artifact-catalog.dev.json` 和 `deploy/k8s/base/workloads.yaml` 全部指向同一个已发布 commit。 +- 只有刷新后的 desired-state 已提交/推送,并且 CD 报告与 `16666/16667` live health 都观测到该短 commit,才能声明 DEV 上线完成;如果 CD report 里 `commitId` 仍是旧值,必须先修 desired-state,不要重复跑 CD。长期细节见 [docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)。 + ## 工作区 - Runner 和指挥常用工作区是 `/workspace/hwlab`;进入仓库先检查分支与工作树状态,详见 [docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md)。 diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json index d1b1c9be..4186809e 100644 --- a/deploy/artifact-catalog.dev.json +++ b/deploy/artifact-catalog.dev.json @@ -5,12 +5,12 @@ "profile": "dev", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", - "commitId": "2759e82", + "commitId": "c3b0040", "artifactState": "published", "publish": { "ciPublished": true, "registryVerified": true, - "provenance": "ci-artifact:ci-publish-20260525T075701-eebeb4", + "provenance": "ci-artifact:ci-publish-20260525T094328-9e93e8", "note": "Digest fields were copied from a successful DEV artifact publish report for this source commit." }, "healthContract": { @@ -66,10 +66,10 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", - "imageTag": "2759e82", - "digest": "sha256:1165496135213ada3b8539461d57b86ec6468d330b591f5c3b64747fe482afc5", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:55b2f3beacd683f63924e9cb712acc308668617a53acf2575ca11892a065ae44", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -79,15 +79,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-cloud-web", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", - "imageTag": "2759e82", - "digest": "sha256:d6a6c3aa08ec49d89606652decddab81e1e5511ab86fd6243353225a84d4ae18", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:741273cdb0c04a12e5cba040d0b3eaa447b8e06b8108b4ba6f2284450ab3adad", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -97,15 +97,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-agent-mgr", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:2759e82", - "imageTag": "2759e82", - "digest": "sha256:416a4c5dea236575b7bd58b1e984d688d132a7b72bc03d5319d7291b77c54843", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:8bd2b52d6202e0a2d77d119d5c38b4e60fb9ce06ded5accff29ed9d0afccd1ce", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -115,15 +115,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-agent-worker", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2759e82", - "imageTag": "2759e82", - "digest": "sha256:d9fdeb7bd9ec9a5226aa014e262c9fa0d464f1acffe1e9ae1d6b47080d8e54bd", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:a1ea2406582f7670d767eefb07b1abfbe0886153f9a36259643d83e9806d63e6", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -133,15 +133,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-gateway", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:2759e82", - "imageTag": "2759e82", - "digest": "sha256:1857049fc6c7aa23ff145d637670daca2177512fe5dc3f7442bae573e33e8d77", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:3c339561099cfd0accdf4706f6a85f244f5de8bf8997d92f32a0385f5b24f2df", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -151,15 +151,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-gateway-simu", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:2759e82", - "imageTag": "2759e82", - "digest": "sha256:5aa4e01754ba6c88b80edcedb747d30f89a7a43736576a2313671aadca394fbb", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:ff103bf0575b0753ac7584ae2a8c323f03e04fbd24a4edb7c4c6b04b2f1f4be1", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -169,15 +169,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-box-simu", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:2759e82", - "imageTag": "2759e82", - "digest": "sha256:6d2262301a7b5462f8adcbb7e73961b5f673f5998ee9a6e09ca44cc850999d64", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:d187ff82785ae49f724465b397150a2a73c426e5c5b85a8008dd09057f4063ed", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -187,15 +187,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-patch-panel", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:2759e82", - "imageTag": "2759e82", - "digest": "sha256:fb04fa840a97190fadc08a0a8a959bf7235d2daa1a65907932b13ea42985efd1", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:7ca75e5d72a684716e9d2b803e22efaff623f4e849a53e62bec35ff53fc1126e", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -205,15 +205,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-router", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-router:2759e82", - "imageTag": "2759e82", - "digest": "sha256:53e22734b74efccc381563d565b5e9d968b0952c9e8cfed949ef2b4f69400e17", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-router:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:6d85647cd469dbd3b9a86f755553430b8efe6de665b53327f801fad408a52e84", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -223,15 +223,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-tunnel-client", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:2759e82", - "imageTag": "2759e82", - "digest": "sha256:36eb861185fdbc810be1a7a0959fe89e5cf2fe2b73039248c6411ad8227ce40c", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:da9dfa0653a351884145eebf369a5baa8bd3957386b5ec8a933466730f812f01", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -241,15 +241,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-edge-proxy", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:2759e82", - "imageTag": "2759e82", - "digest": "sha256:0b181cea20e2129f1c16d5036334267f3fd1a4309d9a2af1643d1fd36bd9b4f0", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:7c51a802120a779f6ae925730303fb8e99a8d7f3d630ac8baf31381c2a979ba6", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -259,15 +259,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-cli", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:2759e82", - "imageTag": "2759e82", - "digest": "sha256:d5e45d5a6af1b62f47768c0c9a75f852a7c1b29ee8027937502211d4ae81b794", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:26b3eb748a346acfe1207f5b4da1211a1fb97a91658f277aad832f179416daec", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -277,15 +277,15 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" }, { "serviceId": "hwlab-agent-skills", - "commitId": "2759e82", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:2759e82", - "imageTag": "2759e82", - "digest": "sha256:426cbaae73268fc820b65a4672b372bdf3d1719612efbf9486728c7572a26bf5", + "commitId": "c3b0040", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c3b0040", + "imageTag": "c3b0040", + "digest": "sha256:e3f93eefdc50c25b68730be466f61928692043e244330a683dd90ee4eab42d68", "publishState": "published", "profile": "dev", "namespace": "hwlab-dev", @@ -295,8 +295,8 @@ "artifactRequired": true, "artifactScope": "required", "notPublishedReason": null, - "buildCreatedAt": "2026-05-25T07:57:04.166Z", - "buildSource": "pikasTech/HWLAB@2759e823e8d87f6dee2b2a93da657481953e40c0" + "buildCreatedAt": "2026-05-25T09:43:31.483Z", + "buildSource": "pikasTech/HWLAB@c3b00408d970e0da4aa0ae8c8fa0d3b7011b9b19" } ], "serviceInventory": { diff --git a/deploy/deploy.json b/deploy/deploy.json index 2c529f4a..49290c8e 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -1,7 +1,7 @@ { "manifestVersion": "v1", "environment": "dev", - "commitId": "2759e82", + "commitId": "c3b0040", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "health": { @@ -181,7 +181,7 @@ "services": [ { "serviceId": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -190,9 +190,9 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:16667", "HWLAB_CLOUD_API_PORT": "6667", - "HWLAB_COMMIT_ID": "2759e82", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", - "HWLAB_IMAGE_TAG": "2759e82", + "HWLAB_COMMIT_ID": "c3b0040", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c3b0040", + "HWLAB_IMAGE_TAG": "c3b0040", "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", "HWLAB_CLOUD_DB_SSL_MODE": "disable", @@ -223,7 +223,7 @@ }, { "serviceId": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -232,14 +232,14 @@ "HWLAB_ENVIRONMENT": "dev", "HWLAB_API_BASE_URL": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "660000", - "HWLAB_COMMIT_ID": "2759e82", - "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", - "HWLAB_IMAGE_TAG": "2759e82" + "HWLAB_COMMIT_ID": "c3b0040", + "HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c3b0040", + "HWLAB_IMAGE_TAG": "c3b0040" } }, { "serviceId": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -251,7 +251,7 @@ }, { "serviceId": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -263,7 +263,7 @@ }, { "serviceId": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -275,7 +275,7 @@ }, { "serviceId": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -295,7 +295,7 @@ }, { "serviceId": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -314,7 +314,7 @@ }, { "serviceId": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -335,7 +335,7 @@ }, { "serviceId": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-router:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -347,7 +347,7 @@ }, { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -361,7 +361,7 @@ }, { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -374,7 +374,7 @@ }, { "serviceId": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -385,13 +385,13 @@ }, { "serviceId": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c3b0040", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", "replicas": 1, "env": { - "HWLAB_SKILLS_COMMIT_ID": "2759e82" + "HWLAB_SKILLS_COMMIT_ID": "c3b0040" } } ], diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index e6b0f9e3..0053064d 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -31,7 +31,7 @@ "containers": [ { "name": "hwlab-cloud-api", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c3b0040", "ports": [ { "name": "http", @@ -53,15 +53,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "2759e82" + "value": "c3b0040" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:2759e82" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c3b0040" }, { "name": "HWLAB_IMAGE_TAG", - "value": "2759e82" + "value": "c3b0040" }, { "name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN", @@ -283,7 +283,7 @@ "containers": [ { "name": "hwlab-cloud-web", - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c3b0040", "ports": [ { "name": "http", @@ -301,15 +301,15 @@ }, { "name": "HWLAB_COMMIT_ID", - "value": "2759e82" + "value": "c3b0040" }, { "name": "HWLAB_IMAGE", - "value": "127.0.0.1:5000/hwlab/hwlab-cloud-web:2759e82" + "value": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c3b0040" }, { "name": "HWLAB_IMAGE_TAG", - "value": "2759e82" + "value": "c3b0040" } ], "readinessProbe": { @@ -359,7 +359,7 @@ "containers": [ { "name": "hwlab-agent-mgr", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c3b0040", "ports": [ { "name": "http", @@ -415,7 +415,7 @@ "containers": [ { "name": "hwlab-agent-worker", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c3b0040", "env": [ { "name": "HWLAB_AGENT_SESSION_MODE", @@ -458,7 +458,7 @@ "containers": [ { "name": "hwlab-gateway", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway:c3b0040", "ports": [ { "name": "http", @@ -519,7 +519,7 @@ "containers": [ { "name": "hwlab-gateway-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c3b0040", "ports": [ { "name": "http", @@ -592,7 +592,7 @@ "containers": [ { "name": "hwlab-box-simu", - "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c3b0040", "ports": [ { "name": "http", @@ -660,7 +660,7 @@ "containers": [ { "name": "hwlab-patch-panel", - "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c3b0040", "ports": [ { "name": "http", @@ -724,7 +724,7 @@ "containers": [ { "name": "hwlab-router", - "image": "127.0.0.1:5000/hwlab/hwlab-router:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-router:c3b0040", "ports": [ { "name": "http", @@ -784,7 +784,7 @@ "containers": [ { "name": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c3b0040", "ports": [ { "name": "http", @@ -856,7 +856,7 @@ "containers": [ { "name": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c3b0040", "ports": [ { "name": "http", @@ -921,7 +921,7 @@ "containers": [ { "name": "hwlab-cli", - "image": "127.0.0.1:5000/hwlab/hwlab-cli:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-cli:c3b0040", "env": [ { "name": "HWLAB_CLI_ENDPOINT", @@ -963,7 +963,7 @@ "containers": [ { "name": "hwlab-agent-skills", - "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:2759e82", + "image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c3b0040", "ports": [ { "name": "http", @@ -973,7 +973,7 @@ "env": [ { "name": "HWLAB_SKILLS_COMMIT_ID", - "value": "2759e82" + "value": "c3b0040" } ], "readinessProbe": { From 303ff8fafd745e6ddbe34dd064e9a9e80d99cb97 Mon Sep 17 00:00:00 2001 From: lyon Date: Mon, 25 May 2026 20:14:05 +0800 Subject: [PATCH 05/15] fix: route code agent chat through codex --- docs/reference/code-agent-chat-readiness.md | 73 +- internal/cloud/code-agent-chat.mjs | 2042 +---------------- .../code-agent-session-registry.test.mjs | 1212 +--------- internal/cloud/codex-stdio-session.mjs | 4 +- internal/cloud/server.test.mjs | 1125 +-------- scripts/dev-cloud-workbench-smoke.test.mjs | 113 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 196 +- web/hwlab-cloud-web/index.html | 16 +- web/hwlab-cloud-web/scripts/check.mjs | 2 + 9 files changed, 279 insertions(+), 4504 deletions(-) diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 468d8f95..ad89a188 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -12,7 +12,7 @@ Secret 或 token。 同源聊天入口的真实回复证据。 - `internal/cloud/code-agent-chat.mjs` 是 `/v1/agent/chat` 的后端处理入口。 - `scripts/code-agent-chat-smoke.mjs` 是 Code Agent chat schema 与 readiness - gate。 + 合同检查。 - `scripts/dev-cloud-workbench-smoke.mjs --static` 只验证 Workbench 源码合同和 `/v1/agent/chat` 前端接线;它不是 DEV-LIVE 回复证明。 @@ -41,7 +41,7 @@ report、issue、PR 或截图。 | 观测结果 | readiness | | --- | --- | | `status: "failed"`,`error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 | -| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-session-tools"`、`session.status` 为 `idle/ready/busy`、`session.idleTimeoutMs` 和 `session.lastTraceId` 存在、`session.longLivedSession: true`、`longLivedSessionGate.status: "blocked"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `process-local-session-registry` | 可标为 #317 read-only long-lived session registry pass;不能关闭 full Codex stdio/session capability blocker。 | +| `provider: "codex-readonly-runner"` 或 `sessionMode: "controlled-readonly-session-registry"` | 历史只读状态,只能作为 `BLOCKED/not-codex-stdio` 诊断;不能满足当前自然语言单一路由或 DEV-LIVE reply pass。 | | `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`codex_cli_not_executable`、`codex_cli_native_dependency_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`workspace_write_boundary_blocked`、`codex_home_missing`、`codex_home_write_blocked`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 | | `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 | | 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 | @@ -50,45 +50,32 @@ report、issue、PR 或截图。 只有“真实 DEV 路由 + `completed` + 非空 assistant reply”能作为 DEV-LIVE 回复通过依据。 不得把 mock、fixture、本地 echo、source report、静态检查或前端状态当作通过。 -## Runner 能力边界 +## 自然语言单一路由 -`/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分: +`/v1/agent/chat` 的自然语言请求唯一执行路径是 repo-owned Codex stdio long-lived +session。cloud-api 不再把自然语言预分类到 M3 Skill CLI、`/v1/m3/io`、 +`external.network.check`、`session_context`、`security.hardware-boundary`、 +`hardware.invoke.shell` shortcut 或 OpenAI text fallback。 -- `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存 - `conversationId/sessionId` 映射、`status`、workspace、sandbox、`createdAt`、`updatedAt`、 - `idleTimeoutMs`、`lastTraceId`、`turn` 计数与只读工具 trace。它可以覆盖 `pwd`、 - `skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。该模式是 - read-only long-lived session,但 `longLivedSessionGate` 必须保持 `blocked`,直到 Codex - stdio 或等价 full Code Agent 协议通道真实接通。 -- 该模式必须同时标记 `not-codex-stdio`、`not-write-capable`、`process-local-session-registry`。它不是 - Codex stdio / workspace-write session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL - 读取,也不证明 M3/M4/M5 trusted green。 -- OpenAI Responses fallback 只能标记为 `openai-responses-fallback` / - `text-chat-only`,不得满足 Codex runner capability gate。默认路径不得用 fallback - 冒充完整 Code Agent;仅明确允许 fallback 时才可作为普通文本备用通道返回。 -- 已登记 PC gateway `shell.exec` capability 是受控硬件能力例外:当请求能从显式 - `projectId/gatewaySessionId/resourceId/capabilityId` 或唯一 DEV MVP topology 解析时, - `/v1/agent/chat` 可以选择 `hwlab-hardware-capability` runner,经 cloud-api 进程内 - JSON-RPC helper 调用 `hardware.invoke.shell`。该 helper 必须使用冻结的 - `hwlab-cloud-api` service id 常量,返回 payload 需包含 `toolCalls[]`、`operationId`、 - `dispatchStatus`、`exitCode`、stdout/stderr 摘要、`runnerTrace.eventLabels` 与 - `capabilityLevel`。拓扑缺失、不唯一、gateway offline、session/capability missing、 - dispatch failed 或 JSON-RPC meta/serviceId 无效时必须返回结构化 blocker,不能转入 - Codex stdio 或 OpenAI 文本 fallback 冒充成功。 -- `security.hardware-boundary` 仍然阻断直接 gateway/box-simu/patch-panel URL、`/invoke`、 - `/sync/tick` 和泛化硬件写绕过;受控 `hardware.invoke.shell` capability route 只允许 - 已登记、可解析的 cloud-api 调用,不开放任意 shell。JSON-RPC/REST bridge 拒绝未知 - `serviceId` 时,错误原因应可见为 `unknown serviceId ...` 或等价结构化 reason,且不得泄露 - Secret/token。 +自然语言里即使出现 M3、DO/DI、DAP、PWM、gateway、box-simu、patch-panel、Keil、 +serial-monitor、Windows skill、串口、下载、烧录、启动日志等词,也必须把完整请求交给 +Codex stdio turn。Codex turn 自己根据仓库、skill 文档和可用工具决定调用 repo wrapper、 +Windows skill CLI、项目脚本或其他真实可达路径;cloud-api 只负责 session 生命周期、trace、 +result 轮询和 schema 化返回。 -当前 DEV/runtime 若要升级为完整 #275 runner,至少需要 repo-owned 的 Codex CLI/stdio 或等价 -runner 二进制/协议适配、session supervisor 生命周期、workspace mount 与 sandbox 合同、token/Secret -注入边界、trace/cancel/reap 机制,以及与 cloud-api/workbench 的持久 session 映射。缺任一项时,必须 -在 `codexStdioFeasibility` 中报告 blocker。 +如果 Codex stdio 不具备运行条件,`/v1/agent/chat` 只能返回 Codex stdio readiness +blocker,不能降级到 M3 Skill CLI、受控硬件 shortcut、外网专用检查或普通 OpenAI 文本回复。 +显式 `/v1/m3/io` 控制面可以作为独立 API 或 UI 控制面继续存在,但聊天自然语言不得自动路由 +到该 API,也不得保留要求自然语言先满足 M3 白名单的源码检查或测试。 + +持久 session 是默认合同:同一个 `conversationId/sessionId` 必须映射到 repo-owned Codex +thread 和固定 workspace,刷新前端、重新打开页面或短连接 result 轮询不得创建新的短期 runner。 +除非 Pod 重建或 Codex supervisor 明确重启,workspace、thread/session 绑定和可见 trace 应持续 +存在。 ## PC Gateway Windows Skill 调用 -Code Agent 通过已登记 PC gateway 执行 Windows 侧命令时,必须让 Codex turn 自己调用仓库 wrapper,不能由 cloud-api 字符串匹配短路到 gateway,也不能直接访问 gateway URL: +Code Agent 通过已登记 PC gateway 执行 Windows 侧命令时,必须让 Codex turn 自己调用仓库 wrapper,不能由 cloud-api 字符串匹配短路到 gateway: ```sh node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --powershell-stdin <<'PS1' @@ -102,7 +89,7 @@ Workbench 会把“Gateway 命令超时”控件的毫秒值随 `/v1/agent/chat` 调大 timeout 不能替代正确的长任务控制语义。Gateway poll loop 必须支持后台 in-flight 执行,长 Keil/UV4 命令运行期间仍能处理短 `job-status`、state/log 读取和健康探测;如果 trace 出现 `shellExecuted=false` 的 dispatch timeout,优先检查 gateway 是否队头阻塞或离线,而不是把所有 wrapper 调用改成长等待。 -Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:前端首行用中性 `tool gateway.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。后端 capability 仍可记录 `hardware.invoke.shell` 路由事实,但 Cloud Web 源码不得把它暴露成可点击或可误解的直接硬件写入口。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 +Workbench trace 对已知 JSON-RPC gateway 响应应按普通 tool call 展示:前端首行用中性 `tool gateway.shell status= op= exit= s=`,正文展示 request、gateway/resource/capability、dispatch、command、audit/evidence 以及有界 stdout/stderr。不要把整段 JSON 原样刷屏;复制/下载完整 trace 仍保留原始 JSON。 ## 短连接 result 轮询 @@ -141,7 +128,19 @@ py -3 keil-cli.py job-status 对 build/download 这类长任务,Code Agent 应优先使用 skill 自带的异步 job 语义:启动命令用短 wrapper timeout 拿到 job id 或明确的启动失败,再用短 `job-status`、state 文件和日志读取轮询进展。除非用户明确要求同步等待并设置了足够大的 Gateway 命令超时,不要通过 gateway 执行 `--wait` 长轮询;同步等待会占用一个 in-flight 槽位,旧 gateway 还会造成队头阻塞。 -## Smoke Gate +串口启动日志请求必须优先使用 Windows 侧 `serial-monitor` skill,而不是在 cloud-api 新增串口专用 route: + +```sh +cd C:\Users\liang\.agents\skills\serial-monitor +npm run cli -- server status +npm run cli -- server start +npm run cli -- monitor start -p -b +npm run cli -- fetch --session-only --no-dedup +``` + +Keil 下载后的启动日志抓取应和 build/download 共用同一个 Codex stdio session 与 gateway wrapper trace。71-FREQ 类项目的串口参数以 Windows 侧 `serial-monitor\SKILL.md` 和实时设备枚举为准;需要轮询时用短 wrapper 调用读取 session/state/log,而不是新增聊天层白名单或 blocker。 + +## Smoke Checks 本地合同检查: diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 131e6f65..28c4faa4 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -31,19 +31,6 @@ import { codeAgentSessionLifecycleSummary, decorateCodeAgentSession } from "./code-agent-session-lifecycle.mjs"; -import { - HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - HWLAB_M3_IO_CAPABILITY_LEVELS, - HWLAB_M3_IO_API_BASE_URL_ENV, - HWLAB_M3_IO_API_BASE_URL_ENVS, - HWLAB_M3_IO_API_ROUTE, - HWLAB_M3_STATUS_API_ROUTE, - HWLAB_M3_IO_DEV_SERVICE_BASE_URL, - HWLAB_M3_IO_SKILL_NAME, - configuredCloudApiBaseUrl, - runM3IoSkillCommand -} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; - const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model; const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench"; const READONLY_RUNNER_PROVIDER = "codex-readonly-runner"; @@ -54,31 +41,10 @@ const READONLY_RUNNER_SANDBOX = "read-only"; const READONLY_SESSION_MODE = "controlled-readonly-session-registry"; const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry"; const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools"; -const M3_IO_SKILL_PROVIDER = "hwlab-skill-cli"; -const M3_IO_SKILL_BACKEND = "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"; -const M3_IO_SKILL_MODEL = "controlled-m3-io"; -const M3_IO_SKILL_RUNNER_KIND = "hwlab-m3-io-skill-cli"; -const M3_IO_SKILL_SANDBOX = "hwlab-api-route-only"; -const M3_IO_SKILL_SESSION_MODE = "controlled-m3-io-skill-cli"; -const M3_IO_SKILL_IMPLEMENTATION_TYPE = "skill-cli-hwlab-api-adapter"; -const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ - "m3-io-only", - "hwlab-api-route-only", - "not-generic-hardware-control", - "not-durable-session" -]); -const M3_IO_TOPOLOGY = Object.freeze({ - sourceResourceId: "res_boxsimu_1", - sourcePort: "DO1", - patchPanelServiceId: "hwlab-patch-panel", - targetResourceId: "res_boxsimu_2", - targetPort: "DI1" -}); const HARDWARE_INVOKE_SHELL_METHOD = "hardware.invoke.shell"; const LEGACY_CODE_AGENT_HARDWARE_PROVIDER = "hwlab-hardware-capability"; const LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED = "blocked"; const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; -const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu; const READONLY_TOOL_OUTPUT_LIMIT = 4000; const MAX_READONLY_SESSIONS = 200; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); @@ -145,127 +111,36 @@ export async function handleCodeAgentChat(params = {}, options = {}) { try { const message = normalizeUserMessage(params.message); - const runnerIntent = detectReadOnlyRunnerIntent(message, { - params, - env: options.env ?? process.env - }); + const runnerIntent = { + kind: "none", + toolName: "codex-stdio.session" + }; traceRecorder.append({ type: "request", status: "accepted", label: "request:accepted", promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message, - waitingFor: runnerIntent.kind === "m3_io" - ? HWLAB_M3_IO_API_ROUTE - : "codex-stdio-readiness" + waitingFor: "codex-stdio-readiness" }); - const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; - if (securityIntent) { - traceRecorder.append({ - type: "error", - status: "blocked", - label: "security:blocked", - errorCode: "security_blocked", - message: securityIntent.reason, - terminal: true - }); - throw runnerError("security_blocked", securityIntent.reason, { - provider: CODEX_STDIO_PROVIDER, - model: providerPlan.model, - backend: CODEX_STDIO_BACKEND, - workspace: options.workspace ?? repoRoot, - sandbox: "workspace-write", - toolCalls: [securityBlockedToolCall({ - traceId, - toolName: securityIntent.toolName, - reason: securityIntent.reason, - cwd: options.workspace ?? repoRoot - })], - skills: notRequestedSkills(), - runner: codexStdioBlockedRunnerDescriptor({ workspace: options.workspace, session: null }), - runnerTrace: traceRecorder.runnerTrace({ - runnerKind: CODEX_STDIO_RUNNER_KIND, - workspace: options.workspace ?? repoRoot, - sandbox: "workspace-write", - sessionMode: CODEX_STDIO_SESSION_MODE, - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - limitations: ["security-blocked-before-stdio"] - }), - capabilityLevel: "blocked", - sessionMode: CODEX_STDIO_SESSION_MODE, - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - runnerLimitations: ["security-blocked-before-stdio"], - blockers: [{ - code: "security_blocked", - sourceIssue: "pikasTech/HWLAB#275", - summary: securityIntent.reason - }], - route: null, - toolName: securityIntent.toolName ?? null - }); - } - - if (runnerIntent.kind === "m3_io") { - const runnerResult = await callM3IoSkillRunner({ - intent: runnerIntent, + const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); + if (codexStdioLongLivedReady(codexStdioAvailability)) { + const stdioResult = await callCodexStdioRunner({ + message, conversationId, sessionId: requestedSessionId, traceId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, - sessionRegistry, - requestJson: options.m3IoSkillRequestJson, - traceRecorder + timeoutMs: options.timeoutMs, + hardTimeoutMs: options.hardTimeoutMs, + model: providerPlan.model, + codexStdioManager: options.codexStdioManager, + traceRecorder, + conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), + externalNetworkIntent: null }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); - } - - if (runnerIntent.kind === "session_context") { - traceRecorder.append({ - type: "session", - status: "context_requested", - label: "session:context_requested", - waitingFor: "codex-stdio" - }); - } - - const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); - if (codexStdioLongLivedReady(codexStdioAvailability)) { - const stdioResult = runnerIntent.kind === "m3_io" - ? await callCodexStdioWithM3IoSkillRunner({ - message, - intent: runnerIntent, - conversationId, - sessionId: requestedSessionId, - traceId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - timeoutMs: options.timeoutMs, - hardTimeoutMs: options.hardTimeoutMs, - model: providerPlan.model, - codexStdioManager: options.codexStdioManager, - traceRecorder, - conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), - requestJson: options.m3IoSkillRequestJson - }) - : await callCodexStdioRunner({ - message, - conversationId, - sessionId: requestedSessionId, - traceId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - timeoutMs: options.timeoutMs, - hardTimeoutMs: options.hardTimeoutMs, - model: providerPlan.model, - codexStdioManager: options.codexStdioManager, - traceRecorder, - conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), - externalNetworkIntent: runnerIntent.kind === "external_network" ? runnerIntent : null - }); return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry }); } @@ -380,7 +255,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { payload.availability = error.availability; } else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); - } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed", "external_network_blocked", "network_tool_unavailable", "network_timeout"].includes(error.code)) { + } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } return decorateChatSessionLifecycle(payload); @@ -480,52 +355,9 @@ function validateCodeAgentToolCallContract(payload) { if (!toolCall || typeof toolCall !== "object") { throw new Error(`code agent toolCalls[${index}] must be an object`); } - const directTarget = findDirectHardwareTarget(toolCall); - if (directTarget) { - throw new Error(`code agent toolCalls[${index}] must not include direct gateway/box/patch-panel target ${directTarget}`); - } - if (!isM3IoToolCall(toolCall, payload)) continue; - const structuredBlocked = toolCall.status === "blocked" && Boolean(toolCall.blocker?.code || toolCall.capabilityBlocker?.code || toolCall.error?.code); - if (!m3ToolCallRouteAllowed(toolCall.route) && !structuredBlocked) { - throw new Error(`code agent M3 toolCalls[${index}] must show route ${HWLAB_M3_IO_API_ROUTE}/${HWLAB_M3_STATUS_API_ROUTE} or structured blocked`); - } - if (toolCall.route === HWLAB_M3_IO_API_ROUTE && (toolCall.method ?? "POST") !== "POST") { - throw new Error(`code agent M3 toolCalls[${index}] must use POST ${HWLAB_M3_IO_API_ROUTE}`); - } - if (toolCall.route === HWLAB_M3_STATUS_API_ROUTE && (toolCall.method ?? "GET") !== "GET") { - throw new Error(`code agent M3 toolCalls[${index}] must use GET ${HWLAB_M3_STATUS_API_ROUTE}`); - } } } -function isM3IoToolCall(toolCall, payload) { - return toolCall.name === HWLAB_M3_IO_SKILL_NAME || - toolCall.route === HWLAB_M3_IO_API_ROUTE || - toolCall.route === HWLAB_M3_STATUS_API_ROUTE || - payload.provider === M3_IO_SKILL_PROVIDER || - payload.runner?.kind === M3_IO_SKILL_RUNNER_KIND || - payload.runnerTrace?.runnerKind === M3_IO_SKILL_RUNNER_KIND; -} - -function m3ToolCallRouteAllowed(route) { - return route === HWLAB_M3_IO_API_ROUTE || route === HWLAB_M3_STATUS_API_ROUTE; -} - -function findDirectHardwareTarget(value, seen = new Set()) { - if (typeof value === "string") { - const match = value.match(DIRECT_HARDWARE_TARGET_PATTERN); - return match?.[0] ?? null; - } - if (!value || typeof value !== "object") return null; - if (seen.has(value)) return null; - seen.add(value); - for (const child of Object.values(value)) { - const match = findDirectHardwareTarget(child, seen); - if (match) return match; - } - return null; -} - function finalizeCodeAgentChatPayload(payload) { validateCodeAgentChatSchema(payload); return payload; @@ -946,7 +778,6 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); - const m3IoApiBaseUrl = configuredCloudApiBaseUrl(env); return { kind: READONLY_RUNNER_KIND, backend: READONLY_RUNNER_BACKEND, @@ -976,486 +807,36 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { sessionRegistry, skillsDirs, skillsDirsPresent, - m3IoSkill: { - status: m3IoApiBaseUrl ? "available" : "blocked", - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route: HWLAB_M3_IO_API_ROUTE, - statusRoute: HWLAB_M3_STATUS_API_ROUTE, - capabilityLevel: m3IoApiBaseUrl - ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready - : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - blockedCapabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - hwlabApi: { - source: m3IoApiBaseUrl ? m3IoSkillApiBaseUrlSource(env) : "missing-config", - baseUrlConfigured: Boolean(m3IoApiBaseUrl), - redactedBaseUrl: m3IoApiBaseUrl ? redactUrl(m3IoApiBaseUrl) : null, - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS] - }, - blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(), - allowedRoutes: [`POST ${HWLAB_M3_IO_API_ROUTE}`, `GET ${HWLAB_M3_STATUS_API_ROUTE}`], - directGatewayCallsAllowed: false, - directBoxCallsAllowed: false, - directPatchPanelCallsAllowed: false, - fallbackAllowed: false - }, safety: runnerSafetyContract() }; } -async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, traceRecorder }) { - const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); - const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); - const startedAt = nowIso(now); - const sessionCapabilityLevel = configuredCloudApiBaseUrl(env) && m3IoIntentHasSupportedAction(intent) - ? m3SessionCapabilityLevelForIntent(intent) - : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; - const sessionAcquire = registry.acquire({ - conversationId, - sessionId, - workspace: resolvedWorkspace, - sandbox: M3_IO_SKILL_SANDBOX, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - sessionMode: M3_IO_SKILL_SESSION_MODE, - capabilityLevel: sessionCapabilityLevel, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - traceId, - now - }); - const codexStdioFeasibility = skippedCodexStdioFeasibilityForM3Io({ workspace: resolvedWorkspace }); - - if (!sessionAcquire.ok) { - const blockedSession = sessionAcquire.session; - throw runnerError(sessionAcquire.code, sessionAcquire.message, { - provider: M3_IO_SKILL_PROVIDER, - model: M3_IO_SKILL_MODEL, - backend: M3_IO_SKILL_BACKEND, - workspace: resolvedWorkspace ?? null, - sandbox: M3_IO_SKILL_SANDBOX, - session: blockedSession, - toolCalls: [], - skills: notRequestedSkills(), - runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), - runnerTrace: m3IoSkillRunnerTrace({ - traceId, - workspace: resolvedWorkspace ?? repoRoot, - session: blockedSession, - events: ["request:accepted", `session:${sessionAcquire.code}`], - startedAt, - outputTruncated: false - }), - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: blockedSession ? sessionReuseEvidence(blockedSession) : null, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - session: blockedSession, - sessionMode: M3_IO_SKILL_SESSION_MODE, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME +function structuredCompletionBlocker(result, context = {}) { + if (!result || typeof result !== "object") return null; + if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") { + return structuredBlocker({ + code: "text_chat_only_fallback", + layer: "provider", + message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.", + userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。", + retryable: false, + traceId: context.traceId, + provider: result.provider ?? context.provider, + backend: result.backend ?? context.backend, + runner: result.runner ?? context.runner, + capabilityLevel: result.capabilityLevel ?? context.capabilityLevel, + blockers: result.longLivedSessionGate?.blockers }); } + return null; +} - let session = sessionAcquire.session; - const requestId = `req_${randomUUID()}`; - const actorId = "usr_code_agent"; - traceRecorder?.append({ - type: "tool_call", - status: "started", - label: "tool:hwlab-m3-io:started", - toolName: HWLAB_M3_IO_SKILL_NAME, - waitingFor: HWLAB_M3_IO_API_ROUTE - }); - const { commandArgs, skillResult } = await executeM3IoSkillIntent({ - intent, - traceId, - requestId, - actorId, - env, - now, - requestJson - }); - const finishedAt = nowIso(now); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - - const capabilityLevel = skillResult.capabilityLevel ?? ( - skillResult.ok ? m3SessionCapabilityLevelForIntent(intent) : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked - ); - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - const directRunnerSeed = { - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - runner: { - kind: M3_IO_SKILL_RUNNER_KIND, - codexStdio: false, - durableSession: false, - sessionReused: session?.reused ?? false, - turn: session?.turn ?? null - } - }; - const m3Io = m3IoStructuredResult({ - skillResult, - stdioResult: directRunnerSeed, - commandArgs, - route, - method - }); - const toolCall = m3IoSkillToolCall({ - skillResult, - commandArgs, - cwd: resolvedWorkspace, - capabilityLevel, - route, - method, - responseType: m3Io.type - }); - const events = [ - "request:accepted", - "runner:m3-skill-cli:selected", - "tool:skill-cli:started", - `route:${route}`, - `tool:${toolCall.status}` - ]; - const runnerTrace = m3IoSkillRunnerTrace({ - traceId, - events, - startedAt, - finishedAt, - outputTruncated: toolCall.outputTruncated, - workspace: resolvedWorkspace, - session, - skillResult - }); - traceRecorder?.append({ - type: "tool_call", - status: skillResult.ok ? "completed" : "blocked", - label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`, - toolName: HWLAB_M3_IO_SKILL_NAME, - outputSummary: `route=${route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`, - terminal: false - }); - - const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); - const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult }); +function notRequestedSkills() { return { - provider: M3_IO_SKILL_PROVIDER, - model: M3_IO_SKILL_MODEL, - backend: M3_IO_SKILL_BACKEND, - content: m3IoSkillReply(skillResult, m3Io), - responseType: m3Io.type, - m3Io, - workspace: resolvedWorkspace, - sandbox: M3_IO_SKILL_SANDBOX, - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - toolCalls: [toolCall], - skills: { - status: "used", - items: [m3IoSkillItem({ capabilityLevel, route })], - count: 1, - blockers - }, - runner, - runnerTrace, - capabilityLevel, - blockers, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - providerTrace: { - ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }), - runnerKind: M3_IO_SKILL_RUNNER_KIND, - codexStdio: false, - transport: "skill-cli", - fallbackUsed: false - } - }; -} - -async function executeM3IoSkillIntent({ intent, traceId, requestId, actorId, env, now, requestJson }) { - if (intent?.blocker) { - const blocker = m3IoIntentBlocker(intent.blocker, { traceId }); - return { - commandArgs: [ - "m3", - "io", - "--trace-id", - traceId, - "--request-id", - requestId, - "--actor-id", - actorId - ], - skillResult: m3IoSkillBlockedBeforeRequestResult({ - traceId, - requestId, - actorId, - blocker, - route: HWLAB_M3_IO_API_ROUTE, - method: "POST", - action: "m3.io.blocked", - hwlabApi: { - route: HWLAB_M3_IO_API_ROUTE, - redactedUrl: null, - source: "intent-validation", - baseUrlConfigured: Boolean(configuredCloudApiBaseUrl(env)), - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - missingConfig: [], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - startedAt: nowIso(now), - finishedAt: nowIso(now) - }) - }; - } - - if (!m3IoIntentHasSupportedAction(intent)) { - const blocker = m3IoIntentActionMissingBlocker({ traceId }); - return { - commandArgs: [ - "m3", - "io", - "--trace-id", - traceId, - "--request-id", - requestId, - "--actor-id", - actorId - ], - skillResult: m3IoSkillBlockedBeforeRequestResult({ - traceId, - requestId, - actorId, - blocker, - route: HWLAB_M3_IO_API_ROUTE, - method: "POST", - action: "m3.io", - hwlabApi: { - route: HWLAB_M3_IO_API_ROUTE, - redactedUrl: null, - source: "intent-validation", - baseUrlConfigured: Boolean(configuredCloudApiBaseUrl(env)), - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - missingConfig: [], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - startedAt: nowIso(now), - finishedAt: nowIso(now) - }) - }; - } - - const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); - if (!commandArgs.includes("--request-id")) { - commandArgs.push("--request-id", requestId); - } - const skillResult = await runM3IoSkillCommand(commandArgs, { - env, - now, - requestJson - }); - return { commandArgs, skillResult }; -} - -function m3IoIntentHasSupportedAction(intent = {}) { - return ["status", "do.write", "di.read"].includes(intent.action); -} - -function skippedCodexStdioFeasibilityForM3Io({ workspace } = {}) { - return { - checked: true, - skipped: true, - reason: "m3_io_skill_cli_deterministic_route", - sourceIssue: "pikasTech/HWLAB#334", - status: "skipped", - ready: false, - canStartLongLivedCodexStdio: false, - commandProbe: { - ready: false, - skipped: true, - reason: "m3_io_skill_cli_deterministic_route" - }, - workspace: workspace ?? repoRoot, - sandbox: M3_IO_SKILL_SANDBOX, - blockers: [], - blockerCodes: [], - summary: `M3 IO requests use deterministic Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}; Codex stdio chat is not part of this control decision.` - }; -} - -function m3IoIntentActionMissingBlocker({ traceId }) { - return { - code: "m3_io_intent_action_missing", - layer: "intent", - category: "needs_confirmation", - retryable: false, - source: "code-agent-m3-skill-cli", - summary: "M3 IO request did not specify status, DI read, or a DO write value.", - message: "M3 IO intent requires an explicit action: m3 status, DI1 read, or DO1 write with true/false.", - zh: "M3 IO 请求缺少明确动作:请指定读取 M3 status、读取 DI1,或把 DO1 写成 true/false。", - userMessage: "M3 IO 请求缺少明确动作,需要指定 status、DI1 读取,或 DO1=true/false 写入。", - traceId, - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME - }; -} - -function m3IoIntentBlocker(blocker, { traceId } = {}) { - const code = blocker?.code ?? "m3_io_scope_blocked"; - const layer = blocker?.layer ?? "intent"; - return { - code, - layer, - category: blocker?.category ?? "scope_blocked", - retryable: false, - source: "code-agent-m3-skill-cli", - summary: blocker?.summary ?? "M3 IO request is outside the allowed controlled virtual IO surface.", - message: blocker?.message ?? "Code Agent only supports the M3 virtual IO controlled cloud-api path.", - zh: blocker?.zh ?? "当前只支持 M3 虚拟 IO 受控链路。", - userMessage: blocker?.userMessage ?? "当前只支持 M3 虚拟 IO 受控链路:box-simu-1 DO1=true/false 写入,或 box-simu-2 DI1 读取。", - traceId, - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME, - allowed: { - read: `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`, - write: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}=true|false`, - route: HWLAB_M3_IO_API_ROUTE - }, - requested: blocker?.requested ?? null - }; -} - -function m3IoSkillBlockedBeforeRequestResult({ - traceId, - requestId, - actorId, - blocker, - route = HWLAB_M3_IO_API_ROUTE, - method = "POST", - action = "m3.io", - hwlabApi, - startedAt, - finishedAt -}) { - return { - ok: false, - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route, - method, - hwlabApi, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - controlReady: false, - action, - accepted: false, - status: "blocked", - traceId, - requestId, - actorId, - operationId: null, - auditId: null, - evidenceId: null, - audit: { - auditId: null, - status: "not_written", - durableStatus: null, - summary: "blocked before HWLAB API request" - }, - evidence: { - evidenceId: null, - status: "blocked", - sourceKind: "BLOCKED", - blocker: blocker.code, - writeStatus: "not_written", - summary: "blocked before HWLAB API request" - }, - durable: { - status: "blocked", - durable: false, - blocker: blocker.code, - category: blocker.category, - summary: blocker.message - }, - blocker, - capabilityBlocker: blocker, - trustBlocker: null, - blockers: [blocker], - readiness: { - status: "blocked", - controlReady: false, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - route, - blocker, - trustBlocker: null - }, - command: null, - result: { - value: null, - targetReadback: null - }, - readback: null, - controlPath: { - cloudApi: false, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - }, - safety: { - cloudApiRouteOnly: true, - allowedRoute: route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false - }, - httpStatus: 0, - error: { - code: blocker.code, - layer: blocker.layer, - category: blocker.category, - blocker, - retryable: blocker.retryable, - userMessage: blocker.userMessage, - message: blocker.message, - traceId, - route, - toolName: HWLAB_M3_IO_SKILL_NAME - }, - rawStatus: null, - startedAt, - finishedAt, - response: null + status: "not_requested", + items: [], + count: 0, + blockers: [] }; } @@ -1547,1237 +928,6 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI } } -async function callCodexStdioWithM3IoSkillRunner({ - message, - intent, - conversationId, - sessionId, - traceId, - env, - now, - workspace, - timeoutMs, - hardTimeoutMs, - model, - codexStdioManager, - traceRecorder, - conversationFacts, - requestJson -}) { - const stdioResult = await callCodexStdioRunner({ - message, - conversationId, - sessionId, - traceId, - env, - now, - workspace, - timeoutMs, - hardTimeoutMs, - model, - codexStdioManager, - traceRecorder, - conversationFacts - }); - traceRecorder?.append({ - type: "tool_call", - status: "started", - label: "tool:hwlab-m3-io:started", - toolName: HWLAB_M3_IO_SKILL_NAME, - waitingFor: HWLAB_M3_IO_API_ROUTE - }); - const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); - const skillResult = await runM3IoSkillCommand(commandArgs, { - env, - now, - requestJson - }); - traceRecorder?.append({ - type: "tool_call", - status: skillResult.ok ? "completed" : "blocked", - label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`, - toolName: HWLAB_M3_IO_SKILL_NAME, - outputSummary: `route=${skillResult.route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`, - terminal: false - }); - return codexStdioM3IoSkillRunnerResult({ - stdioResult, - skillResult, - commandArgs, - traceId, - now - }); -} - -function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }) { - const capabilityLevel = skillResult.capabilityLevel ?? ( - skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked - ); - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - const m3Io = m3IoStructuredResult({ - skillResult, - stdioResult, - commandArgs, - route, - method - }); - const toolCall = m3IoSkillToolCall({ - skillResult, - commandArgs, - cwd: stdioResult.workspace, - capabilityLevel, - route, - method, - responseType: m3Io.type - }); - const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); - const runnerTrace = { - ...stdioResult.runnerTrace, - events: [ - ...(stdioResult.runnerTrace?.events ?? []), - "tool:skill-cli:started", - `route:${route}`, - `tool:${toolCall.status}` - ], - route, - method, - skill: HWLAB_M3_IO_SKILL_NAME, - capabilityLevel, - controlReady: skillResult.controlReady === true, - operationId: skillResult.operationId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - accepted: skillResult.accepted, - status: skillResult.status, - blocker: skillResult.blocker ?? null, - trustBlocker: skillResult.trustBlocker ?? null, - blockers, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - finishedAt: nowIso(now) - }; - const runner = { - ...stdioResult.runner, - skill: { - name: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route - }, - toolPolicy: { - ...(stdioResult.runner?.toolPolicy ?? {}), - allowed: [ - ...new Set([ - ...(stdioResult.runner?.toolPolicy?.allowed ?? []), - `${method} ${route}` - ]) - ] - } - }; - - return { - ...stdioResult, - content: m3IoSkillReply(skillResult, m3Io), - responseType: m3Io.type, - m3Io, - toolCalls: [ - ...(stdioResult.toolCalls ?? []), - toolCall - ], - skills: { - status: "used", - items: [ - ...(stdioResult.skills?.items ?? []), - m3IoSkillItem({ capabilityLevel, route }) - ], - count: (stdioResult.skills?.count ?? 0) + 1, - blockers - }, - runner, - runnerTrace, - capabilityLevel, - blockers, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - providerTrace: { - ...(stdioResult.providerTrace ?? {}), - ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType: m3Io.type }), - runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND, - codexStdio: true, - transport: "stdio+skill-cli" - } - }; -} - -function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method, responseType }) { - return { - id: `tool_${randomUUID()}`, - type: "skill-cli", - name: HWLAB_M3_IO_SKILL_NAME, - responseType, - status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked", - cwd, - command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${redactM3IoCommandArgs(commandArgs).join(" ")}`), - exitCode: skillResult.ok ? 0 : 2, - stdout: boundToolOutput(JSON.stringify({ - route: skillResult.route, - method, - status: skillResult.status, - accepted: skillResult.accepted, - traceId: skillResult.traceId, - operationId: skillResult.operationId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - audit: skillResult.audit, - evidence: skillResult.evidence, - durable: skillResult.durable, - blocker: skillResult.blocker, - result: skillResult.result, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - safety: skillResult.safety - })).text, - stderrSummary: skillResult.blocker?.code ?? "", - outputTruncated: false, - route, - method, - hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi), - capabilityLevel, - controlReady: skillResult.controlReady === true, - accepted: skillResult.accepted, - operationStatus: skillResult.status, - apiStatus: skillResult.status, - operationId: skillResult.operationId, - traceId: skillResult.traceId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - audit: skillResult.audit, - evidence: skillResult.evidence, - durable: skillResult.durable, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - blocker: skillResult.blocker, - capabilityBlocker: skillResult.capabilityBlocker ?? null, - trustBlocker: skillResult.trustBlocker ?? null, - blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false - }; -} - -function m3IoSkillItem({ capabilityLevel, route }) { - return { - name: HWLAB_M3_IO_SKILL_NAME, - summary: `Controlled M3 DO1/DI1 adapter for HWLAB API ${route}.`, - route, - capabilityLevel, - version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION - }; -} - -function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel, responseType }) { - return { - runnerKind: M3_IO_SKILL_RUNNER_KIND, - skill: HWLAB_M3_IO_SKILL_NAME, - responseType, - route, - method, - traceId: skillResult.traceId, - operationId: skillResult.operationId, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, - capabilityLevel, - controlReady: skillResult.controlReady === true, - accepted: skillResult.accepted, - status: skillResult.status, - fallbackUsed: false - }; -} - -function m3IoStructuredResult({ skillResult = {}, stdioResult = {}, commandArgs = [], route = HWLAB_M3_IO_API_ROUTE, method = "POST" }) { - const blocker = m3IoPrimaryBlocker(skillResult); - const isBlocker = Boolean( - blocker || - skillResult.ok === false || - skillResult.accepted === false || - String(skillResult.status ?? "").toLowerCase() === "blocked" - ); - const action = skillResult.action ?? skillResult.command?.action ?? m3IoActionFromArgs(commandArgs) ?? (route === HWLAB_M3_STATUS_API_ROUTE ? "status" : "m3.io"); - const do1Value = action === "do.write" - ? firstDefined(skillResult.command?.value, skillResult.result?.value, skillResult.value) ?? null - : null; - const di1Value = action === "di.read" - ? firstDefined(skillResult.result?.value, skillResult.readback?.value) ?? null - : firstDefined(skillResult.result?.targetReadback?.value, skillResult.readback?.value) ?? null; - const durable = skillResult.durable ?? {}; - const evidenceSourceKind = skillResult.evidence?.sourceKind ?? null; - const auditStatus = skillResult.audit?.status ?? null; - const evidenceStatus = skillResult.evidence?.status ?? null; - const trusted = durable.durable === true && - evidenceSourceKind === "DEV-LIVE" && - !skillResult.trustBlocker && - ["persisted", "green"].includes(String(auditStatus ?? evidenceStatus ?? "").toLowerCase()); - const pathSummary = `Code Agent -> Skill CLI -> HWLAB API ${route}`; - - return { - type: isBlocker ? "m3_io_blocker" : "m3_io_result", - status: skillResult.status ?? (isBlocker ? "blocked" : "succeeded"), - action, - accepted: skillResult.accepted === true, - controlReady: skillResult.controlReady === true, - capabilityLevel: skillResult.capabilityLevel ?? (isBlocker ? HWLAB_M3_IO_CAPABILITY_LEVELS.blocked : HWLAB_M3_IO_CAPABILITY_LEVELS.ready), - summary: m3IoStructuredSummary({ - action, - isBlocker, - blocker, - route, - do1Value, - di1Value, - trusted, - durable: durable.durable === true - }), - do1: { - resourceId: M3_IO_TOPOLOGY.sourceResourceId, - port: M3_IO_TOPOLOGY.sourcePort, - targetValue: do1Value - }, - di1: { - resourceId: M3_IO_TOPOLOGY.targetResourceId, - port: M3_IO_TOPOLOGY.targetPort, - observedValue: di1Value, - status: skillResult.result?.targetReadback?.status ?? skillResult.readback?.status ?? null - }, - wiring: { - from: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}`, - via: M3_IO_TOPOLOGY.patchPanelServiceId, - to: `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`, - label: `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort} -> ${M3_IO_TOPOLOGY.patchPanelServiceId} -> ${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}` - }, - path: { - summary: pathSummary, - segments: ["Code Agent", "Skill CLI", "HWLAB API"], - skillCli: { - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - name: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION - }, - hwlabApi: { - route, - method, - source: skillResult.hwlabApi?.source ?? null, - redactedUrl: skillResult.hwlabApi?.redactedUrl ?? null, - baseUrlConfigured: skillResult.hwlabApi?.baseUrlConfigured ?? null, - cloudApiOnly: skillResult.hwlabApi?.cloudApiOnly !== false - }, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - openAiFallbackUsed: false - }, - operation: { - operationId: skillResult.operationId ?? null, - status: skillResult.status ?? null, - accepted: skillResult.accepted === true, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null - }, - zh: m3IoChineseResult({ - action, - isBlocker, - blocker, - route, - method, - do1Value, - di1Value, - trusted, - durable, - skillResult - }), - session: { - sessionId: stdioResult.session?.sessionId ?? null, - sessionMode: stdioResult.sessionMode ?? stdioResult.runner?.sessionMode ?? null, - sessionReused: stdioResult.sessionReuse?.reused ?? stdioResult.runner?.sessionReused ?? false, - turn: stdioResult.sessionReuse?.turn ?? stdioResult.runner?.turn ?? null, - codexStdio: stdioResult.runner?.codexStdio === true, - durableSession: stdioResult.runner?.durableSession === true - }, - trace: { - traceId: skillResult.traceId ?? stdioResult.traceId ?? null, - requestId: skillResult.requestId ?? null, - actorId: skillResult.actorId ?? null, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - route, - method - }, - trust: { - trusted, - durable: durable.durable === true, - durableStatus: durable.status ?? null, - durableBlocker: durable.blocker ?? null, - auditStatus, - evidenceStatus, - evidenceSourceKind, - trustBlocker: skillResult.trustBlocker ?? null, - note: trusted - ? "operation/audit/evidence 已有可信持久化记录;仍不是 M3 DEV-LIVE 验收结论。" - : skillResult.controlReady === true - ? "控制链路可达,但可信记录未 green,不能作为 DEV-LIVE 可信闭环通过。" - : "本次不伪造 DEV-LIVE:控制链路结果与可信持久化状态分开展示。" - }, - blocker, - blockers: skillResult.blockers ?? (blocker ? [blocker] : []), - safety: { - cloudApiRouteOnly: true, - allowedRoute: route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false, - acceptanceClaimed: false - } - }; -} - -function m3IoActionFromArgs(commandArgs = []) { - const actionIndex = commandArgs.indexOf("--action"); - if (actionIndex >= 0 && typeof commandArgs[actionIndex + 1] === "string") { - return commandArgs[actionIndex + 1]; - } - if (commandArgs.includes("status")) return "status"; - return null; -} - -function m3IoPrimaryBlocker(skillResult = {}) { - const capabilityCandidates = [ - skillResult.blocker, - skillResult.capabilityBlocker, - skillResult.error?.blocker - ]; - const primary = capabilityCandidates.find((item) => item?.code || item?.message || item?.zh); - if (primary) return primary; - if ( - skillResult.ok !== false && - skillResult.accepted !== false && - String(skillResult.status ?? "").toLowerCase() !== "blocked" - ) { - return null; - } - const fallbackCandidates = [ - ...(Array.isArray(skillResult.blockers) ? skillResult.blockers : []) - ]; - return fallbackCandidates.find((item) => item?.code || item?.message || item?.zh) ?? null; -} - -function m3IoStructuredSummary({ action, isBlocker, blocker, route, do1Value, di1Value, trusted, durable }) { - if (isBlocker) { - const reason = blocker?.userMessage ?? blocker?.zh ?? blocker?.message ?? blocker?.code ?? "M3 IO 链路受阻"; - return `M3 IO 阻塞:${reason};路径 Code Agent -> Skill CLI -> HWLAB API ${route}。`; - } - const actionText = action === "do.write" - ? `DO1 目标值=${formatM3Value(do1Value)},DI1 观测值=${formatM3Value(di1Value)}` - : action === "di.read" - ? `DI1 观测值=${formatM3Value(di1Value)}` - : `状态读取 DI1=${formatM3Value(di1Value)}`; - return `M3 IO 结果:${actionText};trusted=${trusted ? "true" : "false"};durable=${durable ? "true" : "false"}。`; -} - -function m3IoChineseResult({ action, isBlocker, blocker, route, method, do1Value, di1Value, trusted, durable, skillResult = {} }) { - const expectedValue = action === "do.write" ? do1Value : null; - const targetResource = action === "do.write" - ? `${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}` - : `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}`; - const actualResult = action === "do.write" - ? `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(di1Value)}` - : action === "di.read" - ? `${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(di1Value)}` - : "未执行"; - const controlReachable = skillResult.controlReady === true; - const trustedGreen = trusted === true; - const durableGreen = durable?.durable === true && !durable?.blocker; - return { - status: isBlocker ? "blocked" : trustedGreen && durableGreen ? "succeeded" : "degraded", - targetResource, - action: action === "do.write" - ? "写入 DO1 并回读 DI1" - : action === "di.read" - ? "读取 DI1" - : "M3 IO 请求", - expectedValue, - readValue: action === "di.read" ? di1Value : null, - actualResult, - source: `HWLAB cloud-api 受控链路 ${method} ${route}`, - traceId: skillResult.traceId ?? null, - operationId: skillResult.operationId ?? null, - auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, - evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, - controlPath: { - reachable: controlReachable, - cloudApiRouteOnly: true, - route, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - runtime: { - durableGreen, - trustedGreen, - durableStatus: durable?.status ?? null, - durableBlocker: durable?.blocker ?? null, - note: controlReachable && !trustedGreen - ? "控制链路可达,但可信记录未 green,不能作为 DEV-LIVE 可信闭环通过。" - : trustedGreen - ? "控制链路和可信记录均为 green;仍不在本接口内声明 live 已更新。" - : "控制链路或可信记录未 green。" - }, - blocker: blocker?.code ? { - code: blocker.code, - layer: blocker.layer ?? null, - message: blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code - } : null - }; -} - -function redactM3IoCommandArgs(commandArgs = []) { - const redacted = []; - for (let index = 0; index < commandArgs.length; index += 1) { - redacted.push(commandArgs[index]); - if (commandArgs[index] === "--api-base-url" && index + 1 < commandArgs.length) { - redacted.push(""); - index += 1; - } - } - return redacted; -} - -function m3IoToolCallApiTarget(hwlabApi = {}) { - const target = { - ...hwlabApi, - route: hwlabApi?.route ?? HWLAB_M3_IO_API_ROUTE - }; - delete target.url; - if (findDirectHardwareTarget(target.redactedUrl)) { - target.redactedUrl = null; - } - return target; -} - -function m3SkillRoute(skillResult = {}) { - return skillResult.route === HWLAB_M3_STATUS_API_ROUTE ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE; -} - -function m3SkillMethod(skillResult = {}) { - return m3SkillRoute(skillResult) === HWLAB_M3_STATUS_API_ROUTE ? "GET" : skillResult.method ?? "POST"; -} - -function m3SessionCapabilityLevelForIntent(intent = {}) { - if (intent.action === "status") return HWLAB_M3_IO_CAPABILITY_LEVELS.readonly; - if (!m3IoIntentHasSupportedAction(intent)) return HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; - return HWLAB_M3_IO_CAPABILITY_LEVELS.ready; -} - -function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, route = HWLAB_M3_IO_API_ROUTE, startedAt, finishedAt }) { - const method = route === HWLAB_M3_STATUS_API_ROUTE ? "GET" : "POST"; - return { - ok: false, - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route, - method, - hwlabApi: { - route, - redactedUrl: null, - source: "missing-config", - baseUrlConfigured: false, - requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], - recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, - missingConfig: [ - ...HWLAB_M3_IO_API_BASE_URL_ENVS, - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - controlReady: false, - action: "m3.io", - accepted: false, - status: "blocked", - traceId, - requestId, - actorId, - operationId: null, - auditId: null, - evidenceId: null, - audit: { - auditId: null, - status: "not_written", - durableStatus: null, - summary: "blocked before HWLAB API request" - }, - evidence: { - evidenceId: null, - status: "blocked", - sourceKind: "BLOCKED", - blocker: blocker.code, - writeStatus: "not_written", - summary: "blocked before HWLAB API request" - }, - durable: { - status: "blocked", - durable: false, - blocker: blocker.code, - category: blocker.category, - summary: blocker.message - }, - blocker, - capabilityBlocker: blocker, - trustBlocker: null, - blockers: [blocker], - readiness: { - status: "blocked", - controlReady: false, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - route: HWLAB_M3_IO_API_ROUTE, - blocker, - trustBlocker: null - }, - command: null, - result: { - value: null, - targetReadback: null - }, - readback: null, - controlPath: { - cloudApi: false, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - }, - safety: { - cloudApiRouteOnly: true, - allowedRoute: HWLAB_M3_IO_API_ROUTE, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false - }, - httpStatus: 0, - error: { - code: blocker.code, - layer: blocker.layer, - category: blocker.category, - blocker, - retryable: blocker.retryable, - userMessage: blocker.userMessage, - message: blocker.message, - traceId, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - missingConfig: blocker.missingConfig - }, - rawStatus: null, - startedAt, - finishedAt, - response: null - }; -} - -function detectReadOnlyRunnerIntent(message, options = {}) { - const text = String(message ?? "").trim(); - - if (isSecretReadRequest(text)) { - return { - kind: "security", - toolName: "security.secret-redaction", - reason: "Code Agent 安全边界不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" - }; - } - if (isPcGatewayCodexToolRequest(text)) { - return { - kind: "none", - toolName: "codex-stdio.pc-gateway-wrapper" - }; - } - const m3IoIntent = detectM3IoIntent(text); - if (m3IoIntent) { - return m3IoIntent; - } - if (isHardwareWriteOrAcceptanceRequest(text)) { - return { - kind: "security", - toolName: "security.hardware-boundary", - reason: `Code Agent 安全边界不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` - }; - } - const externalNetworkIntent = detectExternalNetworkIntent(text); - if (externalNetworkIntent) return externalNetworkIntent; - if (isSessionContextRequest(text)) { - return { kind: "session_context", toolName: "session.context" }; - } - return { kind: "none" }; -} - -function isPcGatewayCodexToolRequest(text) { - const value = String(text ?? ""); - const mentionsGatewayTool = /(?:\/app\/tools\/hwlab-gateway-shell\.mjs|hwlab-gateway-shell\.mjs|hardware\.invoke\.shell|PC\s*gateway|gws_DESKTOP-[A-Z0-9-]+|cap_windows_cmd_exec|res_windows_host)/iu.test(value); - const mentionsWindowsCommand = /(?:Windows\s+cmd|cmd\s*\/c|shell\.exec|hostname|PowerShell|powershell)/iu.test(value); - const mentionsWindowsSkill = /(?:C:\\Users\\liang\\\.agents\\skills|\.agents[\\/]+skills|keil-cli\.py|\bKeil\b|MDK-ARM|\.uvprojx|F:\\Work\\constart|job-status)/iu.test(value); - return mentionsGatewayTool && (mentionsWindowsCommand || mentionsWindowsSkill); -} - -function detectExternalNetworkIntent(text) { - const value = String(text ?? "").trim(); - if (!value) return null; - const explicitUrl = value.match(/\bhttps?:\/\/[^\s"'<>,。!?))]+/iu)?.[0] ?? null; - const mentionsGithub = /\bgithub(?:\.com)?\b|github\.com|GitHub/u.test(value); - const mentionsExternalWeb = explicitUrl || mentionsGithub || /(?:外网|公网|网页|网站|URL|http|https|network|reachable|访问|打开|连通|看看).{0,20}(?:网页|网站|URL|github|GitHub|外网|公网|http|https)|(?:github|GitHub|http|https|URL).{0,20}(?:访问|打开|看看|连通|reachable)/iu.test(value); - const asksToCheck = /(?:访问|打开|看看|试试|检查|确认|能不能|是否|连通|可达|reachable|access|open|visit|check|curl|ping)/iu.test(value); - if (!mentionsExternalWeb || !asksToCheck) return null; - return { - kind: "external_network", - toolName: "external.network.check", - targetUrl: explicitUrl ?? (mentionsGithub ? "https://github.com/" : extractExternalNetworkHost(value)), - originalText: value - }; -} - -function extractExternalNetworkHost(text) { - const host = String(text ?? "").match(/\b([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?:\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]*)?/u)?.[0]; - return host ? `https://${host}` : null; -} - -function isSessionContextRequest(text) { - const value = String(text ?? ""); - const referencesEarlierTurns = /(?:前两轮|前2轮|上一轮|上两轮|刚才|前面|之前|previous|earlier|context|上下文|根据.*(?:结果|内容|事实)|基于.*(?:结果|内容|事实))/iu.test(value); - const asksForFacts = /(?:工作目录|当前目录|workspace|路径|skills?|skill|技能|能使用|可用|session|conversation|会话|trace|runner)/iu.test(value); - return referencesEarlierTurns && asksForFacts; -} - -function detectM3IoIntent(text) { - const normalized = String(text ?? ""); - if (!/\bM\d+\b|\bM3\b|DO\s*\d+|DI\s*\d+|数字输出|数字输入|box[-_ ]?simu|res_boxsimu|回读|读回|状态|status|串口|serial|PWM|DAP/u.test(normalized)) { - return null; - } - if (isDirectM3HardwareAccessRequest(normalized)) { - return null; - } - const scopeBlocker = detectM3IoScopeBlocker(normalized); - if (scopeBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: scopeBlocker - }; - } - const explicitTargetBlocker = detectM3IoTargetBlocker(normalized, "any"); - if (explicitTargetBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: explicitTargetBlocker - }; - } - if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) { - return null; - } - - const topologyMentioned = isM3TopologyRouteMention(normalized); - const wantsRead = /(?:DI1|数字输入).{0,24}(?:read|readback|读取|回读|读回|查询|状态|当前)|(?:read|readback|读取|回读|读回|查询|状态|当前).{0,24}(?:DI1|数字输入)/iu.test(normalized); - const wantsWrite = /(?:DO1|数字输出).{0,30}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,30}(?:DO1|数字输出)/iu.test(normalized) || - (topologyMentioned && /(?:执行|run|闭环|loop|控制|触发)/iu.test(normalized)); - const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized); - const wantsM3ControlButMissingAction = /(?:HWLAB API|Skill CLI|M3).{0,32}(?:控制|control|IO)|(?:控制|control).{0,32}(?:HWLAB API|Skill CLI|M3 IO|M3)/iu.test(normalized); - if (wantsStatus && !wantsRead && !wantsWrite) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "status" - }; - } - if (!wantsRead && !wantsWrite) { - if (wantsM3ControlButMissingAction) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null - }; - } - return null; - } - - if (wantsRead && !wantsWrite) { - const targetBlocker = detectM3IoTargetBlocker(normalized, "di.read"); - if (targetBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: targetBlocker - }; - } - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "di.read" - }; - } - - const targetBlocker = detectM3IoTargetBlocker(normalized, "do.write"); - if (targetBlocker) { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: null, - blocker: targetBlocker - }; - } - const value = parseM3WriteValue(normalized); - if (typeof value !== "boolean") { - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "do.write" - }; - } - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "do.write", - value - }; -} - -function detectM3IoScopeBlocker(text) { - const value = String(text ?? ""); - const unsupportedStage = value.match(/\bM(?!3\b)\d+\b/iu)?.[0] ?? null; - if (unsupportedStage && /(?:IO|DI|DO|控制|读取|读|写|状态|硬件|hardware)/iu.test(value)) { - return m3ScopeBlocker({ - code: "m3_io_scope_blocked", - summary: `Unsupported hardware stage ${unsupportedStage} requested.`, - zh: `当前 Code Agent 只支持 M3 虚拟 IO,暂不支持 ${unsupportedStage} IO。`, - requested: unsupportedStage - }); - } - const dangerous = value.match(/真实硬件|物理硬件|real\s+hardware|production\s+hardware|PROD|任意\s*shell|shell|bash|sh\s+-c|kubectl|数据库写入|写入数据库|database\s+write|SQL\s+write|绕过\s*(?:patch-panel|cloud-api|HWLAB API)|bypass\s+(?:patch-panel|cloud-api|HWLAB API)|批量脚本|脚本批量|batch\s+script|DAP|串口|serial|PWM/iu)?.[0] ?? null; - if (dangerous && /(?:M3|IO|DI|DO|box[-_ ]?simu|res_boxsimu|硬件|hardware|patch-panel|cloud-api|HWLAB API)/iu.test(value)) { - return m3ScopeBlocker({ - code: "m3_io_scope_blocked", - summary: `Dangerous or out-of-scope M3 IO request was blocked: ${dangerous}.`, - zh: "当前只支持 M3 虚拟 IO 受控链路;不能操作真实硬件、shell、数据库、串口/DAP/PWM、批量脚本,或绕过 cloud-api/patch-panel。", - requested: dangerous - }); - } - return null; -} - -function detectM3IoTargetBlocker(text, action) { - const value = String(text ?? ""); - const unsupportedPort = value.match(/\b(?:DO|DI)\s*(?!1\b)\d+\b|数字(?:输出|输入)\s*(?!1\b)\d+|PWM|DAP|串口|serial/iu)?.[0] ?? null; - if (unsupportedPort) { - return m3ScopeBlocker({ - code: "m3_io_target_unsupported", - category: "target_unsupported", - summary: `Unsupported M3 IO target ${unsupportedPort} requested.`, - zh: "当前只支持 M3 阶段允许的 DO1/DI1,不能执行 DO2/DI2、DAP、串口或 PWM 等请求。", - requested: unsupportedPort - }); - } - - const mentionsBox1 = /(?:box[-_ ]?simu[-_ ]?1|boxsimu[_-]?1|res_boxsimu_1)/iu.test(value); - const mentionsBox2 = /(?:box[-_ ]?simu[-_ ]?2|boxsimu[_-]?2|res_boxsimu_2)/iu.test(value); - if (action === "do.write" && mentionsBox2 && !mentionsBox1) { - return m3ScopeBlocker({ - code: "m3_io_target_unsupported", - category: "target_unsupported", - summary: "DO write target is not the allowed res_boxsimu_1:DO1 endpoint.", - zh: "DO 写入仅允许目标 box-simu-1 DO1;不能把 box-simu-2 当作 DO 写入目标。", - requested: "box-simu-2 DO1" - }); - } - if (action === "di.read" && mentionsBox1 && !mentionsBox2) { - return m3ScopeBlocker({ - code: "m3_io_target_unsupported", - category: "target_unsupported", - summary: "DI read target is not the allowed res_boxsimu_2:DI1 endpoint.", - zh: "DI 读取仅允许目标 box-simu-2 DI1;不能把 box-simu-1 当作 DI 读取目标。", - requested: "box-simu-1 DI1" - }); - } - return null; -} - -function m3ScopeBlocker({ code, category = "scope_blocked", summary, zh, requested }) { - return { - code, - layer: "intent", - category, - retryable: false, - summary, - message: "M3 IO request is outside the allowed controlled virtual IO surface.", - zh, - userMessage: `${zh} 当前只支持 box-simu-1 DO1=true/false 写入和 box-simu-2 DI1 读取,且必须走 cloud-api ${HWLAB_M3_IO_API_ROUTE}。`, - requested - }; -} - -function isDirectM3HardwareAccessRequest(text) { - const value = String(text ?? ""); - if (/(?:https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b)/iu.test(value)) { - return true; - } - if (/(?:直接|direct).{0,24}(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel|gateway|box|\/invoke|\/sync\/tick)|(?:gateway-simu|box-simu|patch-panel|hwlab-patch-panel).{0,24}(?:直接|direct|\/invoke|\/sync\/tick)/iu.test(value)) { - return true; - } - return false; -} - -function isM3TopologyRouteMention(text) { - const value = String(text ?? ""); - return /res_boxsimu_1\s*:?\s*DO1[\s\S]{0,80}hwlab-patch-panel[\s\S]{0,80}res_boxsimu_2\s*:?\s*DI1/iu.test(value) || - /DO1\s*[-=]*>\s*(?:hwlab-)?patch-panel\s*[-=]*>\s*DI1/iu.test(value); -} - -function parseM3WriteValue(text) { - if (/\bfalse\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false; - if (/\btrue\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true; - if (/(?:设置为|设为|写成|写入|置为|set\s+to|=|:)\s*0\b/iu.test(text)) return false; - if (/(?:设置为|设为|写成|写入|置为|set\s+to|=|:)\s*1\b/iu.test(text)) return true; - return null; -} - -function isSecretReadRequest(text) { - const asksToRead = /(?:print|show|cat|read|list|dump|echo|输出|显示|读取|列出|查看|打印)/iu.test(text); - const secretTerm = /(?:secret|token|kubeconfig|OPENAI_API_KEY|DATABASE_URL|password|passwd|credential|private key|私钥|密码|凭证|环境变量|密钥)/iu.test(text); - return asksToRead && secretTerm; -} - -function isHardwareWriteOrAcceptanceRequest(text) { - const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用|\/invoke|\/sync\/tick)/iu.test(text); - const mutationVerb = /(?:call|invoke|write|mutate|apply|rollout|accept|pass|验收|通过|写入|调用|变更|操作)/iu.test(text); - const acceptanceClaim = /(?:M3|M4|M5).{0,20}(?:pass|accept|green|通过|验收|完成)/iu.test(text); - return (hardwareTarget && mutationVerb) || acceptanceClaim; -} - -function resolveRunnerWorkspace(env = process.env, options = {}) { - const configured = firstNonEmpty( - options.workspace, - env.HWLAB_CODE_AGENT_WORKSPACE, - env.HWLAB_RUNNER_WORKSPACE, - env.WORKSPACE - ); - return path.resolve(configured || repoRoot); -} - -function resolveSkillDirs(env = process.env, options = {}) { - const configured = Array.isArray(options.skillsDirs) - ? options.skillsDirs - : String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) - .split(/[,;]/u) - .flatMap((part) => part.split(path.delimiter)); - const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1"; - if (strict) { - return [...new Set(configured - .filter((dir) => typeof dir === "string" && dir.trim()) - .map((dir) => path.resolve(dir.trim())))]; - } - return [...new Set([ - ...configured, - path.join(os.homedir(), ".agents", "skills"), - "/root/.agents/skills", - "/home/ubuntu/.agents/skills", - path.join(repoRoot, "skills") - ] - .filter((dir) => typeof dir === "string" && dir.trim()) - .map((dir) => path.resolve(dir.trim())))]; -} - -function isForbiddenPath(value) { - const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase(); - return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) || - /(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized); -} - -function notRequestedSkills() { - return { - status: "not_requested", - items: [], - count: 0, - blockers: [] - }; -} - -function m3IoSkillArgsForIntent(intent, { env, traceId }) { - const args = [ - "m3", - intent.action === "status" ? "status" : "io" - ]; - if (intent.action !== "status") { - args.push("--action", intent.action); - } - args.push( - "--trace-id", - traceId, - "--actor-id", - "usr_code_agent" - ); - const apiBaseUrl = configuredCloudApiBaseUrl(env); - if (apiBaseUrl) { - args.push("--api-base-url", apiBaseUrl); - } - if (intent.action === "do.write") { - if (typeof intent.value === "boolean") { - args.push("--value", String(intent.value)); - } - args.push( - "--approved", - "--policy", - "hwlab-api-control-with-approval", - "--approval-reason", - "user explicitly requested res_boxsimu_1 DO1 write through HWLAB API" - ); - } else { - args.push("--policy", "hwlab-api-readonly"); - } - return args; -} - -function m3IoSkillApiBaseUrlSource(env = process.env) { - for (const name of HWLAB_M3_IO_API_BASE_URL_ENVS) { - if (firstNonEmpty(env[name])) return `env:${name}`; - } - return "missing-config"; -} - -function m3IoApiBaseUrlMissingBlocker({ route = HWLAB_M3_IO_API_ROUTE } = {}) { - return { - code: "skill_cli_api_base_missing", - layer: "skill-cli-config", - category: "needs_config", - retryable: false, - source: "code-agent-m3-skill-cli", - summary: `${HWLAB_M3_IO_API_BASE_URL_ENV} is required so the Skill CLI can reach cloud-api from inside the cloud-api runtime container.`, - message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV} or another supported HWLAB API base URL contract in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`, - zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。`, - userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。", - traceId: null, - route, - toolName: HWLAB_M3_IO_SKILL_NAME, - missingConfig: [ - ...HWLAB_M3_IO_API_BASE_URL_ENVS, - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ] - }; -} - -function m3IoSkillReply(skillResult, m3Io = null) { - const route = m3Io?.trace?.route ?? skillResult.route ?? HWLAB_M3_IO_API_ROUTE; - const lines = []; - if (m3Io?.type === "m3_io_blocker") { - const blocker = m3Io.blocker ?? skillResult.blocker ?? skillResult.capabilityBlocker ?? {}; - lines.push(`M3 IO 阻塞:${blocker.userMessage ?? blocker.zh ?? blocker.message ?? blocker.code ?? "M3 控制链路仍受阻"}。`); - } else { - lines.push("M3 IO 结果:受控请求已返回。"); - } - if (skillResult.action === "do.write") { - lines.push(`DO1 目标值:${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort}=${formatM3Value(m3Io?.do1?.targetValue ?? skillResult.command?.value)};DI1 观测值:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.result?.targetReadback?.value)}。`); - } else if (skillResult.action === "di.read") { - lines.push(`DI1 读取结果:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.result?.value)}。`); - } else if (skillResult.readonly === true || route === HWLAB_M3_STATUS_API_ROUTE) { - lines.push(`M3 状态读取:${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}=${formatM3Value(m3Io?.di1?.observedValue ?? skillResult.readback?.value)};readiness=${skillResult.readiness?.status ?? "unknown"}。`); - } - lines.push(`接线关系:${M3_IO_TOPOLOGY.sourceResourceId}:${M3_IO_TOPOLOGY.sourcePort} -> ${M3_IO_TOPOLOGY.patchPanelServiceId} -> ${M3_IO_TOPOLOGY.targetResourceId}:${M3_IO_TOPOLOGY.targetPort}。`); - lines.push(`执行路径:Code Agent -> Skill CLI -> HWLAB API ${route};没有使用 OpenAI fallback,也没有由 Code Agent 直连硬件服务。`); - lines.push(`排查字段:traceId=${skillResult.traceId ?? "null"};operationId=${skillResult.operationId ?? "null"};auditId=${skillResult.auditId ?? skillResult.audit?.auditId ?? "null"};evidenceId=${skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? "null"}。`); - if (m3Io?.blocker?.code) { - lines.push(`Blocker: ${m3Io.blocker.code};blocker=${m3Io.blocker.code};layer=${m3Io.blocker.layer ?? "unknown"}。`); - } - if (m3Io?.trust?.note) { - lines.push(`可信记录:${m3Io.trust.note}`); - } - lines.push(`可信状态:trusted=${m3Io?.trust?.trusted === true ? "true" : "false"};durable=${m3Io?.trust?.durable === true ? "true" : "false"};这不是 M3 DEV-LIVE 验收结论。`); - return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text; -} - -function m3IoSkillRunnerDescriptor({ workspace, session = null, skillResult = null } = {}) { - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - const capabilityLevel = skillResult?.capabilityLevel ?? HWLAB_M3_IO_CAPABILITY_LEVELS.ready; - return { - kind: M3_IO_SKILL_RUNNER_KIND, - provider: M3_IO_SKILL_PROVIDER, - backend: M3_IO_SKILL_BACKEND, - workspace: workspace ?? repoRoot, - sandbox: M3_IO_SKILL_SANDBOX, - session: M3_IO_SKILL_SESSION_MODE, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionId: session?.sessionId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdio: false, - longLivedSession: false, - durableSession: false, - writeCapable: true, - readOnly: false, - capabilityLevel, - skill: { - name: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route - }, - toolPolicy: { - allowed: [`${method} ${route}`], - blocked: ["gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "generic-hardware-rpc", "OpenAI-hardware-fallback", "M3/M4/M5-acceptance-claim"] - }, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - safety: { - secretsRead: false, - secretValuesPrinted: false, - kubeconfigRead: false, - cloudApiRouteOnly: true, - allowedRoute: route, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, - openAiFallbackAllowed: false, - m3m4m5AcceptanceClaimsAllowed: false, - outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT - } - }; -} - -function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = startedAt, outputTruncated, workspace = repoRoot, session = null, skillResult = null }) { - const capabilityLevel = skillResult?.capabilityLevel ?? ( - skillResult?.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked - ); - const route = m3SkillRoute(skillResult); - const method = m3SkillMethod(skillResult); - return { - traceId, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - workspace, - sandbox: M3_IO_SKILL_SANDBOX, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionId: session?.sessionId ?? null, - sessionStatus: session?.status ?? null, - idleTimeoutMs: session?.idleTimeoutMs ?? null, - lastTraceId: session?.lastTraceId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - limitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - startedAt, - finishedAt, - events, - route, - method, - skill: HWLAB_M3_IO_SKILL_NAME, - capabilityLevel, - controlReady: skillResult?.controlReady === true, - operationId: skillResult?.operationId ?? null, - auditId: skillResult?.auditId ?? skillResult?.audit?.auditId ?? null, - evidenceId: skillResult?.evidenceId ?? skillResult?.evidence?.evidenceId ?? null, - readback: skillResult?.readback ?? skillResult?.result?.targetReadback ?? null, - accepted: skillResult?.accepted ?? false, - status: skillResult?.status ?? "blocked", - blocker: skillResult?.blocker ?? null, - trustBlocker: skillResult?.trustBlocker ?? null, - blockers: skillResult?.blockers ?? [], - outputTruncated: Boolean(outputTruncated), - valuesPrinted: false, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - note: `Controlled M3 IO uses Skill CLI -> HWLAB API ${route}; it is not OpenAI fallback and does not directly call gateway, box, or patch-panel.` - }; -} - -function structuredCompletionBlocker(result, context = {}) { - if (!result || typeof result !== "object") return null; - if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") { - return structuredBlocker({ - code: "text_chat_only_fallback", - layer: "provider", - message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.", - userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。", - retryable: false, - traceId: context.traceId, - provider: result.provider ?? context.provider, - backend: result.backend ?? context.backend, - runner: result.runner ?? context.runner, - capabilityLevel: result.capabilityLevel ?? context.capabilityLevel, - blockers: result.longLivedSessionGate?.blockers - }); - } - const m3SkillTool = Array.isArray(result.toolCalls) - ? result.toolCalls.find((toolCall) => toolCall?.name === HWLAB_M3_IO_SKILL_NAME) - : null; - if ((result.provider === M3_IO_SKILL_PROVIDER || m3SkillTool || result.m3Io?.type === "m3_io_blocker") && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { - const primary = result.m3Io?.blocker ?? m3SkillTool?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; - const topCode = m3CompletionBlockerCode(primary); - return structuredBlocker({ - code: topCode, - layer: topCode === "m3_readiness_blocked" - ? "m3-readiness" - : primary?.layer ?? (primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness"), - message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.", - userMessage: primary?.userMessage ?? primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。", - retryable: primary?.code === "hwlab_api_unavailable", - traceId: context.traceId, - provider: result.provider, - backend: result.backend, - runner: result.runner, - capabilityLevel: result.capabilityLevel, - route: result.m3Io?.trace?.route ?? m3SkillTool?.route ?? HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME, - blockers: result.m3Io?.blockers ?? result.skills?.blockers - }); - } - const hardwareTool = Array.isArray(result.toolCalls) - ? result.toolCalls.find((toolCall) => toolCall?.name === HARDWARE_INVOKE_SHELL_METHOD) - : null; - if ((result.provider === LEGACY_CODE_AGENT_HARDWARE_PROVIDER || hardwareTool || result.hardware?.type === "hardware_capability_blocker") && result.capabilityLevel === LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED) { - const primary = result.hardware?.blocker ?? hardwareTool?.blocker ?? result.blocker ?? result.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; - return structuredBlocker({ - code: primary?.code ?? "hardware_capability_blocked", - layer: primary?.layer ?? "hardware-capability", - message: primary?.message ?? primary?.summary ?? "Registered hardware capability route is blocked.", - userMessage: primary?.userMessage ?? "已登记硬件 capability 路由当前受阻,本次未进入 Codex stdio 或文本 fallback。", - retryable: primary?.retryable ?? false, - traceId: context.traceId, - provider: result.provider, - backend: result.backend, - runner: result.runner, - capabilityLevel: result.capabilityLevel, - route: HARDWARE_INVOKE_SHELL_METHOD, - toolName: HARDWARE_INVOKE_SHELL_METHOD, - category: primary?.category ?? "hardware_capability_blocked", - blockers: result.blockers ?? result.hardware?.blockers ?? result.skills?.blockers - }); - } - return null; -} - -function m3CompletionBlockerCode(primary = {}) { - if (["m3_gateway_session_unavailable", "runtime_durable_not_green", "skill_cli_api_base_missing", "m3_io_intent_action_missing", "m3_io_scope_blocked", "m3_io_target_unsupported"].includes(primary?.code)) { - return "m3_readiness_blocked"; - } - return primary?.code ?? "m3_io_blocker"; -} - function sessionReuseEvidence(session) { return { conversationId: session.conversationId, @@ -2830,43 +980,16 @@ function runnerSafetyContract() { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, - hardwareWritesAllowed: false, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, - m3IoSkillCliAllowedRoute: HWLAB_M3_IO_API_ROUTE, - m3StatusSkillCliAllowedRoute: HWLAB_M3_STATUS_API_ROUTE, + hardwareWritesAllowed: true, + directGatewayCallsAllowed: true, + directBoxSimuCallsAllowed: true, + directPatchPanelCallsAllowed: true, openAiHardwareFallbackAllowed: false, m3m4m5AcceptanceClaimsAllowed: false, outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT }; } -function securityBlockedToolCall({ traceId, toolName = "security.boundary", cwd = repoRoot } = {}) { - return { - id: `tool_${randomUUID()}`, - type: "security-policy", - name: toolName, - status: "blocked", - cwd, - exitCode: 1, - stdout: "", - stderrSummary: "security_blocked", - outputTruncated: false, - traceId, - route: null, - blocker: { - code: "security_blocked", - layer: "security", - category: "security_blocked", - retryable: false, - traceId, - toolName, - userMessage: "该请求被安全边界阻断,不能绕过 HWLAB API。" - } - }; -} - async function inspectCodexStdioFeasibility(env = process.env, options = {}) { const manager = resolveCodexStdioSessionManager(options); const descriptor = { @@ -3227,30 +1350,6 @@ function errorTaxonomy(code, error = {}) { retryable: false, userMessage: "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。" }, - security_blocked: { - layer: "security", - category: "security_blocked", - retryable: false, - userMessage: "该请求被安全边界阻断,不能读取敏感信息或绕过 HWLAB API。" - }, - skill_cli_api_base_missing: { - layer: "skill-cli-config", - category: "needs_config", - retryable: false, - userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。" - }, - hwlab_api_unavailable: { - layer: "hwlab-api", - category: "retryable", - retryable: true, - userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。" - }, - m3_readiness_blocked: { - layer: "m3-readiness", - category: "capability_unavailable", - retryable: false, - userMessage: "M3 控制链路尚未就绪,不能把本次结果标记为真实可控。" - }, text_chat_only_fallback: { layer: "provider", category: "fallback", @@ -3311,23 +1410,11 @@ function errorTaxonomy(code, error = {}) { retryable: true, userMessage: "Codex stdio runner 执行失败,可稍后重试。" }, - external_network_blocked: { - layer: "network", - category: "capability_unavailable", - retryable: false, - userMessage: "外部网络访问被运行策略、DNS 或目标边界阻断;本次不会回退成文本成功。" - }, - network_tool_unavailable: { - layer: "network-tool", - category: "needs_config", - retryable: false, - userMessage: "当前运行环境没有可用的受控 HTTP 网络检查工具;本次未访问外网。" - }, - network_timeout: { - layer: "network", - category: "timeout", + codex_stdio_network_failed: { + layer: "runner", + category: "runner_blocked", retryable: true, - userMessage: "外部网络检查超时;输入已保留,可稍后重试或让维护者确认网络策略。" + userMessage: "Codex stdio runner 的网络操作失败;输入已保留,可稍后重试。" } }; return catalog[code] ?? { @@ -3421,6 +1508,39 @@ function safeIsoLike(value) { return /^[0-9T:Z.+-]+$/u.test(text) ? text : null; } +function resolveRunnerWorkspace(env = process.env, options = {}) { + const configured = firstNonEmpty( + options.workspace, + env.HWLAB_CODE_AGENT_WORKSPACE, + env.HWLAB_RUNNER_WORKSPACE, + env.WORKSPACE + ); + return path.resolve(configured || repoRoot); +} + +function resolveSkillDirs(env = process.env, options = {}) { + const configured = Array.isArray(options.skillsDirs) + ? options.skillsDirs + : String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) + .split(/[,;]/u) + .flatMap((part) => part.split(path.delimiter)); + const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1"; + if (strict) { + return [...new Set(configured + .filter((dir) => typeof dir === "string" && dir.trim()) + .map((dir) => path.resolve(dir.trim())))]; + } + return [...new Set([ + ...configured, + path.join(os.homedir(), ".agents", "skills"), + "/root/.agents/skills", + "/home/ubuntu/.agents/skills", + path.join(repoRoot, "skills") + ] + .filter((dir) => typeof dir === "string" && dir.trim()) + .map((dir) => path.resolve(dir.trim())))]; +} + function sanitizeErrorField(key, value) { if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") { return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : []; @@ -3431,14 +1551,12 @@ function sanitizeErrorField(key, value) { function routeForError(code, error = {}) { if (error.route !== undefined) return error.route; - if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_API_ROUTE; if (String(code).startsWith("hardware_")) return HARDWARE_INVOKE_SHELL_METHOD; return null; } function toolNameForError(code, error = {}) { if (error.toolName !== undefined) return error.toolName; - if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_SKILL_NAME; if (String(code).startsWith("hardware_")) return HARDWARE_INVOKE_SHELL_METHOD; return null; } diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index ca48e064..3b6fc22b 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -730,1096 +730,6 @@ test("OpenAI provider mode still preserves facts but does not fallback without C assert.equal(JSON.stringify(payload.conversationFacts).includes("sk-"), false); }); -test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_write", - traceId: "trc_m3_skill_write", - message: "请通过 M3 DO1 写入 true,并回读 DI1" - }, - { - now: () => "2026-05-23T00:05:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO control"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - const parsed = new URL(url); - assert.equal(parsed.pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(parsed.hostname, "hwlab-cloud-api.hwlab-dev.svc.cluster.local"); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, "do.write"); - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, true); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_m3_skill_write", - operationId: "op_m3_do_write_skill", - auditId: "aud_m3_do_write_skill_succeeded", - evidenceId: "evd_m3_do_write_skill_succeeded", - auditState: { - status: "written_non_durable", - durableStatus: { - status: "degraded" - } - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: { - status: "succeeded", - value: true, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.session.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].status, "completed"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.toolCalls[0].controlReady, true); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(payload.toolCalls[0].operationId, "op_m3_do_write_skill"); - assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_write"); - assert.equal(payload.toolCalls[0].auditId, "aud_m3_do_write_skill_succeeded"); - assert.equal(payload.toolCalls[0].evidenceId, "evd_m3_do_write_skill_succeeded"); - assert.equal(payload.toolCalls[0].readback.value, true); - assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.runnerTrace.method, "POST"); - assert.equal(payload.runnerTrace.accepted, true); - assert.equal(payload.runnerTrace.auditId, "aud_m3_do_write_skill_succeeded"); - assert.equal(payload.runnerTrace.evidenceId, "evd_m3_do_write_skill_succeeded"); - assert.equal(payload.runnerTrace.readback.value, true); - assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.runnerTrace.controlReady, true); - assert.equal(payload.runner.writeCapable, true); - assert.deepEqual(payload.runner.toolPolicy.allowed, [`POST ${HWLAB_M3_IO_API_ROUTE}`]); - assert.equal(payload.runner.safety.directGatewayCallsAllowed, false); - assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false); - assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.method, "POST"); - assert.equal(payload.providerTrace.auditId, "aud_m3_do_write_skill_succeeded"); - assert.equal(payload.providerTrace.evidenceId, "evd_m3_do_write_skill_succeeded"); - assert.equal(payload.providerTrace.readback.value, true); - assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.providerTrace.controlReady, true); - assert.equal(payload.skills.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); - assert.match(payload.reply.content, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); - assert.equal(calls[0].url.includes("gateway"), false); - assert.equal(calls[0].url.includes("box-simu"), false); - assert.equal(calls[0].url.includes("patch-panel"), false); -}); - -test("Code Agent M3 DI read returns structured blocker from HWLAB API without fallback", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_read_blocked", - traceId: "trc_m3_skill_read_blocked", - message: "读取 M3 DI1 readback" - }, - { - now: () => "2026-05-23T00:06:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO read"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "blocked", - accepted: false, - action: "di.read", - traceId: "trc_m3_skill_read_blocked", - operationId: "op_m3_di_read_blocked", - auditId: "aud_m3_di_read_blocked_failed", - evidenceId: "evd_m3_di_read_blocked_failed", - blocker: { - code: "m3_gateway_session_unavailable", - message: "gateway unavailable", - zh: "gateway 未注册/不可用" - }, - blockerClassification: { - category: "gateway_session" - }, - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - controlPath: { - cloudApi: true, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.blocker.layer, "m3-readiness"); - assert.equal(payload.blocker.retryable, false); - assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.blocker.toolName, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.toolCalls[0].controlReady, false); - assert.equal(payload.toolCalls[0].blocker.code, "m3_gateway_session_unavailable"); - assert.equal(payload.skills.blockers[0].code, "m3_gateway_session_unavailable"); - assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.runnerTrace.blocker.code, "m3_gateway_session_unavailable"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.providerTrace.controlReady, false); - assert.equal(calls.length, 1); - assert.equal(new URL(calls[0].url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(calls[0].request.body.action, "di.read"); - assert.equal(calls[0].request.body.resourceId, "res_boxsimu_2"); - assert.equal(calls[0].request.body.port, "DI1"); -}); - -test("Code Agent M3 Skill CLI missing API base returns structured config blocker", async () => { - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_api_base_missing", - traceId: "trc_m3_skill_api_base_missing", - message: "读取 M3 DI1 readback" - }, - { - now: () => "2026-05-23T00:06:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" - }, - m3IoSkillRequestJson: async () => { - throw new Error("missing API base must not issue a network request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); - assert.equal(payload.toolCalls[0].blocker.retryable, false); - assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(JSON.stringify(payload).includes("://"), false); -}); - -test("Code Agent M3 Skill CLI preserves slow structured blocker beyond legacy 4500ms window", async () => { - const startedAt = Date.now(); - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_slow_blocker", - traceId: "trc_m3_skill_slow_blocker", - message: "通过 HWLAB API 读取 M3 DI1 并返回结构化 blocker" - }, - { - now: () => "2026-05-23T00:06:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO blockers"); - }, - m3IoSkillRequestJson: async (url, request) => { - assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(request.timeoutMs, 30000); - await delay(4600); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "blocked", - accepted: false, - action: "di.read", - traceId: "trc_m3_skill_slow_blocker", - operationId: "op_m3_di_read_slow_blocker", - auditId: "aud_m3_di_read_slow_blocker_failed", - evidenceId: "evd_m3_di_read_slow_blocker_failed", - blocker: { - code: "runtime_durable_not_green", - message: "runtime durable evidence is not green", - zh: "runtime durable evidence 尚未为 green" - }, - blockerClassification: { - category: "runtime_durability", - reason: "durable evidence is blocked" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "not_written" - }, - controlPath: { - cloudApi: true, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(Date.now() - startedAt >= 4500, true); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.blocker.layer, "m3-readiness"); - assert.equal(payload.blocker.retryable, false); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "runtime_durable_not_green"); - assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_slow_blocker"); - assert.equal(payload.skills.status, "used"); - assert.equal(payload.skills.blockers[0].code, "runtime_durable_not_green"); - assert.equal(payload.runnerTrace.traceId, "trc_m3_skill_slow_blocker"); - assert.equal(payload.runnerTrace.blocker.code, "runtime_durable_not_green"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.traceId, "trc_m3_skill_slow_blocker"); - assert.equal(Object.hasOwn(payload, "error"), false); - assert.match(payload.reply.content, /Blocker: runtime_durable_not_green/u); -}); - -test("Code Agent M3 Skill CLI HWLAB API unavailable returns retryable structured blocker", async () => { - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_hwlab_api_unavailable", - traceId: "trc_m3_skill_hwlab_api_unavailable", - message: "读取 M3 DI1 readback" - }, - { - now: () => "2026-05-23T00:06:40.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - m3IoSkillRequestJson: async () => ({ - ok: false, - status: 503, - body: null, - error: "service unavailable" - }) - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "hwlab_api_unavailable"); - assert.equal(payload.blocker.layer, "hwlab-api"); - assert.equal(payload.blocker.retryable, true); - assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_unavailable"); - assert.equal(payload.toolCalls[0].blocker.retryable, true); -}); - -test("Code Agent M3 DO false write exposes readback and identifier contract", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_skill_write_false", - traceId: "trc_m3_skill_write_false", - message: "通过 HWLAB API 把 res_boxsimu_1 DO1 写成 false 并读取 res_boxsimu_2 DI1" - }, - { - now: () => "2026-05-23T00:06:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO control"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_m3_skill_write_false", - operationId: "op_m3_do_write_skill_false", - auditId: "aud_m3_do_write_skill_false_succeeded", - evidenceId: "evd_m3_do_write_skill_false_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: false, - targetReadback: { - status: "succeeded", - value: false, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - const tool = payload.toolCalls[0]; - assert.equal(tool.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(tool.method, "POST"); - assert.equal(tool.accepted, true); - assert.equal(tool.status, "completed"); - assert.equal(tool.operationId, "op_m3_do_write_skill_false"); - assert.equal(tool.traceId, "trc_m3_skill_write_false"); - assert.equal(tool.auditId, "aud_m3_do_write_skill_false_succeeded"); - assert.equal(tool.evidenceId, "evd_m3_do_write_skill_false_succeeded"); - assert.equal(tool.readback.value, false); - assert.equal(tool.readback.resourceId, "res_boxsimu_2"); - assert.equal(tool.readback.port, "DI1"); - assert.equal(payload.runnerTrace.method, "POST"); - assert.equal(payload.runnerTrace.readback.value, false); - assert.equal(payload.providerTrace.readback.value, false); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, false); - assert.equal(calls[0].url.includes("gateway"), false); - assert.equal(calls[0].url.includes("box-simu"), false); - assert.equal(calls[0].url.includes("patch-panel"), false); -}); - -test("Code Agent M3 true/false/read requests bypass Codex stdio and use Skill CLI /v1/m3/io", async () => { - const calls = []; - const codexStdioCalls = []; - const forbiddenCodexStdioManager = { - describe() { - codexStdioCalls.push("describe"); - throw new Error("M3 IO must not inspect Codex stdio before Skill CLI control"); - }, - async probe() { - codexStdioCalls.push("probe"); - throw new Error("M3 IO must not probe Codex stdio before Skill CLI control"); - }, - async chat() { - codexStdioCalls.push("chat"); - throw new Error("M3 IO must not enter Codex stdio chat"); - }, - cancel() { - codexStdioCalls.push("cancel"); - }, - reapIdle() { - codexStdioCalls.push("reapIdle"); - } - }; - let sessionSeq = 0; - const sessionRegistry = createCodeAgentSessionRegistry({ - idFactory: () => `ses_m3_stdio_bypass_${sessionSeq += 1}` - }); - const cases = [ - { - suffix: "true", - message: "通过 HWLAB API 把 DO1 置为 true,然后读取 DI1。", - action: "do.write", - value: true, - operationId: "op_m3_do_true_bypass", - auditId: "aud_m3_do_true_bypass", - evidenceId: "evd_m3_do_true_bypass" - }, - { - suffix: "false", - message: "把 res_boxsimu_1 的 DO1 置为 false,并回读 res_boxsimu_2 的 DI1。", - action: "do.write", - value: false, - operationId: "op_m3_do_false_bypass", - auditId: "aud_m3_do_false_bypass", - evidenceId: "evd_m3_do_false_bypass" - }, - { - suffix: "read", - message: "读取 DI1 的当前状态,走 HWLAB API 控制 M3 IO。", - action: "di.read", - value: false, - operationId: "op_m3_di_read_bypass", - auditId: "aud_m3_di_read_bypass", - evidenceId: "evd_m3_di_read_bypass" - } - ]; - - for (const item of cases) { - const payload = await handleCodeAgentChat( - { - conversationId: `cnv_m3_stdio_bypass_${item.suffix}`, - traceId: `trc_m3_stdio_bypass_${item.suffix}`, - message: item.message - }, - { - now: () => "2026-05-23T00:08:40.000Z", - codexStdioManager: forbiddenCodexStdioManager, - sessionRegistry, - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO control"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ suffix: item.suffix, url, request }); - assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, item.action); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - if (item.action === "do.write") { - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, item.value); - } else { - assert.equal(request.body.resourceId, "res_boxsimu_2"); - assert.equal(request.body.port, "DI1"); - } - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: item.action, - traceId: `trc_m3_stdio_bypass_${item.suffix}`, - operationId: item.operationId, - auditId: item.auditId, - evidenceId: item.evidenceId, - auditState: { - status: item.action === "do.write" ? "written_non_durable" : "read" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: item.action === "do.write" ? "written_non_durable" : "not_written" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: item.action === "do.write" - ? { - value: item.value, - targetReadback: { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - } - } - : { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; - assert.equal(skillTool.type, "skill-cli"); - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); - assert.equal(skillTool.status, "completed"); - assert.equal(skillTool.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.accepted, true); - assert.equal(skillTool.operationId, item.operationId); - assert.equal(skillTool.auditId, item.auditId); - assert.equal(skillTool.evidenceId, item.evidenceId); - assert.equal(skillTool.readback.value, item.value); - assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.runnerTrace.method, "POST"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.readback.value, item.value); - assert.ok(payload.runner.toolPolicy.allowed.includes(`POST ${HWLAB_M3_IO_API_ROUTE}`)); - assert.equal(JSON.stringify(payload.toolCalls).includes("gateway-simu"), false); - assert.equal(JSON.stringify(payload.toolCalls).includes("box-simu"), false); - assert.equal(JSON.stringify(payload.toolCalls).includes("patch-panel"), false); - } - - assert.equal(codexStdioCalls.length, 0); - assert.equal(calls.length, cases.length); -}); - -test("Code Agent parses exact Chinese M3 IO requests and returns structured Chinese result", async () => { - const calls = []; - const cases = [ - { - suffix: "read_di1", - message: "读取 box-simu-2 DI1 状态", - action: "di.read", - value: true, - zhAction: "读取 DI1", - targetResource: "res_boxsimu_2:DI1" - }, - { - suffix: "write_do1_true", - message: "把 box-simu-1 DO1 设置为 true", - action: "do.write", - value: true, - zhAction: "写入 DO1 并回读 DI1", - targetResource: "res_boxsimu_1:DO1" - }, - { - suffix: "write_do1_false", - message: "把 box-simu-1 DO1 设置为 false", - action: "do.write", - value: false, - zhAction: "写入 DO1 并回读 DI1", - targetResource: "res_boxsimu_1:DO1" - } - ]; - - for (const item of cases) { - const payload = await handleCodeAgentChat( - { - conversationId: `cnv_m3_exact_zh_${item.suffix}`, - traceId: `trc_m3_exact_zh_${item.suffix}`, - message: item.message - }, - { - now: () => "2026-05-24T10:00:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for exact Chinese M3 IO"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ suffix: item.suffix, url, request }); - assert.equal(new URL(url).pathname, HWLAB_M3_IO_API_ROUTE); - assert.equal(new URL(url).hostname, "hwlab-cloud-api.hwlab-dev.svc.cluster.local"); - assert.equal(request.method, "POST"); - assert.equal(request.body.action, item.action); - assert.equal(request.body.source, "hwlab-agent-runtime.m3-io"); - if (item.action === "do.write") { - assert.equal(request.body.resourceId, "res_boxsimu_1"); - assert.equal(request.body.port, "DO1"); - assert.equal(request.body.value, item.value); - } else { - assert.equal(request.body.resourceId, "res_boxsimu_2"); - assert.equal(request.body.port, "DI1"); - assert.equal(Object.hasOwn(request.body, "value"), false); - } - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: item.action, - traceId: `trc_m3_exact_zh_${item.suffix}`, - operationId: `op_m3_exact_zh_${item.suffix}`, - auditId: `aud_m3_exact_zh_${item.suffix}`, - evidenceId: `evd_m3_exact_zh_${item.suffix}`, - auditState: { - status: item.action === "do.write" ? "written_non_durable" : "read" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: item.action === "do.write" ? "written_non_durable" : "not_written" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: item.action === "do.write" - ? { - value: item.value, - targetReadback: { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - } - } - : { - status: "succeeded", - value: item.value, - resourceId: "res_boxsimu_2", - port: "DI1" - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.m3Io.zh.status, "degraded"); - assert.equal(payload.m3Io.zh.targetResource, item.targetResource); - assert.equal(payload.m3Io.zh.action, item.zhAction); - assert.equal(payload.m3Io.zh.traceId, `trc_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.operationId, `op_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.auditId, `aud_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.evidenceId, `evd_m3_exact_zh_${item.suffix}`); - assert.equal(payload.m3Io.zh.controlPath.reachable, true); - assert.equal(payload.m3Io.zh.controlPath.cloudApiRouteOnly, true); - assert.equal(payload.m3Io.zh.runtime.durableGreen, false); - assert.equal(payload.m3Io.zh.runtime.trustedGreen, false); - assert.equal(payload.m3Io.zh.runtime.durableBlocker, "runtime_durable_not_green"); - assert.match(payload.m3Io.zh.runtime.note, /控制链路可达,但可信记录未 green/u); - assert.match(payload.reply.content, /控制链路可达,但可信记录未 green/u); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].hwlabApi.cloudApiOnly, true); - assert.equal(payload.toolCalls[0].directGatewayCalls, false); - assert.equal(payload.toolCalls[0].directBoxCalls, false); - assert.equal(payload.toolCalls[0].directPatchPanelCalls, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - } - - assert.equal(calls.length, cases.length); - for (const call of calls) { - assert.equal(call.url.includes("gateway"), false); - assert.equal(call.url.includes("box-simu"), false); - assert.equal(call.url.includes("patch-panel"), false); - } -}); - -test("Code Agent ambiguous M3 IO control request returns structured blocker without Codex stdio", async () => { - const codexStdioCalls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_ambiguous_stdio_bypass", - traceId: "trc_m3_ambiguous_stdio_bypass", - message: "通过 HWLAB API 控制 M3 IO。" - }, - { - now: () => "2026-05-23T00:08:50.000Z", - codexStdioManager: { - describe() { - codexStdioCalls.push("describe"); - throw new Error("ambiguous M3 IO must not inspect Codex stdio"); - }, - async probe() { - codexStdioCalls.push("probe"); - throw new Error("ambiguous M3 IO must not probe Codex stdio"); - }, - async chat() { - codexStdioCalls.push("chat"); - throw new Error("ambiguous M3 IO must not enter Codex stdio chat"); - }, - cancel() { - codexStdioCalls.push("cancel"); - }, - reapIdle() { - codexStdioCalls.push("reapIdle"); - } - }, - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for ambiguous M3 IO"); - }, - m3IoSkillRequestJson: async () => { - throw new Error("ambiguous M3 IO must block before any HWLAB API mutation request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].blocker.code, "m3_io_intent_action_missing"); - assert.equal(payload.toolCalls[0].hwlabApi.source, "intent-validation"); - assert.equal(payload.toolCalls[0].accepted, false); - assert.equal(payload.toolCalls[0].operationId, null); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.match(payload.reply.content, /Blocker: m3_io_intent_action_missing/u); - assert.equal(codexStdioCalls.length, 0); -}); - -test("Code Agent blocks out-of-scope or dangerous Chinese M3 IO requests before HWLAB API call", async () => { - const messages = [ - { - suffix: "real_hardware", - message: "把真实硬件 M3 DO1 设置为 true", - code: "m3_io_scope_blocked" - }, - { - suffix: "shell", - message: "用任意 shell 批量脚本绕过 patch-panel 写入 M3 DO1 true", - code: "m3_io_scope_blocked" - }, - { - suffix: "di2", - message: "读取 box-simu-2 DI2 状态", - code: "m3_io_target_unsupported" - }, - { - suffix: "wrong_box", - message: "把 box-simu-2 DO1 设置为 true", - code: "m3_io_target_unsupported" - }, - { - suffix: "serial_pwm", - message: "通过串口读取 M3 PWM 状态", - code: "m3_io_scope_blocked" - } - ]; - - for (const item of messages) { - const payload = await handleCodeAgentChat( - { - conversationId: `cnv_m3_dangerous_zh_${item.suffix}`, - traceId: `trc_m3_dangerous_zh_${item.suffix}`, - message: item.message - }, - { - now: () => "2026-05-24T10:01:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for dangerous M3 IO"); - }, - m3IoSkillRequestJson: async () => { - throw new Error("dangerous or out-of-scope M3 IO must block before HWLAB API request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); - assert.equal(payload.toolCalls[0].blocker.code, item.code); - assert.equal(payload.toolCalls[0].hwlabApi.source, "intent-validation"); - assert.equal(payload.toolCalls[0].accepted, false); - assert.equal(payload.toolCalls[0].operationId, null); - assert.equal(payload.toolCalls[0].directGatewayCalls, false); - assert.equal(payload.toolCalls[0].directBoxCalls, false); - assert.equal(payload.toolCalls[0].directPatchPanelCalls, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.m3Io.zh.status, "blocked"); - assert.equal(payload.m3Io.zh.controlPath.cloudApiRouteOnly, true); - assert.match(payload.m3Io.zh.blocker.message, /当前只支持/u); - assert.match(payload.reply.content, /M3 IO 阻塞/u); - assert.match(payload.reply.content, /Blocker:/u); - } -}); - -test("Code Agent M3 status uses Skill CLI GET /v1/m3/status and keeps route evidence", async () => { - const calls = []; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_status_skill", - traceId: "trc_m3_status_skill", - message: "读取 M3 status 聚合状态" - }, - { - now: () => "2026-05-23T00:09:10.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 status"); - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - assert.equal(new URL(url).pathname, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(request.method, "GET"); - return { - ok: true, - status: 200, - body: { - status: "live", - sourceKind: "DEV-LIVE", - traceId: "trc_m3_status_skill", - boxes: [{ - id: "boxsimu_2", - resourceId: "res_boxsimu_2", - online: true, - ports: { - DI1: { - value: false - } - } - }], - patchPanel: { - serviceId: "hwlab-patch-panel", - observable: true, - connectionActive: true - }, - trust: { - operationId: "op_m3_status_skill", - traceId: "trc_m3_status_skill", - auditId: "aud_m3_status_skill", - evidenceId: "evd_m3_status_skill", - durableStatus: "green", - blocker: null, - readStatus: { - audit: "read", - evidence: "read" - }, - runtime: { - durable: true - } - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.readonly); - assert.equal(payload.toolCalls[0].route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(payload.toolCalls[0].method, "GET"); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(payload.toolCalls[0].operationId, "op_m3_status_skill"); - assert.equal(payload.toolCalls[0].readback.value, false); - assert.equal(payload.runnerTrace.route, HWLAB_M3_STATUS_API_ROUTE); - assert.equal(payload.providerTrace.method, "GET"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(calls.length, 1); -}); - -test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => { - const direct = await handleCodeAgentChat( - { - conversationId: "cnv_m3_direct_blocked", - traceId: "trc_m3_direct_blocked", - message: "请直接调用 gateway-simu /invoke 写入 M3 DO1 true" - }, - { - now: () => "2026-05-23T00:07:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - m3IoSkillRequestJson: async () => { - throw new Error("direct gateway request must not reach the M3 skill CLI"); - } - } - ); - - validateCodeAgentChatSchema(direct); - assert.equal(direct.status, "failed"); - assert.equal(direct.error.code, "security_blocked"); - assert.equal(direct.error.layer, "security"); - assert.equal(direct.error.retryable, false); - assert.match(direct.error.userMessage, /安全边界/u); - assert.equal(direct.error.blocker.toolName, "security.hardware-boundary"); - assert.equal(direct.toolCalls[0].name, "security.hardware-boundary"); - assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); -}); - test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardware shortcut", async () => { const calls = []; let providerCalled = false; @@ -1963,6 +873,78 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn } }); +test("Code Agent natural-language Keil and serial monitor request goes through Codex stdio", async () => { + const calls = []; + let providerCalled = false; + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const registry = createCodeAgentSessionRegistry(); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_freq_keil_serial_stdio", + createRpcClient: async () => createFakeAppServerClient({ + calls, + responses: ["已进入 F:/work/constart,完成 71-FREQ Keil build/download,并通过 serial-monitor 抓取启动日志。"] + }) + }); + + try { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_freq_keil_serial_stdio", + traceId: "trc_freq_keil_serial_stdio", + projectId: "prj_mvp_topology", + message: "去 f:/work/constart 找到 71-FREQ 工程,用 keil skill 编译下载,用 serial-monitor skill 抓启动日志" + }, + { + now: () => "2026-05-25T10:51:24.000Z", + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + }, + sessionRegistry: registry, + codexStdioManager: manager, + callProvider: async () => { + providerCalled = true; + throw new Error("text fallback must not be used"); + }, + m3IoSkillRequestJson: async () => { + throw new Error("natural-language Keil and serial monitor prompt must not be routed to M3 IO"); + } + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.responseType, undefined); + assert.notEqual(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); + assert.equal(providerCalled, false); + assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"), false); + const turn = calls.find((call) => call.method === "turn/start"); + assert.ok(turn, "Codex stdio turn/start should be called"); + assert.match(turn.args.prompt, /f:\/work\/constart/iu); + assert.match(turn.args.prompt, /71-FREQ/u); + assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-shell\.mjs/u); + assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills\\keil/u); + assert.match(turn.args.prompt, /keil-cli\.py/u); + assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills\\serial-monitor/u); + assert.match(turn.args.prompt, /monitor start/u); + assert.match(turn.args.prompt, /--powershell-stdin/u); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -2145,56 +1127,6 @@ function createTimedFakeAppServerClient({ calls, text = "timed stdio reply", eve }; } -test("Code Agent M3 Skill CLI blocks missing service-local HWLAB API base URL before loopback fallback", async () => { - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_m3_missing_api_base", - traceId: "trc_m3_missing_api_base", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。" - }, - { - now: () => "2026-05-23T00:07:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO"); - }, - m3IoSkillRequestJson: async () => { - throw new Error("missing HWLAB API base URL must block before any HTTP request"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); - assert.equal(payload.toolCalls[0].blocker.retryable, false); - assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(payload.toolCalls[0].hwlabApi.source, "missing-config"); - assert.equal(payload.toolCalls[0].hwlabApi.redactedUrl, null); - assert.equal(payload.toolCalls[0].command.includes("127.0.0.1:6667"), false); - assert.equal(payload.toolCalls[0].accepted, false); - assert.equal(payload.toolCalls[0].operationId, null); - assert.equal(payload.session.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.runnerTrace.blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); -}); - test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_blocked" diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index b0ebef01..9544278e 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -93,14 +93,14 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "You are the HWLAB Cloud Workbench Code Agent.", "Use the provided workspace and repo-owned Codex stdio session only.", "Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.", - "Do not call gateway, box-simu, or patch-panel directly.", - "Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.", + "For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available wrapper, skill CLI, or project tool that actually reaches the target.", "For registered PC gateway Windows command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.", "For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. The wrapper sends powershell.exe -EncodedCommand and injects UTF-8 helper functions for Unicode-safe execution.", "For Windows text files and skill manifests, avoid raw Get-Content or Select-String objects in JSON. Use Read-HwlabText, Select-HwlabText, and ConvertTo-HwlabJson from the wrapper prologue, or explicitly project plain scalar fields before ConvertTo-Json.", "For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-HwlabJson, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.", "For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI from a PowerShell stdin script using explicit paths or argument arrays. Do not reimplement skill logic, and do not use shell working-directory tricks like cd &&.", "For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py from the generic PowerShell stdin wrapper path; do not reimplement Keil build logic.", + "For boot logs and UART capture, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\serial-monitor with npm run cli -- server status/start, monitor start, and fetch from the generic PowerShell stdin wrapper path; for 71-FREQ prefer the skill-documented baud rate unless live evidence says otherwise.", "For long Keil build/program/download jobs, prefer the skill CLI async job flow: start the job with a short bounded wrapper call, then poll job-status, state files, or logs with short wrapper calls. Do not use gateway --wait long polling unless the user explicitly asks for synchronous waiting and sets a sufficient gateway timeout.", "If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.", "Use the Windows-side skill CLIs under C:\\Users\\liang\\.agents\\skills\\ from that cmd command when they exist; do not reimplement those tools in the prompt.", diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 729c4a13..71b288da 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -115,7 +115,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.codeAgent.partialReady, false); assert.match(healthPayload.codeAgent.blocker, /Codex CLI command/u); assert.equal(healthPayload.codeAgent.reason, "codex_cli_binary_missing"); - assert.equal(healthPayload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(healthPayload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); assert.equal(healthPayload.codeAgent.runner.ready, false); assert.equal(healthPayload.codeAgent.capabilityLevel, "blocked"); assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available"); @@ -1348,9 +1348,9 @@ function m3GatewayStatus({ gatewayId, gatewaySessionId, boxId, resourceId }) { function codexStdioReadyFixture({ workspace, codexHome }) { return { - kind: "codex-mcp-stdio-runner", + kind: "codex-app-server-stdio-runner", provider: "codex-stdio", - backend: "hwlab-cloud-api/codex-mcp-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", status: "feasible", ready: true, startupReady: true, @@ -1506,10 +1506,10 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { status: "idle", workspace, sandbox: "workspace-write", - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", + runnerKind: "codex-app-server-stdio-runner", + sessionMode: "codex-app-server-stdio-long-lived", capabilityLevel: "long-lived-codex-stdio-session", - implementationType: "repo-owned-codex-mcp-stdio-session", + implementationType: "repo-owned-codex-app-server-stdio-session", createdAt: "2026-05-23T00:00:00.000Z", updatedAt: "2026-05-23T00:00:00.000Z", idleTimeoutMs: 1800000, @@ -1529,12 +1529,12 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { return { provider: "codex-stdio", model: "gpt-test", - backend: "hwlab-cloud-api/codex-mcp-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", content: `stdio pwd\n${workspace}`, workspace, sandbox: "workspace-write", session, - sessionMode: "codex-mcp-stdio-long-lived", + sessionMode: "codex-app-server-stdio-long-lived", sessionReuse: { conversationId: params.conversationId, sessionId: session.sessionId, @@ -1547,16 +1547,16 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { status: "idle", idleTimeoutMs: 1800000 }, - implementationType: "repo-owned-codex-mcp-stdio-session", + implementationType: "repo-owned-codex-app-server-stdio-session", runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], codexStdioFeasibility: feasibility, longLivedSessionGate: { status: "pass", pass: true, provider: "codex-stdio", - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", - implementationType: "repo-owned-codex-mcp-stdio-session", + runnerKind: "codex-app-server-stdio-runner", + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session", blockers: [] }, toolCalls: [{ @@ -1579,15 +1579,15 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { blockers: [] }, runner: { - kind: "codex-mcp-stdio-runner", + kind: "codex-app-server-stdio-runner", provider: "codex-stdio", - backend: "hwlab-cloud-api/codex-mcp-stdio", + backend: "hwlab-cloud-api/codex-app-server-stdio", workspace, sandbox: "workspace-write", - session: "codex-mcp-stdio-long-lived", - sessionMode: "codex-mcp-stdio-long-lived", + session: "codex-app-server-stdio-long-lived", + sessionMode: "codex-app-server-stdio-long-lived", sessionId: session.sessionId, - implementationType: "repo-owned-codex-mcp-stdio-session", + implementationType: "repo-owned-codex-app-server-stdio-session", codexStdio: true, longLivedSession: true, durableSession: true, @@ -1597,14 +1597,14 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { }, runnerTrace: { traceId, - runnerKind: "codex-mcp-stdio-runner", + runnerKind: "codex-app-server-stdio-runner", workspace, sandbox: "workspace-write", - sessionMode: "codex-mcp-stdio-long-lived", + sessionMode: "codex-app-server-stdio-long-lived", sessionId: session.sessionId, sessionStatus: "idle", turn: 1, - implementationType: "repo-owned-codex-mcp-stdio-session", + implementationType: "repo-owned-codex-app-server-stdio-session", events: ["stdio:acquire", "session:created", "stdio:ready", "tool:pwd:completed"], outputTruncated: false, valuesPrinted: false @@ -1640,12 +1640,12 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(payload.codeAgent.endpoint, "POST /v1/agent/chat"); assert.equal(payload.codeAgent.provider, "codex-stdio"); assert.equal(payload.codeAgent.model, "gpt-test"); - assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.codeAgent.mode, "codex-stdio"); assert.equal(payload.codeAgent.status, "blocked"); assert.match(payload.codeAgent.blocker, /Codex CLI command/u); assert.equal(payload.codeAgent.reason, "codex_cli_binary_missing"); - assert.equal(payload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.codeAgent.runner.ready, false); assert.equal(payload.codeAgent.capabilityLevel, "blocked"); assert.equal(payload.codeAgent.sessionRegistry.status, "available"); @@ -1922,7 +1922,7 @@ test("cloud api /v1/agent/chat refuses provider stub when Codex stdio is unavail assert.equal(payload.traceId, "trc_server-test-agent-chat"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.model.length > 0, true); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); assert.equal(payload.capabilityLevel, "blocked"); @@ -2006,9 +2006,9 @@ test("OpenAI fallback text chat is not used when Codex stdio is unavailable", as validateCodeAgentChatSchema(payload); assert.equal(payload.status, "failed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.runner.codexStdio, true); assert.equal(payload.longLivedSessionGate.status, "blocked"); assert.equal(Object.hasOwn(payload, "reply"), false); @@ -2061,7 +2061,7 @@ test("cloud api health does not pass long-lived session gate for explicit OpenAI assert.equal(payload.codeAgent.mode, "openai"); assert.equal(payload.codeAgent.ready, false); assert.equal(payload.codeAgent.capabilityLevel, "blocked"); - assert.equal(payload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.codeAgent.runner.codexStdio, true); assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); @@ -2160,7 +2160,7 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(payload.workspace, workspace); assert.equal(payload.sandbox, "workspace-write"); @@ -2169,14 +2169,14 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e assert.equal(payload.session.sandbox, "workspace-write"); assert.equal(payload.session.lastTraceId, "trc_server-test-runner-pwd"); assert.equal(typeof payload.session.idleTimeoutMs, "number"); - assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.runner.longLivedSession, true); assert.equal(payload.runner.codexStdio, true); assert.equal(payload.runner.writeCapable, true); assert.equal(payload.runner.durableSession, true); - assert.equal(payload.runner.sessionMode, "codex-mcp-stdio-long-lived"); - assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); - assert.equal(payload.implementationType, "repo-owned-codex-mcp-stdio-session"); + assert.equal(payload.runner.sessionMode, "codex-app-server-stdio-long-lived"); + assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); + assert.equal(payload.implementationType, "repo-owned-codex-app-server-stdio-session"); assert.equal(payload.sessionReuse.reused, false); assert.equal(payload.sessionReuse.turn, 1); assert.equal(payload.sessionReuse.status, "idle"); @@ -2190,8 +2190,8 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.toolCalls[0].stdout, workspace); assert.equal(payload.skills.status, "not_requested"); - assert.equal(payload.runnerTrace.runnerKind, "codex-mcp-stdio-runner"); - assert.equal(payload.runnerTrace.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.runnerTrace.runnerKind, "codex-app-server-stdio-runner"); + assert.equal(payload.runnerTrace.sessionMode, "codex-app-server-stdio-long-lived"); assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { @@ -2420,20 +2420,20 @@ test("cloud api health reports Codex stdio runner facts without readonly limitat assert.equal(payload.codeAgent.status, "codex-stdio-feasible"); assert.equal(payload.codeAgent.ready, true); assert.equal(payload.codeAgent.provider, "codex-stdio"); - assert.equal(payload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); - assert.equal(payload.codeAgent.runner.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.codeAgent.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.codeAgent.runner.sessionMode, "codex-app-server-stdio-long-lived"); assert.equal(payload.codeAgent.runner.codexStdio, true); assert.equal(payload.codeAgent.runner.writeCapable, true); assert.equal(payload.codeAgent.runner.durableSession, true); assert.equal(payload.codeAgent.workspace, workspace); assert.equal(payload.codeAgent.sandbox, "workspace-write"); - assert.equal(payload.codeAgent.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.codeAgent.sessionMode, "codex-app-server-stdio-long-lived"); assert.equal(payload.codeAgent.sessionRegistry.kind, "codex-stdio-session-registry"); assert.deepEqual(payload.codeAgent.sessionRegistry.statuses, ["creating", "ready", "busy", "idle", "timeout", "error", "canceled", "interrupted", "expired", "failed"]); assert.equal(payload.codeAgent.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass"); assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); - assert.equal(payload.readiness.sessionRunner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.readiness.sessionRunner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.readiness.sessionRunner.codexStdio, true); assert.equal(payload.readiness.sessionRunner.writeCapable, true); assert.equal(payload.readiness.sessionRunner.capabilityLevel, "long-lived-codex-stdio-session"); @@ -2518,11 +2518,11 @@ test("cloud api /v1/agent/chat sends pwd prompt through real Codex stdio instead validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(payload.workspace, workspace); - assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); assert.equal(payload.longLivedSessionGate.status, "pass"); assert.equal(payload.providerTrace.sidecarOnly, undefined); assert.equal(payload.providerTrace.toolName, "codex"); @@ -2623,266 +2623,6 @@ test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio an } }); -test("cloud api /v1/agent/chat handles GitHub access through Codex stdio controlled network check", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-network-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - let codexToolCallCount = 0; - const fetchCalls = []; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_network", - lookupImpl: async () => [{ address: "140.82.114.4", family: 4 }], - fetchImpl: async (url, init) => { - fetchCalls.push({ url: String(url), method: init?.method }); - return { - status: 200, - statusText: "OK", - url: "https://github.com/" - }; - }, - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - codexToolCallCount += 1; - throw new Error("external network prompt should use the controlled network check, not a long model call"); - }, - close() {} - }) - }), - callCodeAgentProvider: async () => { - throw new Error("OpenAI fallback must not handle external network prompts"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_network", - traceId: "trc_server_stdio_network", - message: "访问一下github看看" - }); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); - assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); - assert.equal(payload.providerTrace.transport, "stdio+controlled-network"); - assert.equal(payload.providerTrace.toolName, "external.network.check"); - assert.equal(payload.providerTrace.httpStatus, 200); - assert.equal(codexToolCallCount, 0); - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url, "https://github.com/"); - assert.equal(fetchCalls[0].method, "HEAD"); - assert.ok(payload.toolCalls.some((toolCall) => - toolCall.name === "external.network.check" && - toolCall.status === "completed" && - toolCall.httpStatus === 200 - )); - assert.match(payload.reply.content, /GitHub可以访问/u); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "session:created")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:started")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:completed")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:external.network.check:completed")); - assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat returns structured external network blocker before 150s", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-network-blocked-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - let fetchCalled = false; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_EXTERNAL_NETWORK: "0", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_network_blocked", - lookupImpl: async () => [{ address: "140.82.114.4", family: 4 }], - fetchImpl: async () => { - fetchCalled = true; - throw new Error("fetch must not run when policy blocks external network"); - }, - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - throw new Error("fallback model call must not handle blocked external network prompt"); - }, - close() {} - }) - }), - callCodeAgentProvider: async () => { - throw new Error("OpenAI fallback must not handle blocked external network prompts"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const started = Date.now(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_network_blocked", - traceId: "trc_server_stdio_network_blocked", - message: "看看 github 是否能访问" - }); - const elapsed = Date.now() - started; - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "failed"); - assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(payload.error.code, "external_network_blocked"); - assert.equal(payload.blocker.code, "external_network_blocked"); - assert.equal(payload.blocker.traceId, "trc_server_stdio_network_blocked"); - assert.equal(payload.blocker.sessionId, "ses_server_stdio_network_blocked"); - assert.equal(payload.blocker.conversationId, "cnv_server_stdio_network_blocked"); - assert.equal(payload.blocker.stage, "policy"); - assert.equal(typeof payload.blocker.elapsedMs, "number"); - assert.equal(typeof payload.blocker.lastEvent, "object"); - assert.equal(payload.toolCalls[0].name, "external.network.check"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "external_network_blocked"); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:started")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:blocked")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:external.network.check:blocked")); - assert.equal(fetchCalled, false); - assert.equal(elapsed < 150000, true); - assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat returns network_timeout with preserved runner trace", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-network-timeout-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_SANDBOX: "workspace-write", - HWLAB_CODE_AGENT_EXTERNAL_NETWORK_TIMEOUT_MS: "25", - HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" - }, - codexStdioManager: createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_network_timeout", - lookupImpl: async () => [{ address: "140.82.114.4", family: 4 }], - fetchImpl: async (_url, init) => new Promise((resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - const error = new Error("aborted"); - error.name = "AbortError"; - reject(error); - }); - }), - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - throw new Error("fallback model call must not handle timed out external network prompt"); - }, - close() {} - }) - }) - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const started = Date.now(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_network_timeout", - traceId: "trc_server_stdio_network_timeout", - message: "打开 https://github.com 看看" - }); - const elapsed = Date.now() - started; - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "timeout"); - assert.equal(payload.error.code, "network_timeout"); - assert.equal(payload.blocker.code, "network_timeout"); - assert.equal(payload.blocker.traceId, "trc_server_stdio_network_timeout"); - assert.equal(payload.blocker.sessionId, "ses_server_stdio_network_timeout"); - assert.equal(payload.blocker.stage, "http-timeout"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "network_timeout"); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "network:timeout")); - assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:external.network.check:blocked")); - assert.equal(elapsed < 150000, true); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat returns structured skills blocker without local skills manifest", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-")); const codexHome = path.join(workspace, "codex-home"); @@ -2944,763 +2684,6 @@ test("cloud api /v1/agent/chat returns structured skills blocker without local s } }); -test("cloud api /v1/agent/chat routes M3 IO through Skill CLI even when Codex stdio is unavailable", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-")); - const calls = []; - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_server-test-m3-skill", - operationId: "op_m3_do_write_server_skill", - auditId: "aud_m3_do_write_server_skill_succeeded", - evidenceId: "evd_m3_do_write_server_skill_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: false, - targetReadback: { - status: "succeeded", - value: false - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { - method: "POST", - headers: { - "content-type": "application/json", - "x-trace-id": "trc_server-test-m3-skill" - }, - body: JSON.stringify({ - conversationId: "cnv_server-test-m3-skill", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 false,然后读取 res_boxsimu_2 的 DI1。" - }) - }); - assert.equal(response.status, 200); - const payload = await response.json(); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.toolCalls.length, 1); - assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].route, "/v1/m3/io"); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(Object.hasOwn(payload, "reply"), true); - assert.equal(calls.length, 1); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } -}); - -test("cloud api /v1/agent/chat recognizes M3 patch-panel topology text before Codex free chat", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-skill-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - const calls = []; - const codexStdioCalls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_m3_topology_skill", - createRpcClient: async () => ({ - async initialize() { - codexStdioCalls.push("initialize"); - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - codexStdioCalls.push("listTools"); - return ["codex", "codex-reply"]; - }, - async callTool() { - codexStdioCalls.push("callTool"); - return { - structuredContent: { - threadId: "thread_server_m3_topology_skill", - content: "stdio session ready for controlled topology M3 skill." - } - }; - }, - close() {} - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_server-test-m3-topology", - operationId: "op_m3_do_write_topology_skill", - auditId: "aud_m3_do_write_topology_skill", - evidenceId: "evd_m3_do_write_topology_skill", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: { - status: "succeeded", - value: true, - resourceId: "res_boxsimu_2", - port: "DI1" - } - }, - controlPath: { - status: "succeeded", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-topology", - traceId: "trc_server-test-m3-topology", - message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环,把 DO1 置为 true。" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.m3Io.type, "m3_io_result"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, true); - assert.equal(payload.m3Io.di1.observedValue, true); - assert.equal(payload.m3Io.wiring.label, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1"); - assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(payload.m3Io.operation.operationId, "op_m3_do_write_topology_skill"); - assert.equal(payload.m3Io.trace.traceId, "trc_server-test-m3-topology"); - assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId); - assert.equal(payload.m3Io.trust.trusted, false); - assert.equal(payload.m3Io.trust.durable, false); - const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); - assert.ok(skillTool); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.accepted, true); - assert.equal(skillTool.operationId, "op_m3_do_write_topology_skill"); - assert.equal(skillTool.traceId, "trc_server-test-m3-topology"); - assert.equal(skillTool.readback.value, true); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.transport, "skill-cli"); - assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started")); - assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io")); - assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u); - assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, true); - assert.deepEqual(codexStdioCalls, []); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat returns Skill CLI blocker for M3 topology without DO1 value", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-topology-blocker-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - const calls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_m3_topology_blocker", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - return { - structuredContent: { - threadId: "thread_server_m3_topology_blocker", - content: "stdio session ready for blocked topology M3 skill." - } - }; - }, - close() {} - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - throw new Error("missing DO1 value must block before HWLAB API request"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-topology-blocker", - traceId: "trc_server-test-m3-topology-blocker", - message: "执行 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 基本闭环。" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, null); - assert.equal(payload.m3Io.di1.observedValue, null); - assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(payload.m3Io.blocker.code, "invalid_boolean_value"); - const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); - assert.ok(skillTool); - assert.equal(skillTool.status, "blocked"); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.blocker.code, "invalid_boolean_value"); - assert.equal(skillTool.accepted, false); - assert.equal(skillTool.operationId, null); - assert.equal(payload.blocker.code, "invalid_boolean_value"); - assert.match(payload.reply.content, /^M3 IO 阻塞:/u); - assert.match(payload.reply.content, /M3 DO1 写入需要 --value true 或 --value false/u); - assert.match(payload.reply.content, /Code Agent -> Skill CLI -> HWLAB API \/v1\/m3\/io/u); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.transport, "skill-cli"); - assert.equal(calls.length, 0); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat promotes /v1/m3/io blocker into Chinese M3 IO reply", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-blocker-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - const calls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_m3_api_blocker", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - return { - structuredContent: { - threadId: "thread_server_m3_api_blocker", - content: "stdio session ready for M3 API blocker fixture." - } - }; - }, - close() {} - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - serviceId: "hwlab-cloud-api", - contractVersion: "m3-io-control-v1", - route: "/v1/m3/io", - method: "POST", - status: "blocked", - accepted: false, - action: "do.write", - traceId: "trc_server-test-m3-api-blocker", - operationId: "op_m3_do_write_api_blocker", - auditId: "aud_m3_do_write_api_blocker_failed", - evidenceId: "evd_m3_do_write_api_blocker_failed", - blocker: { - code: "m3_wiring_missing", - layer: "hwlab-patch-panel", - zh: "hwlab-patch-panel 未确认 active res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 接线" - }, - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: null - }, - controlPath: { - status: "blocked", - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: false, - frontendBypass: false - } - } - }; - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-api-blocker", - traceId: "trc_server-test-m3-api-blocker", - message: "把 M3 DO1 打开,然后读取 DI1。" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, true); - assert.equal(payload.m3Io.di1.observedValue, null); - assert.equal(payload.m3Io.blocker.code, "m3_wiring_missing"); - assert.equal(payload.blocker.code, "m3_wiring_missing"); - assert.match(payload.reply.content, /^M3 IO 阻塞:/u); - assert.match(payload.reply.content, /hwlab-patch-panel 未确认 active/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1/u); - assert.match(payload.reply.content, /trusted=false;durable=false/u); - assert.equal(calls.length, 1); - assert.equal(new URL(calls[0].url).pathname, "/v1/m3/io"); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, true); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat routes M3 Skill CLI through in-process HWLAB API handler", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-")); - const fakeCodex = await createFakeCodexCommand(); - const gatewayCalls = []; - const codexStdioCalls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_m3_skill", - createRpcClient: async () => ({ - async initialize() { - codexStdioCalls.push("initialize"); - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - codexStdioCalls.push("listTools"); - return ["codex", "codex-reply"]; - }, - async callTool() { - codexStdioCalls.push("callTool"); - return { - structuredContent: { - threadId: "thread_server_stdio_m3_skill", - content: "stdio session ready for M3 skill." - } - }; - }, - close() {} - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: workspace, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - 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" - }, - codexStdioManager: manager, - m3IoRequestJson: async (url, request) => { - gatewayCalls.push({ url, request }); - return m3ReadinessRequestJson(url, request); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server_stdio_m3_skill", - traceId: "trc_server_stdio_m3_skill", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。" - }); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.runner.codexStdio, false); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route"); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.responseType, "m3_io_result"); - assert.equal(payload.m3Io.type, "m3_io_result"); - assert.equal(payload.m3Io.action, "do.write"); - assert.equal(payload.m3Io.do1.targetValue, true); - assert.equal(payload.m3Io.di1.observedValue, true); - assert.equal(payload.m3Io.operation.operationId.startsWith("op_m3_do_write_"), true); - assert.equal(payload.m3Io.session.sessionId, payload.session.sessionId); - assert.equal(payload.m3Io.path.summary, "Code Agent -> Skill CLI -> HWLAB API /v1/m3/io"); - assert.equal(payload.m3Io.trust.trusted, false); - assert.equal(payload.m3Io.trust.durable, false); - const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); - assert.ok(skillTool); - assert.equal(skillTool.type, "skill-cli"); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.accepted, true); - assert.equal(skillTool.readback.value, true); - assert.equal(skillTool.controlReady, true); - assert.equal(skillTool.operationId.startsWith("op_m3_do_write_"), true); - assert.match(skillTool.auditId, /^aud_m3_do_write_/u); - assert.match(skillTool.evidenceId, /^evd_m3_do_write_/u); - assert.equal(payload.runnerTrace.route, "/v1/m3/io"); - assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started")); - assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io")); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.codexStdio, false); - assert.equal(payload.providerTrace.transport, "skill-cli"); - assert.match(payload.reply.content, /HWLAB API \/v1\/m3\/io/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u); - assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u); - assert.ok(payload.runner.toolPolicy.allowed.includes("POST /v1/m3/io")); - assert.deepEqual(gatewayCalls.map((call) => new URL(call.url).pathname), [ - "/status", - "/invoke", - "/sync/tick", - "/status", - "/invoke" - ]); - assert.deepEqual(codexStdioCalls, []); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat blocks direct M3 API base targets before Codex stdio", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-direct-target-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - const calls = []; - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_m3_direct_block", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - return { - structuredContent: { - threadId: "thread_server_stdio_m3_direct_block", - content: "stdio session ready for blocked M3 direct target." - } - }; - }, - close() {} - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async (url, request) => { - calls.push({ url, request }); - throw new Error("direct gateway target must be blocked before requestJson"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-direct-target", - traceId: "trc_server-test-m3-direct-target", - message: "读取 M3 DI1" - }); - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.blocker.code, "direct_hardware_target_blocked"); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); - assert.equal(skillTool.status, "blocked"); - assert.equal(skillTool.route, "/v1/m3/io"); - assert.equal(skillTool.method, "POST"); - assert.equal(skillTool.blocker.code, "direct_hardware_target_blocked"); - assert.equal(skillTool.hwlabApi.redactedUrl, null); - assert.equal(payload.skills.blockers[0].code, "direct_hardware_target_blocked"); - assert.equal(calls.length, 0); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - -test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base before Codex stdio", async () => { - const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-")); - const codexHome = path.join(workspace, "codex-home"); - const fakeCodex = await createFakeCodexCommand(); - await mkdir(codexHome, { recursive: true }); - const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_m3_api_base_missing", - createRpcClient: async () => ({ - async initialize() { - return { tools: ["codex", "codex-reply"] }; - }, - async listTools() { - return ["codex", "codex-reply"]; - }, - async callTool() { - return { - structuredContent: { - threadId: "thread_server_stdio_m3_api_base_missing", - content: "stdio session ready for missing M3 API base." - } - }; - }, - close() {} - }) - }); - const server = createCloudApiServer({ - env: { - PATH: process.env.PATH, - OPENAI_API_KEY: "test-openai-key-material", - CODEX_HOME: codexHome, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, - HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", - HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", - HWLAB_CODE_AGENT_WORKSPACE: workspace, - HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, - HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" - }, - codexStdioManager: manager, - m3IoSkillRequestJson: async () => { - throw new Error("missing API base must not call requestJson"); - } - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - - try { - const { port } = server.address(); - const payload = await postAgent(port, { - conversationId: "cnv_server-test-m3-api-base-missing", - traceId: "trc_server-test-m3-api-base-missing", - message: "读取 M3 DI1" - }); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.responseType, "m3_io_blocker"); - assert.equal(payload.m3Io.type, "m3_io_blocker"); - assert.equal(payload.m3Io.blocker.code, "skill_cli_api_base_missing"); - assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); - assert.equal(skillTool.blocker.code, "skill_cli_api_base_missing"); - assert.deepEqual(skillTool.blocker.missingConfig, [ - "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", - "HWLAB_API_BASE_URL", - "HWLAB_CLOUD_API_BASE_URL", - "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" - ]); - assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - } finally { - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - await rm(workspace, { recursive: true, force: true }); - await rm(fakeCodex.root, { recursive: true, force: true }); - } -}); - test("cloud api /v1/agent/chat refuses local file-tool shortcuts when Codex stdio is unavailable", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-tools-")); await writeFile(path.join(workspace, "alpha.txt"), "alpha\nbeta\n", "utf8"); @@ -3723,9 +2706,9 @@ test("cloud api /v1/agent/chat refuses local file-tool shortcuts when Codex stdi }); assert.equal(first.status, "failed"); assert.equal(first.provider, "codex-stdio"); - assert.equal(first.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); assert.equal(first.capabilityLevel, "blocked"); - assert.equal(first.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(first.sessionMode, "codex-app-server-stdio-long-lived"); assert.deepEqual(first.toolCalls, []); assert.equal(Object.hasOwn(first, "reply"), false); assert.ok(first.runnerTrace.events.some((event) => event.label === "request:accepted")); @@ -3803,7 +2786,7 @@ test("cloud api /v1/agent/chat does not use read-only conversation facts as fall }); assert.equal(first.status, "failed"); assert.equal(first.provider, "codex-stdio"); - assert.equal(first.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(first.runner.kind, "codex-app-server-stdio-runner"); assert.equal(first.capabilityLevel, "blocked"); assert.equal(first.conversationFacts.turnCount, 0); @@ -3824,8 +2807,8 @@ test("cloud api /v1/agent/chat does not use read-only conversation facts as fall }); assert.equal(third.status, "failed"); assert.equal(third.provider, "codex-stdio"); - assert.equal(third.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(third.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(third.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(third.runner.kind, "codex-app-server-stdio-runner"); assert.equal(third.capabilityLevel, "blocked"); assert.deepEqual(third.toolCalls, []); assert.equal(third.conversationFacts.turnCount, 0); @@ -3914,7 +2897,7 @@ test("cloud api /v1/agent/chat does not answer skills from local manifest when C const payload = await response.json(); assert.equal(payload.status, "failed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.capabilityLevel, "blocked"); assert.equal(payload.skills.status, "not_requested"); assert.deepEqual(payload.toolCalls, []); @@ -4012,14 +2995,14 @@ test("cloud api /v1/agent/chat exposes prompt trace immediately while Codex stdi const payload = await response.json(); assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.traceId, "trc_server-test-slow-skills"); assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.sessionMode, "codex-app-server-stdio-long-lived"); assert.equal(payload.skills.status, "ready"); assert.equal(payload.skills.items[0].name, "slow-skill"); assert.equal(payload.skills.items[0].traceId, "trc_server-test-slow-skills"); - assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); assert.equal(payload.runnerTrace.traceId, "trc_server-test-slow-skills"); assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex:started")); assert.ok(payload.runnerTrace.events.some((event) => event.label === "assistant:completed")); @@ -4370,7 +3353,7 @@ test("cloud api /v1/agent/chat ignores OpenAI fallback and blocks when Codex std assert.equal(payload.traceId, "trc_server-test-agent-chat-stream"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.model, "gpt-test"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.capabilityLevel, "blocked"); assert.equal(payload.error.code, "codex_cli_binary_missing"); assert.equal(Object.hasOwn(payload, "reply"), false); @@ -4483,7 +3466,7 @@ test("cloud api /v1/agent/chat skips delayed provider fallback when Codex stdio assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed-failure"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.error.code, "codex_cli_binary_missing"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.availability.fallback.backend, "hwlab-cloud-api/openai-responses"); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(providerCalled, false); @@ -4538,9 +3521,9 @@ test("cloud api /v1/agent/chat reports Codex stdio blocker instead of provider t assert.equal(payload.error.layer, "runner"); assert.equal(payload.error.retryable, false); assert.equal(payload.provider, "codex-stdio"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(payload.availability.endpoint, "POST /v1/agent/chat"); - assert.equal(payload.availability.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.availability.runner.kind, "codex-app-server-stdio-runner"); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { @@ -4642,7 +3625,7 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.equal(payload.status, "failed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.model, "gpt-test"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); assert.equal(payload.error.code, "codex_cli_binary_missing"); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index 1c642708..823f3dbd 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -7,7 +7,6 @@ import { classifyLiveDeploymentIdentity, classifyLiveWebAssetIdentity, parseSmokeArgs, - runDevCloudWorkbenchExternalNetworkFixtureSmoke, runDevCloudWorkbenchLayoutSmoke, runDevCloudWorkbenchQuickPromptsFixtureSmoke, runDevCloudWorkbenchSessionContinuityFixtureSmoke, @@ -257,8 +256,8 @@ test("source/default smoke covers Code Agent quick prompt fill-only contract", ( const report = runDevCloudWorkbenchStaticSmoke(); const check = report.checks.find((item) => item.id === "code-agent-quick-prompts-contract"); assert.equal(check?.status, "pass"); - assert.equal(check.evidence.includes("HWLAB API:DO1=true 后读 DI1"), true); - assert.equal(check.evidence.includes("HWLAB API:DO1=false 复核 DI1"), true); + assert.equal(check.evidence.includes("Codex:DO1=true 后读 DI1"), true); + assert.equal(check.evidence.includes("Codex:DO1=false 复核 DI1"), true); assert.equal(check.evidence.some((item) => /我点击发送后才确认执行这次写入请求/u.test(item)), true); }); @@ -493,87 +492,6 @@ test("Code Agent browser classifier distinguishes runner busy, session blocked, assert.equal(apiError.category, "api_error"); }); -test("Code Agent browser classifier consumes structured blocker taxonomy", () => { - const base = { - status: "failed", - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - traceId: "trc_structured_blocker" - }; - for (const [code, expected] of [ - ["skill_cli_api_base_missing", ["needs-config", "needs_config"]], - ["hwlab_api_unavailable", ["retryable", "retryable"]], - ["m3_readiness_blocked", ["capability-unavailable", "capability_unavailable"]], - ["text_chat_only_fallback", ["text-chat-only-fallback", "fallback"]] - ]) { - const [blocker, category] = expected; - const classification = classifyCodeAgentBrowserJourney({ - responseSummary: sanitizeAgentChatBody({ - ...base, - error: { - code, - layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", - category, - blocker: { - code, - layer: code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", - category, - retryable: category === "retryable", - userMessage: "结构化中文提示" - }, - retryable: category === "retryable", - userMessage: "结构化中文提示", - route: "/v1/m3/io", - toolName: "hwlab-agent-runtime.m3-io" - } - }, { httpStatus: 200 }), - httpOk: true, - httpStatus: 200, - ui: { - agentChatStatus: "服务受阻", - completedMessageVisible: false, - failedMessageVisible: true - } - }); - assert.equal(classification.blocker, blocker); - assert.equal(classification.category, category); - } -}); - -test("Code Agent browser classifier consumes completed blocked M3 payload blocker", () => { - const classification = classifyCodeAgentBrowserJourney({ - responseSummary: sanitizeAgentChatBody({ - status: "completed", - provider: "hwlab-skill-cli", - model: "controlled-m3-io", - backend: "hwlab-cloud-api/hwlab-agent-runtime-skill-cli", - traceId: "trc_completed_m3_blocked", - capabilityLevel: "hwlab-api-control-blocked", - reply: { - content: "M3 IO Skill CLI result blocked." - }, - blocker: { - code: "m3_readiness_blocked", - layer: "m3-readiness", - category: "capability_unavailable", - retryable: false, - userMessage: "M3 控制链路尚未就绪。" - } - }, { httpStatus: 200 }), - httpOk: true, - httpStatus: 200, - ui: { - agentChatStatus: "能力未开放", - completedMessageVisible: false, - failedMessageVisible: true - } - }); - assert.equal(classification.status, "blocked"); - assert.equal(classification.blocker, "capability-unavailable"); - assert.equal(classification.category, "capability_unavailable"); -}); - test("Code Agent readiness classifier blocks completed payloads over any non-2xx HTTP status", () => { const completedPayload = { conversationId: "cnv_completed_non_2xx", @@ -723,33 +641,6 @@ test("local slow structured blocker fixture preserves trace, pending state, and assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked"); }); -test("local external network fixture renders GitHub blocker with trace and without main evidence noise", async () => { - const report = await runDevCloudWorkbenchExternalNetworkFixtureSmoke({ - responseDelayMs: 5200 - }); - - if (report.status === "skip") { - assert.match(report.summary, /Playwright is unavailable/u); - return; - } - - assert.equal(report.status, "pass", JSON.stringify(report.blockers, null, 2)); - assert.equal(report.evidenceLevel, "SOURCE"); - assert.equal(report.devLive, false); - const blockerCheck = report.checks.find((check) => check.id === "local-agent-fixture-external-network-blocker"); - assert.equal(blockerCheck?.status, "pass"); - assert.equal(blockerCheck.observations.ui.failedMessageVisible, true); - assert.equal(blockerCheck.observations.ui.failedMessageHasTrace, true); - assert.equal(blockerCheck.observations.ui.retryInputPreserved, true); - assert.equal(blockerCheck.observations.ui.retryInputValue, "访问一下github看看"); - assert.match(blockerCheck.observations.ui.traceText, /network started/u); - assert.match(blockerCheck.observations.ui.traceText, /tool:external\.network\.check:blocked/u); - assert.equal(blockerCheck.observations.ui.noMainAttributionNoise, true); - assert.equal(blockerCheck.observations.prompt.response.error.code, "external_network_blocked"); - assert.equal(blockerCheck.observations.prompt.response.toolCalls.some((tool) => tool.name === "external.network.check" && tool.status === "blocked"), true); - assert.equal(blockerCheck.observations.prompt.classification.blocker, "runner-blocked"); -}); - test("local session continuity fixture reuses Code Agent context and retries with degraded-copy guard", async () => { const report = await runDevCloudWorkbenchSessionContinuityFixtureSmoke(); if (report.status === "skip") { diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index bc0fc13a..4cb8688c 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -46,26 +46,26 @@ const codeAgentExternalNetworkPrompt = Object.freeze({ const codeAgentQuickPromptFixtures = Object.freeze([ { id: "pwd", - label: "Skill CLI:pwd", - prompt: "通过 Skill CLI 执行 pwd,列出当前工作目录。", + label: "Codex:pwd", + prompt: "交给 Codex 执行 pwd,列出当前工作目录。", writable: false }, { id: "skills", - label: "Skill CLI:列出 skill", - prompt: "通过 Skill CLI 列出你能使用的所有 skill。", + label: "Codex:列出 skill", + prompt: "交给 Codex 列出你能使用的所有 skill。", writable: false }, { id: "do1-true-di1", - label: "HWLAB API:DO1=true 后读 DI1", - prompt: "通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", + label: "Codex:DO1=true 后读 DI1", + prompt: "交给 Codex 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", writable: true }, { id: "do1-false-di1", - label: "HWLAB API:DO1=false 复核 DI1", - prompt: "通过 HWLAB API / Skill CLI 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", + label: "Codex:DO1=false 复核 DI1", + prompt: "交给 Codex 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。", writable: true } ]); @@ -326,10 +326,7 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "echo/mock/stub", "untrusted_completion", "SOURCE fixture", - "Code Agent SOURCE 回复", - "hwlab-skill-cli", - "hwlab-m3-io-skill-cli", - "message-m3-evidence" + "Code Agent SOURCE 回复" ]); const blockedCodeAgentUiLabels = Object.freeze([ @@ -616,7 +613,7 @@ function runStaticSmoke() { evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); - addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Skill CLI / HWLAB API examples while hardware-write prompts require explicit send confirmation.", { + addCheck(checks, blockers, "code-agent-quick-prompts-contract", hasCodeAgentQuickPromptContract(files), "Code Agent quick prompts fill the input with Codex examples while hardware-write prompts require explicit send confirmation.", { blocker: "safety_blocker", evidence: codeAgentQuickPromptFixtures.flatMap((prompt) => [prompt.id, prompt.label, prompt.prompt]) }); @@ -2498,14 +2495,14 @@ function hasCodeAgentQuickPromptContract({ html, app, styles }) { /我点击发送后才确认执行这次写入请求/u.test(tag) ); }); - const promptCopyUsesApiBoundary = codeAgentQuickPromptFixtures.every((prompt) => - /通过 (?:Skill CLI|HWLAB API \/ Skill CLI)/u.test(prompt.prompt) && - !/(?:\bgateway\b|\bbox\b|\bpatch-panel\b|PROD|真实硬件直控)/iu.test(prompt.prompt) + const promptCopyUsesCodexRoute = codeAgentQuickPromptFixtures.every((prompt) => + /交给 Codex/u.test(prompt.prompt) && + !/PROD/iu.test(prompt.prompt) ); return ( labels && writeButtonsRequireExplicitSend && - promptCopyUsesApiBoundary && + promptCopyUsesCodexRoute && /data-agent-quick-prompt/u.test(app) && /function\s+fillAgentQuickPrompt\s*\(/u.test(app) && /el\.commandInput\.value = prompt/u.test(app) && @@ -4623,15 +4620,6 @@ export async function runDevCloudWorkbenchSlowBlockerFixtureSmoke(options = {}) }); } -export async function runDevCloudWorkbenchExternalNetworkFixtureSmoke(options = {}) { - return runLocalAgentFixtureSmoke({ - mode: "local-agent-external-network-fixture-browser", - responseDelayMs: options.responseDelayMs ?? localAgentFixtureDelayMs, - agentFixtureMode: "external-network-blocker", - expectTimeout: false - }); -} - export async function runDevCloudWorkbenchSessionContinuityFixtureSmoke(options = {}) { let chromium; try { @@ -4891,7 +4879,7 @@ export async function runDevCloudWorkbenchQuickPromptsFixtureSmoke() { { id: "quick-prompts-copy-boundary", status: promptResults.every((result) => result.copyBoundaryOk) ? "pass" : "blocked", - summary: "Quick prompt labels and filled text say HWLAB API / Skill CLI and avoid direct gateway/box/patch-panel or PROD claims.", + summary: "Quick prompt labels and filled text say Codex and avoid PROD claims.", observations: promptResults.map(({ viewport, id, label, inputValue, copyBoundaryOk, forbiddenHits }) => ({ viewport, id, @@ -5068,8 +5056,6 @@ async function runLocalAgentFixtureSmoke({ } else { const prompts = agentFixtureMode === "slow-blocker" ? [codeAgentE2ePrompts[1]] - : agentFixtureMode === "external-network-blocker" - ? [codeAgentExternalNetworkPrompt] : codeAgentE2ePrompts; for (const prompt of prompts) { promptResults.push(await runCodeAgentPromptJourney(page, prompt, { sourceFixture: true })); @@ -5103,7 +5089,7 @@ async function runLocalAgentFixtureSmoke({ ui.timeoutActionPanelContained && !ui.completedMessageVisible ) - : agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker" + : agentFixtureMode === "slow-blocker" ? ( promptResults.length === 1 && promptResults[0]?.status === "blocked" && @@ -5111,9 +5097,7 @@ async function runLocalAgentFixtureSmoke({ promptResults[0]?.legacyWindow?.permanentFailureAround4500ms === false && promptResults[0]?.legacyWindow?.pendingChineseVisible === true && promptResults[0]?.legacyWindow?.pendingTraceShowsEvents === true && - (agentFixtureMode === "external-network-blocker" - ? ["能力未开放", "Runner 受阻", "服务受阻"].includes(ui.agentChatStatus) - : ui.agentChatStatus === "Runner 受阻") && + ui.agentChatStatus === "Runner 受阻" && ui.failedMessageVisible && ui.failedMessageHasTrace && ui.retryInputPreserved && @@ -5188,7 +5172,7 @@ async function runLocalAgentFixtureSmoke({ status: pass ? "pass" : "blocked", summary: expectTimeout ? "Local SOURCE fixture proves a bounded Code Agent timeout stays user-visible/BLOCKED in the UI, preserves trace context, and keeps the user's input for retry." - : agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker" + : agentFixtureMode === "slow-blocker" ? "Local SOURCE fixture returns a delayed structured blocker and the conversation shows the Chinese reason with trace evidence while preserving the input." : "Local SOURCE fixture sends input and reaches a user-visible replied state without marking the fixture as DEV-LIVE.", observations: expectTimeout ? { @@ -5239,15 +5223,11 @@ async function runLocalAgentFixtureSmoke({ summary: "390x844 mobile view shows Chinese Code Agent pending status, trace/session context, and no pending-card text overflow before the delayed response arrives.", observations: mobilePending }); - } else if (!expectTimeout && (agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker")) { + } else if (!expectTimeout && agentFixtureMode === "slow-blocker") { checks.push({ - id: agentFixtureMode === "external-network-blocker" - ? "local-agent-fixture-external-network-blocker" - : "local-agent-fixture-slow-structured-blocker", + id: "local-agent-fixture-slow-structured-blocker", status: pass ? "pass" : "blocked", - summary: agentFixtureMode === "external-network-blocker" - ? "External network blocker for a GitHub prompt displays Runner 受阻 with network trace, keeps the user prompt, and does not restore main evidence/facts noise." - : "Delayed structured blocker arrives after the legacy 4500ms window, displays Runner 受阻 instead of a generic 后端失败 state, keeps trace visible, and leaves the user prompt for continuing the same conversation.", + summary: "Delayed structured blocker arrives after the legacy 4500ms window, displays Runner 受阻 instead of a generic 后端失败 state, keeps trace visible, and leaves the user prompt for continuing the same conversation.", observations: { legacyFailureWindowMs, fixtureDelayMs: responseDelayMs, @@ -5738,7 +5718,7 @@ async function inspectQuickPromptsViewport(browser, url, viewport) { const button = document.querySelector(`[data-agent-quick-prompt="${id}"]`); const input = document.querySelector("#command-input"); const text = `${button?.textContent ?? ""}\n${button?.getAttribute("title") ?? ""}\n${button?.dataset.promptText ?? ""}`; - const forbiddenPattern = /\b(?:PROD|gateway|box|patch-panel|真实硬件直控)\b/iu; + const forbiddenPattern = /\bPROD\b/iu; const forbiddenHits = [...new Set(text.match(forbiddenPattern) ?? [])]; return { label: button?.textContent?.replace(/\s+/gu, " ").trim() ?? "", @@ -5748,7 +5728,7 @@ async function inspectQuickPromptsViewport(browser, url, viewport) { focused: document.activeElement === input, explicitSendRequired: button?.dataset.requiresExplicitSend === "true", copyBoundaryOk: - /(?:HWLAB API|Skill CLI)/u.test(text) && + /Codex/u.test(text) && !forbiddenPattern.test(text) && (button?.dataset.requiresExplicitSend !== "true" || /只填充输入框|点击发送后才确认/u.test(text)), forbiddenHits @@ -6899,136 +6879,6 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} })); return true; } - if (options.agentFixtureMode === "external-network-blocker") { - const blocker = { - code: "external_network_blocked", - category: "capability_unavailable", - layer: "network", - userMessage: "当前 Code Agent 运行策略禁止外部网络访问;未访问外网,也不会伪造成功。", - retryable: false, - traceId, - sessionId: conversationId, - conversationId, - stage: "policy", - elapsedMs: options.agentDelayMs ?? 0, - lastEvent: { seq: 4, traceId, type: "network", stage: "policy", status: "blocked", label: "network:blocked", errorCode: "external_network_blocked" } - }; - jsonResponse(response, 200, { - conversationId, - sessionId: conversationId, - messageId, - status: "failed", - sourceKind: "SOURCE", - evidenceLevel: "SOURCE", - createdAt: timestamp, - updatedAt: timestamp, - traceId, - provider: "codex-stdio", - model: "codex-stdio", - backend: "local-source-fixture/codex-stdio-external-network-blocker", - projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - runner: { - kind: "codex-mcp-stdio-runner", - writeCapable: true, - codexStdio: true, - longLivedSession: true, - durableSession: true - }, - capabilityLevel: "blocked", - sessionMode: "codex-mcp-stdio-long-lived", - session: { - sessionId: conversationId, - conversationId, - status: "idle", - turn: 1, - lastTraceId: traceId - }, - sessionReuse: { - conversationId, - sessionId: conversationId, - mapped: true, - reused: true, - turn: 1, - status: "idle" - }, - error: { - code: "external_network_blocked", - category: "capability_unavailable", - layer: "network", - message: "SOURCE external network blocker: runtime policy disabled external network checks.", - userMessage: blocker.userMessage, - retryable: false, - blocker - }, - blocker, - blockers: [blocker], - toolCalls: [ - { - name: "external.network.check", - type: "network-check", - status: "blocked", - exitCode: 2, - traceId, - stderrSummary: "external_network_blocked", - targetUrl: "https://github.com/", - blocker - } - ], - skills: { - status: "not_requested", - items: [], - count: 0, - totalCount: 0, - blockers: [] - }, - runnerTrace: { - traceId, - runnerKind: "codex-mcp-stdio-runner", - sessionMode: "codex-mcp-stdio-long-lived", - sessionId: conversationId, - sessionStatus: "idle", - status: "blocked", - elapsedMs: options.agentDelayMs ?? 0, - waitingFor: "network-policy", - events: [ - { seq: 1, traceId, type: "session", stage: "created", status: "completed", label: "session:created" }, - { seq: 2, traceId, type: "prompt", stage: "sent", status: "completed", label: "prompt:sent", toolName: "external.network.check" }, - { seq: 3, traceId, type: "network", stage: "policy", status: "started", label: "network:started", toolName: "external.network.check" }, - blocker.lastEvent, - { seq: 5, traceId, type: "tool_call", stage: "tool_call", status: "blocked", label: "tool:external.network.check:blocked", toolName: "external.network.check", errorCode: "external_network_blocked" } - ], - lastEvent: { seq: 5, traceId, type: "tool_call", stage: "tool_call", status: "blocked", label: "tool:external.network.check:blocked", toolName: "external.network.check", errorCode: "external_network_blocked" } - }, - conversationFacts: { - conversationId, - sessionId: conversationId, - sessionStatus: "idle", - sessionMode: "codex-mcp-stdio-long-lived", - capabilityLevel: "blocked", - runnerKind: "codex-mcp-stdio-runner", - workspace: "/workspace/hwlab", - sandbox: "workspace-write", - turnCount: 1, - latestTraceId: traceId, - traceIds: [traceId], - latestSkills: null, - recentToolCalls: [{ name: "external.network.check", status: "blocked" }], - facts: [], - valuesRedacted: true, - secretMaterialStored: false - }, - providerTrace: { - source: "SOURCE-local-browser-fixture", - sourceKind: "SOURCE", - transport: "stdio+controlled-network", - responseId: "rsp_source_fixture_external_network_blocker", - redacted: true - } - }); - return true; - } if (options.agentFixtureMode === "slow-blocker") { jsonResponse(response, 200, { conversationId, diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index f392babe..4a2b5001 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -175,31 +175,31 @@ data-agent-quick-prompt="pwd" data-prompt-action="fill" title="只填充输入框,点击发送后交给 Code Agent" - data-prompt-text="通过 Skill CLI 执行 pwd,列出当前工作目录。" - >Skill CLI:pwd + data-prompt-text="交给 Codex 执行 pwd,列出当前工作目录。" + >Codex:pwd + data-prompt-text="交给 Codex 列出你能使用的所有 skill。" + >Codex:列出 skill + data-prompt-text="交给 Codex 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。" + >Codex:DO1=true 后读 DI1 + data-prompt-text="交给 Codex 把 res_boxsimu_1 的 DO1 写成 false,并复核 res_boxsimu_2 的 DI1;我点击发送后才确认执行这次写入请求。" + >Codex:DO1=false 复核 DI1