import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants as fsConstants, existsSync } from "node:fs"; import { access, mkdir, open, readFile, readdir, rm, stat } 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"; import { CODEX_STDIO_BACKEND, CODEX_STDIO_IMPLEMENTATION_TYPE, CODEX_STDIO_PROVIDER, CODEX_STDIO_RUNNER_KIND, CODEX_STDIO_SESSION_MODE, createCodexStdioSessionManager, longLivedSessionGate as codexLongLivedSessionGate } from "./codex-stdio-session.mjs"; import { DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; import { HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, HWLAB_M3_IO_CAPABILITY_LEVELS, HWLAB_M3_IO_API_BASE_URL_ENV, HWLAB_M3_IO_API_BASE_URL_ENVS, HWLAB_M3_IO_API_ROUTE, HWLAB_M3_STATUS_API_ROUTE, HWLAB_M3_IO_DEV_SERVICE_BASE_URL, HWLAB_M3_IO_SKILL_NAME, configuredCloudApiBaseUrl, runM3IoSkillCommand } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.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 READONLY_SESSION_MODE = "controlled-readonly-session-registry"; const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry"; const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools"; const M3_IO_SKILL_PROVIDER = "hwlab-skill-cli"; const M3_IO_SKILL_BACKEND = "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"; const M3_IO_SKILL_MODEL = "controlled-m3-io"; const M3_IO_SKILL_RUNNER_KIND = "hwlab-m3-io-skill-cli"; const M3_IO_SKILL_SANDBOX = "hwlab-api-route-only"; const M3_IO_SKILL_SESSION_MODE = "controlled-m3-io-skill-cli"; const M3_IO_SKILL_IMPLEMENTATION_TYPE = "skill-cli-hwlab-api-adapter"; const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ "m3-io-only", "hwlab-api-route-only", "not-generic-hardware-control", "not-durable-session" ]); const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral"; const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu; const READONLY_TOOL_OUTPUT_LIMIT = 4000; const READONLY_FILE_READ_LIMIT = 64 * 1024; const READONLY_FILE_ENTRY_LIMIT = 200; const READONLY_FILE_TREE_DEPTH = 8; const MAX_READONLY_SESSIONS = 200; const MAX_SKILLS_RETURNED = 40; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); const CODEX_STDIO_SOURCE_ISSUE = "pikasTech/HWLAB#275"; const READONLY_LIMITATION_FLAGS = Object.freeze([ "not-codex-stdio", "not-write-capable", "process-local-session-registry" ]); const SKIPPED_READONLY_DIRS = new Set([ ".git", ".hg", ".svn", ".cache", ".worktrees", "node_modules", "dist", "build", "coverage" ]); 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)), "../.."); const defaultCodeAgentSessionRegistry = createCodeAgentSessionRegistry({ idleTimeoutMs: DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, maxSessions: MAX_READONLY_SESSIONS }); const defaultCodexStdioSessionManager = createCodexStdioSessionManager(); export async function handleCodeAgentChat(params = {}, options = {}) { const timestamp = nowIso(options.now); const { conversationId, sessionId, requestedSessionId } = resolveConversationSessionIds(params); 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); const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; if (securityIntent) { const runnerResult = await callReadOnlyRunner({ intent: securityIntent, conversationId, traceId, sessionId: requestedSessionId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sessionRegistry: options.sessionRegistry, codexStdioManager: options.codexStdioManager }); return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); const shouldUseCodexStdio = providerPlan.mode === "codex-stdio" || (providerPlan.mode !== "openai" && codexStdioAvailability.canStartLongLivedCodexStdio === true); if (shouldUseCodexStdio) { const stdioResult = runnerIntent.kind === "m3_io" ? await callCodexStdioWithM3IoSkillRunner({ message, intent: runnerIntent, conversationId, sessionId: requestedSessionId, traceId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, timeoutMs: options.timeoutMs, model: providerPlan.model, codexStdioManager: options.codexStdioManager, requestJson: options.m3IoSkillRequestJson }) : await callCodexStdioRunner({ message, conversationId, sessionId: requestedSessionId, traceId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, timeoutMs: options.timeoutMs, model: providerPlan.model, codexStdioManager: options.codexStdioManager }); return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now }); } if (runnerIntent.kind === "m3_io") { const runnerResult = await callM3IoSkillRunner({ intent: runnerIntent, conversationId, traceId, sessionId: requestedSessionId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, sessionRegistry: options.sessionRegistry, requestJson: options.m3IoSkillRequestJson, codexStdioManager: options.codexStdioManager }); return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } if (runnerIntent.kind !== "none") { const runnerResult = await callReadOnlyRunner({ intent: runnerIntent, conversationId, traceId, sessionId: requestedSessionId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sessionRegistry: options.sessionRegistry, codexStdioManager: options.codexStdioManager }); return completedRunnerPayload({ base, runnerResult, messageId, now: options.now }); } 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); const providerRunner = providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }); const providerLongLivedGate = providerResult.longLivedSessionGate ?? longLivedSessionGate({ provider: providerResult.provider ?? base.provider, runnerKind: providerRunner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND, session: providerResult.session ?? null, sessionMode: providerResult.sessionMode ?? "provider-text-request", implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options) }); const providerCompletionBlocker = structuredCompletionBlocker({ provider: providerResult.provider ?? base.provider, backend: providerResult.backend ?? base.backend, runner: providerRunner, capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only", longLivedSessionGate: providerLongLivedGate }, { traceId, provider: providerResult.provider ?? base.provider, backend: providerResult.backend ?? base.backend, runner: providerRunner, capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only" }); return finalizeCodeAgentChatPayload({ ...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", session: providerResult.session ?? null, sessionMode: providerResult.sessionMode ?? "provider-text-request", sessionReuse: providerResult.sessionReuse ?? null, implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, runnerLimitations: providerResult.runnerLimitations ?? [ "text-chat-only", "not-codex-stdio", "not-workspace-tools", "not-session-runner" ], codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options), longLivedSessionGate: providerLongLivedGate, toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { status: "not_requested", items: [], blockers: [] }, runner: providerRunner, 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, ...(providerCompletionBlocker ? { blocker: providerCompletionBlocker, blockers: [providerCompletionBlocker] } : {}) }); } catch (error) { const failedAt = nowIso(options.now); const payload = { ...base, status: "failed", updatedAt: failedAt, error: normalizeChatError(error, { traceId, provider: error.provider ?? base.provider, model: error.model ?? base.model, backend: error.backend ?? base.backend, runner: error.runner, capabilityLevel: error.capabilityLevel ?? "blocked", route: error.route, toolName: error.toolName }) }; if (payload.error?.blocker) payload.blocker = payload.error.blocker; if (payload.error?.blockers) payload.blockers = payload.error.blockers; 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.session !== undefined) payload.session = error.session; 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.sessionMode !== undefined) payload.sessionMode = error.sessionMode; if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse; if (error.implementationType !== undefined) payload.implementationType = error.implementationType; if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; if (error.longLivedSessionGate !== undefined) payload.longLivedSessionGate = error.longLivedSessionGate; if (error.availability !== undefined) { payload.availability = error.availability; } else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed"].includes(error.code)) { payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options); } return payload; } } function completedRunnerPayload({ base, runnerResult, messageId, now }) { const completedAt = nowIso(now); const blocker = structuredCompletionBlocker(runnerResult, { traceId: base.traceId, provider: runnerResult.provider, backend: runnerResult.backend, runner: runnerResult.runner, capabilityLevel: runnerResult.capabilityLevel }); return finalizeCodeAgentChatPayload({ ...base, sessionId: runnerResult.session?.sessionId ?? base.sessionId, status: "completed", updatedAt: completedAt, provider: runnerResult.provider, model: runnerResult.model, backend: runnerResult.backend, workspace: runnerResult.workspace, sandbox: runnerResult.sandbox, session: runnerResult.session, sessionMode: runnerResult.sessionMode, sessionReuse: runnerResult.sessionReuse, implementationType: runnerResult.implementationType, runnerLimitations: runnerResult.runnerLimitations, codexStdioFeasibility: runnerResult.codexStdioFeasibility, longLivedSessionGate: runnerResult.longLivedSessionGate, toolCalls: runnerResult.toolCalls, skills: runnerResult.skills, runner: runnerResult.runner, runnerTrace: runnerResult.runnerTrace, capabilityLevel: runnerResult.capabilityLevel, reply: { messageId, role: "assistant", content: runnerResult.content, createdAt: completedAt }, ...(blocker ? { blocker, blockers: [blocker, ...(runnerResult.blockers ?? [])].filter(Boolean) } : {}), usage: null, providerTrace: runnerResult.providerTrace }); } 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"); } validateCodeAgentToolCallContract(payload); } function validateCodeAgentToolCallContract(payload) { if (!Array.isArray(payload.toolCalls)) return; for (const [index, toolCall] of payload.toolCalls.entries()) { if (!toolCall || typeof toolCall !== "object") { throw new Error(`code agent toolCalls[${index}] must be an object`); } const directTarget = findDirectHardwareTarget(toolCall); if (directTarget) { throw new Error(`code agent toolCalls[${index}] must not include direct gateway/box/patch-panel target ${directTarget}`); } if (!isM3IoToolCall(toolCall, payload)) continue; const structuredBlocked = toolCall.status === "blocked" && Boolean(toolCall.blocker?.code || toolCall.capabilityBlocker?.code || toolCall.error?.code); if (!m3ToolCallRouteAllowed(toolCall.route) && !structuredBlocked) { throw new Error(`code agent M3 toolCalls[${index}] must show route ${HWLAB_M3_IO_API_ROUTE}/${HWLAB_M3_STATUS_API_ROUTE} or structured blocked`); } if (toolCall.route === HWLAB_M3_IO_API_ROUTE && (toolCall.method ?? "POST") !== "POST") { throw new Error(`code agent M3 toolCalls[${index}] must use POST ${HWLAB_M3_IO_API_ROUTE}`); } if (toolCall.route === HWLAB_M3_STATUS_API_ROUTE && (toolCall.method ?? "GET") !== "GET") { throw new Error(`code agent M3 toolCalls[${index}] must use GET ${HWLAB_M3_STATUS_API_ROUTE}`); } } } function isM3IoToolCall(toolCall, payload) { return toolCall.name === HWLAB_M3_IO_SKILL_NAME || toolCall.route === HWLAB_M3_IO_API_ROUTE || toolCall.route === HWLAB_M3_STATUS_API_ROUTE || payload.provider === M3_IO_SKILL_PROVIDER || payload.runner?.kind === M3_IO_SKILL_RUNNER_KIND || payload.runnerTrace?.runnerKind === M3_IO_SKILL_RUNNER_KIND; } function m3ToolCallRouteAllowed(route) { return route === HWLAB_M3_IO_API_ROUTE || route === HWLAB_M3_STATUS_API_ROUTE; } function findDirectHardwareTarget(value, seen = new Set()) { if (typeof value === "string") { const match = value.match(DIRECT_HARDWARE_TARGET_PATTERN); return match?.[0] ?? null; } if (!value || typeof value !== "object") return null; if (seen.has(value)) return null; seen.add(value); for (const child of Object.values(value)) { const match = findDirectHardwareTarget(child, seen); if (match) return match; } return null; } function finalizeCodeAgentChatPayload(payload) { validateCodeAgentChatSchema(payload); return payload; } export async 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 === "codex-cli" && !env.OPENAI_API_KEY) { missingEnv.push("OPENAI_API_KEY"); } const sessionRegistry = resolveCodeAgentSessionRegistry(options); const runnerAvailability = await inspectReadOnlyRunnerAvailability(env, { ...options, sessionRegistry }); const codexStdio = await inspectCodexStdioFeasibility(env, options); const blocked = providerPlan.mode === "openai" ? !providerContract.ready : providerPlan.mode === "codex-stdio" ? !codexStdio.ready : missingEnv.length > 0; const fallbackAvailability = { provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, mode: providerPlan.mode, status: blocked ? "blocked" : "available", ready: !blocked, capabilityLevel: blocked ? "blocked" : "text-chat-only", blocker: blocked ? fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) : null, reason: blocked ? "provider_unavailable" : null, missingEnv, secretRefs: blocked ? providerContract.secretRefs : [], egress: providerContract.egress, safety: providerContract.safety }; const longLivedGate = longLivedSessionGate({ provider: codexStdio.ready ? CODEX_STDIO_PROVIDER : runnerAvailability.provider, runnerKind: codexStdio.ready ? CODEX_STDIO_RUNNER_KIND : runnerAvailability.kind, sessionMode: codexStdio.ready ? CODEX_STDIO_SESSION_MODE : READONLY_SESSION_MODE, implementationType: codexStdio.ready ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility: codexStdio }); const agentKind = codexStdio.ready ? "codex-stdio-feasible" : runnerAvailability.ready ? "controlled-readonly-session-registry" : blocked ? "openai-fallback-blocked" : "openai-fallback"; const capabilityStatus = codexStdio.ready ? "codex-stdio-feasible" : runnerAvailability.ready ? "controlled-readonly-session-registry" : blocked ? "blocked" : "openai-fallback"; const codeAgentReady = codexStdio.ready; const status = codexStdio.ready ? "codex-stdio-feasible" : blocked && !runnerAvailability.ready ? "blocked" : "partial"; const capabilityLevel = codexStdio.ready ? "long-lived-codex-stdio-session" : runnerAvailability.ready ? READONLY_SESSION_CAPABILITY_LEVEL : blocked ? "blocked" : "text-chat-only"; 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", "session", "sessionMode", "sessionReuse", "implementationType", "runnerLimitations", "codexStdioFeasibility", "longLivedSessionGate", "error.message", "error.code", "error.missingEnv", "availability.status" ], runner: runnerAvailability, fallback: fallbackAvailability, codexStdio, status, readinessStatus: status, agentKind, capabilityStatus, blocker: codeAgentReady ? null : primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }), reason: codeAgentReady ? null : primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }), summary: codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }), m3IoSkill: runnerAvailability.m3IoSkill, missingEnv, secretRefs: blocked ? providerContract.secretRefs : [], egress: providerContract.egress, safety: providerContract.safety, capabilityLevel, sessionRegistry: runnerAvailability.sessionRegistry, sessionMode: runnerAvailability.sessionMode, implementationType: runnerAvailability.implementationType, runnerLimitations: runnerAvailability.runnerLimitations, codexStdioFeasibility: codexStdio, longLivedSessionGate: longLivedGate, blockers: [ ...codexStdio.blockers, ...(blocked && providerPlan.mode !== "codex-stdio" ? [{ code: "openai_fallback_blocked", sourceIssue: "pikasTech/HWLAB#143", summary: providerPlan.mode === "openai" ? providerContract.blocker : "Provider fallback credential/config is incomplete." }] : []) ], blockerCodes: [ ...codexStdio.blockerCodes, ...(blocked && providerPlan.mode !== "codex-stdio" ? ["openai_fallback_blocked"] : []) ], ready: codeAgentReady, partialReady: runnerAvailability.ready || !blocked }; } function fallbackBlockerForPlan({ providerPlan, providerContract, codexStdio }) { if (providerPlan.mode === "openai") return providerContract.blocker; if (providerPlan.mode === "codex-stdio") { return codexStdio.blockers?.[0]?.summary ?? "Codex stdio long-lived session is blocked."; } return "凭证缺口"; } function primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }) { if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].summary; if (blocked && !runnerAvailability.ready) return providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口"; if (runnerAvailability.ready) return "controlled-readonly-session-registry is available, but long-lived Codex stdio is blocked."; return null; } function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) { if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].code; if (blocked && !runnerAvailability.ready) return "provider_unavailable"; if (runnerAvailability.ready) return "codex_stdio_blocked_readonly_session_available"; return null; } function codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability }) { if (codexStdio.ready) { return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat can create/reuse/cancel/reap sessions with trace capture. Hardware control still must use cloud-api/HWLAB API/skill CLI."; } if (runnerAvailability.ready) { return blocked ? `受控只读 runner 可用于 pwd/skills/ls/rg/cat,M3 IO 只允许 Skill CLI 调用 ${HWLAB_M3_IO_API_ROUTE};OpenAI fallback 受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 egress/base-url contract 影响;long-lived Codex stdio 仍 blocked。` : `普通聊天可走 OpenAI Responses fallback,pwd/skills 走受控只读 runner,M3 IO 走 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE};这些能力都不能冒充完整 long-lived Codex stdio Code Agent。`; } return blocked ? "OpenAI fallback 和 Codex stdio long-lived session 都 blocked;不返回伪完成。" : "Only text-chat fallback is available; it does not satisfy the Codex stdio session gate."; } 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" }; } if (provider === "codex-stdio" || provider === "codex-mcp-stdio") { return { provider: CODEX_STDIO_PROVIDER, model, backend: CODEX_STDIO_BACKEND, mode: "codex-stdio" }; } 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 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)); const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe(); const m3IoApiBaseUrl = configuredCloudApiBaseUrl(env); return { kind: READONLY_RUNNER_KIND, backend: READONLY_RUNNER_BACKEND, provider: READONLY_RUNNER_PROVIDER, workspace, sandbox: READONLY_RUNNER_SANDBOX, mode: READONLY_SESSION_MODE, sessionMode: READONLY_SESSION_MODE, session: READONLY_SESSION_MODE, status: workspaceReady ? "available" : "blocked", ready: workspaceReady, capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked", implementationType: READONLY_IMPLEMENTATION_TYPE, longLivedSession: true, durableSession: false, codexStdio: false, writeCapable: false, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, sessionMode: READONLY_SESSION_MODE, implementationType: READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility }), sessionRegistry, skillsDirs, skillsDirsPresent, m3IoSkill: { status: m3IoApiBaseUrl ? "available" : "blocked", service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, statusRoute: HWLAB_M3_STATUS_API_ROUTE, capabilityLevel: m3IoApiBaseUrl ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, blockedCapabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, hwlabApi: { source: m3IoApiBaseUrl ? m3IoSkillApiBaseUrlSource(env) : "missing-config", baseUrlConfigured: Boolean(m3IoApiBaseUrl), redactedBaseUrl: m3IoApiBaseUrl ? redactUrl(m3IoApiBaseUrl) : null, recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS] }, blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(), allowedRoutes: [`POST ${HWLAB_M3_IO_API_ROUTE}`, `GET ${HWLAB_M3_STATUS_API_ROUTE}`], directGatewayCallsAllowed: false, directBoxCallsAllowed: false, directPatchPanelCallsAllowed: false, fallbackAllowed: false }, safety: runnerSafetyContract() }; } async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model }); } catch (error) { const availability = error.availability ?? manager.describe({ env, workspace }); const session = error.session ?? null; const trace = error.runnerTrace ?? { traceId, runnerKind: CODEX_STDIO_RUNNER_KIND, workspace: workspace ?? availability.workspace ?? repoRoot, sandbox: availability.sandbox ?? "workspace-write", sessionMode: CODEX_STDIO_SESSION_MODE, events: [`blocked:${error.code ?? "codex_stdio_failed"}`], valuesPrinted: false }; throw runnerError(error.code ?? "codex_stdio_failed", error.message, { provider: CODEX_STDIO_PROVIDER, model, backend: CODEX_STDIO_BACKEND, workspace: workspace ?? availability.workspace ?? null, sandbox: availability.sandbox ?? "workspace-write", session, toolCalls: Array.isArray(error.toolCalls) ? error.toolCalls : [], skills: error.skills ?? notRequestedSkills(), runner: { kind: CODEX_STDIO_RUNNER_KIND, provider: CODEX_STDIO_PROVIDER, backend: CODEX_STDIO_BACKEND, workspace: workspace ?? availability.workspace ?? repoRoot, sandbox: availability.sandbox ?? "workspace-write", session: CODEX_STDIO_SESSION_MODE, sessionMode: CODEX_STDIO_SESSION_MODE, sessionId: session?.sessionId ?? null, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, codexStdio: true, longLivedSession: true, durableSession: true, writeCapable: true, readOnly: false, capabilityLevel: "blocked" }, runnerTrace: trace, capabilityLevel: "blocked", sessionMode: CODEX_STDIO_SESSION_MODE, sessionReuse: session ? sessionReuseEvidence(session) : null, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, runnerLimitations: ["codex-stdio-blocked"], codexStdioFeasibility: availability, longLivedSessionGate: longLivedSessionGate({ provider: CODEX_STDIO_PROVIDER, runnerKind: CODEX_STDIO_RUNNER_KIND, session, sessionMode: CODEX_STDIO_SESSION_MODE, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, codexStdioFeasibility: availability }), blockers: error.blockers ?? availability.blockers, route: error.route ?? null, toolName: error.toolName ?? (error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session"), missingTools: error.missingTools, availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace }) }); } } async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, skillsDiscoveryDelayMs, sessionRegistry, codexStdioManager }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); const sessionAcquire = registry.acquire({ conversationId, sessionId, workspace: resolvedWorkspace, sandbox: READONLY_RUNNER_SANDBOX, runnerKind: READONLY_RUNNER_KIND, sessionMode: READONLY_SESSION_MODE, capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, implementationType: READONLY_IMPLEMENTATION_TYPE, traceId, now }); if (!sessionAcquire.ok) { const blockedSession = sessionAcquire.session; const blockedCodexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }); const blockedTrace = runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session: blockedSession, events: [`blocked:${sessionAcquire.code}`], startedAt: nowIso(now), outputTruncated: false }); throw runnerError(sessionAcquire.code, sessionAcquire.message, { workspace: resolvedWorkspace ?? null, session: blockedSession, toolCalls: [], skills: notRequestedSkills(), runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), runnerTrace: blockedTrace, capabilityLevel: "blocked", sessionMode: READONLY_SESSION_MODE, sessionReuse: sessionReuseEvidence(blockedSession), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility: blockedCodexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, session: blockedSession, sessionMode: READONLY_SESSION_MODE, implementationType: READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility: blockedCodexStdioFeasibility }), blockers: [sessionAcquire.blocker], route: null, toolName: intent.toolName ?? null }); } let session = sessionAcquire.session; const runner = runnerDescriptor({ workspace: resolvedWorkspace, session }); const startedAt = nowIso(now); const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); const events = [ `intent:${intent.kind}`, "sandbox:read-only", `sessionMode:${READONLY_SESSION_MODE}`, session.reused ? "session:reused" : "session:created", `turn:${session.turn}` ]; const baseEvidence = { sessionMode: READONLY_SESSION_MODE, sessionReuse: sessionReuseEvidence(session), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, session, sessionMode: READONLY_SESSION_MODE, implementationType: READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility }) }; if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) { session = registry.fail(session.sessionId, { now, traceId, conversationId, reused: session.reused, statusReason: "workspace_unreadable" }) ?? session; throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, { workspace: resolvedWorkspace ?? null, session, toolCalls: [], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }), capabilityLevel: "blocked", route: null, toolName: intent.toolName ?? null, ...baseEvidence }); } if (intent.kind === "security") { session = registry.fail(session.sessionId, { now, traceId, conversationId, reused: session.reused, statusReason: "security_blocked" }) ?? session; throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", { workspace: resolvedWorkspace, session, 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, session, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", route: null, toolName: intent.toolName ?? "security.guard", ...baseEvidence, 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 }); session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "当前受控只读 runner 工作目录:", toolCall.stdout, "", sessionReplyLine(session), limitationReplyLine() ].join("\n"), workspace: resolvedWorkspace, toolCalls: [toolCall], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:pwd:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), session, codexStdioFeasibility, outputTruncated: toolCall.outputTruncated }); } if (intent.kind === "ls") { const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); assertReadOnlyToolCompleted(toolCall, { session, workspace: resolvedWorkspace, skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }), baseEvidence }); session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "受控只读 ls 结果:", toolCall.stdout || "(empty)", "", sessionReplyLine(session), limitationReplyLine() ].join("\n"), workspace: resolvedWorkspace, toolCalls: [toolCall], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), session, codexStdioFeasibility, outputTruncated: toolCall.outputTruncated }); } if (intent.kind === "rg_files") { const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env }); assertReadOnlyToolCompleted(toolCall, { session, workspace: resolvedWorkspace, skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }), baseEvidence }); session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "受控只读 rg --files 结果:", toolCall.stdout || "(empty)", "", sessionReplyLine(session), limitationReplyLine() ].join("\n"), workspace: resolvedWorkspace, toolCalls: [toolCall], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), session, codexStdioFeasibility, outputTruncated: toolCall.outputTruncated }); } if (intent.kind === "cat") { const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); assertReadOnlyToolCompleted(toolCall, { session, workspace: resolvedWorkspace, skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }), baseEvidence }); session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: [ "受控只读 cat 结果:", toolCall.stdout || "(empty)", "", sessionReplyLine(session), limitationReplyLine() ].join("\n"), workspace: resolvedWorkspace, toolCalls: [toolCall], skills: notRequestedSkills(), runner, runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), session, codexStdioFeasibility, outputTruncated: toolCall.outputTruncated }); } if (intent.kind === "skills") { const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs: skillsDiscoveryDelayMs }); if (skills.status === "blocked") { session = registry.fail(session.sessionId, { now, traceId, conversationId, reused: session.reused, statusReason: "skills_unavailable" }) ?? session; throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", { workspace: resolvedWorkspace, session, 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, session, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }), capabilityLevel: "blocked", route: null, toolName: "skills.discover", ...baseEvidence, blockers: skills.blockers }); } session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); return readOnlyRunnerResult({ content: skillsReply(skills), workspace: resolvedWorkspace, 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, session, events: [...events, "tool:skills.discover:completed"], startedAt, outputTruncated: Boolean(skills.truncated) }), session, codexStdioFeasibility, outputTruncated: Boolean(skills.truncated) }); } session = registry.fail(session.sessionId, { now, traceId, conversationId, reused: session.reused, statusReason: "tool_unavailable" }) ?? session; throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, { workspace: resolvedWorkspace, session, 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, session, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", route: null, toolName: intent.toolName ?? "unsupported", ...baseEvidence }); } async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, codexStdioManager }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); const m3ApiBaseUrl = configuredCloudApiBaseUrl(env); const sessionCapabilityLevel = m3ApiBaseUrl ? m3SessionCapabilityLevelForIntent(intent) : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; const sessionAcquire = registry.acquire({ conversationId, sessionId, workspace: resolvedWorkspace, sandbox: M3_IO_SKILL_SANDBOX, runnerKind: M3_IO_SKILL_RUNNER_KIND, sessionMode: M3_IO_SKILL_SESSION_MODE, capabilityLevel: sessionCapabilityLevel, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, traceId, now }); const startedAt = nowIso(now); const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); if (!sessionAcquire.ok) { const blockedSession = sessionAcquire.session; throw runnerError(sessionAcquire.code, sessionAcquire.message, { provider: M3_IO_SKILL_PROVIDER, model: M3_IO_SKILL_MODEL, backend: M3_IO_SKILL_BACKEND, workspace: resolvedWorkspace ?? null, sandbox: M3_IO_SKILL_SANDBOX, session: blockedSession, toolCalls: [], skills: notRequestedSkills(), runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), runnerTrace: m3IoSkillRunnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session: blockedSession, events: [`blocked:${sessionAcquire.code}`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", sessionMode: M3_IO_SKILL_SESSION_MODE, sessionReuse: sessionReuseEvidence(blockedSession), implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: M3_IO_SKILL_PROVIDER, runnerKind: M3_IO_SKILL_RUNNER_KIND, session: blockedSession, sessionMode: M3_IO_SKILL_SESSION_MODE, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, codexStdioFeasibility }), blockers: [sessionAcquire.blocker], route: HWLAB_M3_IO_API_ROUTE, toolName: HWLAB_M3_IO_SKILL_NAME }); } let session = sessionAcquire.session; if (!m3ApiBaseUrl) { const route = intent.action === "status" ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE; const blocker = m3IoApiBaseUrlMissingBlocker({ route }); const skillResult = m3IoSkillMissingApiBaseUrlResult({ traceId, requestId: `req_${randomUUID()}`, actorId: "usr_code_agent", blocker, route, startedAt, finishedAt: nowIso(now) }); return m3IoSkillRunnerResult({ skillResult, commandArgs: m3IoSkillArgsForIntent(intent, { env, traceId }), resolvedWorkspace, registry, session, now, traceId, conversationId, codexStdioFeasibility, startedAt }); } const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); const skillResult = await runM3IoSkillCommand(commandArgs, { env, now, requestJson }); return m3IoSkillRunnerResult({ skillResult, commandArgs, resolvedWorkspace, registry, session, now, traceId, conversationId, codexStdioFeasibility, startedAt }); } async function callCodexStdioWithM3IoSkillRunner({ message, intent, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, requestJson }) { const stdioResult = await callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager }); const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); const skillResult = await runM3IoSkillCommand(commandArgs, { env, now, requestJson }); return codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }); } function m3IoSkillRunnerResult({ skillResult, commandArgs, resolvedWorkspace, registry, session, now, traceId, conversationId, codexStdioFeasibility, startedAt }) { const finishedAt = nowIso(now); const capabilityLevel = skillResult.capabilityLevel ?? ( skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked ); const route = m3SkillRoute(skillResult); const method = m3SkillMethod(skillResult); const toolCall = m3IoSkillToolCall({ skillResult, commandArgs, cwd: resolvedWorkspace, capabilityLevel, route, method }); session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult }); const runnerTracePayload = m3IoSkillRunnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [ "intent:m3_io", "tool:skill-cli:started", `route:${route}`, `tool:${toolCall.status}` ], startedAt, finishedAt, outputTruncated: toolCall.outputTruncated, skillResult }); return { provider: M3_IO_SKILL_PROVIDER, model: M3_IO_SKILL_MODEL, backend: M3_IO_SKILL_BACKEND, content: m3IoSkillReply(skillResult), workspace: resolvedWorkspace, sandbox: M3_IO_SKILL_SANDBOX, session, sessionMode: M3_IO_SKILL_SESSION_MODE, sessionReuse: sessionReuseEvidence(session), implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: M3_IO_SKILL_PROVIDER, runnerKind: M3_IO_SKILL_RUNNER_KIND, session, sessionMode: M3_IO_SKILL_SESSION_MODE, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, codexStdioFeasibility }), toolCalls: [toolCall], skills: { status: "used", items: [m3IoSkillItem({ capabilityLevel, route })], count: 1, blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []) }, runner, runnerTrace: runnerTracePayload, capabilityLevel, blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), route, toolName: HWLAB_M3_IO_SKILL_NAME, providerTrace: m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) }; } function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }) { const capabilityLevel = skillResult.capabilityLevel ?? ( skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked ); const route = m3SkillRoute(skillResult); const method = m3SkillMethod(skillResult); const toolCall = m3IoSkillToolCall({ skillResult, commandArgs, cwd: stdioResult.workspace, capabilityLevel, route, method }); const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); const runnerTrace = { ...stdioResult.runnerTrace, events: [ ...(stdioResult.runnerTrace?.events ?? []), "tool:skill-cli:started", `route:${route}`, `tool:${toolCall.status}` ], route, method, skill: HWLAB_M3_IO_SKILL_NAME, capabilityLevel, controlReady: skillResult.controlReady === true, operationId: skillResult.operationId, auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, accepted: skillResult.accepted, status: skillResult.status, blocker: skillResult.blocker ?? null, trustBlocker: skillResult.trustBlocker ?? null, blockers, directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, fallbackUsed: false, finishedAt: nowIso(now) }; const runner = { ...stdioResult.runner, skill: { name: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route }, toolPolicy: { ...(stdioResult.runner?.toolPolicy ?? {}), allowed: [ ...new Set([ ...(stdioResult.runner?.toolPolicy?.allowed ?? []), `${method} ${route}` ]) ] } }; return { ...stdioResult, content: `${stdioResult.content}\n\n${m3IoSkillReply(skillResult)}`, toolCalls: [ ...(stdioResult.toolCalls ?? []), toolCall ], skills: { status: "used", items: [ ...(stdioResult.skills?.items ?? []), m3IoSkillItem({ capabilityLevel, route }) ], count: (stdioResult.skills?.count ?? 0) + 1, blockers }, runner, runnerTrace, capabilityLevel, blockers, route, toolName: HWLAB_M3_IO_SKILL_NAME, providerTrace: { ...(stdioResult.providerTrace ?? {}), ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }), runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND, codexStdio: true, transport: "stdio+skill-cli" } }; } function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method }) { return { id: `tool_${randomUUID()}`, type: "skill-cli", name: HWLAB_M3_IO_SKILL_NAME, status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked", cwd, command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${redactM3IoCommandArgs(commandArgs).join(" ")}`), exitCode: skillResult.ok ? 0 : 2, stdout: boundToolOutput(JSON.stringify({ route: skillResult.route, method, status: skillResult.status, accepted: skillResult.accepted, traceId: skillResult.traceId, operationId: skillResult.operationId, auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, audit: skillResult.audit, evidence: skillResult.evidence, durable: skillResult.durable, blocker: skillResult.blocker, result: skillResult.result, readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, safety: skillResult.safety })).text, stderrSummary: skillResult.blocker?.code ?? "", outputTruncated: false, route, method, hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi), capabilityLevel, controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, operationStatus: skillResult.status, apiStatus: skillResult.status, operationId: skillResult.operationId, traceId: skillResult.traceId, auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, audit: skillResult.audit, evidence: skillResult.evidence, durable: skillResult.durable, readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, blocker: skillResult.blocker, capabilityBlocker: skillResult.capabilityBlocker ?? null, trustBlocker: skillResult.trustBlocker ?? null, blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, fallbackUsed: false }; } function m3IoSkillItem({ capabilityLevel, route }) { return { name: HWLAB_M3_IO_SKILL_NAME, summary: `Controlled M3 DO1/DI1 adapter for HWLAB API ${route}.`, route, capabilityLevel, version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION }; } function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) { return { runnerKind: M3_IO_SKILL_RUNNER_KIND, skill: HWLAB_M3_IO_SKILL_NAME, route, method, traceId: skillResult.traceId, operationId: skillResult.operationId, auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, capabilityLevel, controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, status: skillResult.status, fallbackUsed: false }; } function redactM3IoCommandArgs(commandArgs = []) { const redacted = []; for (let index = 0; index < commandArgs.length; index += 1) { redacted.push(commandArgs[index]); if (commandArgs[index] === "--api-base-url" && index + 1 < commandArgs.length) { redacted.push(""); index += 1; } } return redacted; } function m3IoToolCallApiTarget(hwlabApi = {}) { const target = { ...hwlabApi, route: hwlabApi?.route ?? HWLAB_M3_IO_API_ROUTE }; delete target.url; if (findDirectHardwareTarget(target.redactedUrl)) { target.redactedUrl = null; } return target; } function m3SkillRoute(skillResult = {}) { return skillResult.route === HWLAB_M3_STATUS_API_ROUTE ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE; } function m3SkillMethod(skillResult = {}) { return m3SkillRoute(skillResult) === HWLAB_M3_STATUS_API_ROUTE ? "GET" : skillResult.method ?? "POST"; } function m3SessionCapabilityLevelForIntent(intent = {}) { if (intent.action === "status") return HWLAB_M3_IO_CAPABILITY_LEVELS.readonly; return HWLAB_M3_IO_CAPABILITY_LEVELS.ready; } function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker, route = HWLAB_M3_IO_API_ROUTE, startedAt, finishedAt }) { const method = route === HWLAB_M3_STATUS_API_ROUTE ? "GET" : "POST"; return { ok: false, service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route, method, hwlabApi: { route, redactedUrl: null, source: "missing-config", baseUrlConfigured: false, requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS], recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV, missingConfig: [ ...HWLAB_M3_IO_API_BASE_URL_ENVS, "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" ], cloudApiOnly: true, directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false }, capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, controlReady: false, action: "m3.io", accepted: false, status: "blocked", traceId, requestId, actorId, operationId: null, auditId: null, evidenceId: null, audit: { auditId: null, status: "not_written", durableStatus: null, summary: "blocked before HWLAB API request" }, evidence: { evidenceId: null, status: "blocked", sourceKind: "BLOCKED", blocker: blocker.code, writeStatus: "not_written", summary: "blocked before HWLAB API request" }, durable: { status: "blocked", durable: false, blocker: blocker.code, category: blocker.category, summary: blocker.message }, blocker, capabilityBlocker: blocker, trustBlocker: null, blockers: [blocker], readiness: { status: "blocked", controlReady: false, capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, route: HWLAB_M3_IO_API_ROUTE, blocker, trustBlocker: null }, command: null, result: { value: null, targetReadback: null }, readback: null, controlPath: { cloudApi: false, gatewaySimu: false, boxSimu: false, patchPanel: false, frontendBypass: false }, safety: { cloudApiRouteOnly: true, allowedRoute: HWLAB_M3_IO_API_ROUTE, directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, fallbackUsed: false, openAiFallbackUsed: false }, httpStatus: 0, error: { code: blocker.code, layer: blocker.layer, category: blocker.category, blocker, retryable: blocker.retryable, userMessage: blocker.userMessage, message: blocker.message, traceId, route, toolName: HWLAB_M3_IO_SKILL_NAME, missingConfig: blocker.missingConfig }, rawStatus: null, startedAt, finishedAt, response: null }; } 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、密码、私钥或环境变量原文。" }; } const m3IoIntent = detectM3IoIntent(text); if (m3IoIntent) { return m3IoIntent; } if (isHardwareWriteOrAcceptanceRequest(text)) { return { kind: "security", toolName: "security.hardware-boundary", reason: `只读 runner 不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` }; } 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 (/\brg\s+--files\b/u.test(lower) || /(?:列出|查看).{0,12}(?:文件列表|所有文件|源码文件|代码文件)/u.test(text)) { return { kind: "rg_files", toolName: "rg --files", target: extractReadOnlyTarget(text, { defaultTarget: "." }) }; } if (/\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text)) { return { kind: "ls", toolName: "ls", target: extractReadOnlyTarget(text, { defaultTarget: "." }) }; } if (/\bcat\b/u.test(lower) || /(?:读取|查看).{0,10}(?:文件|源码|代码)/u.test(text)) { const target = extractReadOnlyTarget(text, { preferFile: true }); if (!target) { return { kind: "unsupported", toolName: "cat" }; } return { kind: "cat", toolName: "cat", target }; } if (/\b(?:grep|find|sed|awk)\b/u.test(lower)) { const tool = lower.match(/\b(?:grep|find|sed|awk)\b/u)?.[0] ?? "file.read"; return { kind: "unsupported", toolName: tool }; } return { kind: "none" }; } function detectM3IoIntent(text) { const normalized = String(text ?? ""); if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) { return null; } if (/(?:直接|direct|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|\/invoke|\/sync\/tick)/iu.test(normalized)) { return null; } if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) { return null; } const wantsRead = /(?:DI1|数字输入).{0,16}(?:read|readback|读取|回读|读回|查询)|(?:read|readback|读取|回读|读回|查询).{0,16}(?:DI1|数字输入)/iu.test(normalized); const wantsWrite = /(?:DO1|数字输出).{0,20}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,20}(?:DO1|数字输出)/iu.test(normalized); const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized); if (wantsStatus && !wantsRead && !wantsWrite) { return { kind: "m3_io", toolName: HWLAB_M3_IO_SKILL_NAME, action: "status" }; } if (!wantsRead && !wantsWrite) { return null; } if (wantsRead && !wantsWrite) { return { kind: "m3_io", toolName: HWLAB_M3_IO_SKILL_NAME, action: "di.read" }; } const value = parseM3WriteValue(normalized); if (typeof value !== "boolean") { return { kind: "unsupported", toolName: HWLAB_M3_IO_SKILL_NAME, reason: "M3 DO1 write requires an explicit true/false value." }; } return { kind: "m3_io", toolName: HWLAB_M3_IO_SKILL_NAME, action: "do.write", value }; } function parseM3WriteValue(text) { if (/\btrue\b|\b1\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true; if (/\bfalse\b|\b0\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false; return null; } function extractReadOnlyTarget(text, { defaultTarget = null, preferFile = false } = {}) { const value = String(text ?? ""); const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1]; if (quoted) return quoted.trim(); const tokenPattern = preferFile ? /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+\/?[A-Za-z0-9._@:/+=-]*(?:\.[A-Za-z0-9._-]+)?)(?:\s|$)/gu : /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+)(?:\s|$)/gu; const stopWords = new Set(["cat", "ls", "rg", "--files", "读取", "查看", "列出", "文件", "目录", "源码", "代码", "你", "请用"]); for (const match of value.matchAll(tokenPattern)) { const candidate = match[1]?.trim(); if (!candidate || stopWords.has(candidate)) continue; if (/^(?:pwd|skills?|skill)$/iu.test(candidate)) continue; if (!preferFile && !/[/.]/u.test(candidate)) continue; return candidate; } const inlinePath = value.match(/((?:\.{1,2}\/|\/)[A-Za-z0-9._@:/+=-]+|[A-Za-z0-9._@+=-]+\.[A-Za-z0-9._-]+)/u)?.[1]; if (inlinePath && !stopWords.has(inlinePath) && !/^(?:pwd|skills?|skill)$/iu.test(inlinePath)) { return inlinePath; } return defaultTarget; } 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|硬件写|直接调用|\/invoke|\/sync\/tick)/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 runLsTool({ workspace, target = ".", traceId }) { const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true }); const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); if (blockedReason) { return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: blockedReason }); } let entries = []; try { const targetStat = await stat(targetInfo.path); if (targetStat.isDirectory()) { entries = await readdir(targetInfo.path, { withFileTypes: true }); } else { entries = [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }]; } } catch (error) { return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: redactText(error.message) }); } const lines = entries .sort((a, b) => a.name.localeCompare(b.name, "en")) .slice(0, READONLY_FILE_ENTRY_LIMIT) .map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`); const overflow = entries.length > lines.length; const bounded = boundToolOutput(redactText(lines.join("\n"))); return { id: `tool_${randomUUID()}`, type: "file-list", name: "ls", status: "completed", cwd: workspace, command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`, exitCode: 0, stdout: bounded.text, stderrSummary: overflow ? `entry limit ${READONLY_FILE_ENTRY_LIMIT} reached` : "", outputTruncated: bounded.truncated || overflow, traceId }; } async function runRgFilesTool({ workspace, target = ".", traceId, env }) { const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true }); const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); if (blockedReason) { return blockedToolCall({ name: "rg --files", type: "file-list", workspace, traceId, reason: blockedReason }); } let files = []; let usedFallback = true; if (await commandExists("rg", runnerCommandEnv(env))) { const result = await spawnWithInput("rg", ["--files", targetInfo.path], "", { cwd: workspace, env: runnerCommandEnv(env), timeoutMs: 3000 }); if (result.code === 0) { files = result.stdout.split(/\r?\n/u).filter(Boolean).map((filePath) => path.relative(workspace, path.resolve(filePath))); usedFallback = false; } } if (usedFallback) { files = await listFilesRecursively(targetInfo.path, workspace); } const returned = files .filter((filePath) => filePath && !filePath.startsWith("..") && !path.isAbsolute(filePath)) .sort((a, b) => a.localeCompare(b, "en")) .slice(0, READONLY_FILE_ENTRY_LIMIT); const bounded = boundToolOutput(redactText(returned.join("\n"))); return { id: `tool_${randomUUID()}`, type: "file-list", name: "rg --files", status: "completed", cwd: workspace, command: `rg --files ${safeDisplayPath(targetInfo.relative || ".")}`, exitCode: 0, stdout: bounded.text, stderrSummary: usedFallback ? "node-fallback-used" : "", outputTruncated: bounded.truncated || files.length > returned.length, traceId }; } async function runCatTool({ workspace, target, traceId }) { const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true, requireFile: true }); const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); if (blockedReason) { return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: blockedReason }); } let content = ""; let truncatedByReadLimit = false; try { const file = await open(targetInfo.path, "r"); try { const buffer = Buffer.alloc(READONLY_FILE_READ_LIMIT + 1); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); truncatedByReadLimit = bytesRead > READONLY_FILE_READ_LIMIT; content = buffer.subarray(0, Math.min(bytesRead, READONLY_FILE_READ_LIMIT)).toString("utf8"); } finally { await file.close(); } } catch (error) { return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: redactText(error.message) }); } const bounded = boundToolOutput(redactText(content)); return { id: `tool_${randomUUID()}`, type: "file-read", name: "cat", status: "completed", cwd: workspace, command: `cat ${safeDisplayPath(targetInfo.relative)}`, exitCode: 0, stdout: bounded.text, stderrSummary: truncatedByReadLimit ? `file read limited at ${READONLY_FILE_READ_LIMIT} bytes` : "", outputTruncated: bounded.truncated || truncatedByReadLimit, traceId }; } function blockedToolCall({ name, type, workspace, traceId, reason }) { return { id: `tool_${randomUUID()}`, type, name, status: "blocked", cwd: workspace, exitCode: 1, stdout: "", stderrSummary: `security_blocked: ${redactText(reason)}`, outputTruncated: false, traceId }; } function assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runnerTrace, baseEvidence }) { if (toolCall.status === "completed") return; throw runnerError("security_blocked", toolCall.stderrSummary || `${toolCall.name} was blocked by the read-only runner policy`, { workspace, toolCalls: [toolCall], skills, runner, runnerTrace, capabilityLevel: "blocked", blockers: [{ code: "security_blocked", sourceIssue: "pikasTech/HWLAB#275", summary: toolCall.stderrSummary || "Read-only runner blocked the requested file path." }], route: null, toolName: toolCall.name, ...baseEvidence }); } async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs = 0 }) { await maybeDelayForTest(delayMs); 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 resolveReadOnlyTarget(workspace, target, { mustExist = false, requireFile = false } = {}) { const rawTarget = String(target || ".").trim(); if (!rawTarget) { return { blocked: true, reason: "missing target path" }; } if (isForbiddenPath(rawTarget)) { return { blocked: true, reason: "security_blocked: target path may expose secrets or forbidden runtime material" }; } const resolved = path.resolve(workspace, rawTarget); if (!isPathInside(resolved, workspace)) { return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" }; } if (isForbiddenPath(path.relative(workspace, resolved))) { return { blocked: true, reason: "security_blocked: target path is not allowed" }; } const relative = path.relative(workspace, resolved) || "."; return { path: resolved, relative, async check() { if (!mustExist && !requireFile) return null; try { const targetStat = await stat(resolved); if (requireFile && !targetStat.isFile()) { return "target is not a regular file"; } return null; } catch { return "target path is not readable"; } } }; } function isForbiddenPath(value) { const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase(); return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) || /(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized); } async function listFilesRecursively(rootPath, workspace) { const results = []; async function visit(currentPath, depth) { if (results.length >= READONLY_FILE_ENTRY_LIMIT || depth > READONLY_FILE_TREE_DEPTH) return; let entries = []; try { const currentStat = await stat(currentPath); if (currentStat.isFile()) { results.push(path.relative(workspace, currentPath)); return; } if (!currentStat.isDirectory()) return; entries = await readdir(currentPath, { withFileTypes: true }); } catch { return; } for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) { if (results.length >= READONLY_FILE_ENTRY_LIMIT) break; if (SKIPPED_READONLY_DIRS.has(entry.name) || isForbiddenPath(entry.name)) continue; const nextPath = path.join(currentPath, entry.name); if (entry.isDirectory()) { await visit(nextPath, depth + 1); } else if (entry.isFile()) { results.push(path.relative(workspace, nextPath)); } } } await visit(rootPath, 0); return results; } function safeDisplayPath(value) { return redactText(String(value ?? ".")).replace(/\s+/gu, " "); } 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 readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated }) { return { provider: READONLY_RUNNER_PROVIDER, model: READONLY_RUNNER_MODEL, backend: READONLY_RUNNER_BACKEND, content, workspace, sandbox: READONLY_RUNNER_SANDBOX, session, sessionMode: READONLY_SESSION_MODE, sessionReuse: sessionReuseEvidence(session), implementationType: READONLY_IMPLEMENTATION_TYPE, runnerLimitations: [...READONLY_LIMITATION_FLAGS], codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: READONLY_RUNNER_PROVIDER, runnerKind: READONLY_RUNNER_KIND, session, sessionMode: READONLY_SESSION_MODE, implementationType: READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility }), toolCalls, skills, runner, runnerTrace, capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, providerTrace: { runnerKind: READONLY_RUNNER_KIND, toolCalls: toolCalls.length, mode: READONLY_SESSION_MODE, sessionId: session.sessionId, turn: session.turn, reused: session.reused, outputTruncated: Boolean(outputTruncated) } }; } function m3IoSkillArgsForIntent(intent, { env, traceId }) { const args = [ "m3", intent.action === "status" ? "status" : "io" ]; if (intent.action !== "status") { args.push("--action", intent.action); } args.push( "--trace-id", traceId, "--actor-id", "usr_code_agent" ); const apiBaseUrl = configuredCloudApiBaseUrl(env); if (apiBaseUrl) { args.push("--api-base-url", apiBaseUrl); } if (intent.action === "do.write") { args.push( "--value", String(intent.value), "--approved", "--policy", "hwlab-api-control-with-approval", "--approval-reason", "user explicitly requested res_boxsimu_1 DO1 write through HWLAB API" ); } else { args.push("--policy", "hwlab-api-readonly"); } return args; } function m3IoSkillApiBaseUrlSource(env = process.env) { for (const name of HWLAB_M3_IO_API_BASE_URL_ENVS) { if (firstNonEmpty(env[name])) return `env:${name}`; } return "missing-config"; } function m3IoApiBaseUrlMissingBlocker({ route = HWLAB_M3_IO_API_ROUTE } = {}) { return { code: "skill_cli_api_base_missing", layer: "skill-cli-config", category: "needs_config", retryable: false, source: "code-agent-m3-skill-cli", summary: `${HWLAB_M3_IO_API_BASE_URL_ENV} is required so the Skill CLI can reach cloud-api from inside the cloud-api runtime container.`, message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV} or another supported HWLAB API base URL contract in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`, zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。`, userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。", traceId: null, route, toolName: HWLAB_M3_IO_SKILL_NAME, missingConfig: [ ...HWLAB_M3_IO_API_BASE_URL_ENVS, "contract:hwlab-agent-runtime.m3-io.apiBaseUrl" ] }; } function m3IoSkillReply(skillResult) { const lines = [ `M3 IO Skill CLI result: status=${skillResult.status}; accepted=${skillResult.accepted}; route=${skillResult.route}; traceId=${skillResult.traceId}; operationId=${skillResult.operationId ?? "null"}.` ]; if (skillResult.audit?.summary) { lines.push(`Audit: ${skillResult.audit.summary}.`); } if (skillResult.evidence?.summary) { lines.push(`Evidence: ${skillResult.evidence.summary}.`); } if (skillResult.blocker) { lines.push(`Blocker: ${skillResult.blocker.code}; ${skillResult.blocker.zh ?? skillResult.blocker.message}.`); } if (skillResult.action === "do.write") { lines.push(`DO1 requested value=${String(skillResult.command?.value)}; DI1 readback=${String(skillResult.result?.targetReadback?.value ?? "unknown")}.`); } else if (skillResult.action === "di.read") { lines.push(`DI1 value=${String(skillResult.result?.value ?? "unknown")}.`); } else if (skillResult.readonly === true || skillResult.route === HWLAB_M3_STATUS_API_ROUTE) { lines.push(`M3 status readback DI1=${String(skillResult.readback?.value ?? "unknown")}; readiness=${skillResult.readiness?.status ?? "unknown"}.`); } lines.push(`Boundary: Code Agent -> Skill CLI -> HWLAB API ${skillResult.route}; no direct gateway/box/patch-panel call; no OpenAI fallback used. This is source/artifact control capability, not DEV-LIVE verification.`); return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text; } function m3IoSkillRunnerDescriptor({ workspace, session = null, skillResult = null } = {}) { const route = m3SkillRoute(skillResult); const method = m3SkillMethod(skillResult); const capabilityLevel = skillResult?.capabilityLevel ?? HWLAB_M3_IO_CAPABILITY_LEVELS.ready; return { kind: M3_IO_SKILL_RUNNER_KIND, provider: M3_IO_SKILL_PROVIDER, backend: M3_IO_SKILL_BACKEND, workspace: workspace ?? repoRoot, sandbox: M3_IO_SKILL_SANDBOX, session: M3_IO_SKILL_SESSION_MODE, sessionMode: M3_IO_SKILL_SESSION_MODE, sessionId: session?.sessionId ?? null, turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, codexStdio: false, longLivedSession: false, durableSession: false, writeCapable: true, readOnly: false, capabilityLevel, skill: { name: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route }, toolPolicy: { allowed: [`${method} ${route}`], blocked: ["gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "generic-hardware-rpc", "OpenAI-hardware-fallback", "M3/M4/M5-acceptance-claim"] }, runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], safety: { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, cloudApiRouteOnly: true, allowedRoute: route, directGatewayCallsAllowed: false, directBoxSimuCallsAllowed: false, directPatchPanelCallsAllowed: false, openAiFallbackAllowed: false, m3m4m5AcceptanceClaimsAllowed: false, outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT } }; } function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = startedAt, outputTruncated, workspace = repoRoot, session = null, skillResult = null }) { const capabilityLevel = skillResult?.capabilityLevel ?? ( skillResult?.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked ); const route = m3SkillRoute(skillResult); const method = m3SkillMethod(skillResult); return { traceId, runnerKind: M3_IO_SKILL_RUNNER_KIND, workspace, sandbox: M3_IO_SKILL_SANDBOX, sessionMode: M3_IO_SKILL_SESSION_MODE, sessionId: session?.sessionId ?? null, sessionStatus: session?.status ?? null, idleTimeoutMs: session?.idleTimeoutMs ?? null, lastTraceId: session?.lastTraceId ?? null, turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, limitations: [...M3_IO_SKILL_LIMITATION_FLAGS], startedAt, finishedAt, events, route, method, skill: HWLAB_M3_IO_SKILL_NAME, capabilityLevel, controlReady: skillResult?.controlReady === true, operationId: skillResult?.operationId ?? null, auditId: skillResult?.auditId ?? skillResult?.audit?.auditId ?? null, evidenceId: skillResult?.evidenceId ?? skillResult?.evidence?.evidenceId ?? null, readback: skillResult?.readback ?? skillResult?.result?.targetReadback ?? null, accepted: skillResult?.accepted ?? false, status: skillResult?.status ?? "blocked", blocker: skillResult?.blocker ?? null, trustBlocker: skillResult?.trustBlocker ?? null, blockers: skillResult?.blockers ?? [], outputTruncated: Boolean(outputTruncated), valuesPrinted: false, directGatewayCalls: false, directBoxCalls: false, directPatchPanelCalls: false, fallbackUsed: false, note: `Controlled M3 IO uses Skill CLI -> HWLAB API ${route}; it is not OpenAI fallback and does not directly call gateway, box, or patch-panel.` }; } function structuredCompletionBlocker(result, context = {}) { if (!result || typeof result !== "object") return null; if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") { return structuredBlocker({ code: "text_chat_only_fallback", layer: "provider", message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.", userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。", retryable: false, traceId: context.traceId, provider: result.provider ?? context.provider, backend: result.backend ?? context.backend, runner: result.runner ?? context.runner, capabilityLevel: result.capabilityLevel ?? context.capabilityLevel, blockers: result.longLivedSessionGate?.blockers }); } if (result.provider === M3_IO_SKILL_PROVIDER && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) { const primary = result.toolCalls?.[0]?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; return structuredBlocker({ code: primary?.code === "hwlab_api_unavailable" ? "hwlab_api_unavailable" : "m3_readiness_blocked", layer: primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness", message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.", userMessage: primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。", retryable: primary?.code === "hwlab_api_unavailable", traceId: context.traceId, provider: result.provider, backend: result.backend, runner: result.runner, capabilityLevel: result.capabilityLevel, route: HWLAB_M3_IO_API_ROUTE, toolName: HWLAB_M3_IO_SKILL_NAME, blockers: result.skills?.blockers }); } return null; } function sessionReuseEvidence(session) { return { conversationId: session.conversationId, sessionId: session.sessionId, mapped: true, reused: session.reused, turn: session.turn, previousTurns: Math.max(0, session.turn - 1), workspace: session.workspace, createdAt: session.createdAt, updatedAt: session.updatedAt, idleTimeoutMs: session.idleTimeoutMs, expiresAt: session.expiresAt, lastTraceId: session.lastTraceId, status: session.status }; } function sessionReplyLine(session) { return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`; } function limitationReplyLine() { return "边界:controlled-readonly-session-registry;long-lived read-only session;not-codex-stdio;not-write-capable;process-local-session-registry。"; } function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) { return registry.release(session.sessionId, { now, traceId, conversationId, reused: session.reused, status: "idle" }) ?? session; } function resolveCodeAgentSessionRegistry(options = {}) { return options.sessionRegistry && typeof options.sessionRegistry.acquire === "function" && typeof options.sessionRegistry.release === "function" && typeof options.sessionRegistry.describe === "function" ? options.sessionRegistry : defaultCodeAgentSessionRegistry; } function resolveCodexStdioSessionManager(options = {}) { return options.codexStdioManager && typeof options.codexStdioManager.describe === "function" && typeof options.codexStdioManager.chat === "function" && typeof options.codexStdioManager.cancel === "function" && typeof options.codexStdioManager.reapIdle === "function" ? options.codexStdioManager : defaultCodexStdioSessionManager; } function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) { const allowed = kind === READONLY_RUNNER_KIND ? ["pwd", "skills.discover", "ls", "rg --files", "cat", `m3.io.skill-cli:${HWLAB_M3_IO_API_ROUTE}`] : ["pwd", "skills.discover", "ls", "rg --files", "cat"]; 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" : READONLY_SESSION_MODE, sessionMode: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE, sessionId: session?.sessionId ?? null, turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: kind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", codexStdio: false, longLivedSession: kind === READONLY_RUNNER_KIND, durableSession: false, writeCapable: false, readOnly: true, capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only", toolPolicy: { allowed, blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"] }, runnerLimitations: kind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"], safety: runnerSafetyContract() }; } function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND, session = null }) { return { traceId, runnerKind, workspace, sandbox: READONLY_RUNNER_SANDBOX, sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot", sessionId: session?.sessionId ?? null, sessionStatus: session?.status ?? null, idleTimeoutMs: session?.idleTimeoutMs ?? null, lastTraceId: session?.lastTraceId ?? null, turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", limitations: runnerKind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"], 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." : "controlled-readonly-session-registry stores conversation/session mapping, idle timeout, status, trace id, and turn counters for a reusable read-only session; it is not Codex stdio, write-capable, or DB-durable." }; } function runnerSafetyContract() { return { secretsRead: false, secretValuesPrinted: false, kubeconfigRead: false, hardwareWritesAllowed: false, directGatewayCallsAllowed: false, directBoxSimuCallsAllowed: false, directPatchPanelCallsAllowed: false, m3IoSkillCliAllowedRoute: HWLAB_M3_IO_API_ROUTE, m3StatusSkillCliAllowedRoute: HWLAB_M3_STATUS_API_ROUTE, openAiHardwareFallbackAllowed: false, m3m4m5AcceptanceClaimsAllowed: false, outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT }; } async function inspectCodexStdioFeasibility(env = process.env, options = {}) { const manager = resolveCodexStdioSessionManager(options); const descriptor = { env, workspace: options.workspace, command: options.command, sandbox: options.sandbox }; const feasibility = typeof manager.probe === "function" ? await manager.probe(descriptor) : manager.describe(descriptor); return { ...feasibility, checked: true, implementationRequired: "codex-stdio-or-equivalent-long-lived-runner", currentImplementation: feasibility.ready ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE, sourceIssue: CODEX_STDIO_SOURCE_ISSUE }; } function longLivedSessionGate({ provider, runnerKind, session, sessionMode, implementationType, codexStdioFeasibility } = {}) { const normalizedProvider = String(provider ?? "").trim(); const normalizedRunnerKind = String(runnerKind ?? "").trim(); const normalizedSessionMode = String(sessionMode ?? "").trim(); const normalizedImplementation = String(implementationType ?? "").trim(); return codexLongLivedSessionGate({ provider: normalizedProvider, runnerKind: normalizedRunnerKind, session, sessionMode: normalizedSessionMode, implementationType: normalizedImplementation, codexStdioFeasibility }); } function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }) { 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", sessionMode: "provider-text-request", sessionId, conversationId, implementationType: OPENAI_FALLBACK_RUNNER_KIND, codexStdio: false, longLivedSession: false, durableSession: false, writeCapable: 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." }, runnerLimitations: [ "text-chat-only", "not-codex-stdio", "not-workspace-tools", "not-session-runner" ], 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: Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "/usr/local/bin:/usr/bin:/bin", HOME: env.HOME || process.env.HOME || os.homedir() }; } async function maybeDelayForTest(value) { const delayMs = Number.parseInt(value ?? "", 10); if (!Number.isInteger(delayMs) || delayMs <= 0) return; await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 10000))); } 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 }) { if (env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "1" && env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "true") { return { provider: "openai-responses", model: providerPlan.model, backend: "hwlab-cloud-api/openai-responses-fallback", content: "当前完整 Codex stdio Code Agent 未通过 readiness;OpenAI Responses fallback 保留但默认不自动调用。请查看 blocker 后补齐运行底座。", usage: null, capabilityLevel: "text-chat-only", implementationType: OPENAI_FALLBACK_RUNNER_KIND, runnerLimitations: [ "text-chat-only", "not-codex-stdio", "not-workspace-tools", "not-session-runner" ], providerTrace: { fallbackUsed: true, fallbackKind: OPENAI_FALLBACK_RUNNER_KIND, networkCallAttempted: false, valuesPrinted: false } }; } 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`, { code: "provider_timeout", provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, timeoutMs: effectiveTimeout(timeoutMs) }); } 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}`, { code: "codex_cli_binary_missing", missingCommands: [command], missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"], provider: providerPlan.provider, model: providerPlan.model, backend: providerPlan.backend, command, nextEvidence: `Install/provide a repo-controlled Codex CLI binary on PATH or set HWLAB_CODE_AGENT_CODEX_COMMAND to an approved binary path; checked command=${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, sessionMode: "ephemeral-one-shot", session: { sessionId: conversationId, conversationId, status: "expired", workspace: repoRoot, sandbox: READONLY_RUNNER_SANDBOX, runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, capabilityLevel: "text-chat-only", implementationType: "codex-cli-one-shot-ephemeral", createdAt: nowIso(), updatedAt: nowIso(), idleTimeoutMs: 0, expiresAt: nowIso(), lastTraceId: traceId, turn: 1, longLivedSession: false, codexStdio: false, durable: false, secretMaterialStored: false, valuesRedacted: true }, sessionReuse: { conversationId, sessionId: conversationId, mapped: true, reused: false, turn: 1, previousTurns: 0, workspace: repoRoot }, implementationType: "codex-cli-one-shot-ephemeral", runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"], codexStdioFeasibility, longLivedSessionGate: longLivedSessionGate({ provider: providerPlan.provider, runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, sessionMode: "ephemeral-one-shot", implementationType: "codex-cli-one-shot-ephemeral", codexStdioFeasibility }), 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; } export function createCodeAgentErrorPayload({ code = "code_agent_failed", message = "Code Agent request failed", reason, traceId = "trc_unassigned", conversationId = "cnv_unassigned", sessionId = "cnv_unassigned", messageId = "msg_unassigned", provider = "unassigned", model = "unknown", backend = "hwlab-cloud-api/code-agent-chat", layer, retryable, capabilityLevel = "blocked", route = "/v1/agent/chat", toolName = null, now } = {}) { const timestamp = nowIso(now); const error = new Error(message); Object.assign(error, { code, reason, layer, retryable, provider, model, backend, capabilityLevel, route, toolName }); const normalized = normalizeChatError(error, { traceId, provider, model, backend, capabilityLevel, route, toolName }); return { conversationId, sessionId, messageId, status: "failed", createdAt: timestamp, updatedAt: timestamp, traceId, provider, model, backend, capabilityLevel, error: normalized, blocker: normalized.blocker, blockers: normalized.blockers }; } function normalizeChatError(error, context = {}) { const code = String(error.code || "code_agent_failed"); const taxonomy = errorTaxonomy(code, error); const provider = error.provider ?? context.provider ?? READONLY_RUNNER_PROVIDER; const model = error.model ?? context.model ?? READONLY_RUNNER_MODEL; const backend = error.backend ?? context.backend ?? READONLY_RUNNER_BACKEND; const runnerKind = error.runner?.kind ?? error.runnerKind ?? context.runner?.kind ?? null; const traceId = error.traceId ?? context.traceId ?? null; const capabilityLevel = error.capabilityLevel ?? context.capabilityLevel ?? "blocked"; const route = error.route ?? context.route ?? routeForError(code, error); const toolName = error.toolName ?? context.toolName ?? toolNameForError(code, error); const message = redactText(error.message || taxonomy.message || "Code Agent request failed"); const blockers = normalizeBlockers(error.blockers, { fallbackCode: code, layer: taxonomy.layer, message, userMessage: taxonomy.userMessage, retryable: taxonomy.retryable }); const blocker = structuredBlocker({ code, layer: taxonomy.layer, message, userMessage: error.userMessage ?? taxonomy.userMessage, retryable: error.retryable ?? taxonomy.retryable, traceId, provider, backend, runner: error.runner ?? context.runner, capabilityLevel, route, toolName, category: taxonomy.category, blockers }); const normalized = { code, layer: taxonomy.layer, category: taxonomy.category, blocker, retryable: blocker.retryable, userMessage: blocker.userMessage, message, traceId, provider, model, backend, runner: runnerKind, runnerKind, capabilityLevel, route, toolName, missingConfig: safeMissingConfig(error), blockers }; for (const key of [ "missingEnv", "missingCommands", "command", "exitCode", "providerStatus", "stderrSummary", "missingTools", "nextEvidence", "reason" ]) { if (error[key] !== undefined) { normalized[key] = sanitizeErrorField(key, error[key]); } } return normalized; } function errorTaxonomy(code, error = {}) { const catalog = { invalid_params: { layer: "api", category: "api_error", retryable: true, userMessage: "请求参数不完整或格式不正确,请修正后重试。" }, parse_error: { layer: "api", category: "api_error", retryable: true, userMessage: "请求 JSON 无法解析,请修正后重试。" }, provider_unavailable: { layer: error.missingEnv?.length || error.missingCommands?.length ? "provider-config" : "provider", category: error.missingEnv?.length || error.missingCommands?.length ? "needs_config" : "provider", retryable: !(error.missingEnv?.length || error.missingCommands?.length), userMessage: error.missingEnv?.length || error.missingCommands?.length ? "Code Agent provider 配置缺失,需要维护者补齐后才能使用。" : "Code Agent provider 暂不可用,可稍后重试。" }, provider_timeout: { layer: "provider", category: "timeout", retryable: true, userMessage: "Code Agent provider 响应超时,输入已保留,可稍后重试。" }, codex_cli_binary_missing: { layer: "runner", category: "needs_config", retryable: false, userMessage: "Codex CLI binary 未在运行时 PATH 中找到;当前不会安装临时二进制,也不会冒充真实 Codex。" }, session_busy: { layer: "session", category: "runner_busy", retryable: true, userMessage: "Code Agent session 正在处理上一轮请求,请稍后重试。" }, session_expired: { layer: "session", category: "session_blocked", retryable: true, userMessage: "Code Agent session 已过期,可重新发送建立新的 session。" }, session_reuse_conflict: { layer: "session", category: "session_blocked", retryable: true, userMessage: "当前 conversation 与 session 绑定不一致,可新建会话后重试。" }, session_failed: { layer: "session", category: "session_blocked", retryable: true, userMessage: "Code Agent session 已失败,可重新发送建立新的 session。" }, session_interrupted: { layer: "session", category: "session_blocked", retryable: true, userMessage: "Code Agent session 已中断,可重新发送建立新的 session。" }, runner_unavailable: { layer: "runner", category: "runner_blocked", retryable: true, userMessage: "Code Agent runner 暂不可用,可稍后重试或等待工作区挂载恢复。" }, tool_unavailable: { layer: "tool", category: "capability_unavailable", retryable: false, userMessage: "当前 Code Agent 未开放该工具能力。" }, skills_unavailable: { layer: "skill-cli", category: "needs_config", retryable: false, userMessage: "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。" }, security_blocked: { layer: "security", category: "security_blocked", retryable: false, userMessage: "该请求被安全边界阻断,不能读取敏感信息或绕过 HWLAB API。" }, skill_cli_api_base_missing: { layer: "skill-cli-config", category: "needs_config", retryable: false, userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。" }, hwlab_api_unavailable: { layer: "hwlab-api", category: "retryable", retryable: true, userMessage: "HWLAB API 当前不可达,M3 控制未执行,可稍后重试。" }, m3_readiness_blocked: { layer: "m3-readiness", category: "capability_unavailable", retryable: false, userMessage: "M3 控制链路尚未就绪,不能把本次结果标记为真实可控。" }, text_chat_only_fallback: { layer: "provider", category: "fallback", retryable: false, userMessage: "当前仍是文本 fallback,不具备 runner/session/tool/HWLAB API 控制能力。" }, codex_stdio_blocked: { layer: "runner", category: "capability_unavailable", retryable: false, userMessage: "Codex stdio 长会话能力仍受阻,不能作为完整 Code Agent。" }, codex_stdio_protocol_blocked: { layer: "runner", category: "capability_unavailable", retryable: false, userMessage: "Codex stdio 协议工具不完整,不能作为完整 Code Agent。" }, codex_stdio_empty_response: { layer: "runner", category: "runner_blocked", retryable: true, userMessage: "Codex stdio 未返回有效回复,可稍后重试。" }, codex_stdio_command_probe_failed: { layer: "runner", category: "runner_blocked", retryable: true, userMessage: "Codex stdio 基础命令探针失败,readiness 不会标为完整 ready;输入已保留,可稍后重试。" }, codex_stdio_failed: { layer: "runner", category: "runner_blocked", retryable: true, userMessage: "Codex stdio runner 执行失败,可稍后重试。" } }; return catalog[code] ?? { layer: "api", category: "api_error", retryable: true, userMessage: "Code Agent 请求失败,输入已保留,可稍后重试。" }; } function structuredBlocker({ code, layer, message, userMessage, retryable, traceId, provider, backend, runner, capabilityLevel, route, toolName, category, blockers }) { return { code, layer, category: category ?? layer, retryable: Boolean(retryable), summary: redactText(message), userMessage, traceId: traceId ?? null, provider: provider ?? null, backend: backend ?? null, runner: runner?.kind ?? runner ?? null, capabilityLevel: capabilityLevel ?? null, route: route ?? null, toolName: toolName ?? null, blockerCodes: Array.isArray(blockers) ? blockers.map((blocker) => blocker.code).filter(Boolean) : [] }; } function normalizeBlockers(blockers, fallback) { const items = Array.isArray(blockers) ? blockers : []; const normalized = items .filter((blocker) => blocker && typeof blocker === "object") .map((blocker) => ({ code: String(blocker.code ?? fallback.fallbackCode), layer: blocker.layer ?? fallback.layer, category: blocker.category ?? fallback.layer, retryable: Boolean(blocker.retryable ?? fallback.retryable), summary: redactText(blocker.summary ?? blocker.message ?? fallback.message), userMessage: blocker.userMessage ?? blocker.zh ?? fallback.userMessage, sourceIssue: blocker.sourceIssue, route: blocker.route, toolName: blocker.toolName })); if (normalized.length > 0) return normalized; return [{ code: fallback.fallbackCode, layer: fallback.layer, category: fallback.layer, retryable: Boolean(fallback.retryable), summary: redactText(fallback.message), userMessage: fallback.userMessage }]; } function safeMissingConfig(error) { return [ ...(Array.isArray(error.missingEnv) ? error.missingEnv : []), ...(Array.isArray(error.missingCommands) ? error.missingCommands.map((command) => `command:${command}`) : []), ...(Array.isArray(error.missingTools) ? error.missingTools.map((tool) => `tool:${tool}`) : []) ].filter((item) => typeof item === "string" && /^[A-Za-z0-9_./:-]+$/u.test(item)); } function sanitizeErrorField(key, value) { if (key === "missingEnv" || key === "missingCommands" || key === "missingTools") { return Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => redactText(item)) : []; } if (key === "providerStatus" || key === "exitCode") return value; return redactText(value); } function routeForError(code, error = {}) { if (error.route !== undefined) return error.route; if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_API_ROUTE; return null; } function toolNameForError(code, error = {}) { if (error.toolName !== undefined) return error.toolName; if (["skill_cli_api_base_missing", "hwlab_api_unavailable", "m3_readiness_blocked"].includes(code)) return HWLAB_M3_IO_SKILL_NAME; return null; } 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 = details.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 resolveConversationSessionIds(params = {}) { const requestedConversationId = cleanProtocolId(params.conversationId, "cnv"); const requestedSessionId = cleanProtocolId(params.sessionId, "ses"); if (requestedConversationId && requestedSessionId) { return { conversationId: requestedConversationId, sessionId: requestedSessionId, requestedSessionId }; } if (requestedConversationId) { return { conversationId: requestedConversationId, sessionId: requestedConversationId, requestedSessionId: null }; } if (requestedSessionId) { const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`; return { conversationId, sessionId: requestedSessionId, requestedSessionId }; } const conversationId = `cnv_${randomUUID()}`; return { conversationId, sessionId: conversationId, requestedSessionId: null }; } 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 redactUrl(value) { try { const url = new URL(value); url.username = ""; url.password = ""; return url.toString(); } catch { return redactText(String(value ?? "")).replace(/\/\/[^/@]+@/u, "//***@"); } } function shellQuote(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; }