import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants as fsConstants, existsSync } from "node:fs"; import { access, mkdir, readFile, readdir, 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 READONLY_RUNNER_PROVIDER = "codex-readonly-runner"; const READONLY_RUNNER_BACKEND = "hwlab-cloud-api/codex-readonly-runner"; const READONLY_RUNNER_MODEL = "read-only-tools"; const READONLY_RUNNER_KIND = "hwlab-readonly-runner"; const READONLY_RUNNER_SANDBOX = "read-only"; const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral"; const READONLY_TOOL_OUTPUT_LIMIT = 4000; const MAX_SKILLS_RETURNED = 40; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); const CODE_AGENT_SYSTEM_PROMPT = [ "你是 HWLAB 云工作台的 Code Agent。", "请用中文直接回答用户的工作台问题。", "当前最小版本可以帮助用户整理任务、查看云工作台资源和说明下一步;不要声称已经执行硬件变更。", "不要声称已通过 M3、M4 或 M5 验收。", "不要声称 OpenAI Responses 文本 fallback 具备 Codex runner、workspace、tools 或 skills 能力。", "不要输出 secret、token、kubeconfig 或环境变量原文。" ].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 runnerIntent = detectReadOnlyRunnerIntent(message); if (runnerIntent.kind !== "none") { const runnerResult = await callReadOnlyRunner({ intent: runnerIntent, conversationId, traceId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact }); const completedAt = nowIso(options.now); return { ...base, status: "completed", updatedAt: completedAt, provider: runnerResult.provider, model: runnerResult.model, backend: runnerResult.backend, workspace: runnerResult.workspace, sandbox: runnerResult.sandbox, toolCalls: runnerResult.toolCalls, skills: runnerResult.skills, runner: runnerResult.runner, runnerTrace: runnerResult.runnerTrace, capabilityLevel: runnerResult.capabilityLevel, reply: { messageId, role: "assistant", content: runnerResult.content, createdAt: completedAt }, usage: null, providerTrace: runnerResult.providerTrace }; } 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, workspace: providerResult.workspace ?? null, sandbox: providerResult.sandbox ?? "none", toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { status: "not_requested", items: [], blockers: [] }, runner: providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId }), runnerTrace: providerResult.runnerTrace ?? { traceId, runnerKind: OPENAI_FALLBACK_RUNNER_KIND, events: ["fallback:text-chat-only"], note: "OpenAI Responses fallback can answer ordinary chat, but it does not satisfy the Codex runner capability gate." }, capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only", 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.provider) payload.provider = error.provider; if (error.model) payload.model = error.model; if (error.backend) payload.backend = error.backend; if (error.workspace !== undefined) payload.workspace = error.workspace; if (error.sandbox !== undefined) payload.sandbox = error.sandbox; if (error.toolCalls !== undefined) payload.toolCalls = error.toolCalls; if (error.skills !== undefined) payload.skills = error.skills; if (error.runner !== undefined) payload.runner = error.runner; if (error.runnerTrace !== undefined) payload.runnerTrace = error.runnerTrace; if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel; if (error.code === "provider_unavailable") { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) { 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; const runnerAvailability = inspectReadOnlyRunnerAvailability(env, options); 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", "runner", "workspace", "sandbox", "toolCalls", "skills", "runnerTrace", "capabilityLevel", "error.message", "error.code", "error.missingEnv", "availability.status" ], runner: runnerAvailability, status: blocked && !runnerAvailability.ready ? "blocked" : "available", blocker: blocked && !runnerAvailability.ready ? (providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口") : null, reason: blocked && !runnerAvailability.ready ? "provider_unavailable" : null, summary: blocked ? `受控只读 runner 可用于 pwd/skills 等工作区能力;OpenAI Responses fallback 当前受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 DEV egress/base-url contract 影响。` : "真实后端已接入;pwd/skills 走受控只读 runner,普通聊天可走 OpenAI Responses fallback。OpenAI fallback 不满足 Codex runner capability gate。", missingEnv, secretRefs: blocked ? providerContract.secretRefs : [], egress: providerContract.egress, safety: providerContract.safety, capabilityLevel: runnerAvailability.ready ? "read-only-tools" : blocked ? "blocked" : "text-chat-only", ready: !blocked || runnerAvailability.ready }; } 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 }); } function inspectReadOnlyRunnerAvailability(env, options = {}) { const workspace = resolveRunnerWorkspace(env, options); const workspaceReady = Boolean(workspace && existsSync(workspace)); const skillsDirs = resolveSkillDirs(env, options); const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); return { kind: READONLY_RUNNER_KIND, backend: READONLY_RUNNER_BACKEND, provider: READONLY_RUNNER_PROVIDER, workspace, sandbox: READONLY_RUNNER_SANDBOX, mode: "direct-read-only-tools", session: "stateless-one-shot", status: workspaceReady ? "available" : "blocked", ready: workspaceReady, capabilityLevel: workspaceReady ? "read-only-tools" : "blocked", skillsDirs, skillsDirsPresent, safety: runnerSafetyContract() }; } async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skillsDirs, skillsDirsExact }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const runner = runnerDescriptor({ workspace: resolvedWorkspace }); const startedAt = nowIso(now); const events = [ `intent:${intent.kind}`, "sandbox:read-only", "session:stateless-one-shot" ]; if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) { throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, { workspace: resolvedWorkspace ?? null, toolCalls: [], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }), capabilityLevel: "blocked" }); } if (intent.kind === "security") { throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", { workspace: resolvedWorkspace, toolCalls: [{ id: `tool_${randomUUID()}`, type: "security", name: intent.toolName ?? "security.guard", status: "blocked", cwd: resolvedWorkspace, exitCode: 1, stdout: "", stderrSummary: "security_blocked", outputTruncated: false }], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", blockers: [{ code: "security_blocked", sourceIssue: "pikasTech/HWLAB#275", summary: intent.reason ?? "Read-only runner guardrail blocked the request." }] }); } if (intent.kind === "pwd") { const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env }); return { provider: READONLY_RUNNER_PROVIDER, model: READONLY_RUNNER_MODEL, backend: READONLY_RUNNER_BACKEND, content: [ "当前受控只读 runner 工作目录:", toolCall.stdout, "", "说明:这是一次 stateless/read-only runner 工具调用,不是长驻 stdio 会话,也不会触发硬件写操作。" ].join("\n"), workspace: resolvedWorkspace, sandbox: READONLY_RUNNER_SANDBOX, toolCalls: [toolCall], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, "tool:pwd:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), capabilityLevel: "read-only-tools", providerTrace: { runnerKind: READONLY_RUNNER_KIND, toolCalls: 1, mode: "direct-read-only-tools" } }; } if (intent.kind === "skills") { const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }); if (skills.status === "blocked") { throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", { workspace: resolvedWorkspace, toolCalls: [{ id: `tool_${randomUUID()}`, type: "file-read", name: "skills.discover", status: "blocked", cwd: resolvedWorkspace, exitCode: 1, stdout: "", stderrSummary: "skills_unavailable", outputTruncated: false }], skills, runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }), capabilityLevel: "blocked", blockers: skills.blockers }); } return { provider: READONLY_RUNNER_PROVIDER, model: READONLY_RUNNER_MODEL, backend: READONLY_RUNNER_BACKEND, content: skillsReply(skills), workspace: resolvedWorkspace, sandbox: READONLY_RUNNER_SANDBOX, toolCalls: [{ id: `tool_${randomUUID()}`, type: "file-read", name: "skills.discover", status: "completed", cwd: resolvedWorkspace, exitCode: 0, stdout: `skills=${skills.items.length}`, stderrSummary: "", outputTruncated: Boolean(skills.truncated) }], skills, runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, "tool:skills.discover:completed"], startedAt, outputTruncated: Boolean(skills.truncated) }), capabilityLevel: "read-only-tools", providerTrace: { runnerKind: READONLY_RUNNER_KIND, toolCalls: 1, mode: "direct-read-only-tools" } }; } throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, { workspace: resolvedWorkspace, toolCalls: [{ id: `tool_${randomUUID()}`, type: "tool", name: intent.toolName ?? "unsupported", status: "blocked", cwd: resolvedWorkspace, exitCode: 1, stdout: "", stderrSummary: "tool_unavailable", outputTruncated: false }], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }), capabilityLevel: "blocked" }); } function detectReadOnlyRunnerIntent(message) { const text = String(message ?? "").trim(); const lower = text.toLowerCase(); if (isSecretReadRequest(text)) { return { kind: "security", toolName: "security.secret-redaction", reason: "只读 runner 不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" }; } if (isHardwareWriteOrAcceptanceRequest(text)) { return { kind: "security", toolName: "security.hardware-boundary", reason: "只读 runner 不直接调用 gateway/box-simu/patch-panel、硬件写接口或宣称 M3/M4/M5 验收通过。" }; } if (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) { return { kind: "pwd", toolName: "pwd" }; } if (/(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text)) { return { kind: "skills", toolName: "skills.discover" }; } if (/\b(?:ls|cat|rg|grep|find|sed|awk)\b/u.test(lower) || /(?:列出|读取|查看).{0,10}(?:文件|目录|源码|代码)/u.test(text)) { const tool = lower.match(/\b(?:ls|cat|rg|grep|find|sed|awk)\b/u)?.[0] ?? "file.read"; return { kind: "unsupported", toolName: tool }; } return { kind: "none" }; } function isSecretReadRequest(text) { const asksToRead = /(?:print|show|cat|read|list|dump|echo|输出|显示|读取|列出|查看|打印)/iu.test(text); const secretTerm = /(?:secret|token|kubeconfig|OPENAI_API_KEY|DATABASE_URL|password|passwd|credential|private key|私钥|密码|凭证|环境变量|密钥)/iu.test(text); return asksToRead && secretTerm; } function isHardwareWriteOrAcceptanceRequest(text) { const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用)/iu.test(text); const mutationVerb = /(?:call|invoke|write|mutate|apply|rollout|accept|pass|验收|通过|写入|调用|变更|操作)/iu.test(text); const acceptanceClaim = /(?:M3|M4|M5).{0,20}(?:pass|accept|green|通过|验收|完成)/iu.test(text); return (hardwareTarget && mutationVerb) || acceptanceClaim; } function resolveRunnerWorkspace(env = process.env, options = {}) { const configured = firstNonEmpty( options.workspace, env.HWLAB_CODE_AGENT_WORKSPACE, env.HWLAB_RUNNER_WORKSPACE, env.WORKSPACE ); return path.resolve(configured || repoRoot); } function resolveSkillDirs(env = process.env, options = {}) { const configured = Array.isArray(options.skillsDirs) ? options.skillsDirs : String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) .split(/[,;]/u) .flatMap((part) => part.split(path.delimiter)); const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1"; if (strict) { return [...new Set(configured .filter((dir) => typeof dir === "string" && dir.trim()) .map((dir) => path.resolve(dir.trim())))]; } return [...new Set([ ...configured, path.join(os.homedir(), ".agents", "skills"), "/root/.agents/skills", "/home/ubuntu/.agents/skills", path.join(repoRoot, "skills") ] .filter((dir) => typeof dir === "string" && dir.trim()) .map((dir) => path.resolve(dir.trim())))]; } async function pathReadable(targetPath) { try { await access(targetPath, fsConstants.R_OK); return true; } catch { return false; } } async function runPwdTool({ workspace, traceId, env }) { const result = await spawnWithInput("pwd", [], "", { cwd: workspace, env: runnerCommandEnv(env), timeoutMs: 3000 }); const output = redactText((result.stdout || workspace).trim() || workspace); const bounded = boundToolOutput(output); return { id: `tool_${randomUUID()}`, type: "shell", name: "pwd", status: result.code === 0 ? "completed" : "blocked", cwd: workspace, command: "pwd", exitCode: result.code, stdout: bounded.text, stderrSummary: redactText(tailText(result.stderr, 300)), outputTruncated: bounded.truncated, traceId }; } async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }) { const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact }); const sourceSummaries = []; const items = []; for (const skillsDir of checkedDirs) { if (!(await pathReadable(skillsDir))) { sourceSummaries.push({ path: skillsDir, status: "missing_or_unreadable", commit: null, version: null }); continue; } const commit = await gitCommitFor(skillsDir, env); const version = firstNonEmpty(env.HWLAB_SKILLS_VERSION, env.HWLAB_SKILL_VERSION, null); sourceSummaries.push({ path: skillsDir, status: "readable", commit, version }); const manifests = await skillManifestPaths(skillsDir); for (const manifestPath of manifests) { const manifest = await readSkillManifest(manifestPath); if (!manifest) continue; items.push({ name: manifest.name ?? path.basename(path.dirname(manifestPath)), summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.", source: manifestPath, sourceRoot: skillsDir, version: manifest.version ?? version, commit: manifest.commit ?? commit, traceId }); } } const uniqueItems = dedupeSkills(items) .sort((a, b) => a.name.localeCompare(b.name, "en")); const returned = uniqueItems.slice(0, MAX_SKILLS_RETURNED); if (returned.length === 0) { return { status: "blocked", code: "skills_unavailable", items: [], count: 0, totalCount: 0, checkedDirs, sources: sourceSummaries, blockers: [{ code: "skills_unavailable", sourceIssue: "pikasTech/HWLAB#136", linkedIssues: ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"], summary: "No readable SKILL.md files were found in the configured runner skills directories.", nextTask: "Mount or sync canonical ~/.agents/skills or repo-owned skills into the Code Agent runner image/runtime, then rerun /v1/agent/chat skills discovery." }], valuesPrinted: false }; } return { status: "ready", code: "skills_ready", items: returned, count: returned.length, totalCount: uniqueItems.length, truncated: uniqueItems.length > returned.length, checkedDirs, sources: sourceSummaries, blockers: [], valuesPrinted: false }; } async function skillManifestPaths(skillsDir) { const direct = path.join(skillsDir, "SKILL.md"); const manifests = []; if (await pathReadable(direct)) manifests.push(direct); let entries = []; try { entries = await readdir(skillsDir, { withFileTypes: true }); } catch { return manifests; } for (const entry of entries) { if (!entry.isDirectory()) continue; const manifestPath = path.join(skillsDir, entry.name, "SKILL.md"); if (await pathReadable(manifestPath)) manifests.push(manifestPath); } return manifests; } async function readSkillManifest(manifestPath) { let text = ""; try { text = await readFile(manifestPath, "utf8"); } catch { return null; } const frontmatter = parseFrontmatter(text); const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, ""); const name = frontmatter.name ?? path.basename(path.dirname(manifestPath)); if (!name) return null; return { name, description: frontmatter.description, version: frontmatter.version, commit: frontmatter.commit ?? frontmatter.commitId, body }; } function parseFrontmatter(text) { const match = String(text ?? "").match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/u); if (!match) return {}; const data = {}; for (const line of match[1].split(/\r?\n/u)) { const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)\s*$/u); if (!field) continue; data[field[1]] = field[2].replace(/^["']|["']$/gu, "").trim(); } return data; } function firstMarkdownSummary(body) { return String(body ?? "") .split(/\r?\n/u) .map((line) => line.trim()) .find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null; } function dedupeSkills(items) { const seen = new Set(); const deduped = []; for (const item of items) { const key = item.name.toLowerCase(); if (seen.has(key)) continue; seen.add(key); deduped.push(item); } return deduped; } async function gitCommitFor(targetPath, env = process.env) { if (isPathInside(targetPath, repoRoot)) { return firstNonEmpty(env.HWLAB_SKILLS_COMMIT_ID, env.HWLAB_COMMIT_ID, env.HWLAB_GIT_SHA, await gitRevParse(repoRoot)); } return firstNonEmpty(await gitRevParse(targetPath), null); } async function gitRevParse(targetPath) { const result = await spawnWithInput("git", ["-C", targetPath, "rev-parse", "--short=12", "HEAD"], "", { cwd: targetPath, env: runnerCommandEnv(process.env), timeoutMs: 3000 }); return result.code === 0 ? result.stdout.trim() : null; } function isPathInside(child, parent) { const relative = path.relative(parent, child); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } function skillsReply(skills) { const lines = [ `当前只读 runner 已加载 ${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})`); } lines.push("说明:这是 read-only skills discovery;未读取 secret/token/kubeconfig,也未触发硬件写操作。"); return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text; } function notRequestedSkills() { return { status: "not_requested", items: [], count: 0, blockers: [] }; } function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND } = {}) { return { kind, provider: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_PROVIDER : "codex-cli", backend: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_BACKEND : "hwlab-cloud-api/codex-cli", workspace: workspace ?? repoRoot, sandbox: READONLY_RUNNER_SANDBOX, session: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : "stateless-one-shot", longLivedSession: false, readOnly: true, capabilityLevel: kind === READONLY_RUNNER_KIND ? "read-only-tools" : "text-chat-only", toolPolicy: { allowed: ["pwd", "skills.discover"], blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"] }, safety: runnerSafetyContract() }; } function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND }) { return { traceId, runnerKind, workspace, sandbox: READONLY_RUNNER_SANDBOX, startedAt, finishedAt: startedAt, events, outputTruncated: Boolean(outputTruncated), valuesPrinted: false, note: runnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session." : "direct read-only runner path for bounded workspace/tool evidence." }; } function runnerSafetyContract() { return { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, hardwareWritesAllowed: false, directGatewayCallsAllowed: false, directBoxSimuCallsAllowed: false, directPatchPanelCallsAllowed: false, m3m4m5AcceptanceClaimsAllowed: false, outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT }; } function openAiFallbackRunnerEvidence({ providerResult, traceId }) { return { kind: OPENAI_FALLBACK_RUNNER_KIND, provider: providerResult.provider ?? "openai-responses", backend: providerResult.backend ?? "hwlab-cloud-api/openai-responses", workspace: null, sandbox: "none", session: "provider-text-request", longLivedSession: false, readOnly: false, capabilityLevel: "text-chat-only", toolPolicy: { allowed: [], blocked: ["workspace-tools", "skills-discovery", "shell-tools", "file-tools", "hardware-write"] }, capabilityGate: { codexRunner: false, reason: "OpenAI Responses fallback is text chat only and cannot satisfy the Codex runner capability gate." }, traceId }; } function runnerError(code, message, details = {}) { const error = new Error(message); error.code = code; Object.assign(error, { provider: READONLY_RUNNER_PROVIDER, model: READONLY_RUNNER_MODEL, backend: READONLY_RUNNER_BACKEND, sandbox: READONLY_RUNNER_SANDBOX, ...details }); return error; } function runnerCommandEnv(env = process.env) { return { PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin", HOME: env.HOME || process.env.HOME || os.homedir() }; } function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) { const text = String(value ?? ""); if (text.length <= maxLength) return { text, truncated: false }; return { text: `${text.slice(0, maxLength)}\n[output truncated at ${maxLength} bytes]`, truncated: true }; } 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", accept: "text/event-stream", 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, stream: true }), signal: controller.signal }); payload = parseOpenAiResponseBody(await response.text()); } 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); } const providerError = payload.error ?? payload.raw?.error ?? null; if (!response.ok) { throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${providerError?.message || "request rejected"}`, { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, providerStatus: response.status }); } if (providerError) { throw providerUnavailable(`OpenAI Responses stream error: ${providerError.message || "request rejected"}`, { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend }); } const content = payload.content || extractOpenAiOutputText(payload.raw); 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.responseId ?? 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, workspace: repoRoot, sandbox: READONLY_RUNNER_SANDBOX, toolCalls: [], skills: notRequestedSkills(), runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }), runnerTrace: { traceId, runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, workspace: repoRoot, sandbox: READONLY_RUNNER_SANDBOX, events: ["codex-cli:exec", "codex-cli:ephemeral", "codex-cli:completed"], outputTruncated: false, valuesPrinted: false, note: "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session." }, capabilityLevel: "text-chat-only", 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", "blockers" ]) { 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(); } function parseOpenAiResponseBody(text) { const raw = parseJsonOrNull(text); if (raw) { return { raw, content: extractOpenAiOutputText(raw), responseId: raw.id ?? null, model: raw.model ?? null, usage: raw.usage ?? null, error: raw.error ?? null }; } const stream = parseOpenAiResponsesSse(text); return { raw: stream.finalResponse, content: stream.content, responseId: stream.responseId, model: stream.model, usage: stream.usage, error: stream.error }; } function parseOpenAiResponsesSse(text) { const deltas = []; let finalResponse = null; let responseId = null; let model = null; let usage = null; let error = null; for (const event of parseSseDataMessages(text)) { const payload = parseJsonOrNull(event); if (!payload) continue; const response = payload.response && typeof payload.response === "object" ? payload.response : null; if (response) { finalResponse = response; responseId = response.id ?? responseId; model = response.model ?? model; usage = response.usage ?? usage; if (response.error) error = response.error; } if (payload.response_id) responseId = payload.response_id; if (payload.item?.id) responseId = responseId ?? payload.item.id; if (payload.error) error = payload.error; if (payload.type === "error" && !payload.error) error = payload; if (typeof payload.delta === "string") deltas.push(payload.delta); else if (typeof payload.text === "string" && payload.type === "response.output_text.done" && deltas.length === 0) { deltas.push(payload.text); } } const content = deltas.join("").trim() || extractOpenAiOutputText(finalResponse); return { finalResponse, content, responseId, model, usage, error }; } function parseSseDataMessages(text) { const messages = []; for (const block of String(text ?? "").split(/\r?\n\r?\n/u)) { const data = []; for (const line of block.split(/\r?\n/u)) { if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); } const message = data.join("\n").trim(); if (message && message !== "[DONE]") messages.push(message); } return messages; } function parseJsonOrNull(value) { try { return JSON.parse(value); } catch { return null; } } 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, cwd = repoRoot }) { return new Promise((resolve) => { const child = spawn(command, args, { cwd, 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.on("error", (error) => { if (error?.code !== "EPIPE") { stderr += `\n${error.message}`; } }); 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("'", "'\\''")}'`; }