From 871756e006b68588361763d5dbe6609855761ceb Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 16:13:49 +0000 Subject: [PATCH] fix: codify Codex runner feasibility contract --- internal/cloud/code-agent-chat.mjs | 13 +- .../code-agent-session-registry.test.mjs | 3 +- internal/cloud/codex-stdio-session.mjs | 315 +++++++++++++++--- internal/cloud/server.test.mjs | 32 +- scripts/code-agent-chat-smoke.mjs | 23 +- scripts/src/code-agent-response-contract.mjs | 21 ++ web/hwlab-cloud-web/app.mjs | 42 ++- web/hwlab-cloud-web/code-agent-status.mjs | 3 + .../code-agent-status.test.mjs | 28 ++ web/hwlab-cloud-web/live-status.mjs | 16 +- 10 files changed, 431 insertions(+), 65 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 345add9c..78357760 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -320,7 +320,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; if (error.availability !== undefined) { payload.availability = error.availability; - } else if (["provider_unavailable", "provider_timeout"].includes(error.code)) { + } else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) { payload.availability = 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"].includes(error.code)) { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); @@ -2687,12 +2687,14 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); if (!(await commandExists(command, env))) { throw providerUnavailable(`Codex CLI command is not available: ${command}`, { + code: "codex_cli_binary_missing", missingCommands: [command], missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"], provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, - command + command, + nextEvidence: `Install/provide a repo-controlled Codex CLI binary on PATH or set HWLAB_CODE_AGENT_CODEX_COMMAND to an approved binary path; checked command=${command}.` }); } @@ -2944,6 +2946,7 @@ function normalizeChatError(error, context = {}) { "providerStatus", "stderrSummary", "missingTools", + "nextEvidence", "reason" ]) { if (error[key] !== undefined) { @@ -2981,6 +2984,12 @@ function errorTaxonomy(code, error = {}) { retryable: true, userMessage: "Code Agent provider 响应超时,输入已保留,可稍后重试。" }, + codex_cli_binary_missing: { + layer: "runner", + category: "needs_config", + retryable: false, + userMessage: "Codex CLI binary 未在运行时 PATH 中找到;当前不会安装临时二进制,也不会冒充真实 Codex。" + }, session_busy: { layer: "session", category: "runner_busy", diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 36df452a..13da9b90 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -1108,7 +1108,8 @@ test("Codex stdio protocol missing tools is a structured blocker, not a passed s assert.equal(payload.session.status, "failed"); assert.equal(payload.longLivedSessionGate.status, "blocked"); assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "session_failed")); - assert.equal(payload.availability.codexStdio.ready, true); + assert.equal(payload.availability.codexStdio.ready, false); + assert.ok(payload.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); assert.deepEqual(payload.availability.codexStdio.protocol.toolsObserved, ["codex"]); assert.equal(classifyCodexRunnerCapability(payload, { httpStatus: 200 }).capabilityPass, false); }); diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 9aea0df5..da4462bf 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { accessSync, constants as fsConstants, existsSync } from "node:fs"; import os from "node:os"; @@ -47,23 +47,50 @@ export function createCodexStdioSessionManager(options = {}) { const enabled = codexStdioEnabled(env, params, options); const supervisor = supervisorState(env, params, options, enabled); const tokenBoundary = tokenBoundaryState(env); - const binaryOnPath = commandOnPathSync(command, env); + const binary = codexBinaryState(command, env); + const binaryOnPath = binary.present; const workspaceInfo = workspaceStateSync(workspace, sandbox); const egress = egressState(env); + const protocol = protocolState({ + command, + binary, + supervisor, + toolsObserved: rpcToolNames + }); + const lifecycle = lifecycleState({ + supervisor, + idleTimeoutMs, + maxSessions, + activeSessions: sessions.size + }); const blockers = []; + if (!binaryOnPath) { + blockers.push({ + code: "codex_cli_binary_missing", + sourceIssue: "pikasTech/HWLAB#275", + summary: `Codex CLI command ${command} is not present on the runtime PATH.`, + evidence: binary.nextEvidence + }); + } if (!enabled || supervisor.configured !== true) { blockers.push({ code: "codex_stdio_supervisor_disabled", sourceIssue: "pikasTech/HWLAB#275", summary: "Repo-owned Codex stdio supervisor is not enabled for this runtime." }); - } - if (!binaryOnPath) { blockers.push({ - code: "codex_cli_binary_missing", + code: "runner_lifecycle_missing", sourceIssue: "pikasTech/HWLAB#275", - summary: `Codex CLI command ${command} is not present on the runtime PATH.` + summary: "Repo-owned lifecycle supervisor is not configured, so create/reuse/cancel/reap/trace readiness cannot pass." + }); + } + if (!protocol.wired) { + blockers.push({ + code: "stdio_protocol_not_wired", + sourceIssue: "pikasTech/HWLAB#275", + summary: protocol.summary, + evidence: protocol.nextEvidence }); } if (!workspaceInfo.exists || !workspaceInfo.readable) { @@ -104,6 +131,7 @@ export function createCodexStdioSessionManager(options = {}) { ready, canStartLongLivedCodexStdio: ready, command, + binary, binaryOnPath, workspace, workspaceState: workspaceInfo, @@ -112,29 +140,23 @@ export function createCodexStdioSessionManager(options = {}) { supervisor, tokenBoundary, egress, - protocol: { - transport: "stdio", - command: `${command} mcp-server`, - protocolVersion: MCP_PROTOCOL_VERSION, - adapter: "Model Context Protocol tools/call", - requiredTools: [...CODEX_STDIO_REQUIRED_TOOLS], - toolsObserved: rpcToolNames ? [...rpcToolNames] : [] - }, - sessionLifecycle: { - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - create: true, - reuse: true, - cancel: true, - reap: true, - idleTimeout: true, - idleTimeoutMs, - traceCapture: true, - maxSessions, - activeSessions: sessions.size, - inMemoryIndex: true, - codexThreadIdCaptured: true, - valuesRedacted: true - }, + protocol, + stdioProtocol: protocol, + sessionLifecycle: lifecycle, + lifecycleSupervisor: lifecycle, + workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace), + cancelReapTraceReadiness: cancelReapTraceState(lifecycle), + runtimeContract: runtimeContract({ + ready, + binary, + protocol, + lifecycle, + workspaceInfo, + workspace, + sandbox, + tokenBoundary, + egress + }), blockers, blockerCodes: blockers.map((blocker) => blocker.code), safety: codexStdioSafety() @@ -149,11 +171,16 @@ export function createCodexStdioSessionManager(options = {}) { const conversationId = requiredId(params.conversationId, "cnv"); const workspace = resolveCodexWorkspace(env, params); const sandbox = resolveCodexSandbox(env, params); - const availability = describe({ ...params, env, workspace, sandbox }); - if (!availability.ready) { + let availability = describe({ ...params, env, workspace, sandbox }); + const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired"); + if (startupBlockers.length > 0) { throw codexStdioError("codex_stdio_blocked", "Codex stdio session is blocked by runtime readiness gates.", { - availability, - blockers: availability.blockers + availability: { + ...availability, + blockers: startupBlockers, + blockerCodes: startupBlockers.map((blocker) => blocker.code) + }, + blockers: startupBlockers }); } @@ -184,6 +211,7 @@ export function createCodexStdioSessionManager(options = {}) { try { const client = await ensureRpcClient({ env, availability, timeoutMs: params.timeoutMs }); + availability = describe({ ...params, env, workspace, sandbox }); events.push("stdio:ready"); const toolName = session.threadId ? "codex-reply" : "codex"; const toolArguments = session.threadId @@ -388,7 +416,12 @@ export function createCodexStdioSessionManager(options = {}) { const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((name) => !rpcToolNames?.includes(name)); if (missingTools.length > 0) { throw codexStdioError("codex_stdio_protocol_blocked", `Codex stdio server did not expose required tools: ${missingTools.join(", ")}.`, { - availability, + availability: describe({ + env, + workspace: availability.workspace, + sandbox: availability.sandbox, + command: availability.command + }), missingTools }); } @@ -1029,13 +1062,219 @@ function accessSyncBoolean(target, mode) { } } -function commandOnPathSync(command, env = process.env) { - if (!command) return false; +function codexBinaryState(command, env = process.env) { + const resolvedPath = commandPathSync(command, env); + const present = Boolean(resolvedPath); + const versionProbe = present ? codexVersionProbe(command, env) : { + attempted: false, + ok: false, + version: null, + error: null, + exitCode: null + }; + return { + command, + present, + binaryPresent: present, + path: resolvedPath, + version: versionProbe.version, + versionDetected: versionProbe.ok, + versionProbe, + nextEvidence: present + ? "codex --version was probed without printing secrets; stdio protocol readiness is checked separately." + : `Install/provide a repo-controlled Codex CLI binary on PATH or set HWLAB_CODE_AGENT_CODEX_COMMAND to an approved binary path; checked command=${command}.`, + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function commandPathSync(command, env = process.env) { + if (!command) return null; if (command.includes("/") || command.includes("\\")) { - return existsSync(command); + return existsSync(command) ? command : null; } const paths = String(Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "").split(path.delimiter).filter(Boolean); - return paths.some((dir) => existsSync(path.join(dir, command))); + const resolved = paths.map((dir) => path.join(dir, command)).find((candidate) => existsSync(candidate)); + return resolved ?? null; +} + +function commandOnPathSync(command, env = process.env) { + return Boolean(commandPathSync(command, env)); +} + +function codexVersionProbe(command, env = process.env) { + try { + const result = spawnSync(command, ["--version"], { + env: childProcessEnv(env), + encoding: "utf8", + timeout: 3000, + windowsHide: true + }); + const output = redactText(`${result.stdout ?? ""}\n${result.stderr ?? ""}`).trim(); + const version = output.split(/\s+/u).find((part) => /\d+\.\d+(?:\.\d+)?/u.test(part)) ?? (output ? tailText(output, 160) : null); + return { + attempted: true, + ok: result.status === 0 && Boolean(version), + version, + exitCode: result.status, + error: result.error ? redactText(result.error.message) : null + }; + } catch (error) { + return { + attempted: true, + ok: false, + version: null, + exitCode: null, + error: redactText(error.message) + }; + } +} + +function protocolState({ command, binary, supervisor, toolsObserved }) { + const observedTools = Array.isArray(toolsObserved) ? toolsObserved.filter(Boolean) : []; + const missingTools = CODEX_STDIO_REQUIRED_TOOLS.filter((tool) => !observedTools.includes(tool)); + const wired = binary.present === true && supervisor.configured === true && observedTools.length > 0 && missingTools.length === 0; + const probeReady = binary.present === true && supervisor.configured === true; + return { + transport: "stdio", + command: `${command} mcp-server`, + protocolVersion: MCP_PROTOCOL_VERSION, + adapter: "Model Context Protocol tools/call", + requiredTools: [...CODEX_STDIO_REQUIRED_TOOLS], + toolsObserved: observedTools, + missingTools, + wired, + probeReady, + status: wired ? "wired" : "blocked", + blocker: wired ? null : "stdio_protocol_not_wired", + summary: wired + ? "Codex stdio MCP protocol is wired and required tools were observed." + : "Codex stdio MCP protocol has not been proven: required tools codex/codex-reply were not observed through repo-owned stdio.", + nextEvidence: "Start `codex mcp-server`, run MCP initialize plus tools/list, and observe codex and codex-reply without printing token material.", + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function lifecycleState({ supervisor, idleTimeoutMs, maxSessions, activeSessions }) { + const ready = supervisor.configured === true; + return { + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + status: ready ? "present" : "blocked", + present: ready, + create: ready, + reuse: ready, + cancel: ready, + reap: ready, + idleTimeout: ready, + idleTimeoutMs, + traceCapture: ready, + maxSessions, + activeSessions, + inMemoryIndex: ready, + codexThreadIdCaptured: ready, + blocker: ready ? null : "runner_lifecycle_missing", + summary: ready + ? "Repo-owned supervisor contract covers create/reuse/cancel/reap/idle timeout/trace capture." + : "Repo-owned lifecycle supervisor is missing; long-lived Code Agent session gate must remain blocked.", + secretMaterialStored: false, + valuesRedacted: true + }; +} + +function workspaceContractState(workspaceInfo, sandbox, workspace) { + const ready = workspaceInfo.exists && workspaceInfo.readable && (sandbox !== "workspace-write" || workspaceInfo.writable === true); + return { + path: workspace, + status: ready ? "ready" : "blocked", + mounted: workspaceInfo.exists, + readable: workspaceInfo.readable, + writable: workspaceInfo.writable, + sandbox, + writeRequired: sandbox === "workspace-write", + blocker: ready ? null : "workspace_mount_missing", + secretMaterialRead: false, + valuesRedacted: true + }; +} + +function cancelReapTraceState(lifecycle) { + const ready = lifecycle.cancel === true && lifecycle.reap === true && lifecycle.traceCapture === true; + return { + status: ready ? "ready" : "blocked", + cancel: lifecycle.cancel === true, + reap: lifecycle.reap === true, + traceCapture: lifecycle.traceCapture === true, + idleTimeout: lifecycle.idleTimeout === true, + blocker: ready ? null : "runner_lifecycle_missing" + }; +} + +function runtimeContract({ + ready, + binary, + protocol, + lifecycle, + workspaceInfo, + workspace, + sandbox, + tokenBoundary, + egress +}) { + return { + contractVersion: "codex-runtime-feasibility-v1", + status: ready ? "ready" : "blocked", + ready, + binary: { + status: binary.present ? "present" : "missing", + command: binary.command, + present: binary.present, + version: binary.version, + versionDetected: binary.versionDetected, + nextEvidence: binary.nextEvidence + }, + stdioProtocol: { + status: protocol.status, + wired: protocol.wired, + command: protocol.command, + requiredTools: [...protocol.requiredTools], + toolsObserved: [...protocol.toolsObserved], + missingTools: [...protocol.missingTools], + nextEvidence: protocol.nextEvidence + }, + lifecycleSupervisor: { + status: lifecycle.status, + present: lifecycle.present, + create: lifecycle.create, + reuse: lifecycle.reuse, + cancel: lifecycle.cancel, + reap: lifecycle.reap, + traceCapture: lifecycle.traceCapture, + idleTimeoutMs: lifecycle.idleTimeoutMs + }, + workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace), + sandbox, + tokenBoundary: { + status: tokenBoundary.present ? "present" : "blocked", + present: tokenBoundary.present, + sources: tokenBoundary.sources, + secretMaterialRead: false, + valuesRedacted: true + }, + egress: { + status: egress.directPublicOpenAi ? "blocked" : egress.configured ? "configured" : "not_configured", + configured: egress.configured, + directPublicOpenAi: egress.directPublicOpenAi, + valueRedacted: true + }, + cancelReapTraceReadiness: cancelReapTraceState(lifecycle), + secretBoundary: { + secretsRead: false, + secretValuesPrinted: false, + kubeconfigRead: false, + valuesRedacted: true + } + }; } function childProcessEnv(env = process.env) { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 4b3cd033..c21ebe61 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -72,14 +72,21 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.codeAgent.status, "partial"); assert.equal(healthPayload.codeAgent.agentKind, "controlled-readonly-session-registry"); assert.equal(healthPayload.codeAgent.partialReady, true); - assert.match(healthPayload.codeAgent.blocker, /long-lived Codex stdio|Codex stdio/u); - assert.equal(healthPayload.codeAgent.reason, "codex_stdio_supervisor_disabled"); + assert.match(healthPayload.codeAgent.blocker, /Codex CLI command/u); + assert.equal(healthPayload.codeAgent.reason, "codex_cli_binary_missing"); assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner"); assert.equal(healthPayload.codeAgent.runner.ready, true); assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-session-tools"); assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available"); assert.equal(healthPayload.codeAgent.longLivedSessionGate.status, "blocked"); assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); + assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.binary.status, "missing"); + assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); + assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.lifecycleSupervisor.status, "blocked"); + assert.equal(healthPayload.codeAgent.codexStdio.runtimeContract.cancelReapTraceReadiness.status, "blocked"); + assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); + assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("runner_lifecycle_missing")); + assert.ok(healthPayload.codeAgent.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]); assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key"); @@ -851,8 +858,8 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses"); assert.equal(payload.codeAgent.mode, "openai"); assert.equal(payload.codeAgent.status, "partial"); - assert.match(payload.codeAgent.blocker, /Codex stdio|long-lived/u); - assert.equal(payload.codeAgent.reason, "codex_stdio_supervisor_disabled"); + assert.match(payload.codeAgent.blocker, /Codex CLI command/u); + assert.equal(payload.codeAgent.reason, "codex_cli_binary_missing"); assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner"); assert.equal(payload.codeAgent.runner.ready, true); assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); @@ -1042,6 +1049,9 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.sessionReuse.status, "idle"); assert.ok(payload.runnerLimitations.includes("not-codex-stdio")); assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "codex_stdio_supervisor_disabled")); + assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "stdio_protocol_not_wired")); + assert.equal(payload.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "blocked"); + assert.equal(payload.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "blocked"); assert.equal(payload.longLivedSessionGate.status, "blocked"); assert.ok(payload.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); assert.equal(payload.toolCalls[0].name, "pwd"); @@ -1855,19 +1865,23 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.equal(payload.backend, "hwlab-cloud-api/codex-cli"); assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); - assert.equal(payload.error.code, "provider_unavailable"); - assert.equal(payload.error.layer, "provider-config"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.error.layer, "runner"); assert.equal(payload.error.retryable, false); - assert.match(payload.error.userMessage, /配置缺失/u); + assert.match(payload.error.userMessage, /Codex CLI binary/u); assert.match(payload.error.message, /Codex CLI command is not available/); + assert.match(payload.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u); assert.deepEqual(payload.error.missingCommands, ["codex"]); assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); assert.ok(payload.error.missingConfig.includes("OPENAI_API_KEY")); assert.ok(payload.error.missingConfig.includes("command:codex")); assert.equal(payload.availability.status, "partial"); - assert.match(payload.availability.blocker, /Codex stdio|long-lived/u); - assert.equal(payload.availability.reason, "codex_stdio_supervisor_disabled"); + assert.match(payload.availability.blocker, /Codex CLI command/u); + assert.equal(payload.availability.reason, "codex_cli_binary_missing"); assert.equal(payload.availability.runner.ready, true); + assert.equal(payload.availability.codexStdio.runtimeContract.binary.status, "missing"); + assert.equal(payload.availability.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); + assert.ok(payload.availability.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); assert.equal(payload.availability.secretRefs[0].secretName, "hwlab-code-agent-provider"); assert.equal(payload.availability.secretRefs[0].secretKey, "openai-api-key"); assert.equal(payload.availability.secretRefs[0].redacted, true); diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index b336a49a..25e141ef 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -113,14 +113,22 @@ async function runLocalContractSmoke() { assert.equal(failed.provider, "codex-cli"); assert.equal(failed.model, "gpt-test"); assert.equal(failed.backend, "hwlab-cloud-api/codex-cli"); - assert.equal(failed.error.code, "provider_unavailable"); + assert.equal(failed.error.code, "codex_cli_binary_missing"); + assert.equal(failed.error.blocker.code, "codex_cli_binary_missing"); + assert.match(failed.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u); assert.match(failed.error.message, /Codex CLI command is not available/); assert.deepEqual(failed.error.missingCommands, ["codex"]); assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY")); assert.equal(failed.availability.status, "partial"); - assert.match(failed.availability.blocker, /Codex stdio|long-lived/u); - assert.equal(failed.availability.reason, "codex_stdio_supervisor_disabled"); + assert.match(failed.availability.blocker, /Codex CLI command/u); + assert.equal(failed.availability.reason, "codex_cli_binary_missing"); assert.equal(failed.availability.runner.ready, true); + assert.equal(failed.availability.codexStdio.runtimeContract.binary.status, "missing"); + assert.equal(failed.availability.codexStdio.runtimeContract.lifecycleSupervisor.status, "blocked"); + assert.equal(failed.availability.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); + assert.ok(failed.availability.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); + assert.ok(failed.availability.codexStdio.blockerCodes.includes("runner_lifecycle_missing")); + assert.ok(failed.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); assert.match(failed.availability.summary, /受控只读 runner/u); assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u); assert.equal(JSON.stringify(failed).includes("sk-"), false); @@ -129,10 +137,10 @@ async function runLocalContractSmoke() { const credentialReadiness = classifyCodeAgentChatReadiness(failed, { realDevLive: true }); assert.equal(credentialReadiness.status, "blocked"); - assert.equal(credentialReadiness.level, "BLOCKED/credential"); - assert.equal(credentialReadiness.blocker, "credential"); + assert.equal(credentialReadiness.level, "BLOCKED"); + assert.equal(credentialReadiness.blocker, "runtime"); assert.equal(credentialReadiness.devLiveReplyPass, false); - logOk("provider credential blocker readiness"); + logOk("codex binary missing blocker readiness"); const upstreamBlocked = classifyCodeAgentChatReadiness({ ...failed, @@ -196,6 +204,9 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.sessionReuse.status, "idle"); assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio")); assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked"); + assert.ok(runnerPwd.codexStdioFeasibility.blockerCodes.includes("stdio_protocol_not_wired")); + assert.equal(runnerPwd.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "blocked"); + assert.equal(runnerPwd.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "blocked"); assert.equal(runnerPwd.longLivedSessionGate.status, "blocked"); assert.equal(runnerPwd.longLivedSessionGate.pass, false); assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "controlled_readonly_not_long_lived_stdio")); diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 049f872f..5dd1cfc6 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -801,6 +801,27 @@ function summarizeCodexStdioFeasibility(value) { currentImplementation: stringOrNull(value.currentImplementation), implementationRequired: stringOrNull(value.implementationRequired), binaryOnPath: value.binaryOnPath === true, + binary: value.binary && typeof value.binary === "object" + ? { + status: stringOrNull(value.binary.status), + present: value.binary.present === true, + command: stringOrNull(value.binary.command), + version: stringOrNull(value.binary.version), + versionDetected: value.binary.versionDetected === true + } + : null, + runtimeContract: value.runtimeContract && typeof value.runtimeContract === "object" + ? { + status: stringOrNull(value.runtimeContract.status), + ready: value.runtimeContract.ready === true, + binaryStatus: stringOrNull(value.runtimeContract.binary?.status), + stdioProtocolStatus: stringOrNull(value.runtimeContract.stdioProtocol?.status), + lifecycleStatus: stringOrNull(value.runtimeContract.lifecycleSupervisor?.status), + workspaceStatus: stringOrNull(value.runtimeContract.workspaceMount?.status), + tokenBoundaryStatus: stringOrNull(value.runtimeContract.tokenBoundary?.status), + cancelReapTraceStatus: stringOrNull(value.runtimeContract.cancelReapTraceReadiness?.status) + } + : null, workspace: stringOrNull(value.workspace), sandbox: stringOrNull(value.sandbox), ready: value.ready === true, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 24de9e9b..fdc28515 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -72,6 +72,9 @@ const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-c const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]); const CODEX_READONLY_PARTIAL_PROVIDERS = Object.freeze(["codex-readonly-runner"]); const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ + "codex_cli_binary_missing", + "runner_lifecycle_missing", + "stdio_protocol_not_wired", "codex_stdio_supervisor_disabled", "not-codex-stdio", "not-write-capable", @@ -2790,13 +2793,40 @@ function codeAgentBlockedSummary(availability) { } function codeAgentBlockerDetail(availability) { - const blockers = Array.isArray(availability?.blockers) - ? availability.blockers.map((blocker) => blocker?.code).filter(Boolean) - : Array.isArray(availability?.codexStdioFeasibility?.blockers) - ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code).filter(Boolean) - : []; + const blockers = codeAgentBlockerCodes(availability); if (blockers.length === 0 && availability?.reason) blockers.push(availability.reason); - return blockers.length > 0 ? ` blocker=${blockers.slice(0, 3).join(",")}。` : ""; + if (blockers.length === 0) return ""; + const labels = blockers.slice(0, 3).map(codeAgentBlockerChineseLabel); + return ` 阻塞:${labels.join(";")}。证据 code=${blockers.slice(0, 3).join(",")}。`; +} + +function codeAgentBlockerCodes(availability) { + const values = [ + ...(Array.isArray(availability?.blockers) ? availability.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.blockerCodes) ? availability.blockerCodes : []), + ...(Array.isArray(availability?.longLivedSessionGate?.blockers) ? availability.longLivedSessionGate.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.codexStdioFeasibility?.blockers) ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.runtimeContract?.stdioProtocol?.missingTools) && availability.runtimeContract.stdioProtocol.missingTools.length > 0 ? ["stdio_protocol_not_wired"] : []), + availability?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null, + availability?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null + ].filter(Boolean).map((code) => String(code)); + return [...new Set(values)]; +} + +function codeAgentBlockerChineseLabel(code) { + return { + codex_cli_binary_missing: "未找到受控 Codex CLI binary", + runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor", + stdio_protocol_not_wired: "Codex stdio 协议尚未接入", + codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用", + controlled_readonly_not_long_lived_stdio: "当前只是只读 session registry", + openai_responses_fallback_not_session: "文本 fallback 不是长会话", + one_shot_runner_not_long_lived: "一次性执行不是可复用 session", + provider_token_boundary: "token 边界未配置", + workspace_mount_missing: "工作区挂载不可读", + workspace_write_boundary_blocked: "workspace-write 沙箱不可写", + codex_stdio_egress_boundary: "DEV egress 边界不合规" + }[code] ?? code; } function untrustedCompletionMessage(result) { diff --git a/web/hwlab-cloud-web/code-agent-status.mjs b/web/hwlab-cloud-web/code-agent-status.mjs index ce8148b1..7bf57171 100644 --- a/web/hwlab-cloud-web/code-agent-status.mjs +++ b/web/hwlab-cloud-web/code-agent-status.mjs @@ -276,6 +276,9 @@ function readinessBlockers(payload) { ...array(payload?.blockers).map((item) => item?.code ?? item), ...array(payload?.longLivedSessionGate?.blockers).map((item) => item?.code ?? item), ...array(payload?.codexStdioFeasibility?.blockers).map((item) => item?.code ?? item), + payload?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null, + payload?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null, + payload?.runtimeContract?.stdioProtocol?.status === "blocked" ? "stdio_protocol_not_wired" : null, payload?.reason, payload?.error?.code, payload?.providerStatus ? `provider_http_${payload.providerStatus}` : null, diff --git a/web/hwlab-cloud-web/code-agent-status.test.mjs b/web/hwlab-cloud-web/code-agent-status.test.mjs index 38ef5f2d..666ca60e 100644 --- a/web/hwlab-cloud-web/code-agent-status.test.mjs +++ b/web/hwlab-cloud-web/code-agent-status.test.mjs @@ -105,6 +105,34 @@ test("maps degraded readonly session tools to warning, not green", () => { assert.equal(summary.lastTraceId, "trc_readonly"); }); +test("summarizes Codex runtimeContract blockers from health availability", () => { + const summary = classifyCodeAgentStatusSummary({ + availability: { + status: "partial", + ready: false, + provider: "codex-readonly-runner", + backend: "hwlab-cloud-api/codex-readonly-runner", + capabilityLevel: "read-only-session-tools", + sessionMode: "controlled-readonly-session-registry", + runner: { + kind: "hwlab-readonly-runner", + writeCapable: false + }, + runtimeContract: { + binary: { status: "missing" }, + lifecycleSupervisor: { status: "blocked" }, + stdioProtocol: { status: "blocked" } + } + } + }); + + assert.equal(summary.kind, "read-only-session-tools"); + assert.equal(summary.tone, "warn"); + assert.equal(summary.readinessBlockers.includes("codex_cli_binary_missing"), true); + assert.equal(summary.readinessBlockers.includes("runner_lifecycle_missing"), true); + assert.equal(summary.readinessBlockers.includes("stdio_protocol_not_wired"), true); +}); + test("maps text fallback to warning with safe labels", () => { const summary = classifyCodeAgentStatusSummary({ availability: { diff --git a/web/hwlab-cloud-web/live-status.mjs b/web/hwlab-cloud-web/live-status.mjs index 20d40a17..54aeccd8 100644 --- a/web/hwlab-cloud-web/live-status.mjs +++ b/web/hwlab-cloud-web/live-status.mjs @@ -44,6 +44,9 @@ const READ_ONLY_REASON_CODES = Object.freeze([ "fallback:text-chat-only", "codex_stdio_blocked_readonly_available", "controlled_readonly_not_long_lived_stdio", + "codex_cli_binary_missing", + "runner_lifecycle_missing", + "stdio_protocol_not_wired", "codex_stdio_supervisor_disabled", "security_blocked" ]); @@ -377,9 +380,16 @@ function classifyCodeAgentProbe(live = {}) { } function blockerCodes(availability) { - if (Array.isArray(availability?.blockerCodes)) return availability.blockerCodes.filter(Boolean); - if (Array.isArray(availability?.blockers)) return availability.blockers.map((blocker) => blocker?.code).filter(Boolean); - return []; + const values = [ + ...(Array.isArray(availability?.blockerCodes) ? availability.blockerCodes : []), + ...(Array.isArray(availability?.blockers) ? availability.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.longLivedSessionGate?.blockers) ? availability.longLivedSessionGate.blockers.map((blocker) => blocker?.code) : []), + ...(Array.isArray(availability?.codexStdioFeasibility?.blockers) ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code) : []), + availability?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null, + availability?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null, + availability?.runtimeContract?.stdioProtocol?.status === "blocked" ? "stdio_protocol_not_wired" : null + ].filter(Boolean); + return [...new Set(values.map(String))]; } function classifyM3ControlProbe(live = {}) {