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.
This commit is contained in:
Lyon
2026-05-24 06:38:50 +08:00
committed by GitHub
parent 3868be499e
commit 3df89fe151
5 changed files with 777 additions and 30 deletions
+11 -5
View File
@@ -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",
+354 -18
View File
@@ -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-stdiobackend=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,
+12 -4
View File
@@ -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,
+395
View File
@@ -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 = [];
+5 -3
View File
@@ -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);