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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user