diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 7be56d96..9fceb23a 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -58,7 +58,14 @@ const defaultHomepageForbiddenTerms = Object.freeze([ "BLOCKED", "db-live", "m3-hardware-loop-runtime", - "执行轨迹" + "执行轨迹", + "runtime_durable_adapter_query_blocked", + "OPENAI_API_KEY", + "Secret", + "secretRef", + "hwlab-code-agent-provider", + "m3-route-required", + "waiting-for-dev-live" ]); const diagnosticsRequiredTerms = Object.freeze([ @@ -367,9 +374,19 @@ function runStaticSmoke() { evidence: ["command-send", "agent-chat-status", "/v1/agent/chat", "conversationId/messageId/status/timestamps/error.message"] }); - addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider credential blockers and only real completed replies can become DEV-LIVE.", { + addCheck(checks, blockers, "code-agent-provider-readiness-visibility", hasCodeAgentReadinessVisibility(files), "Workbench shows provider blockers without exposing credential internals and only real completed replies can become DEV-LIVE.", { blocker: "observability_blocker", - evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard"] + evidence: ["服务受阻 or legacy BLOCKED 凭证缺口", "provider_unavailable classifier", "completed -> dev-live guard", "default workspace hides credential internals"] + }); + + addCheck(checks, blockers, "default-workspace-sanitized", defaultWorkspaceIsSanitized(files), "Default user workspace hides raw blocker codes, internal route markers, and credential reference material.", { + blocker: "observability_blocker", + evidence: defaultHomepageForbiddenTerms + }); + + addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", { + blocker: "runtime_blocker", + evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile message evidence column"] }); addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed Code Agent replies expose provider/model/trace/conversation evidence and reject echo/mock/stub completions.", { @@ -377,7 +394,7 @@ function runStaticSmoke() { evidence: requiredCodeAgentEvidenceTerms }); - addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and provider_unavailable states as distinct conversation replies with bounded evidence.", { + addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded evidence.", { blocker: "observability_blocker", evidence: [ "DEV-LIVE 回复", @@ -1053,6 +1070,27 @@ function defaultHomepageHidesBackstage({ html }) { return defaultHomepageForbiddenTerms.every((term) => !visibleShellHtml.includes(term)); } +function defaultWorkspaceIsSanitized({ html, app }) { + const workspaceHtml = sectionSource(html, "workspace"); + const defaultShell = `${beforeSection(html, "help")}\n${workspaceHtml}`; + const userFacingBodies = [ + functionBody(app, "codeAgentStatusMessage"), + functionBody(app, "codeAgentPromptText"), + functionBody(app, "codeAgentControlSummary"), + functionBody(app, "codeAgentBlockedSummary"), + functionBody(app, "codeAgentAvailabilitySummary"), + functionBody(app, "failureMessage"), + functionBody(app, "renderWiringList") + ].join("\n"); + return ( + !/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef|m3-route-required|waiting-for-dev-live/u.test(defaultShell) && + !/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef|m3-route-required|waiting-for-dev-live|BLOCKED 凭证缺口/u.test(userFacingBodies) && + /服务受阻/u.test(functionBody(app, "renderAgentChatStatus")) && + /Code Agent 服务暂不可用/u.test(functionBody(app, "codeAgentBlockedSummary")) && + /等待可信记录/u.test(functionBody(app, "renderWiringList")) + ); +} + function hasCompletePrimaryNavigation(html) { const navLabelsPresent = ["工作台", "内部复核", "使用说明", "资源树"].every((label) => html.includes(`>${label}<`)); const legacyLabelsAbsent = incompletePrimaryNavLabels.every((label) => !html.includes(`>${label}<`)); @@ -1142,6 +1180,7 @@ function trustedRecordGroups({ html, app, styles }) { /function\s+evidenceCard\s*\(/u.test(app) && /function\s+recordGroup\s*\(/u.test(app) && /function\s+recordField\s*\(/u.test(app) && + /function\s+safeFailureReason\s*\(/u.test(app) && /function\s+sourceKindLabel\s*\(/u.test(app) && /function\s+liveFailureCard\s*\(/u.test(app) && /state\.liveSurface/u.test(app) && @@ -1156,6 +1195,7 @@ function trustedRecordGroups({ html, app, styles }) { /gateSummary\.operations\.map/u.test(app) && /gateSummary\.auditEvents\.slice\(0,\s*4\)\.map/u.test(app) && /gateSummary\.evidenceRecords\.map/u.test(app) && + /safeFailureReason\(message\.error\.message\)/u.test(app) && /message\.status === "failed" \? "blocked" : "source"/u.test(app) && !/message\.status === "completed"\s*\?\s*"dev-live"/u.test(app) ); @@ -1201,6 +1241,19 @@ function hasMobileWorkbenchLayoutContract({ html, app, styles }) { ); } +function hasStableRouteControls({ html, styles }) { + return ( + ["工作台", "内部复核", "使用说明", "资源树", "控制", "接线", "可信记录"].every((label) => html.includes(`>${label}<`)) && + /\.rail-button\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*36px;[^}]*text-align:\s*center;[^}]*word-break:\s*keep-all;/su.test(styles) && + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.rail-button\s*\{[\s\S]*?min-height:\s*42px;/u.test(styles) && + /\.side-tab\s*\{[^}]*min-width:\s*0;[^}]*min-height:\s*34px;[^}]*overflow-wrap:\s*anywhere;[^}]*word-break:\s*keep-all;/su.test(styles) && + /\.(?:status-dot|state-tag|badge)[^{]*\{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && + /\.probe-card\s*\{[^}]*min-width:\s*0;/su.test(styles) && + /\.probe-card strong\s*\{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-evidence\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles) + ); +} + function hasSameOriginReadOnlyBoundary(app) { const rpcCalls = [...app.matchAll(/callRpc\(\s*["']([^"']+)["']/gu)].map((match) => match[1]).sort(); return ( @@ -1257,9 +1310,18 @@ function hasCodeAgentChatContract({ html, app }) { function hasCodeAgentReadinessVisibility({ html, app }) { const source = `${html}\n${app}`; + const userFacingBodies = [ + functionBody(app, "codeAgentStatusMessage"), + functionBody(app, "codeAgentPromptText"), + functionBody(app, "codeAgentControlSummary"), + functionBody(app, "codeAgentBlockedSummary"), + functionBody(app, "codeAgentAvailabilitySummary"), + functionBody(app, "failureMessage") + ].join("\n"); return ( /codeAgentAvailability/u.test(app) && /codeAgentAvailabilityFrom/u.test(app) && + /sanitizeCodeAgentAvailability/u.test(app) && /latestCompletedAgentMessage/u.test(app) && blockedCodeAgentUiLabels.some((label) => app.includes(label)) && /provider_unavailable/u.test(app) && @@ -1277,6 +1339,7 @@ function hasCodeAgentReadinessVisibility({ html, app }) { !/status === "failed" \? "dev-live"/u.test(app) && !/tone:\s*state\.conversationId\s*\?\s*"dev-live"/u.test(app) && !/provider_unavailable[\s\S]{0,120}tone-[\w-]*dev-live/iu.test(source) && + !/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef/u.test(userFacingBodies) && !/sk-[A-Za-z0-9._-]{8,}/u.test(source) ); } diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 4e791e66..f6b77065 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1320,7 +1320,7 @@ function renderWiringList(summary = null) { const m3Live = hasTrustedM3LiveEvidence(liveM3Evidence); const traceEvidence = m3Live ? [liveM3Evidence.operationId, liveM3Evidence.traceId, liveM3Evidence.auditId, liveM3Evidence.evidenceId].join(" / ") - : "m3-route-required / waiting-for-dev-live"; + : "等待可信记录"; const tr = document.createElement("tr"); tr.append(cell(M3_TRUSTED_ROUTE.fromResourceId, "mono wrap")); tr.append(cell(M3_TRUSTED_ROUTE.fromPort, "mono")); @@ -1330,7 +1330,7 @@ function renderWiringList(summary = null) { const status = document.createElement("td"); status.append(badge(m3Live ? "开发实况 DEV-LIVE" : "阻塞 BLOCKED", m3Live ? "dev-live" : "blocked")); tr.append(status); - tr.append(cell(m3Live ? "DEV-LIVE" : "SOURCE / required M3 route", "wrap")); + tr.append(cell(m3Live ? "DEV-LIVE" : "来源拓扑", "wrap")); tr.append(cell(traceEvidence, "mono wrap")); replaceChildren(el.wiringBody, tr); @@ -1383,7 +1383,7 @@ function codeAgentRecordCards() { recordField("conversation", state.conversationId), recordField("trace", message.traceId), recordField("message", message.messageId), - message.error?.message ? `失败原因=${message.error.message}` : null + message.error?.message ? `失败原因=${safeFailureReason(message.error.message)}` : null ].filter(Boolean).join(" / "), tone: message.status === "failed" ? "blocked" : "source" }) @@ -1572,6 +1572,15 @@ function recordField(label, value) { return `${label}=${value}`; } +function safeFailureReason(value) { + const text = String(value ?? "").trim(); + if (!text) return "未知错误"; + if (/OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef/iu.test(text)) { + return "服务暂不可用"; + } + return text.length > 120 ? `${text.slice(0, 117)}...` : text; +} + function sourceKindLabel(tone) { return statusLabel(tone); } @@ -1703,7 +1712,7 @@ function renderAgentChatStatus(status, result = null) { completed: "DEV-LIVE 回复", source: "SOURCE 回复", failed: "发送失败", - blocked: "BLOCKED 凭证缺口" + blocked: "服务受阻" }; el.agentChatStatus.textContent = labels[status] ?? statusLabel(status); el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : "source")}`; @@ -1755,10 +1764,7 @@ function failureMessage(result) { const availability = result.error?.providerStatus || result.availability?.status !== "blocked" ? codeAgentAvailabilityFromResult(result) : result.availability ?? codeAgentAvailabilityFromResult(result); - const missing = Array.isArray(result.error?.missingEnv) && result.error.missingEnv.length > 0 - ? ` 当前缺口:${result.error.missingEnv.join("、")}。` - : ""; - return `${availability.summary}${missing}`; + return codeAgentBlockedSummary(availability); } const missing = [ ...(result.error?.missingCommands ?? []), @@ -1947,7 +1953,8 @@ function isHttpNon2xxStatus(value) { } function codeAgentAvailabilityFrom(live) { - return live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? null; + const availability = live?.restIndex?.data?.codeAgent ?? live?.healthLive?.data?.codeAgent ?? null; + return sanitizeCodeAgentAvailability(availability); } function codeAgentAvailabilityFromResult(result) { @@ -1973,11 +1980,21 @@ function codeAgentAvailabilityFromResult(result) { }; } +function sanitizeCodeAgentAvailability(availability) { + if (!availability || typeof availability !== "object") return availability; + if (availability.status !== "blocked") return availability; + return { + ...availability, + blocker: "服务受阻", + summary: codeAgentBlockedSummary(availability) + }; +} + function codeAgentStatusMessage(availability) { if (availability?.status === "blocked") { return { role: "system", - title: "Code Agent 状态:BLOCKED 凭证缺口", + title: "Code Agent 状态:服务受阻", text: codeAgentBlockedSummary(availability), status: "blocked" }; @@ -2008,7 +2025,7 @@ function codeAgentStatusMessage(availability) { function codeAgentPromptText(availability) { if (availability?.status === "blocked") { - return "可以继续输入并保留草稿;发送会走真实 /v1/agent/chat,但当前 DEV provider 凭证缺口会返回结构化失败,不会冒充真实 Codex 回复。"; + return "可以继续输入并保留草稿;发送会走真实 /v1/agent/chat。服务恢复前会显示结构化失败,不会冒充真实 Codex 回复。"; } return "请在底部输入中文消息并点击发送;完成态只来自真实 /v1/agent/chat completed 回复。"; } @@ -2029,7 +2046,11 @@ function codeAgentControlSummary(availability) { } function codeAgentBlockedSummary(availability) { - return availability?.summary ?? "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。"; + const providerStatus = availability?.error?.providerStatus ?? availability?.providerStatus; + if (providerStatus) { + return `真实后端已接入,但当前上游响应 HTTP ${providerStatus};工作台保持只读和可重试,不会冒充真实 Code Agent 回复。`; + } + return "真实后端已接入,但当前 Code Agent 服务暂不可用;工作台保持只读和可重试,不会冒充真实回复。"; } function untrustedCompletionMessage(result) { @@ -2039,7 +2060,7 @@ function untrustedCompletionMessage(result) { function codeAgentAvailabilitySummary() { if (state.codeAgentAvailability?.status === "blocked") { - return "同源 API 可响应;Code Agent 为 BLOCKED/凭证缺口,真实后端已接入但 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入。"; + return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; } if (String(state.codeAgentAvailability?.sourceKind ?? state.codeAgentAvailability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") { return "同源 API 可响应;当前 Code Agent 回复来源是 SOURCE fixture,仅用于本地浏览器 smoke,不是 DEV-LIVE。"; diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index f16e8663..0bb60828 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -230,7 +230,7 @@ hwlab-patch-panel SOURCE - SOURCE route + 来源拓扑 res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1 仅为源码兜底显示。
@@ -238,7 +238,7 @@ DO1 -> patch-panel -> DI1 BLOCKED
- waiting-for-dev-live + 等待可信记录 缺少 patch-panel live report 与 operation/trace/audit/evidence IDs,不能标为 DEV-LIVE。 @@ -321,8 +321,8 @@ res_boxsimu_2 DI1 阻塞 BLOCKED - SOURCE / required M3 route - m3-route-required / waiting-for-dev-live + 来源拓扑 / required M3 route + 等待可信记录(m3-route-required / waiting-for-dev-live) diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 6bca6e9b..9f870a4e 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -169,6 +169,7 @@ assert.match(app, /auditCard\(event, liveRecordTone\(event, live\), liveRecordDu assert.match(app, /evidenceCard\(record, liveRecordTone\(record, live\), liveRecordDurabilityBlocker\(live\)\)/); assert.match(app, /function recordGroup/); assert.match(app, /function recordField/); +assert.match(app, /function safeFailureReason/); assert.match(app, /function sourceKindLabel/); assert.match(app, /function liveFailureCard/); assert.match(app, /state\.liveSurface/); @@ -274,7 +275,7 @@ for (const helpTerm of [ } const workspaceHtml = sectionSource(html, "workspace"); const visibleShellHtml = `${beforeSection(html, "help")}\n${workspaceHtml}`; -for (const backendTerm of [/Gate/u, /诊断/u, /验收/u, /BLOCKED/u, /M0-M5/u, /执行轨迹/u]) { +for (const backendTerm of [/Gate/u, /诊断/u, /验收/u, /BLOCKED/u, /M0-M5/u, /执行轨迹/u, /OPENAI_API_KEY/u, /Secret/u, /secretRef/u, /hwlab-code-agent-provider/u, /m3-route-required/u, /waiting-for-dev-live/u]) { assert.doesNotMatch(visibleShellHtml, backendTerm, `default workbench must not expose backend term ${backendTerm}`); } for (const fullNavLabel of ["工作台", "内部复核", "使用说明", "资源树"]) { @@ -355,11 +356,11 @@ assert.match(app, /isHttpNon2xxStatus/); assert.match(app, /providerStatus/); assert.match(app, /codeAgentAvailability/); assert.match(app, /codeAgentAvailabilityFrom/); +assert.match(app, /sanitizeCodeAgentAvailability/); assert.match(app, /latestCompletedAgentMessage/); -assert.match(app, /BLOCKED 凭证缺口/); +assert.match(app, /服务受阻/); assert.match(app, /provider_unavailable/); assert.match(app, /OPENAI_API_KEY/); -assert.match(app, /hwlab-code-agent-provider\/openai-api-key/); assert.match(app, /只有真实 completed 回复才标 DEV-LIVE/); assert.match(app, /不能因为只有 conversationId 标为开发实况/); assert.match(app, /TRUSTED_CODE_AGENT_PROVIDERS/); @@ -400,6 +401,23 @@ assert.match(styles, /\.message-user\s*{/); assert.match(styles, /\.message-agent,\s*\n\.message-system\s*{/); assert.match(styles, /\.message-evidence\s*{/); assert.match(styles, /\.message-evidence-chip\s*{/); +assert.match(styles, /\.rail-button\s*{[^}]*min-width:\s*0;[^}]*min-height:\s*36px;[^}]*text-align:\s*center;[^}]*word-break:\s*keep-all;/s); +assert.match(styles, /\.side-tab\s*{[^}]*min-width:\s*0;[^}]*min-height:\s*34px;[^}]*overflow-wrap:\s*anywhere;[^}]*word-break:\s*keep-all;/s); +assert.match(styles, /\.(?:status-dot|state-tag|badge)[^{]*{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/s); +assert.match(styles, /\.probe-card\s*{[^}]*min-width:\s*0;/s); +assert.match(styles, /\.probe-card strong\s*{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/s); +assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-evidence\s*{[\s\S]*?grid-column:\s*1;/); +for (const userFacingFunctionName of [ + "codeAgentStatusMessage", + "codeAgentPromptText", + "codeAgentControlSummary", + "codeAgentBlockedSummary", + "codeAgentAvailabilitySummary", + "failureMessage", + "renderWiringList" +]) { + assert.doesNotMatch(functionBody(app, userFacingFunctionName), /OPENAI_API_KEY|hwlab-code-agent-provider|Secret|secretRef|m3-route-required|waiting-for-dev-live|BLOCKED 凭证缺口/u, `${userFacingFunctionName} must keep default copy sanitized`); +} assert.match(app, /state\.conversationId/); assert.match(app, /conversationId/); assert.match(app, /messageId/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 5cef3a8f..fdd51b7d 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -103,6 +103,7 @@ ul { .rail-button { width: 100%; + min-width: 0; min-height: 36px; padding: 6px 5px; border: 1px solid transparent; @@ -112,7 +113,9 @@ ul { font-weight: 760; line-height: 1.2; cursor: pointer; + text-align: center; overflow-wrap: anywhere; + word-break: keep-all; } .rail-button:hover, @@ -202,6 +205,8 @@ h3 { .badge { display: inline-flex; align-items: center; + justify-content: center; + flex: 0 1 auto; width: fit-content; max-width: 100%; min-height: 22px; @@ -212,6 +217,9 @@ h3 { font-size: 10px; font-weight: 760; text-transform: uppercase; + line-height: 1.25; + white-space: normal; + text-align: center; overflow-wrap: anywhere; } @@ -383,6 +391,7 @@ h3 { } .probe-card { + min-width: 0; display: grid; align-content: center; gap: 4px; @@ -393,8 +402,9 @@ h3 { .probe-card strong { font-size: 17px; - line-height: 1; + line-height: 1.2; text-transform: uppercase; + overflow-wrap: anywhere; } .probe-card small { @@ -875,6 +885,7 @@ h3 { } .side-tab { + min-width: 0; min-height: 34px; padding: 6px 4px; border: 0; @@ -884,6 +895,8 @@ h3 { font-size: 11px; font-weight: 760; cursor: pointer; + overflow-wrap: anywhere; + word-break: keep-all; } .side-tab:last-child { @@ -1254,6 +1267,7 @@ tbody tr:last-child td { .rail-button { min-height: 42px; + line-height: 1.15; } .explorer { @@ -1296,6 +1310,10 @@ tbody tr:last-child td { padding: 8px 10px; } + .probe-card strong { + font-size: 15px; + } + .workbench-view, .gate-view, .help-view, @@ -1341,6 +1359,10 @@ tbody tr:last-child td { .trace-item { grid-template-columns: 1fr; } + + .message-evidence { + grid-column: 1; + } } @media (max-width: 520px) {