1365 lines
45 KiB
JavaScript
1365 lines
45 KiB
JavaScript
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("'", "'\\''")}'`;
|
|
}
|