import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { access, mkdir, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { DEV_CODE_AGENT_PROVIDER_CONTRACT, codeAgentSecretRefPlaceholder, inspectCodeAgentProviderEnv } from "./code-agent-contract.mjs"; const DEFAULT_CODE_AGENT_TIMEOUT_MS = 120000; const DEFAULT_CODEX_COMMAND = "codex"; const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model; const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench"; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); const CODE_AGENT_SYSTEM_PROMPT = [ "你是 HWLAB 云工作台的 Code Agent。", "请用中文直接回答用户的工作台问题。", "当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。", "不要输出 secret、token 或环境变量原文。" ].join("\n"); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export async function handleCodeAgentChat(params = {}, options = {}) { const timestamp = nowIso(options.now); const conversationId = cleanProtocolId(params.conversationId, "cnv") || `cnv_${randomUUID()}`; const sessionId = conversationId; const messageId = `msg_${randomUUID()}`; const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`; const providerPlan = resolveProviderPlan(options.env ?? process.env, options); const base = { conversationId, sessionId, messageId, status: "running", createdAt: timestamp, updatedAt: timestamp, traceId, provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, projectId: cleanProjectId(params.projectId) || DEFAULT_PROJECT_ID }; try { const message = normalizeUserMessage(params.message); const providerResult = await callConfiguredProvider({ providerPlan, message, conversationId, traceId, timeoutMs: options.timeoutMs, env: options.env ?? process.env, now: options.now, callProvider: options.callProvider }); const content = typeof providerResult.content === "string" ? providerResult.content.trim() : ""; if (!content) { throw providerUnavailable("Code Agent provider returned no assistant text", { provider: providerResult.provider ?? base.provider, model: providerResult.model ?? base.model, backend: providerResult.backend ?? base.backend }); } const completedAt = nowIso(options.now); return { ...base, status: "completed", updatedAt: completedAt, provider: providerResult.provider ?? base.provider, model: providerResult.model ?? base.model, backend: providerResult.backend ?? base.backend, reply: { messageId, role: "assistant", content, createdAt: completedAt }, usage: providerResult.usage ?? null, providerTrace: providerResult.providerTrace ?? null }; } catch (error) { const failedAt = nowIso(options.now); const payload = { ...base, status: "failed", updatedAt: failedAt, error: normalizeChatError(error) }; if (error.code === "provider_unavailable") { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); } return payload; } } export function validateCodeAgentChatSchema(payload) { const required = [ "conversationId", "sessionId", "messageId", "status", "createdAt", "updatedAt", "traceId", "provider", "model", "backend" ]; for (const field of required) { if (!payload || typeof payload !== "object" || !Object.hasOwn(payload, field)) { throw new Error(`code agent chat response missing ${field}`); } } if (!["running", "completed", "failed"].includes(payload.status)) { throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`); } if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) { throw new Error("code agent chat response timestamps must be RFC 3339 strings"); } if (payload.status === "failed" && typeof payload.error?.message !== "string") { throw new Error("failed code agent chat response must include error.message"); } if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) { throw new Error("completed code agent chat response must include non-empty reply.content"); } } export function describeCodeAgentAvailability(env = process.env, options = {}) { const providerPlan = resolveProviderPlan(env, options); const providerContract = inspectCodeAgentProviderEnv(env); const missingEnv = providerPlan.mode === "openai" ? providerContract.missingEnv : []; if (providerPlan.mode !== "openai" && !env.OPENAI_API_KEY) { missingEnv.push("OPENAI_API_KEY"); } const blocked = providerPlan.mode === "openai" ? !providerContract.ready : missingEnv.length > 0; return { endpoint: "POST /v1/agent/chat", provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, mode: providerPlan.mode, schema: [ "conversationId", "sessionId", "messageId", "status", "createdAt", "updatedAt", "traceId", "provider", "model", "backend", "error.message", "error.code", "error.missingEnv", "availability.status" ], status: blocked ? "blocked" : "available", blocker: blocked ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null, reason: blocked ? "provider_unavailable" : null, summary: blocked ? `真实后端已接入,但当前 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 未满足,因此不能返回真实回复。` : "真实后端已接入,发送会走 DEV egress/proxy 后的真实 /v1/agent/chat;只有实际 completed 回复才标记 DEV-LIVE。", missingEnv, secretRefs: blocked ? providerContract.secretRefs : [], egress: providerContract.egress, safety: providerContract.safety, ready: !blocked }; } function resolveProviderPlan(env, options = {}) { const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase(); const model = firstNonEmpty( options.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, env.CODE_QUEUE_DEFAULT_MODEL, DEFAULT_MODEL ); if (provider === "openai") { return { provider: "openai-responses", model, backend: "hwlab-cloud-api/openai-responses", mode: "openai" }; } if (provider === "codex-cli" || provider === "codex") { return { provider: "codex-cli", model, backend: "hwlab-cloud-api/codex-cli", mode: "codex-cli" }; } return { provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli", model, backend: env.OPENAI_API_KEY ? "hwlab-cloud-api/openai-responses" : "hwlab-cloud-api/codex-cli", mode: env.OPENAI_API_KEY ? "openai" : "codex-cli", auto: true }; } async function callConfiguredProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now, callProvider }) { if (callProvider) { return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now }); } if (providerPlan.mode === "openai") { return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }); } return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }); } async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { const providerContract = inspectCodeAgentProviderEnv(env); if (!providerContract.ready) { throw providerUnavailable(providerContract.blocker, { missingEnv: providerContract.missingEnv, provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, egress: providerContract.egress }); } const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs)); let response; let payload; try { response = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${env.OPENAI_API_KEY}` }, body: JSON.stringify({ model: providerPlan.model, instructions: CODE_AGENT_SYSTEM_PROMPT, input: [ { role: "user", content: [ { type: "input_text", text: buildAgentPrompt({ message, conversationId, traceId }) } ] } ], store: false }), signal: controller.signal }); payload = await response.json().catch(() => null); } catch (error) { if (error.name === "AbortError") { throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend }); } throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend }); } finally { clearTimeout(timer); } if (!response.ok) { throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${payload?.error?.message || "request rejected"}`, { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, providerStatus: response.status }); } const content = extractOpenAiOutputText(payload); if (!content) { throw providerUnavailable("OpenAI Responses returned no assistant text", { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend }); } return { provider: providerPlan.provider, model: payload.model || providerPlan.model, backend: providerPlan.backend, content, usage: payload.usage ?? null, providerTrace: { responseId: payload.id ?? null } }; } async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env }) { const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); if (!(await commandExists(command, env))) { throw providerUnavailable(`Codex CLI command is not available: ${command}`, { missingCommands: [command], missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"], provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, command }); } const outputDir = await makeTempDir(); const outputFile = path.join(outputDir, "last-message.txt"); const args = [ "exec", "--cd", repoRoot, "--skip-git-repo-check", "--sandbox", "read-only", "--ephemeral", "--output-last-message", outputFile ]; if (providerPlan.model) { args.push("--model", providerPlan.model); } args.push("-"); const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId }), { env, timeoutMs: effectiveTimeout(timeoutMs) }); try { if (result.code !== 0) { throw providerUnavailable(`Codex CLI exited with code ${result.code}`, { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, command: commandLine(command, args), exitCode: result.code, stderrSummary: redactText(tailText(result.stderr || result.stdout)) }); } const content = (await readFile(outputFile, "utf8")).trim(); if (!content) { throw providerUnavailable("Codex CLI completed without an assistant message", { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, command: commandLine(command, args), stderrSummary: redactText(tailText(result.stderr || result.stdout)) }); } return { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, content, usage: null, providerTrace: { command: commandLine(command, args), stdoutSummary: redactText(tailText(result.stdout, 600)) } }; } finally { await rm(outputDir, { recursive: true, force: true }); } } function buildAgentPrompt({ message, conversationId, traceId }) { return [ CODE_AGENT_SYSTEM_PROMPT, "", `conversationId: ${conversationId}`, `traceId: ${traceId}`, "", "用户消息:", message ].join("\n"); } function normalizeUserMessage(value) { if (typeof value !== "string") { throw badRequest("message must be a string"); } const message = value.trim(); if (!message) { throw badRequest("message is required"); } if (message.length > 4000) { throw badRequest("message exceeds 4000 characters"); } return message; } function normalizeChatError(error) { const normalized = { code: error.code || "code_agent_failed", message: error.message || "Code Agent request failed" }; for (const key of [ "missingEnv", "missingCommands", "provider", "model", "backend", "command", "exitCode", "providerStatus", "stderrSummary" ]) { if (error[key] !== undefined) { normalized[key] = error[key]; } } return normalized; } function badRequest(message) { const error = new Error(message); error.code = "invalid_params"; return error; } function providerUnavailable(message, details = {}) { const error = new Error(message); error.code = "provider_unavailable"; Object.assign(error, details); return error; } function extractOpenAiOutputText(payload) { if (typeof payload?.output_text === "string" && payload.output_text.trim()) { return payload.output_text.trim(); } const chunks = []; for (const item of payload?.output ?? []) { for (const content of item.content ?? []) { if (typeof content.text === "string") chunks.push(content.text); else if (typeof content.output_text === "string") chunks.push(content.output_text); } } return chunks.join("\n").trim(); } async function commandExists(command, env) { if (command.includes("/") || command.includes("\\")) { try { await access(command, fsConstants.X_OK); return true; } catch { return false; } } const result = await spawnWithInput("sh", ["-lc", `command -v ${shellQuote(command)}`], "", { env, timeoutMs: 3000 }); return result.code === 0; } function spawnWithInput(command, args, input, { env, timeoutMs }) { return new Promise((resolve) => { const child = spawn(command, args, { cwd: repoRoot, env, stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; let settled = false; const timer = setTimeout(() => { if (!settled) { child.kill("SIGTERM"); } }, timeoutMs); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", (error) => { settled = true; clearTimeout(timer); resolve({ code: 127, stdout, stderr: `${stderr}\n${error.message}` }); }); child.on("close", (code) => { settled = true; clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr }); }); child.stdin.end(input); }); } async function makeTempDir() { const dir = path.join(os.tmpdir(), `hwlab-code-agent-${randomUUID()}`); await mkdir(dir, { recursive: true }); return dir; } function cleanProtocolId(value, prefix) { if (typeof value !== "string") return null; const trimmed = value.trim(); if (!trimmed) return null; if (/^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/u.test(trimmed)) return trimmed; return `${prefix}_${trimmed.replace(/[^A-Za-z0-9._:-]/gu, "-").slice(0, 80)}`; } function cleanProjectId(value) { return cleanProtocolId(value, "prj"); } function nowIso(now) { return typeof now === "function" ? now() : new Date().toISOString(); } function effectiveTimeout(timeoutMs) { return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODE_AGENT_TIMEOUT_MS; } function firstNonEmpty(...values) { for (const value of values) { if (typeof value === "string" && value.trim()) return value.trim(); } return ""; } function commandLine(command, args) { return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); } function tailText(value, maxLength = 1200) { const text = String(value ?? "").trim(); if (text.length <= maxLength) return text; return text.slice(text.length - maxLength); } function redactText(value) { return String(value ?? "") .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") .replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***") .replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***") .replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***"); } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }