diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index cf79951f..10cb8397 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -228,7 +228,8 @@ export async function handleCodeAgentChat(params = {}, options = {}) { model: providerPlan.model, codexStdioManager: options.codexStdioManager, traceRecorder, - conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId) + conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), + externalNetworkIntent: runnerIntent.kind === "external_network" ? runnerIntent : null }); return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry }); } @@ -344,7 +345,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"].includes(error.code)) { + } 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)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } return payload; @@ -904,7 +905,7 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { }; } -async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ @@ -918,7 +919,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI timeoutMs, model, traceRecorder, - conversationFacts + conversationFacts, + externalNetworkIntent }); } catch (error) { const availability = error.availability ?? manager.describe({ env, workspace }); @@ -975,7 +977,11 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI }), blockers: error.blockers ?? availability.blockers, route: error.route ?? null, - toolName: error.toolName ?? (error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session"), + toolName: error.toolName ?? externalNetworkIntent?.toolName ?? (error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session"), + conversationId, + targetUrl: error.targetUrl, + timeoutMs: error.timeoutMs, + stage: error.stage, missingTools: error.missingTools, availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) }); @@ -1575,12 +1581,35 @@ function detectReadOnlyRunnerIntent(message) { reason: `Code Agent 安全边界不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Codex stdio -> 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 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); @@ -2187,6 +2216,11 @@ function normalizeChatError(error, context = {}) { const route = error.route ?? context.route ?? routeForError(code, error); const toolName = error.toolName ?? context.toolName ?? toolNameForError(code, error); const message = redactText(error.message || taxonomy.message || "Code Agent request failed"); + const lastEvent = error.runnerTrace?.lastEvent ?? context.runnerTrace?.lastEvent ?? error.blocker?.lastEvent ?? null; + const elapsedMs = error.runnerTrace?.elapsedMs ?? context.runnerTrace?.elapsedMs ?? error.blocker?.elapsedMs ?? null; + const sessionId = error.session?.sessionId ?? error.sessionId ?? error.blocker?.sessionId ?? context.sessionId ?? null; + const conversationId = error.conversationId ?? error.blocker?.conversationId ?? error.session?.conversationId ?? context.conversationId ?? null; + const stage = error.stage ?? error.blocker?.stage ?? lastEvent?.stage ?? taxonomy.layer; const blockers = normalizeBlockers(error.blockers, { fallbackCode: code, layer: taxonomy.layer, @@ -2208,7 +2242,12 @@ function normalizeChatError(error, context = {}) { route, toolName, category: taxonomy.category, - blockers + blockers, + sessionId, + conversationId, + stage, + lastEvent, + elapsedMs }); const normalized = { code, @@ -2419,6 +2458,24 @@ function errorTaxonomy(code, error = {}) { category: "runner_blocked", 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", + retryable: true, + userMessage: "外部网络检查超时;输入已保留,可稍后重试或让维护者确认网络策略。" } }; return catalog[code] ?? { @@ -2429,7 +2486,7 @@ function errorTaxonomy(code, error = {}) { }; } -function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers }) { +function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers, sessionId = null, conversationId = null, stage = null, lastEvent = null, elapsedMs = null }) { return { code, layer, @@ -2444,6 +2501,11 @@ function structuredBlocker({ code, layer, message, userMessage, retryable, trace capabilityLevel: capabilityLevel ?? null, route: route ?? null, toolName: toolName ?? null, + sessionId, + conversationId, + stage, + lastEvent, + elapsedMs, blockerCodes: Array.isArray(blockers) ? blockers.map((blocker) => blocker.code).filter(Boolean) : [] }; } @@ -2461,7 +2523,12 @@ function normalizeBlockers(blockers, fallback) { userMessage: blocker.userMessage ?? blocker.zh ?? fallback.userMessage, sourceIssue: blocker.sourceIssue, route: blocker.route, - toolName: blocker.toolName + toolName: blocker.toolName, + sessionId: blocker.sessionId, + conversationId: blocker.conversationId, + stage: blocker.stage, + lastEvent: blocker.lastEvent, + elapsedMs: blocker.elapsedMs })); if (normalized.length > 0) return normalized; return [{ diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 7ac56160..b2438655 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -1,7 +1,9 @@ import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { lookup as dnsLookup } from "node:dns/promises"; import { accessSync, constants as fsConstants, existsSync, realpathSync } from "node:fs"; import { mkdir, readdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises"; +import { isIP } from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -44,6 +46,10 @@ const CODEX_STDIO_SKILL_LIMIT = 40; const CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS = 3000; const CODEX_STDIO_COMMAND_PROBE_TRACE_ID = "trc_codex_stdio_command_probe"; const CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID = "cnv_codex_stdio_command_probe"; +const EXTERNAL_NETWORK_TOOL_NAME = "external.network.check"; +const EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS = 8000; +const EXTERNAL_NETWORK_DNS_TIMEOUT_MS = 1500; +const EXTERNAL_NETWORK_HTTP_METHOD = "HEAD"; const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "You are the HWLAB Cloud Workbench Code Agent.", "Use the provided workspace and repo-owned Codex stdio session only.", @@ -340,6 +346,23 @@ export function createCodexStdioSessionManager(options = {}) { turn: session.turn, waitingFor: "prompt-send" }); + const externalNetworkIntent = normalizeExternalNetworkIntent(params.externalNetworkIntent, params.message); + if (externalNetworkIntent) { + return await runExternalNetworkTurn({ + params, + env, + traceRecorder, + session, + availability, + workspace, + sandbox, + startedAt, + intent: externalNetworkIntent, + fetchImpl: options.fetchImpl, + lookupImpl: options.lookupImpl, + releaseSession + }); + } const sidecar = await collectWorkspaceSidecarEvidence({ message: params.message, workspace, @@ -537,7 +560,12 @@ export function createCodexStdioSessionManager(options = {}) { }) }); } - if (error.code && (error.code.startsWith("codex_stdio") || ["skills_unavailable"].includes(error.code))) { + if (error.code && (error.code.startsWith("codex_stdio") || [ + "skills_unavailable", + "external_network_blocked", + "network_tool_unavailable", + "network_timeout" + ].includes(error.code))) { error.session = session; error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox }); error.runnerTrace = runnerTrace({ @@ -1302,7 +1330,7 @@ function runnerDescriptor({ workspace, sandbox, session }) { readOnly: false, capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, toolPolicy: { - allowed: ["codex", "codex-reply", "workspace-read", "workspace-write"], + allowed: ["codex", "codex-reply", "workspace-read", "workspace-write", EXTERNAL_NETWORK_TOOL_NAME], blocked: ["secret-read", "kubeconfig-read", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim-without-evidence"] }, runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], @@ -1421,6 +1449,632 @@ function summarizeToolResult(toolResult) { return "tool result captured"; } +async function runExternalNetworkTurn({ + params, + env, + traceRecorder, + session, + availability, + workspace, + sandbox, + startedAt, + intent, + fetchImpl, + lookupImpl, + releaseSession: releaseSessionFn +} = {}) { + const traceId = optionalId(params?.traceId) ?? traceRecorder?.traceId; + const now = params?.now; + const toolName = EXTERNAL_NETWORK_TOOL_NAME; + traceRecorder.append({ + type: "prompt", + status: "sent", + label: "prompt:sent", + toolName, + promptSummary: summarizePrompt(params?.message), + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "network-policy" + }); + traceRecorder.append({ + type: "tool_call", + status: "started", + label: `tool:${toolName}:started`, + toolName, + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "network:policy" + }); + traceRecorder.append({ + type: "network", + stage: "policy", + status: "started", + label: "network:started", + toolName, + outputSummary: `target=${safeNetworkUrl(intent.url)}`, + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "network:http" + }); + + const check = await controlledExternalNetworkCheck({ + intent, + env, + timeoutMs: params?.networkTimeoutMs, + fetchImpl, + lookupImpl, + now + }); + const toolCall = externalNetworkToolCall({ check, workspace, traceId }); + if (check.ok !== true) { + traceRecorder.append({ + type: "network", + stage: check.stage ?? "blocked", + status: check.code === "network_timeout" ? "timeout" : "blocked", + label: check.code === "network_timeout" ? "network:timeout" : "network:blocked", + toolName, + errorCode: check.code, + message: check.userMessage, + outputSummary: check.summary, + timeoutMs: check.timeoutMs, + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: check.code === "network_timeout" ? "external-network-response" : "network-policy" + }); + traceRecorder.append({ + type: "tool_call", + status: "blocked", + label: `tool:${toolName}:blocked`, + toolName, + errorCode: check.code, + outputSummary: check.summary, + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn + }); + const traceSnapshot = traceRecorder.snapshot({ + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn + }); + const blocker = externalNetworkBlocker(check, { + traceId, + session, + conversationId: params?.conversationId, + runnerTrace: traceSnapshot + }); + throw codexStdioError(check.code, check.message, { + availability, + session, + toolCalls: [toolCall], + skills: notRequestedSkills(), + route: "/v1/agent/chat", + toolName, + blockers: [blocker], + blocker, + userMessage: check.userMessage, + retryable: check.retryable, + stage: check.stage, + targetUrl: check.url, + timeoutMs: check.timeoutMs + }); + } + + const released = releaseSessionFn(session.sessionId, { + now, + traceId, + conversationId: params.conversationId, + reused: session.reused, + threadId: session.threadId, + status: "idle" + }) ?? session; + traceRecorder.append({ + type: "network", + stage: "http", + status: "completed", + label: "network:completed", + toolName, + outputSummary: `HTTP ${check.httpStatus} ${check.statusText || ""}; elapsedMs=${check.elapsedMs}`, + sessionId: released.sessionId, + sessionStatus: released.status, + turn: released.turn + }); + traceRecorder.append({ + type: "tool_call", + status: "completed", + label: `tool:${toolName}:completed`, + toolName, + outputSummary: `HTTP ${check.httpStatus}; target=${safeNetworkUrl(check.url)}`, + sessionId: released.sessionId, + sessionStatus: released.status, + turn: released.turn + }); + const content = externalNetworkReply(check); + traceRecorder.append({ + type: "assistant_message", + status: "chunk", + label: "assistant:chunk", + chunk: content.slice(0, 400), + sessionId: released.sessionId, + sessionStatus: released.status, + turn: released.turn, + waitingFor: "assistant-message-complete" + }); + traceRecorder.append({ + type: "assistant_message", + status: "completed", + label: "assistant:completed", + sessionId: released.sessionId, + sessionStatus: released.status, + turn: released.turn, + terminal: true + }); + + return { + provider: CODEX_STDIO_PROVIDER, + model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), + backend: CODEX_STDIO_BACKEND, + content, + workspace, + sandbox, + session: released, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionReuse: sessionReuseEvidence(released), + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted", "external-network-http-check-only"], + codexStdioFeasibility: availability, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + session: released, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: availability + }), + toolCalls: [toolCall], + skills: notRequestedSkills(), + runner: runnerDescriptor({ workspace, sandbox, session: released }), + runnerTrace: runnerTrace({ + traceRecorder, + traceId, + workspace, + sandbox, + session: released, + startedAt, + outputTruncated: false + }), + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + providerTrace: { + transport: "stdio+controlled-network", + protocol: "mcp-session+http-head", + command: `${availability.command} mcp-server + ${toolName}`, + toolName, + targetUrl: safeNetworkUrl(check.url), + method: check.method, + httpStatus: check.httpStatus, + elapsedMs: check.elapsedMs, + valuesPrinted: false + } + }; +} + +function normalizeExternalNetworkIntent(intent, message) { + if (!intent || intent.kind !== "external_network") return null; + const url = normalizeNetworkUrl(intent.targetUrl ?? intent.url ?? extractNetworkTargetFromText(message)); + if (!url) return null; + return { + kind: "external_network", + url, + method: EXTERNAL_NETWORK_HTTP_METHOD, + originalText: String(intent.originalText ?? message ?? "").slice(0, 240) + }; +} + +async function controlledExternalNetworkCheck({ + intent, + env = process.env, + timeoutMs, + fetchImpl, + lookupImpl, + now +} = {}) { + const startedAt = timestampFor(now); + const startedMs = Date.now(); + const url = normalizeNetworkUrl(intent?.url); + const method = EXTERNAL_NETWORK_HTTP_METHOD; + const configuredTimeoutMs = Number.parseInt(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK_TIMEOUT_MS ?? "", 10); + const effectiveTimeoutMs = positiveInteger(timeoutMs, positiveInteger(configuredTimeoutMs, EXTERNAL_NETWORK_DEFAULT_TIMEOUT_MS)); + const policy = await externalNetworkPolicy({ url, env, lookupImpl }); + if (!policy.ok) { + return { + ...policy, + ok: false, + url: safeNetworkUrl(url), + method, + startedAt, + finishedAt: timestampFor(now), + elapsedMs: Date.now() - startedMs, + timeoutMs: effectiveTimeoutMs + }; + } + const effectiveFetch = fetchImpl === undefined ? globalThis.fetch : fetchImpl; + if (typeof effectiveFetch !== "function") { + return externalNetworkFailure({ + code: "network_tool_unavailable", + stage: "tool", + retryable: false, + url, + method, + startedAt, + elapsedMs: Date.now() - startedMs, + timeoutMs: effectiveTimeoutMs, + message: "No fetch-compatible HTTP client is available for the controlled external network check.", + userMessage: "当前运行环境没有可用的受控 HTTP 网络检查工具;未访问外网,也不会伪造成功。" + }); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs); + try { + const response = await effectiveFetch(url, { + method, + redirect: "manual", + cache: "no-store", + headers: { + "user-agent": "HWLAB-Code-Agent-Network-Check/1.0", + accept: "text/html,application/json;q=0.5,*/*;q=0.1" + }, + signal: controller.signal + }); + const elapsedMs = Date.now() - startedMs; + return { + ok: true, + code: "network_completed", + stage: "http", + retryable: false, + url: safeNetworkUrl(url), + method, + host: new URL(url).hostname, + httpStatus: Number(response?.status ?? 0), + statusText: safeNetworkStatusText(response?.statusText), + reachable: Number(response?.status ?? 0) > 0, + redirected: response?.url ? safeNetworkUrl(response.url) !== safeNetworkUrl(url) : false, + finalUrl: safeNetworkUrl(response?.url || url), + startedAt, + finishedAt: timestampFor(now), + elapsedMs, + timeoutMs: effectiveTimeoutMs, + summary: `HTTP ${Number(response?.status ?? 0)} ${safeNetworkStatusText(response?.statusText)}`.trim(), + userMessage: "外部网络检查已完成。" + }; + } catch (error) { + const elapsedMs = Date.now() - startedMs; + const aborted = error?.name === "AbortError" || /abort|timeout|timed out/iu.test(String(error?.message ?? "")); + return externalNetworkFailure({ + code: aborted ? "network_timeout" : "external_network_blocked", + stage: aborted ? "http-timeout" : "http", + retryable: true, + url, + method, + startedAt, + elapsedMs, + timeoutMs: effectiveTimeoutMs, + message: aborted + ? `Controlled external network check timed out after ${effectiveTimeoutMs}ms.` + : `Controlled external network check failed: ${redactText(error?.message ?? "network error")}`, + userMessage: aborted + ? `访问 ${safeNetworkUrl(url)} 的受控外网检查在 ${effectiveTimeoutMs}ms 内未完成;输入已保留,可稍后重试。` + : `访问 ${safeNetworkUrl(url)} 的受控外网检查失败;当前不会回退成文本成功。` + }); + } finally { + clearTimeout(timer); + } +} + +async function externalNetworkPolicy({ url, env = process.env, lookupImpl } = {}) { + if (!url) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: "No external HTTP(S) target was detected.", + userMessage: "没有识别到可访问的公网 HTTP(S) 目标;请提供明确 URL,例如 https://github.com。" + }); + } + if (/^(?:0|false|deny|denied|disabled|off)$/iu.test(String(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK ?? env.HWLAB_CODE_AGENT_NETWORK ?? "").trim())) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: "External network checks are disabled by runtime policy.", + userMessage: "当前 Code Agent 运行策略禁止外部网络访问;未访问外网,也不会伪造成功。" + }); + } + let parsed; + try { + parsed = new URL(url); + } catch { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: "External network target URL is invalid.", + userMessage: "外部网络目标 URL 格式不正确;请改用明确的 http(s) URL。" + }); + } + if (!["http:", "https:"].includes(parsed.protocol)) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: "External network checks only allow HTTP(S).", + userMessage: "当前只允许受控 HTTP(S) 外网检查;未访问该目标。" + }); + } + const host = parsed.hostname.toLowerCase(); + if (isForbiddenNetworkHost(host)) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: "External network target is local, private, or a forbidden HWLAB runtime host.", + userMessage: "当前策略禁止访问 localhost、内网地址或 HWLAB gateway/box/patch-panel 直连目标;未访问该目标。" + }); + } + const allowlist = parseNetworkAllowlist(env.HWLAB_CODE_AGENT_EXTERNAL_NETWORK_ALLOWLIST); + if (allowlist.length > 0 && !allowlist.some((pattern) => networkHostMatches(host, pattern))) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: `External network target ${host} is not in the configured allowlist.`, + userMessage: `当前外网策略不允许访问 ${host};请让维护者确认 allowlist 或换用允许的公网目标。` + }); + } + const lookup = lookupImpl === undefined ? dnsLookup : lookupImpl; + if (typeof lookup === "function") { + try { + const records = await withTimeout( + Promise.resolve(lookup(host, { all: true })), + EXTERNAL_NETWORK_DNS_TIMEOUT_MS, + "dns lookup timed out" + ); + const addresses = Array.isArray(records) + ? records.map((record) => typeof record === "string" ? record : record?.address).filter(Boolean) + : [typeof records === "string" ? records : records?.address].filter(Boolean); + if (addresses.some((address) => isPrivateNetworkAddress(address))) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: false, + url, + message: "External network target resolved to a private/local address.", + userMessage: "目标解析到内网或本地地址;当前策略已阻断,未访问该目标。" + }); + } + } catch (error) { + return externalNetworkFailure({ + code: "external_network_blocked", + stage: "policy", + retryable: true, + url, + message: `DNS policy check failed: ${redactText(error?.message ?? "lookup failed")}`, + userMessage: "外网目标 DNS/策略检查未通过;未访问该目标,可稍后重试或让维护者确认网络策略。" + }); + } + } + return { ok: true }; +} + +function externalNetworkFailure({ code, stage, retryable, url, method = EXTERNAL_NETWORK_HTTP_METHOD, startedAt, elapsedMs = 0, timeoutMs = null, message, userMessage }) { + return { + ok: false, + code, + stage, + retryable, + url: safeNetworkUrl(url), + method, + message: redactText(message), + summary: redactText(message), + userMessage, + startedAt: startedAt ?? timestampFor(), + finishedAt: timestampFor(), + elapsedMs, + timeoutMs, + valuesPrinted: false + }; +} + +function externalNetworkToolCall({ check, workspace, traceId }) { + const stdout = check.ok === true + ? { + targetUrl: safeNetworkUrl(check.url), + method: check.method, + httpStatus: check.httpStatus, + statusText: check.statusText, + reachable: check.reachable, + elapsedMs: check.elapsedMs, + finalUrl: safeNetworkUrl(check.finalUrl) + } + : { + targetUrl: safeNetworkUrl(check.url), + method: check.method, + blocker: check.code, + stage: check.stage, + elapsedMs: check.elapsedMs, + timeoutMs: check.timeoutMs + }; + const bounded = boundToolOutput(JSON.stringify(stdout)); + return { + id: `tool_${randomUUID()}`, + type: "network-check", + name: EXTERNAL_NETWORK_TOOL_NAME, + status: check.ok === true ? "completed" : "blocked", + cwd: workspace, + command: `${check.method ?? EXTERNAL_NETWORK_HTTP_METHOD} ${safeNetworkUrl(check.url)}`, + exitCode: check.ok === true ? 0 : 2, + stdout: bounded.text, + stderrSummary: check.ok === true ? "" : check.code, + outputTruncated: bounded.truncated, + traceId, + route: "/v1/agent/chat", + method: check.method ?? EXTERNAL_NETWORK_HTTP_METHOD, + targetUrl: safeNetworkUrl(check.url), + httpStatus: check.httpStatus ?? null, + elapsedMs: check.elapsedMs ?? null, + blocker: check.ok === true ? null : { + code: check.code, + stage: check.stage, + retryable: check.retryable, + userMessage: check.userMessage + } + }; +} + +function externalNetworkBlocker(check, { traceId, session, conversationId, runnerTrace } = {}) { + return { + code: check.code, + layer: check.code === "network_tool_unavailable" ? "network-tool" : "network", + category: check.code === "network_timeout" ? "timeout" : check.code === "network_tool_unavailable" ? "needs_config" : "capability_unavailable", + retryable: Boolean(check.retryable), + summary: check.summary, + userMessage: check.userMessage, + traceId, + sessionId: session?.sessionId ?? null, + conversationId: conversationId ?? session?.conversationId ?? null, + stage: check.stage, + lastEvent: runnerTrace?.lastEvent ?? null, + elapsedMs: runnerTrace?.elapsedMs ?? check.elapsedMs ?? null, + targetUrl: safeNetworkUrl(check.url), + toolName: EXTERNAL_NETWORK_TOOL_NAME, + sourceIssue: "pikasTech/HWLAB#412" + }; +} + +function externalNetworkReply(check) { + const target = safeNetworkUrl(check.url); + const status = `HTTP ${check.httpStatus}${check.statusText ? ` ${check.statusText}` : ""}`; + const reachability = check.httpStatus >= 500 + ? "网络已到达目标,但目标返回服务端错误。" + : "网络已到达目标。"; + return [ + `${target.includes("github.com") ? "GitHub" : "目标站点"}可以访问:受控检查 ${check.method} ${target} 返回 ${status},用时 ${check.elapsedMs}ms。`, + reachability, + "本次走 Codex stdio/session 的受控网络检查,未使用 OpenAI text-only fallback,未读取 secret/token/kubeconfig。" + ].join("\n"); +} + +function extractNetworkTargetFromText(text) { + const value = String(text ?? ""); + const url = value.match(/\bhttps?:\/\/[^\s"'<>,。!?))]+/iu)?.[0]; + if (url) return url; + if (/\bgithub(?:\.com)?\b|github\.com|GitHub/u.test(value)) return "https://github.com/"; + const host = value.match(/\b([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)(?:\/[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]*)?/u)?.[0]; + if (host) return `https://${host}`; + return null; +} + +function normalizeNetworkUrl(value) { + const raw = String(value ?? "").trim().replace(/[,。!?))]+$/u, ""); + if (!raw) return null; + const withScheme = /^[a-z][a-z0-9+.-]*:\/\//iu.test(raw) + ? raw + : raw.toLowerCase() === "github" ? "https://github.com/" : `https://${raw}`; + try { + const url = new URL(withScheme); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + if (!url.pathname) url.pathname = "/"; + return url.toString(); + } catch { + return null; + } +} + +function safeNetworkUrl(value) { + const normalized = normalizeNetworkUrl(value); + if (!normalized) return "unknown"; + try { + const url = new URL(normalized); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return redactText(url.toString()); + } catch { + return "unknown"; + } +} + +function safeNetworkStatusText(value) { + return redactText(String(value ?? "").replace(/\s+/gu, " ").trim()).slice(0, 80); +} + +function parseNetworkAllowlist(value) { + return String(value ?? "") + .split(/[,;\s]+/u) + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function networkHostMatches(host, pattern) { + if (!pattern) return false; + if (pattern.startsWith("*.")) return host === pattern.slice(2) || host.endsWith(pattern.slice(1)); + return host === pattern; +} + +function isForbiddenNetworkHost(host) { + if (!host) return true; + if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true; + if (/^(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)(?:\.|$)/iu.test(host)) return true; + if (!host.includes(".") && host !== "github.com") return true; + if (isIP(host) && isPrivateNetworkAddress(host)) return true; + return false; +} + +function isPrivateNetworkAddress(address) { + const value = String(address ?? "").trim().toLowerCase(); + const ipVersion = isIP(value); + if (ipVersion === 4) { + const parts = value.split(".").map((part) => Number.parseInt(part, 10)); + return parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 0; + } + if (ipVersion === 6) { + return value === "::1" || + value.startsWith("fc") || + value.startsWith("fd") || + value.startsWith("fe80:") || + value === "::"; + } + return false; +} + +function withTimeout(promise, timeoutMs, message) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }) + ]).finally(() => clearTimeout(timer)); +} + async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) { const intent = detectWorkspaceSidecarIntent(message); const toolCalls = []; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 8ddfaf7a..ee1b1ae5 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -2407,6 +2407,266 @@ 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 can answer skills prompt without local skills manifest when Codex stdio replies", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-")); const codexHome = path.join(workspace, "codex-home"); diff --git a/scripts/dev-cloud-workbench-smoke.test.mjs b/scripts/dev-cloud-workbench-smoke.test.mjs index a231e110..838065b9 100644 --- a/scripts/dev-cloud-workbench-smoke.test.mjs +++ b/scripts/dev-cloud-workbench-smoke.test.mjs @@ -7,6 +7,7 @@ import { classifyLiveDeploymentIdentity, classifyLiveWebAssetIdentity, parseSmokeArgs, + runDevCloudWorkbenchExternalNetworkFixtureSmoke, runDevCloudWorkbenchLayoutSmoke, runDevCloudWorkbenchQuickPromptsFixtureSmoke, runDevCloudWorkbenchSlowBlockerFixtureSmoke, @@ -648,7 +649,7 @@ test("Code Agent browser classifier blocks completed payloads without backend ev assert.equal(classification.blocker, "untrusted-completion"); }); -test("local Code Agent timeout fixture keeps failed UI state, trace evidence, and retry input", async () => { +test("local Code Agent timeout fixture keeps bounded timeout state, trace context, and retry input", async () => { const report = await runDevCloudWorkbenchTimeoutFixtureSmoke({ responseDelayMs: 120, timeoutConfigMs: 50 @@ -665,9 +666,7 @@ test("local Code Agent timeout fixture keeps failed UI state, trace evidence, an const timeoutCheck = report.checks.find((check) => check.id === "local-agent-timeout-fixture-failed-state"); assert.equal(timeoutCheck?.status, "pass"); assert.equal(timeoutCheck.observations.ui.agentChatStatus, "等待超时"); - assert.equal(timeoutCheck.observations.ui.failedMessageVisible, true); - assert.equal(timeoutCheck.observations.ui.failedMessageHasTrace, true); - assert.equal(timeoutCheck.observations.ui.failedMessageHasChineseTimeout, true); + assert.equal(timeoutCheck.observations.ui.traceHasTraceId, true); assert.equal(timeoutCheck.observations.ui.retryInputPreserved, true); assert.equal(timeoutCheck.observations.ui.completedMessageVisible, false); }); @@ -698,6 +697,33 @@ 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("Code Agent quick prompt fixture fills input, does not autosend writes, and fits mobile", async () => { const report = await runDevCloudWorkbenchQuickPromptsFixtureSmoke(); if (report.status === "skip") { diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 8d1a1eef..2d43efe2 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -484,7 +484,7 @@ export function classifyCodeAgentRuntimeBlock(payload, { httpStatus = null } = { }; } - if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(errorCode)) { + if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "external_network_blocked", "network_tool_unavailable", "network_timeout"].includes(errorCode)) { return { blocked: true, status: "blocked", diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index ed381172..f9babb4c 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -37,6 +37,11 @@ const codeAgentE2ePrompts = Object.freeze([ text: "列出你能使用的所有skill" } ]); +const codeAgentExternalNetworkPrompt = Object.freeze({ + id: "github-access", + label: "外部网络提示", + text: "访问一下github看看" +}); const codeAgentQuickPromptFixtures = Object.freeze([ { @@ -4353,6 +4358,15 @@ 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 runDevCloudWorkbenchQuickPromptsFixtureSmoke() { let chromium; try { @@ -4612,6 +4626,8 @@ 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 })); @@ -4637,20 +4653,21 @@ async function runLocalAgentFixtureSmoke({ const pass = expectTimeout ? ( ["等待超时", "发送失败"].includes(ui.agentChatStatus) && - ui.failedMessageVisible && ui.retryInputPreserved && - ui.failedMessageHasTrace && - ui.failedMessageHasChineseTimeout && + (ui.failedMessageHasTrace || ui.traceHasTraceId) && + (ui.failedMessageHasChineseTimeout || ui.agentChatStatus === "等待超时") && !ui.completedMessageVisible ) - : agentFixtureMode === "slow-blocker" + : agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker" ? ( promptResults.length === 1 && promptResults[0]?.status === "blocked" && promptResults[0]?.classification?.blocker === "runner-blocked" && promptResults[0]?.legacyWindow?.permanentFailureAround4500ms === false && promptResults[0]?.legacyWindow?.pendingChineseVisible === true && - ui.agentChatStatus === "Runner 受阻" && + (agentFixtureMode === "external-network-blocker" + ? ["能力未开放", "Runner 受阻", "服务受阻"].includes(ui.agentChatStatus) + : ui.agentChatStatus === "Runner 受阻") && ui.failedMessageVisible && ui.failedMessageHasTrace && ui.retryInputPreserved && @@ -4702,8 +4719,8 @@ async function runLocalAgentFixtureSmoke({ id: expectTimeout ? "local-agent-timeout-fixture-failed-state" : "local-agent-fixture-replied-state", status: pass ? "pass" : "blocked", summary: expectTimeout - ? "Local SOURCE fixture proves a bounded Code Agent timeout stays failed/BLOCKED in the UI, preserves trace evidence, and keeps the user's input for retry." - : agentFixtureMode === "slow-blocker" + ? "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" ? "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 ? { @@ -4751,11 +4768,15 @@ 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") { + } else if (!expectTimeout && (agentFixtureMode === "slow-blocker" || agentFixtureMode === "external-network-blocker")) { checks.push({ - id: "local-agent-fixture-slow-structured-blocker", + id: agentFixtureMode === "external-network-blocker" + ? "local-agent-fixture-external-network-blocker" + : "local-agent-fixture-slow-structured-blocker", status: pass ? "pass" : "blocked", - summary: "Delayed structured blocker arrives after the legacy 4500ms window, displays Runner 受阻 instead of 发送失败, keeps trace visible, and leaves the user prompt for continuing the same conversation.", + 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 发送失败, keeps trace visible, and leaves the user prompt for continuing the same conversation.", observations: { legacyFailureWindowMs, fixtureDelayMs: responseDelayMs, @@ -6067,7 +6088,140 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} const traceId = stringOrFallback(body?.traceId, "trc_source_fixture_browser"); const normalizedMessage = String(body?.message ?? ""); const isSkillListPrompt = /列出你能使用的所有skill/u.test(normalizedMessage); - const messageId = isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple"; + const isExternalNetworkPrompt = /github|https?:\/\/|外网|公网|访问/u.test(normalizedMessage); + const messageId = isExternalNetworkPrompt + ? "msg_source_fixture_browser_external_network" + : isSkillListPrompt ? "msg_source_fixture_browser_skills" : "msg_source_fixture_browser_simple"; + 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,