Files
pikasTech-HWLAB/internal/cloud/code-agent-chat.mjs
T
2026-05-24 00:25:28 +00:00

4013 lines
143 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_CAPABILITY_LEVEL,
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 = 150000;
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 CODEX_STDIO_LIMITATION_FLAGS = Object.freeze([
"hardware-control-via-cloud-api-only",
"no-direct-gateway-link",
"no-direct-box-simu-link",
"no-direct-patch-panel-link",
"secret-values-redacted"
]);
const OPENAI_FALLBACK_LIMITATION_FLAGS = Object.freeze([
"openai-responses-fallback",
"text-chat-only",
"not-workspace-tools",
"not-session-runner"
]);
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 sessionRegistry = resolveCodeAgentSessionRegistry(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,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
if (runnerIntent.kind === "session_context") {
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,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
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, sessionRegistry });
}
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,
requestJson: options.m3IoSkillRequestJson,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
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,
codexStdioManager: options.codexStdioManager
});
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry });
}
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 ?? [...OPENAI_FALLBACK_LIMITATION_FLAGS],
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.conversationFacts !== undefined) {
payload.conversationFacts = error.conversationFacts;
} else if (typeof sessionRegistry.getConversationFacts === "function") {
payload.conversationFacts = sessionRegistry.getConversationFacts(conversationId);
}
if (payload.conversationFacts && payload.error?.blocker) payload.error.blocker.conversationFacts = payload.conversationFacts;
if (payload.conversationFacts && payload.blocker) payload.blocker.conversationFacts = payload.conversationFacts;
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, sessionRegistry }) {
const completedAt = nowIso(now);
const blocker = structuredCompletionBlocker(runnerResult, {
traceId: base.traceId,
provider: runnerResult.provider,
backend: runnerResult.backend,
runner: runnerResult.runner,
capabilityLevel: runnerResult.capabilityLevel
});
const payload = 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
});
return attachConversationFacts(payload, sessionRegistry, { now });
}
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;
}
function attachConversationFacts(payload, sessionRegistry, { now } = {}) {
if (!sessionRegistry || typeof sessionRegistry.recordFact !== "function") return payload;
const conversationFacts = sessionRegistry.recordFact(payload.conversationId, {
kind: payload.toolCalls?.some((toolCall) => toolCall?.name === "session.context") ? "session_context" : "runner_turn",
conversationId: payload.conversationId,
sessionId: payload.sessionId,
traceId: payload.traceId,
provider: payload.provider,
backend: payload.backend,
workspace: payload.workspace,
sandbox: payload.sandbox,
session: payload.session,
sessionMode: payload.sessionMode,
sessionReuse: payload.sessionReuse,
implementationType: payload.implementationType,
runner: payload.runner,
runnerTrace: payload.runnerTrace,
capabilityLevel: payload.capabilityLevel,
toolCalls: payload.toolCalls,
skills: payload.skills
}, { now });
return {
...payload,
conversationFacts
};
}
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 codexStdioReady = codexStdioLongLivedReady(codexStdio);
const codexStdioActive = providerPlan.mode !== "openai" && codexStdioReady;
const activeRunnerAvailability = codexStdioActive
? codexStdioRunnerAvailability(codexStdio)
: runnerAvailability;
const blocked = providerPlan.mode === "openai"
? !providerContract.ready
: providerPlan.mode === "codex-stdio"
? !codexStdioReady
: 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: codexStdioActive ? CODEX_STDIO_PROVIDER : runnerAvailability.provider,
runnerKind: codexStdioActive ? CODEX_STDIO_RUNNER_KIND : runnerAvailability.kind,
sessionMode: codexStdioActive ? CODEX_STDIO_SESSION_MODE : READONLY_SESSION_MODE,
implementationType: codexStdioActive ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE,
codexStdioFeasibility: codexStdio
});
const agentKind = codexStdioActive
? "codex-stdio-feasible"
: runnerAvailability.ready
? "controlled-readonly-session-registry"
: blocked
? "openai-fallback-blocked"
: "openai-fallback";
const capabilityStatus = codexStdioActive
? "codex-stdio-feasible"
: runnerAvailability.ready
? "controlled-readonly-session-registry"
: blocked
? "blocked"
: "openai-fallback";
const codeAgentReady = codexStdioActive;
const status = codexStdioActive ? "codex-stdio-feasible" : blocked && !runnerAvailability.ready ? "blocked" : "partial";
const capabilityLevel = codexStdioActive
? "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: activeRunnerAvailability,
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,
workspace: activeRunnerAvailability.workspace,
sandbox: activeRunnerAvailability.sandbox,
session: activeRunnerAvailability.sessionRegistry?.recentSessions?.[0] ?? null,
sessionStatus: activeRunnerAvailability.sessionRegistry?.recentSessions?.[0]?.status ?? null,
sessionRegistry: activeRunnerAvailability.sessionRegistry ?? runnerAvailability.sessionRegistry,
sessionMode: activeRunnerAvailability.sessionMode,
implementationType: activeRunnerAvailability.implementationType,
runnerLimitations: activeRunnerAvailability.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 codexStdioLongLivedReady(codexStdio = {}) {
return Boolean(
(codexStdio.ready === true || codexStdio.canStartLongLivedCodexStdio === true) &&
codexStdio.commandProbe?.ready === true
);
}
function codexStdioRunnerAvailability(codexStdio = {}) {
const workspace = codexStdio.workspace ?? repoRoot;
const sandbox = codexStdio.sandbox ?? "workspace-write";
return {
kind: CODEX_STDIO_RUNNER_KIND,
backend: CODEX_STDIO_BACKEND,
provider: CODEX_STDIO_PROVIDER,
workspace,
sandbox,
mode: CODEX_STDIO_SESSION_MODE,
sessionMode: CODEX_STDIO_SESSION_MODE,
session: CODEX_STDIO_SESSION_MODE,
status: "available",
ready: true,
capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
longLivedSession: true,
durableSession: true,
durable: true,
codexStdio: true,
writeCapable: true,
readOnly: false,
runnerLimitations: [...CODEX_STDIO_LIMITATION_FLAGS],
codexStdioFeasibility: codexStdio,
longLivedSessionGate: longLivedSessionGate({
provider: CODEX_STDIO_PROVIDER,
runnerKind: CODEX_STDIO_RUNNER_KIND,
sessionMode: CODEX_STDIO_SESSION_MODE,
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
codexStdioFeasibility: codexStdio
}),
sessionRegistry: codexStdioSessionRegistrySummary(codexStdio),
safety: {
secretsRead: false,
secretValuesPrinted: false,
kubeconfigRead: false,
hardwareControlViaCloudApiOnly: true,
directGatewayCallsAllowed: false,
directBoxSimuCallsAllowed: false,
directPatchPanelCallsAllowed: false,
m3m4m5AcceptanceClaimsAllowed: false
}
};
}
function codexStdioSessionRegistrySummary(codexStdio = {}) {
return {
kind: "codex-stdio-session-registry",
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
status: "available",
sessionCount: codexStdio.sessionLifecycle?.activeSessions ?? codexStdio.lifecycleSupervisor?.activeSessions ?? null,
maxSessions: codexStdio.sessionLifecycle?.maxSessions ?? codexStdio.lifecycleSupervisor?.maxSessions ?? null,
idleTimeoutMs: codexStdio.sessionLifecycle?.idleTimeoutMs ?? codexStdio.lifecycleSupervisor?.idleTimeoutMs ?? null,
statuses: ["creating", "ready", "busy", "idle", "interrupted", "expired", "failed"],
sessionMode: CODEX_STDIO_SESSION_MODE,
recentSessions: Array.isArray(codexStdio.recentSessions) ? codexStdio.recentSessions : [],
longLivedCodexStdio: true,
longLivedSession: true,
durable: true,
codexStdio: true,
secretMaterialStored: false,
valuesRedacted: true
};
}
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/catM3 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 fallbackpwd/skills 走受控只读 runnerM3 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 conversationFacts = registry.getConversationFacts(conversationId);
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,
conversationFacts,
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)
});
}
if (intent.kind === "session_context") {
const conversationFacts = registry.getConversationFacts(conversationId);
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
const contextToolCall = sessionContextToolCall({ conversationFacts, workspace: resolvedWorkspace, traceId });
return readOnlyRunnerResult({
content: sessionContextReply({ conversationFacts, session }),
workspace: resolvedWorkspace,
toolCalls: [contextToolCall],
skills: conversationFacts.latestSkills
? {
status: conversationFacts.latestSkills.status ?? "ready",
items: conversationFacts.latestSkills.names.map((name) => ({ name })),
count: conversationFacts.latestSkills.count ?? conversationFacts.latestSkills.names.length,
totalCount: conversationFacts.latestSkills.totalCount ?? conversationFacts.latestSkills.names.length,
blockers: []
}
: notRequestedSkills(),
runner,
runnerTrace: runnerTrace({
traceId,
workspace: resolvedWorkspace,
session,
events: [...events, "tool:session.context:completed"],
startedAt,
outputTruncated: false
}),
session,
codexStdioFeasibility,
outputTruncated: false,
conversationFacts
});
}
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("<hwlab-api-base-url>");
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 (isSessionContextRequest(text)) {
return { kind: "session_context", toolName: "session.context" };
}
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 isSessionContextRequest(text) {
const value = String(text ?? "");
const referencesEarlierTurns = /(?:前两轮|前2轮|上一轮|上两轮|刚才|前面|之前|previous|earlier|context|上下文|根据.*(?:结果|内容|事实)|基于.*(?:结果|内容|事实))/iu.test(value);
const asksForFacts = /(?:工作目录|当前目录|workspace|路径|skills?|skill|技能|能使用|可用|session|conversation|会话|trace|runner)/iu.test(value);
return referencesEarlierTurns && asksForFacts;
}
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, conversationFacts }) {
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,
...(conversationFacts ? { conversationFacts } : {}),
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 sessionContextToolCall({ conversationFacts, workspace, traceId }) {
const bounded = boundToolOutput(JSON.stringify({
conversationId: conversationFacts.conversationId,
sessionId: conversationFacts.sessionId,
turnCount: conversationFacts.turnCount,
workspace: conversationFacts.workspace,
skills: {
status: conversationFacts.latestSkills?.status ?? "not_requested",
count: conversationFacts.latestSkills?.totalCount ?? conversationFacts.latestSkills?.count ?? 0,
names: conversationFacts.latestSkills?.names?.slice(0, 8) ?? []
},
latestTraceId: conversationFacts.latestTraceId,
recentToolCalls: conversationFacts.recentToolCalls?.slice(-6) ?? [],
valuesRedacted: true
}));
return {
id: `tool_${randomUUID()}`,
type: "session-facts",
name: "session.context",
status: "completed",
cwd: workspace,
command: "session.context",
exitCode: 0,
stdout: bounded.text,
stderrSummary: "",
outputTruncated: bounded.truncated,
traceId
};
}
function sessionContextReply({ conversationFacts, session }) {
const skills = conversationFacts.latestSkills;
const skillNames = skills?.names?.length ? skills.names.join(", ") : "上一轮没有可用 skill 清单";
const skillCount = skills ? (skills.totalCount ?? skills.count ?? skills.names?.length ?? 0) : 0;
const toolSummary = conversationFacts.recentToolCalls?.length
? conversationFacts.recentToolCalls.map((toolCall) => `${toolCall.name ?? "tool"}:${toolCall.status ?? "unknown"}`).join(", ")
: "none";
const traceSummary = conversationFacts.traceIds?.length ? conversationFacts.traceIds.join(", ") : "none";
return [
"根据同一个 conversation/session 中已记录的 runner facts",
`- conversationId=${conversationFacts.conversationId}; sessionId=${conversationFacts.sessionId}; sessionStatus=${conversationFacts.sessionStatus ?? session.status}; sessionMode=${conversationFacts.sessionMode ?? session.sessionMode}; turnCount=${conversationFacts.turnCount}.`,
`- 当前工作目录/workspace=${conversationFacts.workspace ?? session.workspace ?? "unknown"}.`,
`- 可用 skills=${skillCount}; ${skillNames}.`,
`- 最近 toolCalls=${toolSummary}; traceIds=${traceSummary}.`,
"证明:本轮走 session.context 的 Code Agent session fact 路径,runner=hwlab-readonly-runnercapabilityLevel=read-only-session-tools;不是 openai-responses-fallback,也不是 stateless-one-shot。",
limitationReplyLine()
].join("\n");
}
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: 已通过 Code Agent -> Skill CLI -> HWLAB API ${skillResult.route} 处理请求;status=${skillResult.status}; accepted=${skillResult.accepted}; traceId=${skillResult.traceId}; operationId=${skillResult.operationId ?? "null"}。`
];
if (skillResult.audit?.summary) {
lines.push(`Audit: ${skillResult.audit.summary}audit 摘要)。`);
}
if (skillResult.evidence?.summary) {
lines.push(`Evidence: ${skillResult.evidence.summary}evidence 摘要)。`);
}
if (skillResult.blocker) {
lines.push(`Blocker: ${skillResult.blocker.code}; ${skillResult.blocker.zh ?? skillResult.blocker.message}blocker)。`);
}
if (skillResult.action === "do.write") {
lines.push(`DO1 写入请求:res_boxsimu_1:DO1=${String(skillResult.command?.value)}DI1 readbackres_boxsimu_2:DI1=${String(skillResult.result?.targetReadback?.value ?? "unknown")}。`);
} else if (skillResult.action === "di.read") {
lines.push(`DI1 读取结果:res_boxsimu_2:DI1=${String(skillResult.result?.value ?? "unknown")}。`);
} else if (skillResult.readonly === true || skillResult.route === HWLAB_M3_STATUS_API_ROUTE) {
lines.push(`M3 status readbackres_boxsimu_2:DI1=${String(skillResult.readback?.value ?? "unknown")}readiness=${skillResult.readiness?.status ?? "unknown"}。`);
}
lines.push(`Boundary/边界:本次只走 Code Agent -> Skill CLI -> HWLAB API ${skillResult.route};没有直连 gateway/box/patch-panel,也没有使用 OpenAI fallback 冒充 toolCall。这是受控操作结果,不是 M3 DEV-LIVE 验收结论。`);
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-registrylong-lived read-only sessionnot-codex-stdionot-write-capableprocess-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: [...OPENAI_FALLBACK_LIMITATION_FLAGS],
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 未通过 readinessOpenAI Responses fallback 保留但默认不自动调用。请查看 blocker 后补齐运行底座。",
usage: null,
capabilityLevel: "text-chat-only",
implementationType: OPENAI_FALLBACK_RUNNER_KIND,
runnerLimitations: [...OPENAI_FALLBACK_LIMITATION_FLAGS],
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("'", "'\\''")}'`;
}