Merge pull request #423 from pikasTech/fix/code-agent-external-network-003

fix: 修复 Code Agent 外部网络请求可见体验
This commit is contained in:
Lyon
2026-05-24 12:35:59 +08:00
committed by GitHub
6 changed files with 1187 additions and 26 deletions
+75 -8
View File
@@ -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 [{
+656 -2
View File
@@ -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 = [];
+260
View File
@@ -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");
+30 -4
View File
@@ -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") {
+1 -1
View File
@@ -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",
+165 -11
View File
@@ -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,