From 3df89fe15100f3ef3c2e12e1a001b5370d54eff9 Mon Sep 17 00:00:00 2001 From: Lyon <88232613+pikasTech@users.noreply.github.com> Date: Sun, 24 May 2026 06:38:50 +0800 Subject: [PATCH] fix: make codex stdio command path executable Merge PR #400.\n\nValidated with node --test internal/cloud/server.test.mjs internal/cloud/code-agent-session-registry.test.mjs, node scripts/code-agent-chat-smoke.mjs, node web/hwlab-cloud-web/scripts/check.mjs, and npm run check. No DEV deploy apply or rollout performed. --- internal/cloud/code-agent-chat.mjs | 16 +- internal/cloud/codex-stdio-session.mjs | 372 +++++++++++++++++++++-- internal/cloud/health-contract.mjs | 16 +- internal/cloud/server.test.mjs | 395 +++++++++++++++++++++++++ scripts/code-agent-chat-smoke.mjs | 8 +- 5 files changed, 777 insertions(+), 30 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 1dbed385..5a794ef8 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -343,7 +343,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"].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"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } return payload; @@ -820,8 +820,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI workspace: workspace ?? availability.workspace ?? null, sandbox: availability.sandbox ?? "workspace-write", session, - toolCalls: [], - skills: notRequestedSkills(), + toolCalls: Array.isArray(error.toolCalls) ? error.toolCalls : [], + skills: error.skills ?? notRequestedSkills(), runner: { kind: CODEX_STDIO_RUNNER_KIND, provider: CODEX_STDIO_PROVIDER, @@ -855,8 +855,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI codexStdioFeasibility: availability }), blockers: error.blockers ?? availability.blockers, - route: null, - toolName: error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session", + route: error.route ?? null, + toolName: error.toolName ?? (error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session"), missingTools: error.missingTools, availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) }); @@ -3419,6 +3419,12 @@ function errorTaxonomy(code, error = {}) { retryable: true, userMessage: "Codex stdio 未返回有效回复,可稍后重试。" }, + codex_stdio_command_probe_failed: { + layer: "runner", + category: "runner_blocked", + retryable: true, + userMessage: "Codex stdio 基础命令探针失败,readiness 不会标为完整 ready;输入已保留,可稍后重试。" + }, codex_stdio_failed: { layer: "runner", category: "runner_blocked", diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index ff70b8bd..c6c855ab 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -23,6 +23,9 @@ const MCP_PROTOCOL_VERSION = "2024-11-05"; const CODEX_STDIO_REQUIRED_TOOLS = Object.freeze(["codex", "codex-reply"]); const CODEX_STDIO_TOOL_OUTPUT_LIMIT = 4000; 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 CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "You are the HWLAB Cloud Workbench Code Agent.", "Use the provided workspace and repo-owned Codex stdio session only.", @@ -44,6 +47,7 @@ export function createCodexStdioSessionManager(options = {}) { let rpcStartedAt = null; let rpcToolNames = null; let protocolProbe = null; + let commandProbe = null; function describe(params = {}) { const env = params.env ?? options.env ?? process.env; @@ -115,6 +119,15 @@ export function createCodexStdioSessionManager(options = {}) { evidence: protocol.nextEvidence }); } + const commandProbeState = commandProbeSummary({ command, workspace, codexHome, probe: commandProbe }); + if (commandProbeState.status === "blocked") { + blockers.push({ + code: "codex_stdio_command_probe_failed", + sourceIssue: "pikasTech/HWLAB#275", + summary: "Codex stdio command/session probe failed on the same controlled workspace tool path used by /v1/agent/chat.", + evidence: commandProbeState.error + }); + } if (!workspaceInfo.exists || !workspaceInfo.readable) { blockers.push({ code: "workspace_mount_missing", @@ -184,6 +197,7 @@ export function createCodexStdioSessionManager(options = {}) { protocol, stdioProtocol: protocol, protocolProbe: protocolProbeSummary({ command, workspace, codexHome, probe: protocolProbe }), + commandProbe: commandProbeState, sessionLifecycle: lifecycle, lifecycleSupervisor: lifecycle, workspaceMount: workspaceContractState(workspaceInfo, sandbox, workspace), @@ -199,7 +213,8 @@ export function createCodexStdioSessionManager(options = {}) { codexHome, codexHomeInfo, tokenBoundary, - egress + egress, + commandProbe: commandProbeState }), blockers, blockerCodes: blockers.map((blocker) => blocker.code), @@ -257,6 +272,48 @@ export function createCodexStdioSessionManager(options = {}) { const client = await ensureRpcClient({ env, availability, timeoutMs: params.timeoutMs }); availability = describe({ ...params, env, workspace, sandbox }); events.push("stdio:ready"); + const sidecar = await collectWorkspaceSidecarEvidence({ + message: params.message, + workspace, + traceId, + env, + now + }); + if (shouldReturnSidecarOnly(sidecar)) { + if (sidecar.skills.status === "blocked") { + throw codexStdioError("skills_unavailable", "No usable SKILL.md manifest was found for the Codex stdio runner.", { + availability, + session, + toolCalls: sidecar.toolCalls, + skills: sidecar.skills, + blockers: sidecar.skills.blockers, + route: null, + toolName: "skills.discover" + }); + } + session = releaseSession(session.sessionId, { + now, + traceId, + conversationId, + reused: session.reused, + status: "idle" + }) ?? session; + events.push(...sidecar.events); + return codexStdioSidecarResult({ + message: params.message, + workspace, + sandbox, + session, + sidecar, + availability, + traceId, + startedAt, + events, + model: params.model, + env, + providerTraceToolName: "workspace-sidecar" + }); + } const toolName = session.threadId ? "codex-reply" : "codex"; const toolArguments = session.threadId ? { @@ -279,13 +336,6 @@ export function createCodexStdioSessionManager(options = {}) { session }); } - const sidecar = await collectWorkspaceSidecarEvidence({ - message: params.message, - workspace, - traceId, - env, - now - }); session = releaseSession(session.sessionId, { now, traceId, @@ -359,7 +409,7 @@ export function createCodexStdioSessionManager(options = {}) { reused: session.reused, statusReason: error.code ?? "codex_stdio_failed" }) ?? session; - if (error.code && error.code.startsWith("codex_stdio")) { + if (error.code && (error.code.startsWith("codex_stdio") || ["skills_unavailable"].includes(error.code))) { error.session = session; error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox }); error.runnerTrace = runnerTrace({ @@ -399,10 +449,30 @@ export function createCodexStdioSessionManager(options = {}) { const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired"); if (startupBlockers.length > 0) return availability; if (rpcClient && Array.isArray(rpcToolNames) && rpcToolNames.length > 0) { - return describe({ ...params, env, workspace, command, sandbox, toolsObserved: rpcToolNames }); + availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved: rpcToolNames }); + return ensureCommandProbeReady({ + env, + workspace, + command, + sandbox, + codexHome, + availability, + now: params.now ?? nowDefault, + probeTimeoutMs: params.commandProbeTimeoutMs + }); } if (!params.forceProbe && cachedProbeTools({ command, workspace, codexHome, probe: protocolProbe })) { - return describe({ ...params, env, workspace, command, sandbox, toolsObserved: protocolProbe.toolsObserved }); + availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved: protocolProbe.toolsObserved }); + return ensureCommandProbeReady({ + env, + workspace, + command, + sandbox, + codexHome, + availability, + now: params.now ?? nowDefault, + probeTimeoutMs: params.commandProbeTimeoutMs + }); } let probeClient = null; @@ -439,7 +509,16 @@ export function createCodexStdioSessionManager(options = {}) { valuesRedacted: true }; availability = describe({ ...params, env, workspace, command, sandbox, toolsObserved }); - return availability; + return ensureCommandProbeReady({ + env, + workspace, + command, + sandbox, + codexHome, + availability, + now: params.now ?? nowDefault, + probeTimeoutMs: params.commandProbeTimeoutMs + }); } catch (error) { protocolProbe = { status: "blocked", @@ -517,6 +596,84 @@ export function createCodexStdioSessionManager(options = {}) { rpcStartedAt = null; rpcToolNames = null; protocolProbe = null; + commandProbe = null; + } + + async function ensureCommandProbeReady({ + env, + workspace, + command, + sandbox, + codexHome, + availability, + now, + probeTimeoutMs + } = {}) { + if (cachedCommandProbe({ command, workspace, codexHome, probe: commandProbe })) { + return describe({ env, workspace, command, sandbox, toolsObserved: availability.protocol?.toolsObserved ?? rpcToolNames }); + } + const startedAt = timestampFor(now); + try { + const sidecar = await collectWorkspaceSidecarEvidence({ + message: "用pwd列出你当前的工作目录", + workspace, + traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID, + env, + now + }); + const pwdCall = sidecar.toolCalls.find((toolCall) => toolCall.name === "pwd"); + if (pwdCall?.status === "blocked") { + throw new Error(pwdCall.stderrSummary || "controlled pwd probe was blocked"); + } + if (!pwdCall || pwdCall.status !== "completed" || String(pwdCall.stdout ?? "").trim() !== workspace) { + throw new Error("controlled pwd probe did not return the configured workspace"); + } + commandProbe = { + status: "ready", + ready: true, + command, + workspace, + codexHome, + probe: "workspace.pwd", + conversationId: CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID, + traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID, + toolCalls: [{ + name: pwdCall.name, + status: pwdCall.status, + cwd: pwdCall.cwd, + command: pwdCall.command, + exitCode: pwdCall.exitCode, + stdoutSummary: "workspace path matched", + outputTruncated: pwdCall.outputTruncated === true + }], + startedAt, + finishedAt: timestampFor(now), + expiresAtMs: Date.now() + probeCacheTtlMs, + timeoutMs: positiveInteger(probeTimeoutMs, CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS), + secretMaterialRead: false, + valuesRedacted: true + }; + } catch (error) { + commandProbe = { + status: "blocked", + ready: false, + command, + workspace, + codexHome, + probe: "workspace.pwd", + conversationId: CODEX_STDIO_COMMAND_PROBE_CONVERSATION_ID, + traceId: CODEX_STDIO_COMMAND_PROBE_TRACE_ID, + toolCalls: [], + startedAt, + finishedAt: timestampFor(now), + expiresAtMs: Date.now() + Math.min(probeCacheTtlMs, 5000), + timeoutMs: positiveInteger(probeTimeoutMs, CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS), + error: redactText(error.message), + secretMaterialRead: false, + valuesRedacted: true + }; + } + return describe({ env, workspace, command, sandbox, toolsObserved: availability.protocol?.toolsObserved ?? rpcToolNames }); } async function ensureRpcClient({ env, availability, timeoutMs } = {}) { @@ -1016,6 +1173,117 @@ function runnerTrace({ traceId, workspace, sandbox, session, events, startedAt, }; } +function codexStdioSidecarResult({ + message, + workspace, + sandbox, + session, + sidecar, + availability, + traceId, + startedAt, + events, + model, + env, + providerTraceToolName +}) { + return { + provider: CODEX_STDIO_PROVIDER, + model: firstNonEmpty(model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), + backend: CODEX_STDIO_BACKEND, + content: sidecarOnlyReply({ message, sidecar, session }), + workspace, + sandbox, + session, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionReuse: sessionReuseEvidence(session), + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], + codexStdioFeasibility: availability, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + session, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: availability + }), + toolCalls: sidecar.toolCalls, + skills: sidecar.skills, + runner: runnerDescriptor({ workspace, sandbox, session }), + runnerTrace: runnerTrace({ + traceId, + workspace, + sandbox, + session, + events, + startedAt, + outputTruncated: sidecar.outputTruncated + }), + capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, + providerTrace: { + transport: "stdio", + protocol: "mcp", + command: `${availability.command} mcp-server`, + toolName: providerTraceToolName, + threadId: session.threadId ?? null, + sidecarOnly: true, + valuesPrinted: false + } + }; +} + +function shouldReturnSidecarOnly(sidecar = {}) { + const intent = sidecar.intent ?? {}; + const requested = ["pwd", "ls", "skills", "smoke"].filter((name) => intent[name] === true); + if (requested.length === 0) return false; + return sidecar.toolCalls.length > 0; +} + +function sidecarOnlyReply({ message, sidecar, session }) { + const intent = sidecar.intent ?? {}; + const lines = []; + if (intent.pwd) { + const pwd = sidecar.toolCalls.find((toolCall) => toolCall.name === "pwd"); + lines.push("当前 Codex stdio 受控 workspace 目录:", pwd?.stdout ?? ""); + } + if (intent.ls) { + const ls = sidecar.toolCalls.find((toolCall) => toolCall.name === "ls"); + lines.push(lines.length ? "" : "", "目录列表:", ls?.stdout || "(empty)"); + } + if (sidecar.skills.status === "ready") { + lines.push(lines.length ? "" : "", stdioSkillsReply(sidecar.skills)); + } + if (intent.smoke) { + const smokeCalls = sidecar.toolCalls + .filter((toolCall) => toolCall.name.startsWith("workspace.smoke.")) + .map((toolCall) => `${toolCall.name}:${toolCall.status}`); + if (smokeCalls.length > 0) lines.push(lines.length ? "" : "", `workspace smoke: ${smokeCalls.join(", ")}`); + } + lines.push( + lines.length ? "" : "", + `Session: mode=${CODEX_STDIO_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}.`, + "边界:provider=codex-stdio;backend=hwlab-cloud-api/codex-mcp-stdio;硬件控制仅允许 cloud-api/HWLAB API/skill CLI;未读取 secret/token/kubeconfig。" + ); + return boundToolOutput(lines.filter((line, index) => line !== "" || index > 0).join("\n")).text || String(message ?? "").trim(); +} + +function stdioSkillsReply(skills) { + const lines = [ + `已发现 ${skills.totalCount} 个可用 skill${skills.truncated ? `,本次显示前 ${skills.count} 个` : ""}:` + ]; + for (const skill of skills.items) { + const meta = [ + `source=${skill.source}`, + skill.version ? `version=${skill.version}` : null, + skill.commit ? `commit=${skill.commit}` : null + ].filter(Boolean).join(" / "); + lines.push(`- ${skill.name}: ${skill.summary}${meta ? ` (${meta})` : ""}`); + } + lines.push("说明:这是 Codex stdio runner 的受控 skills discovery,未读取 secret/token/kubeconfig。"); + return lines.join("\n"); +} + function sessionReuseEvidence(session) { return { conversationId: session.conversationId, @@ -1081,9 +1349,9 @@ async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, en let outputTruncated = false; if (intent.pwd) { - const toolCall = pwdToolCall({ workspace, traceId }); + const toolCall = await pwdToolCall({ workspace, traceId }); toolCalls.push(toolCall); - events.push("tool:workspace.pwd:completed"); + events.push(`tool:workspace.pwd:${toolCall.status}`); } if (intent.ls) { @@ -1130,9 +1398,11 @@ async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, en function detectWorkspaceSidecarIntent(message) { const text = String(message ?? ""); const lower = text.toLowerCase(); + const wantsPwd = /\bpwd\b/u.test(lower) || /(?:当前|打印|显示|查看).{0,12}(?:工作目录|目录|路径|workspace)/iu.test(text); + const explicitLs = /\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text); return { - pwd: /\bpwd\b/u.test(lower) || /(?:当前|打印|显示|查看).{0,12}(?:工作目录|目录|路径|workspace)/iu.test(text), - ls: /\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text), + pwd: wantsPwd, + ls: explicitLs && (!wantsPwd || /\bls\b/u.test(lower) || /(?:文件|目录列表|所有文件)/u.test(text)), skills: /(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text), smoke: /(?:write|写入|读取|清理|cleanup|smoke|冒烟).{0,24}(?:workspace|工作区|临时|tmp)|(?:workspace|工作区).{0,24}(?:write|写入|读取|清理|smoke|冒烟)/iu.test(text), target: extractWorkspaceTarget(text) @@ -1147,7 +1417,27 @@ function extractWorkspaceTarget(text) { return inlinePath || "."; } -function pwdToolCall({ workspace, traceId }) { +async function pwdToolCall({ workspace, traceId }) { + try { + const workspaceStat = await stat(workspace); + if (!workspaceStat.isDirectory()) { + return blockedToolCall({ + name: "pwd", + type: "workspace-read", + workspace, + traceId, + reason: "configured workspace is not a directory" + }); + } + } catch (error) { + return blockedToolCall({ + name: "pwd", + type: "workspace-read", + workspace, + traceId, + reason: error.message || "configured workspace is not readable" + }); + } return { id: `tool_${randomUUID()}`, type: "workspace-read", @@ -1331,6 +1621,9 @@ function resolveSkillDirs(env = process.env) { .flatMap((part) => part.split(path.delimiter)) .map((dir) => dir.trim()) .filter(Boolean); + if (env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1") { + return [...new Set(configured.map((dir) => path.resolve(dir)))]; + } return [...new Set([ ...configured, "/app/skills", @@ -1785,6 +2078,13 @@ function cachedProbeTools({ command, workspace, codexHome, probe }) { return Array.isArray(probe.toolsObserved) ? probe.toolsObserved : null; } +function cachedCommandProbe({ command, workspace, codexHome, probe }) { + if (!probe || probe.ready !== true) return null; + if (probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) return null; + if (Number.isFinite(probe.expiresAtMs) && probe.expiresAtMs <= Date.now()) return null; + return probe; +} + function protocolProbeSummary({ command, workspace, codexHome, probe }) { if (!probe || probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) { return { @@ -1807,6 +2107,33 @@ function protocolProbeSummary({ command, workspace, codexHome, probe }) { }; } +function commandProbeSummary({ command, workspace, codexHome, probe }) { + if (!probe || probe.command !== command || probe.workspace !== workspace || probe.codexHome !== codexHome) { + return { + status: "not_run", + ready: false, + probe: "workspace.pwd", + toolCalls: [], + secretMaterialRead: false, + valuesRedacted: true + }; + } + return { + status: probe.status, + ready: probe.ready === true, + probe: probe.probe ?? "workspace.pwd", + conversationId: probe.conversationId ?? null, + traceId: probe.traceId ?? null, + toolCalls: Array.isArray(probe.toolCalls) ? [...probe.toolCalls] : [], + startedAt: probe.startedAt, + finishedAt: probe.finishedAt, + error: probe.error ?? null, + timeoutMs: probe.timeoutMs ?? CODEX_STDIO_COMMAND_PROBE_TIMEOUT_MS, + secretMaterialRead: false, + valuesRedacted: true + }; +} + function lifecycleState({ supervisor, idleTimeoutMs, maxSessions, activeSessions }) { const ready = supervisor.configured === true; return { @@ -1872,7 +2199,8 @@ function runtimeContract({ codexHome, codexHomeInfo, tokenBoundary, - egress + egress, + commandProbe }) { return { contractVersion: "codex-runtime-feasibility-v1", @@ -1897,6 +2225,14 @@ function runtimeContract({ missingTools: [...protocol.missingTools], nextEvidence: protocol.nextEvidence }, + commandProbe: commandProbe ?? { + status: "not_run", + ready: false, + probe: "workspace.pwd", + toolCalls: [], + secretMaterialRead: false, + valuesRedacted: true + }, lifecycleSupervisor: { status: lifecycle.status, present: lifecycle.present, diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index 47a1f240..4248522b 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -231,7 +231,8 @@ function buildProviderReadiness(codeAgent = {}) { function buildSessionRunnerReadiness(codeAgent = {}) { const stdio = codeAgent?.codexStdioFeasibility ?? codeAgent?.codexStdio ?? {}; - const stdioReady = stdio.ready === true || stdio.canStartLongLivedCodexStdio === true; + const stdioCommandReady = stdio.commandProbe?.ready === true; + const stdioReady = (stdio.ready === true || stdio.canStartLongLivedCodexStdio === true) && stdioCommandReady; const runner = codeAgent?.runner ?? {}; const runnerReady = stdioReady || runner.ready === true || codeAgent?.sessionRegistry?.status === "available"; const status = stdioReady ? "codex_stdio_ready" : runnerReady ? "read_only_long_lived_ready" : "blocked"; @@ -263,23 +264,30 @@ function buildSessionRunnerReadiness(codeAgent = {}) { function buildCodexStdioReadiness(codeAgent = {}) { const stdio = codeAgent?.codexStdioFeasibility ?? codeAgent?.codexStdio ?? {}; - const ready = stdio.ready === true || stdio.canStartLongLivedCodexStdio === true; + const commandReady = stdio.commandProbe?.ready === true; + const ready = (stdio.ready === true || stdio.canStartLongLivedCodexStdio === true) && commandReady; const blockerCodes = Array.isArray(stdio.blockerCodes) ? [...stdio.blockerCodes] : Array.isArray(stdio.blockers) ? stdio.blockers.map((blocker) => blocker?.code).filter(Boolean) : []; - const blocker = ready ? null : firstCode(blockerCodes[0], stdio.blockers?.[0]?.code, codeAgent?.longLivedSessionGate?.blockers?.[0]?.code, "codex_stdio_feasibility_blocked"); + const effectiveBlockerCodes = [...blockerCodes]; + if ((stdio.ready === true || stdio.canStartLongLivedCodexStdio === true) && !commandReady) { + effectiveBlockerCodes.unshift("codex_stdio_command_probe_failed"); + } + const blocker = ready ? null : firstCode(effectiveBlockerCodes[0], stdio.blockers?.[0]?.code, codeAgent?.longLivedSessionGate?.blockers?.[0]?.code, "codex_stdio_feasibility_blocked"); return { status: ready ? "ready" : "blocked", ready, feasible: ready, blocker, - blockerCodes, + blockerCodes: effectiveBlockerCodes, sourceIssue: stdio.sourceIssue ?? "pikasTech/HWLAB#275", commandPresent: stdio.binaryOnPath === true, commandExecutable: stdio.binary?.executable === true, nativeDependencyPresent: stdio.binary?.nativeDependencyPresent === true, + commandProbeReady: commandReady, + commandProbe: stdio.commandProbe ?? null, supervisorConfigured: stdio.supervisor?.configured === true, workspaceReady: stdio.workspaceState?.exists === true && stdio.workspaceState?.readable === true, tokenBoundaryPresent: stdio.tokenBoundary?.present === true, diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 81ecf5f8..bd861b71 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -10,6 +10,7 @@ import { createCloudApiServer } from "./server.mjs"; import { validateCodeAgentChatSchema } from "./code-agent-chat.mjs"; import { createCodexStdioSessionManager } from "./codex-stdio-session.mjs"; import { createCloudRuntimeStore } from "../db/runtime-store.mjs"; +import { classifyCodexRunnerCapability } from "../../scripts/src/code-agent-response-contract.mjs"; import { RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED, RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED, @@ -1201,6 +1202,23 @@ function codexStdioReadyFixture({ workspace, codexHome }) { missingTools: [], command: "codex mcp-server" }, + commandProbe: { + status: "ready", + ready: true, + probe: "workspace.pwd", + traceId: "trc_codex_stdio_command_probe", + toolCalls: [{ + name: "pwd", + status: "completed", + cwd: workspace, + command: "pwd", + exitCode: 0, + stdoutSummary: "workspace path matched", + outputTruncated: false + }], + secretMaterialRead: false, + valuesRedacted: true + }, stdioProtocol: { status: "wired", wired: true, @@ -1246,6 +1264,23 @@ function codexStdioReadyFixture({ workspace, codexHome }) { toolsObserved: ["codex", "codex-reply"], missingTools: [] }, + commandProbe: { + status: "ready", + ready: true, + probe: "workspace.pwd", + traceId: "trc_codex_stdio_command_probe", + toolCalls: [{ + name: "pwd", + status: "completed", + cwd: workspace, + command: "pwd", + exitCode: 0, + stdoutSummary: "workspace path matched", + outputTruncated: false + }], + secretMaterialRead: false, + valuesRedacted: true + }, lifecycleSupervisor: { status: "present", present: true, @@ -1574,6 +1609,9 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass"); assert.equal(payload.codeAgent.codexStdio.runtimeContract.binary.status, "present"); assert.deepEqual(payload.codeAgent.codexStdio.protocol.toolsObserved, ["codex", "codex-reply"]); + assert.equal(payload.codeAgent.codexStdio.commandProbe.ready, true); + assert.equal(payload.codeAgent.codexStdio.runtimeContract.commandProbe.ready, true); + assert.equal(payload.readiness.codexStdio.commandProbeReady, true); assert.equal(JSON.stringify(payload).includes("provider_unavailable"), false); assert.equal(JSON.stringify(payload).includes("dns_resolution_failed"), false); assert.equal(JSON.stringify(payload).includes("runtime_durable_adapter_auth_blocked"), false); @@ -1588,6 +1626,92 @@ test("cloud api health reports provider, durable DB, and codex stdio ready when } }); +test("cloud api health blocks full Code Agent readiness when Codex stdio command probe fails", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-health-codex-stdio-probe-fail-")); + const codexHome = path.join(workspace, "codex-home"); + const workspaceFile = path.join(workspace, "workspace-file"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); + await writeFile(workspaceFile, "not a directory\n", "utf8"); + const manager = createCodexStdioSessionManager({ + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + close() {} + }) + }); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceFile, + HWLAB_CODE_AGENT_WORKSPACE: workspaceFile, + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:17680/v1/responses", + HWLAB_CLOUD_DB_URL: "postgres://hwlab_test:password@db.internal.local:5432/hwlab", + HWLAB_CLOUD_DB_SSL_MODE: "disable" + }, + dbProbe: { + probe: async ({ endpointSource, timeoutMs }) => ({ + attempted: true, + networkAttempted: true, + endpointSource, + probeType: "tcp-connect", + endpointRedacted: true, + valueRedacted: true, + timeoutMs, + durationMs: 1, + result: "connected", + classification: "tcp_connected", + errorCode: null, + missingEnv: [] + }) + }, + runtimeStore: { + async readiness() { + return durableReadyRuntime(); + } + }, + codexStdioManager: manager + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health/live`); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.status, "degraded"); + assert.equal(payload.ready, false); + assert.equal(payload.readiness.components.codeAgent, "blocked"); + assert.equal(payload.readiness.codexStdio.status, "blocked"); + assert.equal(payload.readiness.codexStdio.ready, false); + assert.equal(payload.readiness.codexStdio.commandProbeReady, false); + assert.ok(payload.readiness.codexStdio.blockerCodes.includes("codex_stdio_command_probe_failed")); + assert.ok(payload.blockerCodes.includes("codex_stdio_command_probe_failed")); + assert.equal(payload.codeAgent.ready, false); + assert.equal(payload.codeAgent.codexStdio.commandProbe.ready, false); + assert.equal(payload.codeAgent.codexStdio.commandProbe.status, "blocked"); + assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); + assert.equal(JSON.stringify(payload).includes("password"), 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 completed Code Agent payload", async () => { const server = createCloudApiServer({ callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({ @@ -1637,6 +1761,56 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload", } }); +test("OpenAI fallback text chat does not satisfy Codex stdio capability gate", async () => { + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_PROVIDER: "openai", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + callCodeAgentProvider: async ({ providerPlan }) => ({ + provider: providerPlan.provider, + model: providerPlan.model, + backend: providerPlan.backend, + content: "普通文本回复,不包含 runner/tool/session 能力。", + usage: null, + providerTrace: { + source: "test-provider" + } + }) + }); + 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_openai_fallback_not_runner", + traceId: "trc_server_openai_fallback_not_runner", + message: "请用一句话说明当前 HWLAB 工作台可以做什么。" + }); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "openai-responses"); + assert.equal(payload.backend, "hwlab-cloud-api/openai-responses"); + assert.equal(payload.capabilityLevel, "text-chat-only"); + assert.equal(payload.runner.kind, "openai-responses-fallback"); + assert.equal(payload.runner.codexStdio, false); + assert.equal(payload.longLivedSessionGate.status, "blocked"); + assert.equal(payload.blocker.code, "text_chat_only_fallback"); + const capability = classifyCodexRunnerCapability(payload, { httpStatus: 200 }); + assert.equal(capability.capabilityPass, false); + assert.equal(capability.blocker, "openai-fallback-not-runner"); + assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => { const server = createCloudApiServer({ env: { @@ -1760,6 +1934,227 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e } }); +test("cloud api /v1/agent/chat runs Codex stdio pwd through controlled command path without OpenAI fallback", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-pwd-")); + const codexHome = path.join(workspace, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); + const codexToolCalls = []; + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_server_stdio_pwd_sidecar", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool(name, args) { + codexToolCalls.push({ name, args }); + throw new Error("model turn must not be needed for pwd"); + }, + close() {} + }) + }); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: manager, + callCodeAgentProvider: async () => { + throw new Error("OpenAI fallback must not handle Codex stdio pwd"); + } + }); + 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_pwd_sidecar", + traceId: "trc_server_stdio_pwd_sidecar", + message: "用pwd列出你当前的工作目录" + }); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(payload.workspace, workspace); + assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.longLivedSessionGate.status, "pass"); + assert.equal(payload.providerTrace.sidecarOnly, true); + assert.equal(payload.providerTrace.toolName, "workspace-sidecar"); + assert.deepEqual(codexToolCalls, []); + assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "pwd" && toolCall.status === "completed" && toolCall.stdout === workspace)); + assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); + assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); + assert.equal(JSON.stringify(payload).includes("text-chat-only"), 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 discovers skills through Codex stdio command path", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-")); + const codexHome = path.join(workspace, "codex-home"); + const skillsDir = path.join(workspace, "skills"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(path.join(skillsDir, "hwlab-test-skill"), { recursive: true }); + await mkdir(codexHome, { recursive: true }); + await writeFile(path.join(skillsDir, "hwlab-test-skill", "SKILL.md"), [ + "---", + "name: hwlab-test-skill", + "description: Test skill mounted for Codex stdio discovery.", + "version: v-test", + "---", + "", + "# HWLAB Test Skill" + ].join("\n")); + 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_SKILLS_DIRS: skillsDir, + HWLAB_CODE_AGENT_SKILLS_STRICT: "1", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: createCodexStdioSessionManager({ + idFactory: () => "ses_server_stdio_skills", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + throw new Error("model turn must not be needed for skills discovery"); + }, + close() {} + }) + }) + }); + 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_skills", + traceId: "trc_server_stdio_skills", + message: "列出你可用的skills" + }); + + 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.providerTrace.sidecarOnly, true); + assert.equal(payload.skills.status, "ready"); + assert.ok(payload.skills.items.some((skill) => skill.name === "hwlab-test-skill")); + assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed")); + assert.match(payload.reply.content, /hwlab-test-skill/u); + 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 reports Codex stdio skills_unavailable as structured blocker", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-")); + 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_SKILLS_DIRS: path.join(workspace, "missing-skills"), + HWLAB_CODE_AGENT_SKILLS_STRICT: "1", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: createCodexStdioSessionManager({ + idFactory: () => "ses_server_stdio_skills_missing", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + throw new Error("model turn must not be needed for skills discovery"); + }, + close() {} + }) + }) + }); + 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_skills_missing", + traceId: "trc_server_stdio_skills_missing", + message: "列出你可用的skills" + }); + + 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, "skills_unavailable"); + assert.equal(payload.error.userMessage, "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.skills.status, "blocked"); + assert.equal(payload.skills.blockers[0].code, "skills_unavailable"); + assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "blocked")); + assert.equal(payload.session.status, "failed"); + assert.equal(payload.session.statusReason, "skills_unavailable"); + 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 routes M3 IO through Skill CLI to /v1/m3/io only", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-")); const calls = []; diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index be3c39ae..6c869051 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -313,7 +313,8 @@ async function runLocalContractSmoke() { assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.codexHome.status, "ready"); assert.equal(stdioPwd.longLivedSessionGate.status, "pass"); assert.equal(stdioPwd.longLivedSessionGate.pass, true); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "codex" && call.status === "completed")); + assert.equal(stdioPwd.providerTrace.sidecarOnly, true); + assert.equal(stdioPwd.providerTrace.toolName, "workspace-sidecar"); assert.ok(stdioPwd.toolCalls.some((call) => call.name === "pwd" && call.status === "completed")); assert.ok(stdioPwd.toolCalls.some((call) => call.name === "ls" && call.status === "completed")); assert.ok(stdioPwd.toolCalls.some((call) => call.name === "skills.discover" && call.status === "completed")); @@ -345,10 +346,11 @@ async function runLocalContractSmoke() { assert.equal(stdioSecondTurn.provider, "codex-stdio"); assert.equal(stdioSecondTurn.sessionId, stdioPwd.sessionId); assert.equal(stdioSecondTurn.session.sessionId, stdioPwd.session.sessionId); - assert.equal(stdioSecondTurn.session.threadId, "thread_code_agent_chat_smoke_stdio"); + assert.equal(stdioSecondTurn.session.threadId, null); assert.equal(stdioSecondTurn.sessionReuse.reused, true); assert.equal(stdioSecondTurn.sessionReuse.turn, 2); - assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "codex-reply" && call.status === "completed")); + assert.equal(stdioSecondTurn.providerTrace.sidecarOnly, true); + assert.equal(stdioSecondTurn.providerTrace.toolName, "workspace-sidecar"); assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "ls" && call.status === "completed")); assert.equal(classifyCodexRunnerCapability(stdioSecondTurn, { httpStatus: 200 }).capabilityPass, true); assert.equal(JSON.stringify(stdioSecondTurn).includes("test-openai-key-material"), false);