034c90763c
Align Code Agent chat, live health, and workbench UI session facts for Codex stdio vs fallback paths.
3864 lines
136 KiB
JavaScript
3864 lines
136 KiB
JavaScript
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 = 120000;
|
||
const DEFAULT_CODEX_COMMAND = "codex";
|
||
const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model;
|
||
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
|
||
const READONLY_RUNNER_PROVIDER = "codex-readonly-runner";
|
||
const READONLY_RUNNER_BACKEND = "hwlab-cloud-api/codex-readonly-runner";
|
||
const READONLY_RUNNER_MODEL = "read-only-tools";
|
||
const READONLY_RUNNER_KIND = "hwlab-readonly-runner";
|
||
const READONLY_RUNNER_SANDBOX = "read-only";
|
||
const READONLY_SESSION_MODE = "controlled-readonly-session-registry";
|
||
const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry";
|
||
const READONLY_SESSION_CAPABILITY_LEVEL = "read-only-session-tools";
|
||
const M3_IO_SKILL_PROVIDER = "hwlab-skill-cli";
|
||
const M3_IO_SKILL_BACKEND = "hwlab-cloud-api/hwlab-agent-runtime-skill-cli";
|
||
const M3_IO_SKILL_MODEL = "controlled-m3-io";
|
||
const M3_IO_SKILL_RUNNER_KIND = "hwlab-m3-io-skill-cli";
|
||
const M3_IO_SKILL_SANDBOX = "hwlab-api-route-only";
|
||
const M3_IO_SKILL_SESSION_MODE = "controlled-m3-io-skill-cli";
|
||
const M3_IO_SKILL_IMPLEMENTATION_TYPE = "skill-cli-hwlab-api-adapter";
|
||
const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([
|
||
"m3-io-only",
|
||
"hwlab-api-route-only",
|
||
"not-generic-hardware-control",
|
||
"not-durable-session"
|
||
]);
|
||
const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback";
|
||
const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral";
|
||
const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu;
|
||
const READONLY_TOOL_OUTPUT_LIMIT = 4000;
|
||
const READONLY_FILE_READ_LIMIT = 64 * 1024;
|
||
const READONLY_FILE_ENTRY_LIMIT = 200;
|
||
const READONLY_FILE_TREE_DEPTH = 8;
|
||
const MAX_READONLY_SESSIONS = 200;
|
||
const MAX_SKILLS_RETURNED = 40;
|
||
const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", "");
|
||
const CODEX_STDIO_SOURCE_ISSUE = "pikasTech/HWLAB#275";
|
||
const READONLY_LIMITATION_FLAGS = Object.freeze([
|
||
"not-codex-stdio",
|
||
"not-write-capable",
|
||
"process-local-session-registry"
|
||
]);
|
||
const 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 base = {
|
||
conversationId,
|
||
sessionId,
|
||
messageId,
|
||
status: "running",
|
||
createdAt: timestamp,
|
||
updatedAt: timestamp,
|
||
traceId,
|
||
provider: providerPlan.provider,
|
||
model: providerPlan.model,
|
||
backend: providerPlan.backend,
|
||
projectId: cleanProjectId(params.projectId) || DEFAULT_PROJECT_ID
|
||
};
|
||
|
||
try {
|
||
const message = normalizeUserMessage(params.message);
|
||
const runnerIntent = detectReadOnlyRunnerIntent(message);
|
||
|
||
const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null;
|
||
if (securityIntent) {
|
||
const runnerResult = await callReadOnlyRunner({
|
||
intent: securityIntent,
|
||
conversationId,
|
||
traceId,
|
||
sessionId: requestedSessionId,
|
||
env: options.env ?? process.env,
|
||
now: options.now,
|
||
workspace: options.workspace,
|
||
skillsDirs: options.skillsDirs,
|
||
skillsDirsExact: options.skillsDirsExact,
|
||
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
||
sessionRegistry: options.sessionRegistry,
|
||
codexStdioManager: options.codexStdioManager
|
||
});
|
||
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
|
||
}
|
||
|
||
const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options);
|
||
const shouldUseCodexStdio =
|
||
providerPlan.mode === "codex-stdio" ||
|
||
(providerPlan.mode !== "openai" && codexStdioAvailability.canStartLongLivedCodexStdio === true);
|
||
if (shouldUseCodexStdio) {
|
||
const stdioResult = runnerIntent.kind === "m3_io"
|
||
? await callCodexStdioWithM3IoSkillRunner({
|
||
message,
|
||
intent: runnerIntent,
|
||
conversationId,
|
||
sessionId: requestedSessionId,
|
||
traceId,
|
||
env: options.env ?? process.env,
|
||
now: options.now,
|
||
workspace: options.workspace,
|
||
timeoutMs: options.timeoutMs,
|
||
model: providerPlan.model,
|
||
codexStdioManager: options.codexStdioManager,
|
||
requestJson: options.m3IoSkillRequestJson
|
||
})
|
||
: await callCodexStdioRunner({
|
||
message,
|
||
conversationId,
|
||
sessionId: requestedSessionId,
|
||
traceId,
|
||
env: options.env ?? process.env,
|
||
now: options.now,
|
||
workspace: options.workspace,
|
||
timeoutMs: options.timeoutMs,
|
||
model: providerPlan.model,
|
||
codexStdioManager: options.codexStdioManager
|
||
});
|
||
return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now });
|
||
}
|
||
|
||
if (runnerIntent.kind === "m3_io") {
|
||
const runnerResult = await callM3IoSkillRunner({
|
||
intent: runnerIntent,
|
||
conversationId,
|
||
traceId,
|
||
sessionId: requestedSessionId,
|
||
env: options.env ?? process.env,
|
||
now: options.now,
|
||
workspace: options.workspace,
|
||
sessionRegistry: options.sessionRegistry,
|
||
requestJson: options.m3IoSkillRequestJson,
|
||
codexStdioManager: options.codexStdioManager
|
||
});
|
||
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
|
||
}
|
||
|
||
if (runnerIntent.kind !== "none") {
|
||
const runnerResult = await callReadOnlyRunner({
|
||
intent: runnerIntent,
|
||
conversationId,
|
||
traceId,
|
||
sessionId: requestedSessionId,
|
||
env: options.env ?? process.env,
|
||
now: options.now,
|
||
workspace: options.workspace,
|
||
skillsDirs: options.skillsDirs,
|
||
skillsDirsExact: options.skillsDirsExact,
|
||
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
||
sessionRegistry: options.sessionRegistry,
|
||
codexStdioManager: options.codexStdioManager
|
||
});
|
||
return completedRunnerPayload({ base, runnerResult, messageId, now: options.now });
|
||
}
|
||
|
||
const providerResult = await callConfiguredProvider({
|
||
providerPlan,
|
||
message,
|
||
conversationId,
|
||
traceId,
|
||
timeoutMs: options.timeoutMs,
|
||
env: options.env ?? process.env,
|
||
now: options.now,
|
||
callProvider: options.callProvider
|
||
});
|
||
const content = typeof providerResult.content === "string" ? providerResult.content.trim() : "";
|
||
if (!content) {
|
||
throw providerUnavailable("Code Agent provider returned no assistant text", {
|
||
provider: providerResult.provider ?? base.provider,
|
||
model: providerResult.model ?? base.model,
|
||
backend: providerResult.backend ?? base.backend
|
||
});
|
||
}
|
||
const completedAt = nowIso(options.now);
|
||
const providerRunner = providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId });
|
||
const providerLongLivedGate = providerResult.longLivedSessionGate ?? longLivedSessionGate({
|
||
provider: providerResult.provider ?? base.provider,
|
||
runnerKind: providerRunner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND,
|
||
session: providerResult.session ?? null,
|
||
sessionMode: providerResult.sessionMode ?? "provider-text-request",
|
||
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
|
||
codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options)
|
||
});
|
||
const providerCompletionBlocker = structuredCompletionBlocker({
|
||
provider: providerResult.provider ?? base.provider,
|
||
backend: providerResult.backend ?? base.backend,
|
||
runner: providerRunner,
|
||
capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only",
|
||
longLivedSessionGate: providerLongLivedGate
|
||
}, {
|
||
traceId,
|
||
provider: providerResult.provider ?? base.provider,
|
||
backend: providerResult.backend ?? base.backend,
|
||
runner: providerRunner,
|
||
capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only"
|
||
});
|
||
return finalizeCodeAgentChatPayload({
|
||
...base,
|
||
status: "completed",
|
||
updatedAt: completedAt,
|
||
provider: providerResult.provider ?? base.provider,
|
||
model: providerResult.model ?? base.model,
|
||
backend: providerResult.backend ?? base.backend,
|
||
workspace: providerResult.workspace ?? null,
|
||
sandbox: providerResult.sandbox ?? "none",
|
||
session: providerResult.session ?? null,
|
||
sessionMode: providerResult.sessionMode ?? "provider-text-request",
|
||
sessionReuse: providerResult.sessionReuse ?? null,
|
||
implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND,
|
||
runnerLimitations: providerResult.runnerLimitations ?? [...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.availability !== undefined) {
|
||
payload.availability = error.availability;
|
||
} else if (["provider_unavailable", "provider_timeout", "codex_cli_binary_missing"].includes(error.code)) {
|
||
payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
||
} else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked", "codex_stdio_blocked", "codex_stdio_failed", "codex_stdio_protocol_blocked", "codex_stdio_empty_response", "codex_stdio_command_probe_failed"].includes(error.code)) {
|
||
payload.availability = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
||
}
|
||
return payload;
|
||
}
|
||
}
|
||
|
||
function completedRunnerPayload({ base, runnerResult, messageId, now }) {
|
||
const completedAt = nowIso(now);
|
||
const blocker = structuredCompletionBlocker(runnerResult, {
|
||
traceId: base.traceId,
|
||
provider: runnerResult.provider,
|
||
backend: runnerResult.backend,
|
||
runner: runnerResult.runner,
|
||
capabilityLevel: runnerResult.capabilityLevel
|
||
});
|
||
return finalizeCodeAgentChatPayload({
|
||
...base,
|
||
sessionId: runnerResult.session?.sessionId ?? base.sessionId,
|
||
status: "completed",
|
||
updatedAt: completedAt,
|
||
provider: runnerResult.provider,
|
||
model: runnerResult.model,
|
||
backend: runnerResult.backend,
|
||
workspace: runnerResult.workspace,
|
||
sandbox: runnerResult.sandbox,
|
||
session: runnerResult.session,
|
||
sessionMode: runnerResult.sessionMode,
|
||
sessionReuse: runnerResult.sessionReuse,
|
||
implementationType: runnerResult.implementationType,
|
||
runnerLimitations: runnerResult.runnerLimitations,
|
||
codexStdioFeasibility: runnerResult.codexStdioFeasibility,
|
||
longLivedSessionGate: runnerResult.longLivedSessionGate,
|
||
toolCalls: runnerResult.toolCalls,
|
||
skills: runnerResult.skills,
|
||
runner: runnerResult.runner,
|
||
runnerTrace: runnerResult.runnerTrace,
|
||
capabilityLevel: runnerResult.capabilityLevel,
|
||
reply: {
|
||
messageId,
|
||
role: "assistant",
|
||
content: runnerResult.content,
|
||
createdAt: completedAt
|
||
},
|
||
...(blocker ? { blocker, blockers: [blocker, ...(runnerResult.blockers ?? [])].filter(Boolean) } : {}),
|
||
usage: null,
|
||
providerTrace: runnerResult.providerTrace
|
||
});
|
||
}
|
||
|
||
export function validateCodeAgentChatSchema(payload) {
|
||
const required = [
|
||
"conversationId",
|
||
"sessionId",
|
||
"messageId",
|
||
"status",
|
||
"createdAt",
|
||
"updatedAt",
|
||
"traceId",
|
||
"provider",
|
||
"model",
|
||
"backend"
|
||
];
|
||
for (const field of required) {
|
||
if (!payload || typeof payload !== "object" || !Object.hasOwn(payload, field)) {
|
||
throw new Error(`code agent chat response missing ${field}`);
|
||
}
|
||
}
|
||
if (!["running", "completed", "failed"].includes(payload.status)) {
|
||
throw new Error(`code agent chat response has invalid status ${JSON.stringify(payload.status)}`);
|
||
}
|
||
if (Number.isNaN(Date.parse(payload.createdAt)) || Number.isNaN(Date.parse(payload.updatedAt))) {
|
||
throw new Error("code agent chat response timestamps must be RFC 3339 strings");
|
||
}
|
||
if (payload.status === "failed" && typeof payload.error?.message !== "string") {
|
||
throw new Error("failed code agent chat response must include error.message");
|
||
}
|
||
if (payload.status === "completed" && (typeof payload.reply?.content !== "string" || !payload.reply.content.trim())) {
|
||
throw new Error("completed code agent chat response must include non-empty reply.content");
|
||
}
|
||
validateCodeAgentToolCallContract(payload);
|
||
}
|
||
|
||
function validateCodeAgentToolCallContract(payload) {
|
||
if (!Array.isArray(payload.toolCalls)) return;
|
||
for (const [index, toolCall] of payload.toolCalls.entries()) {
|
||
if (!toolCall || typeof toolCall !== "object") {
|
||
throw new Error(`code agent toolCalls[${index}] must be an object`);
|
||
}
|
||
const directTarget = findDirectHardwareTarget(toolCall);
|
||
if (directTarget) {
|
||
throw new Error(`code agent toolCalls[${index}] must not include direct gateway/box/patch-panel target ${directTarget}`);
|
||
}
|
||
if (!isM3IoToolCall(toolCall, payload)) continue;
|
||
const structuredBlocked = toolCall.status === "blocked" && Boolean(toolCall.blocker?.code || toolCall.capabilityBlocker?.code || toolCall.error?.code);
|
||
if (!m3ToolCallRouteAllowed(toolCall.route) && !structuredBlocked) {
|
||
throw new Error(`code agent M3 toolCalls[${index}] must show route ${HWLAB_M3_IO_API_ROUTE}/${HWLAB_M3_STATUS_API_ROUTE} or structured blocked`);
|
||
}
|
||
if (toolCall.route === HWLAB_M3_IO_API_ROUTE && (toolCall.method ?? "POST") !== "POST") {
|
||
throw new Error(`code agent M3 toolCalls[${index}] must use POST ${HWLAB_M3_IO_API_ROUTE}`);
|
||
}
|
||
if (toolCall.route === HWLAB_M3_STATUS_API_ROUTE && (toolCall.method ?? "GET") !== "GET") {
|
||
throw new Error(`code agent M3 toolCalls[${index}] must use GET ${HWLAB_M3_STATUS_API_ROUTE}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function isM3IoToolCall(toolCall, payload) {
|
||
return toolCall.name === HWLAB_M3_IO_SKILL_NAME ||
|
||
toolCall.route === HWLAB_M3_IO_API_ROUTE ||
|
||
toolCall.route === HWLAB_M3_STATUS_API_ROUTE ||
|
||
payload.provider === M3_IO_SKILL_PROVIDER ||
|
||
payload.runner?.kind === M3_IO_SKILL_RUNNER_KIND ||
|
||
payload.runnerTrace?.runnerKind === M3_IO_SKILL_RUNNER_KIND;
|
||
}
|
||
|
||
function m3ToolCallRouteAllowed(route) {
|
||
return route === HWLAB_M3_IO_API_ROUTE || route === HWLAB_M3_STATUS_API_ROUTE;
|
||
}
|
||
|
||
function findDirectHardwareTarget(value, seen = new Set()) {
|
||
if (typeof value === "string") {
|
||
const match = value.match(DIRECT_HARDWARE_TARGET_PATTERN);
|
||
return match?.[0] ?? null;
|
||
}
|
||
if (!value || typeof value !== "object") return null;
|
||
if (seen.has(value)) return null;
|
||
seen.add(value);
|
||
for (const child of Object.values(value)) {
|
||
const match = findDirectHardwareTarget(child, seen);
|
||
if (match) return match;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function finalizeCodeAgentChatPayload(payload) {
|
||
validateCodeAgentChatSchema(payload);
|
||
return payload;
|
||
}
|
||
|
||
export async function describeCodeAgentAvailability(env = process.env, options = {}) {
|
||
const providerPlan = resolveProviderPlan(env, options);
|
||
const providerContract = inspectCodeAgentProviderEnv(env);
|
||
const missingEnv = providerPlan.mode === "openai"
|
||
? providerContract.missingEnv
|
||
: [];
|
||
|
||
if (providerPlan.mode === "codex-cli" && !env.OPENAI_API_KEY) {
|
||
missingEnv.push("OPENAI_API_KEY");
|
||
}
|
||
const sessionRegistry = resolveCodeAgentSessionRegistry(options);
|
||
const runnerAvailability = await inspectReadOnlyRunnerAvailability(env, {
|
||
...options,
|
||
sessionRegistry
|
||
});
|
||
const codexStdio = await inspectCodexStdioFeasibility(env, options);
|
||
const 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/cat,M3 IO 只允许 Skill CLI 调用 ${HWLAB_M3_IO_API_ROUTE};OpenAI fallback 受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 egress/base-url contract 影响;long-lived Codex stdio 仍 blocked。`
|
||
: `普通聊天可走 OpenAI Responses fallback,pwd/skills 走受控只读 runner,M3 IO 走 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE};这些能力都不能冒充完整 long-lived Codex stdio Code Agent。`;
|
||
}
|
||
return blocked
|
||
? "OpenAI fallback 和 Codex stdio long-lived session 都 blocked;不返回伪完成。"
|
||
: "Only text-chat fallback is available; it does not satisfy the Codex stdio session gate.";
|
||
}
|
||
|
||
function resolveProviderPlan(env, options = {}) {
|
||
const provider = String(env.HWLAB_CODE_AGENT_PROVIDER || "auto").trim().toLowerCase();
|
||
const model = firstNonEmpty(
|
||
options.model,
|
||
env.HWLAB_CODE_AGENT_MODEL,
|
||
env.OPENAI_MODEL,
|
||
env.CODE_QUEUE_DEFAULT_MODEL,
|
||
DEFAULT_MODEL
|
||
);
|
||
|
||
if (provider === "openai") {
|
||
return {
|
||
provider: "openai-responses",
|
||
model,
|
||
backend: "hwlab-cloud-api/openai-responses",
|
||
mode: "openai"
|
||
};
|
||
}
|
||
if (provider === "codex-cli" || provider === "codex") {
|
||
return {
|
||
provider: "codex-cli",
|
||
model,
|
||
backend: "hwlab-cloud-api/codex-cli",
|
||
mode: "codex-cli"
|
||
};
|
||
}
|
||
if (provider === "codex-stdio" || provider === "codex-mcp-stdio") {
|
||
return {
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
model,
|
||
backend: CODEX_STDIO_BACKEND,
|
||
mode: "codex-stdio"
|
||
};
|
||
}
|
||
return {
|
||
provider: env.OPENAI_API_KEY ? "openai-responses" : "codex-cli",
|
||
model,
|
||
backend: env.OPENAI_API_KEY ? "hwlab-cloud-api/openai-responses" : "hwlab-cloud-api/codex-cli",
|
||
mode: env.OPENAI_API_KEY ? "openai" : "codex-cli",
|
||
auto: true
|
||
};
|
||
}
|
||
|
||
async function callConfiguredProvider({
|
||
providerPlan,
|
||
message,
|
||
conversationId,
|
||
traceId,
|
||
timeoutMs,
|
||
env,
|
||
now,
|
||
callProvider
|
||
}) {
|
||
if (callProvider) {
|
||
return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now });
|
||
}
|
||
if (providerPlan.mode === "openai") {
|
||
return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env });
|
||
}
|
||
return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env });
|
||
}
|
||
|
||
async function inspectReadOnlyRunnerAvailability(env, options = {}) {
|
||
const workspace = resolveRunnerWorkspace(env, options);
|
||
const workspaceReady = Boolean(workspace && existsSync(workspace));
|
||
const skillsDirs = resolveSkillDirs(env, options);
|
||
const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir));
|
||
const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, options);
|
||
const sessionRegistry = resolveCodeAgentSessionRegistry(options).describe();
|
||
const m3IoApiBaseUrl = configuredCloudApiBaseUrl(env);
|
||
return {
|
||
kind: READONLY_RUNNER_KIND,
|
||
backend: READONLY_RUNNER_BACKEND,
|
||
provider: READONLY_RUNNER_PROVIDER,
|
||
workspace,
|
||
sandbox: READONLY_RUNNER_SANDBOX,
|
||
mode: READONLY_SESSION_MODE,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
session: READONLY_SESSION_MODE,
|
||
status: workspaceReady ? "available" : "blocked",
|
||
ready: workspaceReady,
|
||
capabilityLevel: workspaceReady ? READONLY_SESSION_CAPABILITY_LEVEL : "blocked",
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
longLivedSession: true,
|
||
durableSession: false,
|
||
codexStdio: false,
|
||
writeCapable: false,
|
||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||
codexStdioFeasibility,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: READONLY_RUNNER_PROVIDER,
|
||
runnerKind: READONLY_RUNNER_KIND,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility
|
||
}),
|
||
sessionRegistry,
|
||
skillsDirs,
|
||
skillsDirsPresent,
|
||
m3IoSkill: {
|
||
status: m3IoApiBaseUrl ? "available" : "blocked",
|
||
service: HWLAB_M3_IO_SKILL_NAME,
|
||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||
route: HWLAB_M3_IO_API_ROUTE,
|
||
statusRoute: HWLAB_M3_STATUS_API_ROUTE,
|
||
capabilityLevel: m3IoApiBaseUrl
|
||
? HWLAB_M3_IO_CAPABILITY_LEVELS.ready
|
||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
blockedCapabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||
hwlabApi: {
|
||
source: m3IoApiBaseUrl ? m3IoSkillApiBaseUrlSource(env) : "missing-config",
|
||
baseUrlConfigured: Boolean(m3IoApiBaseUrl),
|
||
redactedBaseUrl: m3IoApiBaseUrl ? redactUrl(m3IoApiBaseUrl) : null,
|
||
recommendedEnv: HWLAB_M3_IO_API_BASE_URL_ENV,
|
||
requiredEnv: [...HWLAB_M3_IO_API_BASE_URL_ENVS]
|
||
},
|
||
blocker: m3IoApiBaseUrl ? null : m3IoApiBaseUrlMissingBlocker(),
|
||
allowedRoutes: [`POST ${HWLAB_M3_IO_API_ROUTE}`, `GET ${HWLAB_M3_STATUS_API_ROUTE}`],
|
||
directGatewayCallsAllowed: false,
|
||
directBoxCallsAllowed: false,
|
||
directPatchPanelCallsAllowed: false,
|
||
fallbackAllowed: false
|
||
},
|
||
safety: runnerSafetyContract()
|
||
};
|
||
}
|
||
|
||
async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager }) {
|
||
const manager = resolveCodexStdioSessionManager({ codexStdioManager });
|
||
try {
|
||
return await manager.chat({
|
||
message,
|
||
conversationId,
|
||
sessionId,
|
||
traceId,
|
||
env,
|
||
now,
|
||
workspace,
|
||
timeoutMs,
|
||
model
|
||
});
|
||
} catch (error) {
|
||
const availability = error.availability ?? manager.describe({ env, workspace });
|
||
const session = error.session ?? null;
|
||
const trace = error.runnerTrace ?? {
|
||
traceId,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
workspace: workspace ?? availability.workspace ?? repoRoot,
|
||
sandbox: availability.sandbox ?? "workspace-write",
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
events: [`blocked:${error.code ?? "codex_stdio_failed"}`],
|
||
valuesPrinted: false
|
||
};
|
||
throw runnerError(error.code ?? "codex_stdio_failed", error.message, {
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
model,
|
||
backend: CODEX_STDIO_BACKEND,
|
||
workspace: workspace ?? availability.workspace ?? null,
|
||
sandbox: availability.sandbox ?? "workspace-write",
|
||
session,
|
||
toolCalls: Array.isArray(error.toolCalls) ? error.toolCalls : [],
|
||
skills: error.skills ?? notRequestedSkills(),
|
||
runner: {
|
||
kind: CODEX_STDIO_RUNNER_KIND,
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
backend: CODEX_STDIO_BACKEND,
|
||
workspace: workspace ?? availability.workspace ?? repoRoot,
|
||
sandbox: availability.sandbox ?? "workspace-write",
|
||
session: CODEX_STDIO_SESSION_MODE,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
codexStdio: true,
|
||
longLivedSession: true,
|
||
durableSession: true,
|
||
writeCapable: true,
|
||
readOnly: false,
|
||
capabilityLevel: "blocked"
|
||
},
|
||
runnerTrace: trace,
|
||
capabilityLevel: "blocked",
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
sessionReuse: session ? sessionReuseEvidence(session) : null,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: ["codex-stdio-blocked"],
|
||
codexStdioFeasibility: availability,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: CODEX_STDIO_PROVIDER,
|
||
runnerKind: CODEX_STDIO_RUNNER_KIND,
|
||
session,
|
||
sessionMode: CODEX_STDIO_SESSION_MODE,
|
||
implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility: availability
|
||
}),
|
||
blockers: error.blockers ?? availability.blockers,
|
||
route: error.route ?? null,
|
||
toolName: error.toolName ?? (error.missingTools?.length ? "codex-stdio.required-tools" : "codex-stdio.session"),
|
||
missingTools: error.missingTools,
|
||
availability: await describeCodeAgentAvailability(env, { codexStdioManager: manager, workspace })
|
||
});
|
||
}
|
||
}
|
||
|
||
async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, skillsDiscoveryDelayMs, sessionRegistry, codexStdioManager }) {
|
||
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
|
||
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
|
||
const sessionAcquire = registry.acquire({
|
||
conversationId,
|
||
sessionId,
|
||
workspace: resolvedWorkspace,
|
||
sandbox: READONLY_RUNNER_SANDBOX,
|
||
runnerKind: READONLY_RUNNER_KIND,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
traceId,
|
||
now
|
||
});
|
||
if (!sessionAcquire.ok) {
|
||
const blockedSession = sessionAcquire.session;
|
||
const blockedCodexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace });
|
||
const blockedTrace = runnerTrace({
|
||
traceId,
|
||
workspace: resolvedWorkspace ?? repoRoot,
|
||
session: blockedSession,
|
||
events: [`blocked:${sessionAcquire.code}`],
|
||
startedAt: nowIso(now),
|
||
outputTruncated: false
|
||
});
|
||
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
|
||
workspace: resolvedWorkspace ?? null,
|
||
session: blockedSession,
|
||
toolCalls: [],
|
||
skills: notRequestedSkills(),
|
||
runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
|
||
runnerTrace: blockedTrace,
|
||
capabilityLevel: "blocked",
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(blockedSession),
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||
codexStdioFeasibility: blockedCodexStdioFeasibility,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: READONLY_RUNNER_PROVIDER,
|
||
runnerKind: READONLY_RUNNER_KIND,
|
||
session: blockedSession,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility: blockedCodexStdioFeasibility
|
||
}),
|
||
blockers: [sessionAcquire.blocker],
|
||
route: null,
|
||
toolName: intent.toolName ?? null
|
||
});
|
||
}
|
||
let session = sessionAcquire.session;
|
||
const runner = runnerDescriptor({ workspace: resolvedWorkspace, session });
|
||
const startedAt = nowIso(now);
|
||
const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace });
|
||
const events = [
|
||
`intent:${intent.kind}`,
|
||
"sandbox:read-only",
|
||
`sessionMode:${READONLY_SESSION_MODE}`,
|
||
session.reused ? "session:reused" : "session:created",
|
||
`turn:${session.turn}`
|
||
];
|
||
const baseEvidence = {
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(session),
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||
codexStdioFeasibility,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: READONLY_RUNNER_PROVIDER,
|
||
runnerKind: READONLY_RUNNER_KIND,
|
||
session,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility
|
||
})
|
||
};
|
||
|
||
if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) {
|
||
session = registry.fail(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
statusReason: "workspace_unreadable"
|
||
}) ?? session;
|
||
throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, {
|
||
workspace: resolvedWorkspace ?? null,
|
||
session,
|
||
toolCalls: [],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }),
|
||
capabilityLevel: "blocked",
|
||
route: null,
|
||
toolName: intent.toolName ?? null,
|
||
...baseEvidence
|
||
});
|
||
}
|
||
|
||
if (intent.kind === "security") {
|
||
session = registry.fail(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
statusReason: "security_blocked"
|
||
}) ?? session;
|
||
throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", {
|
||
workspace: resolvedWorkspace,
|
||
session,
|
||
toolCalls: [{
|
||
id: `tool_${randomUUID()}`,
|
||
type: "security",
|
||
name: intent.toolName ?? "security.guard",
|
||
status: "blocked",
|
||
cwd: resolvedWorkspace,
|
||
exitCode: 1,
|
||
stdout: "",
|
||
stderrSummary: "security_blocked",
|
||
outputTruncated: false
|
||
}],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }),
|
||
capabilityLevel: "blocked",
|
||
route: null,
|
||
toolName: intent.toolName ?? "security.guard",
|
||
...baseEvidence,
|
||
blockers: [{
|
||
code: "security_blocked",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: intent.reason ?? "Read-only runner guardrail blocked the request."
|
||
}]
|
||
});
|
||
}
|
||
|
||
if (intent.kind === "pwd") {
|
||
const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env });
|
||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||
return readOnlyRunnerResult({
|
||
content: [
|
||
"当前受控只读 runner 工作目录:",
|
||
toolCall.stdout,
|
||
"",
|
||
sessionReplyLine(session),
|
||
limitationReplyLine()
|
||
].join("\n"),
|
||
workspace: resolvedWorkspace,
|
||
toolCalls: [toolCall],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:pwd:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
|
||
session,
|
||
codexStdioFeasibility,
|
||
outputTruncated: toolCall.outputTruncated
|
||
});
|
||
}
|
||
|
||
if (intent.kind === "ls") {
|
||
const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
|
||
assertReadOnlyToolCompleted(toolCall, {
|
||
session,
|
||
workspace: resolvedWorkspace,
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }),
|
||
baseEvidence
|
||
});
|
||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||
return readOnlyRunnerResult({
|
||
content: [
|
||
"受控只读 ls 结果:",
|
||
toolCall.stdout || "(empty)",
|
||
"",
|
||
sessionReplyLine(session),
|
||
limitationReplyLine()
|
||
].join("\n"),
|
||
workspace: resolvedWorkspace,
|
||
toolCalls: [toolCall],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
|
||
session,
|
||
codexStdioFeasibility,
|
||
outputTruncated: toolCall.outputTruncated
|
||
});
|
||
}
|
||
|
||
if (intent.kind === "rg_files") {
|
||
const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env });
|
||
assertReadOnlyToolCompleted(toolCall, {
|
||
session,
|
||
workspace: resolvedWorkspace,
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }),
|
||
baseEvidence
|
||
});
|
||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||
return readOnlyRunnerResult({
|
||
content: [
|
||
"受控只读 rg --files 结果:",
|
||
toolCall.stdout || "(empty)",
|
||
"",
|
||
sessionReplyLine(session),
|
||
limitationReplyLine()
|
||
].join("\n"),
|
||
workspace: resolvedWorkspace,
|
||
toolCalls: [toolCall],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
|
||
session,
|
||
codexStdioFeasibility,
|
||
outputTruncated: toolCall.outputTruncated
|
||
});
|
||
}
|
||
|
||
if (intent.kind === "cat") {
|
||
const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId });
|
||
assertReadOnlyToolCompleted(toolCall, {
|
||
session,
|
||
workspace: resolvedWorkspace,
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }),
|
||
baseEvidence
|
||
});
|
||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||
return readOnlyRunnerResult({
|
||
content: [
|
||
"受控只读 cat 结果:",
|
||
toolCall.stdout || "(empty)",
|
||
"",
|
||
sessionReplyLine(session),
|
||
limitationReplyLine()
|
||
].join("\n"),
|
||
workspace: resolvedWorkspace,
|
||
toolCalls: [toolCall],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:completed"], startedAt, outputTruncated: toolCall.outputTruncated }),
|
||
session,
|
||
codexStdioFeasibility,
|
||
outputTruncated: toolCall.outputTruncated
|
||
});
|
||
}
|
||
|
||
if (intent.kind === "skills") {
|
||
const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs: skillsDiscoveryDelayMs });
|
||
if (skills.status === "blocked") {
|
||
session = registry.fail(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
statusReason: "skills_unavailable"
|
||
}) ?? session;
|
||
throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", {
|
||
workspace: resolvedWorkspace,
|
||
session,
|
||
toolCalls: [{
|
||
id: `tool_${randomUUID()}`,
|
||
type: "file-read",
|
||
name: "skills.discover",
|
||
status: "blocked",
|
||
cwd: resolvedWorkspace,
|
||
exitCode: 1,
|
||
stdout: "",
|
||
stderrSummary: "skills_unavailable",
|
||
outputTruncated: false
|
||
}],
|
||
skills,
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }),
|
||
capabilityLevel: "blocked",
|
||
route: null,
|
||
toolName: "skills.discover",
|
||
...baseEvidence,
|
||
blockers: skills.blockers
|
||
});
|
||
}
|
||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||
return readOnlyRunnerResult({
|
||
content: skillsReply(skills),
|
||
workspace: resolvedWorkspace,
|
||
toolCalls: [{
|
||
id: `tool_${randomUUID()}`,
|
||
type: "file-read",
|
||
name: "skills.discover",
|
||
status: "completed",
|
||
cwd: resolvedWorkspace,
|
||
exitCode: 0,
|
||
stdout: `skills=${skills.items.length}`,
|
||
stderrSummary: "",
|
||
outputTruncated: Boolean(skills.truncated)
|
||
}],
|
||
skills,
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:completed"], startedAt, outputTruncated: Boolean(skills.truncated) }),
|
||
session,
|
||
codexStdioFeasibility,
|
||
outputTruncated: Boolean(skills.truncated)
|
||
});
|
||
}
|
||
|
||
session = registry.fail(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
statusReason: "tool_unavailable"
|
||
}) ?? session;
|
||
throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, {
|
||
workspace: resolvedWorkspace,
|
||
session,
|
||
toolCalls: [{
|
||
id: `tool_${randomUUID()}`,
|
||
type: "tool",
|
||
name: intent.toolName ?? "unsupported",
|
||
status: "blocked",
|
||
cwd: resolvedWorkspace,
|
||
exitCode: 1,
|
||
stdout: "",
|
||
stderrSummary: "tool_unavailable",
|
||
outputTruncated: false
|
||
}],
|
||
skills: notRequestedSkills(),
|
||
runner,
|
||
runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }),
|
||
capabilityLevel: "blocked",
|
||
route: null,
|
||
toolName: intent.toolName ?? "unsupported",
|
||
...baseEvidence
|
||
});
|
||
}
|
||
|
||
async function callM3IoSkillRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, sessionRegistry, requestJson, codexStdioManager }) {
|
||
const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace });
|
||
const registry = resolveCodeAgentSessionRegistry({ sessionRegistry });
|
||
const m3ApiBaseUrl = configuredCloudApiBaseUrl(env);
|
||
const sessionCapabilityLevel = m3ApiBaseUrl
|
||
? m3SessionCapabilityLevelForIntent(intent)
|
||
: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked;
|
||
const sessionAcquire = registry.acquire({
|
||
conversationId,
|
||
sessionId,
|
||
workspace: resolvedWorkspace,
|
||
sandbox: M3_IO_SKILL_SANDBOX,
|
||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
capabilityLevel: sessionCapabilityLevel,
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
traceId,
|
||
now
|
||
});
|
||
const startedAt = nowIso(now);
|
||
const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace });
|
||
if (!sessionAcquire.ok) {
|
||
const blockedSession = sessionAcquire.session;
|
||
throw runnerError(sessionAcquire.code, sessionAcquire.message, {
|
||
provider: M3_IO_SKILL_PROVIDER,
|
||
model: M3_IO_SKILL_MODEL,
|
||
backend: M3_IO_SKILL_BACKEND,
|
||
workspace: resolvedWorkspace ?? null,
|
||
sandbox: M3_IO_SKILL_SANDBOX,
|
||
session: blockedSession,
|
||
toolCalls: [],
|
||
skills: notRequestedSkills(),
|
||
runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }),
|
||
runnerTrace: m3IoSkillRunnerTrace({
|
||
traceId,
|
||
workspace: resolvedWorkspace ?? repoRoot,
|
||
session: blockedSession,
|
||
events: [`blocked:${sessionAcquire.code}`],
|
||
startedAt,
|
||
outputTruncated: false
|
||
}),
|
||
capabilityLevel: "blocked",
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(blockedSession),
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||
codexStdioFeasibility,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: M3_IO_SKILL_PROVIDER,
|
||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||
session: blockedSession,
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility
|
||
}),
|
||
blockers: [sessionAcquire.blocker],
|
||
route: HWLAB_M3_IO_API_ROUTE,
|
||
toolName: HWLAB_M3_IO_SKILL_NAME
|
||
});
|
||
}
|
||
|
||
let session = sessionAcquire.session;
|
||
if (!m3ApiBaseUrl) {
|
||
const route = intent.action === "status" ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE;
|
||
const blocker = m3IoApiBaseUrlMissingBlocker({ route });
|
||
const skillResult = m3IoSkillMissingApiBaseUrlResult({
|
||
traceId,
|
||
requestId: `req_${randomUUID()}`,
|
||
actorId: "usr_code_agent",
|
||
blocker,
|
||
route,
|
||
startedAt,
|
||
finishedAt: nowIso(now)
|
||
});
|
||
return m3IoSkillRunnerResult({
|
||
skillResult,
|
||
commandArgs: m3IoSkillArgsForIntent(intent, { env, traceId }),
|
||
resolvedWorkspace,
|
||
registry,
|
||
session,
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
codexStdioFeasibility,
|
||
startedAt
|
||
});
|
||
}
|
||
|
||
const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId });
|
||
const skillResult = await runM3IoSkillCommand(commandArgs, {
|
||
env,
|
||
now,
|
||
requestJson
|
||
});
|
||
return m3IoSkillRunnerResult({
|
||
skillResult,
|
||
commandArgs,
|
||
resolvedWorkspace,
|
||
registry,
|
||
session,
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
codexStdioFeasibility,
|
||
startedAt
|
||
});
|
||
}
|
||
|
||
async function callCodexStdioWithM3IoSkillRunner({
|
||
message,
|
||
intent,
|
||
conversationId,
|
||
sessionId,
|
||
traceId,
|
||
env,
|
||
now,
|
||
workspace,
|
||
timeoutMs,
|
||
model,
|
||
codexStdioManager,
|
||
requestJson
|
||
}) {
|
||
const stdioResult = await callCodexStdioRunner({
|
||
message,
|
||
conversationId,
|
||
sessionId,
|
||
traceId,
|
||
env,
|
||
now,
|
||
workspace,
|
||
timeoutMs,
|
||
model,
|
||
codexStdioManager
|
||
});
|
||
const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId });
|
||
const skillResult = await runM3IoSkillCommand(commandArgs, {
|
||
env,
|
||
now,
|
||
requestJson
|
||
});
|
||
return codexStdioM3IoSkillRunnerResult({
|
||
stdioResult,
|
||
skillResult,
|
||
commandArgs,
|
||
traceId,
|
||
now
|
||
});
|
||
}
|
||
|
||
function m3IoSkillRunnerResult({
|
||
skillResult,
|
||
commandArgs,
|
||
resolvedWorkspace,
|
||
registry,
|
||
session,
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
codexStdioFeasibility,
|
||
startedAt
|
||
}) {
|
||
const finishedAt = nowIso(now);
|
||
const capabilityLevel = skillResult.capabilityLevel ?? (
|
||
skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||
);
|
||
const route = m3SkillRoute(skillResult);
|
||
const method = m3SkillMethod(skillResult);
|
||
const toolCall = m3IoSkillToolCall({
|
||
skillResult,
|
||
commandArgs,
|
||
cwd: resolvedWorkspace,
|
||
capabilityLevel,
|
||
route,
|
||
method
|
||
});
|
||
|
||
session = releaseReadOnlySession(registry, session, { now, traceId, conversationId });
|
||
const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult });
|
||
const runnerTracePayload = m3IoSkillRunnerTrace({
|
||
traceId,
|
||
workspace: resolvedWorkspace,
|
||
session,
|
||
events: [
|
||
"intent:m3_io",
|
||
"tool:skill-cli:started",
|
||
`route:${route}`,
|
||
`tool:${toolCall.status}`
|
||
],
|
||
startedAt,
|
||
finishedAt,
|
||
outputTruncated: toolCall.outputTruncated,
|
||
skillResult
|
||
});
|
||
|
||
return {
|
||
provider: M3_IO_SKILL_PROVIDER,
|
||
model: M3_IO_SKILL_MODEL,
|
||
backend: M3_IO_SKILL_BACKEND,
|
||
content: m3IoSkillReply(skillResult),
|
||
workspace: resolvedWorkspace,
|
||
sandbox: M3_IO_SKILL_SANDBOX,
|
||
session,
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(session),
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||
codexStdioFeasibility,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: M3_IO_SKILL_PROVIDER,
|
||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||
session,
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility
|
||
}),
|
||
toolCalls: [toolCall],
|
||
skills: {
|
||
status: "used",
|
||
items: [m3IoSkillItem({ capabilityLevel, route })],
|
||
count: 1,
|
||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : [])
|
||
},
|
||
runner,
|
||
runnerTrace: runnerTracePayload,
|
||
capabilityLevel,
|
||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []),
|
||
route,
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
providerTrace: m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel })
|
||
};
|
||
}
|
||
|
||
function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }) {
|
||
const capabilityLevel = skillResult.capabilityLevel ?? (
|
||
skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||
);
|
||
const route = m3SkillRoute(skillResult);
|
||
const method = m3SkillMethod(skillResult);
|
||
const toolCall = m3IoSkillToolCall({
|
||
skillResult,
|
||
commandArgs,
|
||
cwd: stdioResult.workspace,
|
||
capabilityLevel,
|
||
route,
|
||
method
|
||
});
|
||
const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []);
|
||
const runnerTrace = {
|
||
...stdioResult.runnerTrace,
|
||
events: [
|
||
...(stdioResult.runnerTrace?.events ?? []),
|
||
"tool:skill-cli:started",
|
||
`route:${route}`,
|
||
`tool:${toolCall.status}`
|
||
],
|
||
route,
|
||
method,
|
||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||
capabilityLevel,
|
||
controlReady: skillResult.controlReady === true,
|
||
operationId: skillResult.operationId,
|
||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||
accepted: skillResult.accepted,
|
||
status: skillResult.status,
|
||
blocker: skillResult.blocker ?? null,
|
||
trustBlocker: skillResult.trustBlocker ?? null,
|
||
blockers,
|
||
directGatewayCalls: false,
|
||
directBoxCalls: false,
|
||
directPatchPanelCalls: false,
|
||
fallbackUsed: false,
|
||
finishedAt: nowIso(now)
|
||
};
|
||
const runner = {
|
||
...stdioResult.runner,
|
||
skill: {
|
||
name: HWLAB_M3_IO_SKILL_NAME,
|
||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||
route
|
||
},
|
||
toolPolicy: {
|
||
...(stdioResult.runner?.toolPolicy ?? {}),
|
||
allowed: [
|
||
...new Set([
|
||
...(stdioResult.runner?.toolPolicy?.allowed ?? []),
|
||
`${method} ${route}`
|
||
])
|
||
]
|
||
}
|
||
};
|
||
|
||
return {
|
||
...stdioResult,
|
||
content: `${stdioResult.content}\n\n${m3IoSkillReply(skillResult)}`,
|
||
toolCalls: [
|
||
...(stdioResult.toolCalls ?? []),
|
||
toolCall
|
||
],
|
||
skills: {
|
||
status: "used",
|
||
items: [
|
||
...(stdioResult.skills?.items ?? []),
|
||
m3IoSkillItem({ capabilityLevel, route })
|
||
],
|
||
count: (stdioResult.skills?.count ?? 0) + 1,
|
||
blockers
|
||
},
|
||
runner,
|
||
runnerTrace,
|
||
capabilityLevel,
|
||
blockers,
|
||
route,
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
providerTrace: {
|
||
...(stdioResult.providerTrace ?? {}),
|
||
...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }),
|
||
runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND,
|
||
codexStdio: true,
|
||
transport: "stdio+skill-cli"
|
||
}
|
||
};
|
||
}
|
||
|
||
function m3IoSkillToolCall({ skillResult, commandArgs, cwd, capabilityLevel, route, method }) {
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "skill-cli",
|
||
name: HWLAB_M3_IO_SKILL_NAME,
|
||
status: skillResult.status === "succeeded" || skillResult.accepted === true ? "completed" : "blocked",
|
||
cwd,
|
||
command: redactText(`node skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs ${redactM3IoCommandArgs(commandArgs).join(" ")}`),
|
||
exitCode: skillResult.ok ? 0 : 2,
|
||
stdout: boundToolOutput(JSON.stringify({
|
||
route: skillResult.route,
|
||
method,
|
||
status: skillResult.status,
|
||
accepted: skillResult.accepted,
|
||
traceId: skillResult.traceId,
|
||
operationId: skillResult.operationId,
|
||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||
audit: skillResult.audit,
|
||
evidence: skillResult.evidence,
|
||
durable: skillResult.durable,
|
||
blocker: skillResult.blocker,
|
||
result: skillResult.result,
|
||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||
safety: skillResult.safety
|
||
})).text,
|
||
stderrSummary: skillResult.blocker?.code ?? "",
|
||
outputTruncated: false,
|
||
route,
|
||
method,
|
||
hwlabApi: m3IoToolCallApiTarget(skillResult.hwlabApi),
|
||
capabilityLevel,
|
||
controlReady: skillResult.controlReady === true,
|
||
accepted: skillResult.accepted,
|
||
operationStatus: skillResult.status,
|
||
apiStatus: skillResult.status,
|
||
operationId: skillResult.operationId,
|
||
traceId: skillResult.traceId,
|
||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||
audit: skillResult.audit,
|
||
evidence: skillResult.evidence,
|
||
durable: skillResult.durable,
|
||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||
blocker: skillResult.blocker,
|
||
capabilityBlocker: skillResult.capabilityBlocker ?? null,
|
||
trustBlocker: skillResult.trustBlocker ?? null,
|
||
blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []),
|
||
directGatewayCalls: false,
|
||
directBoxCalls: false,
|
||
directPatchPanelCalls: false,
|
||
fallbackUsed: false
|
||
};
|
||
}
|
||
|
||
function m3IoSkillItem({ capabilityLevel, route }) {
|
||
return {
|
||
name: HWLAB_M3_IO_SKILL_NAME,
|
||
summary: `Controlled M3 DO1/DI1 adapter for HWLAB API ${route}.`,
|
||
route,
|
||
capabilityLevel,
|
||
version: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION
|
||
};
|
||
}
|
||
|
||
function m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) {
|
||
return {
|
||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||
route,
|
||
method,
|
||
traceId: skillResult.traceId,
|
||
operationId: skillResult.operationId,
|
||
auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null,
|
||
evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null,
|
||
readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null,
|
||
capabilityLevel,
|
||
controlReady: skillResult.controlReady === true,
|
||
accepted: skillResult.accepted,
|
||
status: skillResult.status,
|
||
fallbackUsed: false
|
||
};
|
||
}
|
||
|
||
function redactM3IoCommandArgs(commandArgs = []) {
|
||
const redacted = [];
|
||
for (let index = 0; index < commandArgs.length; index += 1) {
|
||
redacted.push(commandArgs[index]);
|
||
if (commandArgs[index] === "--api-base-url" && index + 1 < commandArgs.length) {
|
||
redacted.push("<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 (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) {
|
||
return { kind: "pwd", toolName: "pwd" };
|
||
}
|
||
if (/(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text)) {
|
||
return { kind: "skills", toolName: "skills.discover" };
|
||
}
|
||
if (/\brg\s+--files\b/u.test(lower) || /(?:列出|查看).{0,12}(?:文件列表|所有文件|源码文件|代码文件)/u.test(text)) {
|
||
return { kind: "rg_files", toolName: "rg --files", target: extractReadOnlyTarget(text, { defaultTarget: "." }) };
|
||
}
|
||
if (/\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text)) {
|
||
return { kind: "ls", toolName: "ls", target: extractReadOnlyTarget(text, { defaultTarget: "." }) };
|
||
}
|
||
if (/\bcat\b/u.test(lower) || /(?:读取|查看).{0,10}(?:文件|源码|代码)/u.test(text)) {
|
||
const target = extractReadOnlyTarget(text, { preferFile: true });
|
||
if (!target) {
|
||
return { kind: "unsupported", toolName: "cat" };
|
||
}
|
||
return { kind: "cat", toolName: "cat", target };
|
||
}
|
||
if (/\b(?:grep|find|sed|awk)\b/u.test(lower)) {
|
||
const tool = lower.match(/\b(?:grep|find|sed|awk)\b/u)?.[0] ?? "file.read";
|
||
return { kind: "unsupported", toolName: tool };
|
||
}
|
||
return { kind: "none" };
|
||
}
|
||
|
||
function detectM3IoIntent(text) {
|
||
const normalized = String(text ?? "");
|
||
if (!/\bM3\b|DO1|DI1|数字输出|数字输入|回读|读回|状态|status/u.test(normalized)) {
|
||
return null;
|
||
}
|
||
if (/(?:直接|direct|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|\/invoke|\/sync\/tick)/iu.test(normalized)) {
|
||
return null;
|
||
}
|
||
if (/DEV-LIVE|验收|acceptance|(?:M3|M4|M5).{0,20}(?:通过|pass|green|accept)/iu.test(normalized)) {
|
||
return null;
|
||
}
|
||
|
||
const wantsRead = /(?:DI1|数字输入).{0,16}(?:read|readback|读取|回读|读回|查询)|(?:read|readback|读取|回读|读回|查询).{0,16}(?:DI1|数字输入)/iu.test(normalized);
|
||
const wantsWrite = /(?:DO1|数字输出).{0,20}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,20}(?:DO1|数字输出)/iu.test(normalized);
|
||
const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized);
|
||
if (wantsStatus && !wantsRead && !wantsWrite) {
|
||
return {
|
||
kind: "m3_io",
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
action: "status"
|
||
};
|
||
}
|
||
if (!wantsRead && !wantsWrite) {
|
||
return null;
|
||
}
|
||
|
||
if (wantsRead && !wantsWrite) {
|
||
return {
|
||
kind: "m3_io",
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
action: "di.read"
|
||
};
|
||
}
|
||
|
||
const value = parseM3WriteValue(normalized);
|
||
if (typeof value !== "boolean") {
|
||
return {
|
||
kind: "unsupported",
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
reason: "M3 DO1 write requires an explicit true/false value."
|
||
};
|
||
}
|
||
return {
|
||
kind: "m3_io",
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
action: "do.write",
|
||
value
|
||
};
|
||
}
|
||
|
||
function parseM3WriteValue(text) {
|
||
if (/\btrue\b|\b1\b|\bon\b|打开|置高|高电平|拉高|闭合/iu.test(text)) return true;
|
||
if (/\bfalse\b|\b0\b|\boff\b|关闭|置低|低电平|拉低|断开/iu.test(text)) return false;
|
||
return null;
|
||
}
|
||
|
||
function extractReadOnlyTarget(text, { defaultTarget = null, preferFile = false } = {}) {
|
||
const value = String(text ?? "");
|
||
const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1];
|
||
if (quoted) return quoted.trim();
|
||
const tokenPattern = preferFile
|
||
? /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+\/?[A-Za-z0-9._@:/+=-]*(?:\.[A-Za-z0-9._-]+)?)(?:\s|$)/gu
|
||
: /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+)(?:\s|$)/gu;
|
||
const stopWords = new Set(["cat", "ls", "rg", "--files", "读取", "查看", "列出", "文件", "目录", "源码", "代码", "你", "请用"]);
|
||
for (const match of value.matchAll(tokenPattern)) {
|
||
const candidate = match[1]?.trim();
|
||
if (!candidate || stopWords.has(candidate)) continue;
|
||
if (/^(?:pwd|skills?|skill)$/iu.test(candidate)) continue;
|
||
if (!preferFile && !/[/.]/u.test(candidate)) continue;
|
||
return candidate;
|
||
}
|
||
const inlinePath = value.match(/((?:\.{1,2}\/|\/)[A-Za-z0-9._@:/+=-]+|[A-Za-z0-9._@+=-]+\.[A-Za-z0-9._-]+)/u)?.[1];
|
||
if (inlinePath && !stopWords.has(inlinePath) && !/^(?:pwd|skills?|skill)$/iu.test(inlinePath)) {
|
||
return inlinePath;
|
||
}
|
||
return defaultTarget;
|
||
}
|
||
|
||
function isSecretReadRequest(text) {
|
||
const asksToRead = /(?:print|show|cat|read|list|dump|echo|输出|显示|读取|列出|查看|打印)/iu.test(text);
|
||
const secretTerm = /(?:secret|token|kubeconfig|OPENAI_API_KEY|DATABASE_URL|password|passwd|credential|private key|私钥|密码|凭证|环境变量|密钥)/iu.test(text);
|
||
return asksToRead && secretTerm;
|
||
}
|
||
|
||
function isHardwareWriteOrAcceptanceRequest(text) {
|
||
const hardwareTarget = /(?:hardware\.operation\.request|hardware\.invoke\.shell|audit\.event\.write|evidence\.record\.write|gateway-simu|box-simu|patch-panel|hwlab-patch-panel|硬件写|直接调用|\/invoke|\/sync\/tick)/iu.test(text);
|
||
const mutationVerb = /(?:call|invoke|write|mutate|apply|rollout|accept|pass|验收|通过|写入|调用|变更|操作)/iu.test(text);
|
||
const acceptanceClaim = /(?:M3|M4|M5).{0,20}(?:pass|accept|green|通过|验收|完成)/iu.test(text);
|
||
return (hardwareTarget && mutationVerb) || acceptanceClaim;
|
||
}
|
||
|
||
function resolveRunnerWorkspace(env = process.env, options = {}) {
|
||
const configured = firstNonEmpty(
|
||
options.workspace,
|
||
env.HWLAB_CODE_AGENT_WORKSPACE,
|
||
env.HWLAB_RUNNER_WORKSPACE,
|
||
env.WORKSPACE
|
||
);
|
||
return path.resolve(configured || repoRoot);
|
||
}
|
||
|
||
function resolveSkillDirs(env = process.env, options = {}) {
|
||
const configured = Array.isArray(options.skillsDirs)
|
||
? options.skillsDirs
|
||
: String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, ""))
|
||
.split(/[,;]/u)
|
||
.flatMap((part) => part.split(path.delimiter));
|
||
const strict = options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1";
|
||
if (strict) {
|
||
return [...new Set(configured
|
||
.filter((dir) => typeof dir === "string" && dir.trim())
|
||
.map((dir) => path.resolve(dir.trim())))];
|
||
}
|
||
return [...new Set([
|
||
...configured,
|
||
path.join(os.homedir(), ".agents", "skills"),
|
||
"/root/.agents/skills",
|
||
"/home/ubuntu/.agents/skills",
|
||
path.join(repoRoot, "skills")
|
||
]
|
||
.filter((dir) => typeof dir === "string" && dir.trim())
|
||
.map((dir) => path.resolve(dir.trim())))];
|
||
}
|
||
|
||
async function pathReadable(targetPath) {
|
||
try {
|
||
await access(targetPath, fsConstants.R_OK);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function runPwdTool({ workspace, traceId, env }) {
|
||
const result = await spawnWithInput("pwd", [], "", {
|
||
cwd: workspace,
|
||
env: runnerCommandEnv(env),
|
||
timeoutMs: 3000
|
||
});
|
||
const output = redactText((result.stdout || workspace).trim() || workspace);
|
||
const bounded = boundToolOutput(output);
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "shell",
|
||
name: "pwd",
|
||
status: result.code === 0 ? "completed" : "blocked",
|
||
cwd: workspace,
|
||
command: "pwd",
|
||
exitCode: result.code,
|
||
stdout: bounded.text,
|
||
stderrSummary: redactText(tailText(result.stderr, 300)),
|
||
outputTruncated: bounded.truncated,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
async function runLsTool({ workspace, target = ".", traceId }) {
|
||
const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true });
|
||
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
|
||
if (blockedReason) {
|
||
return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: blockedReason });
|
||
}
|
||
let entries = [];
|
||
try {
|
||
const targetStat = await stat(targetInfo.path);
|
||
if (targetStat.isDirectory()) {
|
||
entries = await readdir(targetInfo.path, { withFileTypes: true });
|
||
} else {
|
||
entries = [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }];
|
||
}
|
||
} catch (error) {
|
||
return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: redactText(error.message) });
|
||
}
|
||
|
||
const lines = entries
|
||
.sort((a, b) => a.name.localeCompare(b.name, "en"))
|
||
.slice(0, READONLY_FILE_ENTRY_LIMIT)
|
||
.map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`);
|
||
const overflow = entries.length > lines.length;
|
||
const bounded = boundToolOutput(redactText(lines.join("\n")));
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "file-list",
|
||
name: "ls",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`,
|
||
exitCode: 0,
|
||
stdout: bounded.text,
|
||
stderrSummary: overflow ? `entry limit ${READONLY_FILE_ENTRY_LIMIT} reached` : "",
|
||
outputTruncated: bounded.truncated || overflow,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
async function runRgFilesTool({ workspace, target = ".", traceId, env }) {
|
||
const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true });
|
||
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
|
||
if (blockedReason) {
|
||
return blockedToolCall({ name: "rg --files", type: "file-list", workspace, traceId, reason: blockedReason });
|
||
}
|
||
|
||
let files = [];
|
||
let usedFallback = true;
|
||
if (await commandExists("rg", runnerCommandEnv(env))) {
|
||
const result = await spawnWithInput("rg", ["--files", targetInfo.path], "", {
|
||
cwd: workspace,
|
||
env: runnerCommandEnv(env),
|
||
timeoutMs: 3000
|
||
});
|
||
if (result.code === 0) {
|
||
files = result.stdout.split(/\r?\n/u).filter(Boolean).map((filePath) => path.relative(workspace, path.resolve(filePath)));
|
||
usedFallback = false;
|
||
}
|
||
}
|
||
if (usedFallback) {
|
||
files = await listFilesRecursively(targetInfo.path, workspace);
|
||
}
|
||
|
||
const returned = files
|
||
.filter((filePath) => filePath && !filePath.startsWith("..") && !path.isAbsolute(filePath))
|
||
.sort((a, b) => a.localeCompare(b, "en"))
|
||
.slice(0, READONLY_FILE_ENTRY_LIMIT);
|
||
const bounded = boundToolOutput(redactText(returned.join("\n")));
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "file-list",
|
||
name: "rg --files",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `rg --files ${safeDisplayPath(targetInfo.relative || ".")}`,
|
||
exitCode: 0,
|
||
stdout: bounded.text,
|
||
stderrSummary: usedFallback ? "node-fallback-used" : "",
|
||
outputTruncated: bounded.truncated || files.length > returned.length,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
async function runCatTool({ workspace, target, traceId }) {
|
||
const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true, requireFile: true });
|
||
const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check();
|
||
if (blockedReason) {
|
||
return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: blockedReason });
|
||
}
|
||
|
||
let content = "";
|
||
let truncatedByReadLimit = false;
|
||
try {
|
||
const file = await open(targetInfo.path, "r");
|
||
try {
|
||
const buffer = Buffer.alloc(READONLY_FILE_READ_LIMIT + 1);
|
||
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
||
truncatedByReadLimit = bytesRead > READONLY_FILE_READ_LIMIT;
|
||
content = buffer.subarray(0, Math.min(bytesRead, READONLY_FILE_READ_LIMIT)).toString("utf8");
|
||
} finally {
|
||
await file.close();
|
||
}
|
||
} catch (error) {
|
||
return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: redactText(error.message) });
|
||
}
|
||
|
||
const bounded = boundToolOutput(redactText(content));
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type: "file-read",
|
||
name: "cat",
|
||
status: "completed",
|
||
cwd: workspace,
|
||
command: `cat ${safeDisplayPath(targetInfo.relative)}`,
|
||
exitCode: 0,
|
||
stdout: bounded.text,
|
||
stderrSummary: truncatedByReadLimit ? `file read limited at ${READONLY_FILE_READ_LIMIT} bytes` : "",
|
||
outputTruncated: bounded.truncated || truncatedByReadLimit,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
function blockedToolCall({ name, type, workspace, traceId, reason }) {
|
||
return {
|
||
id: `tool_${randomUUID()}`,
|
||
type,
|
||
name,
|
||
status: "blocked",
|
||
cwd: workspace,
|
||
exitCode: 1,
|
||
stdout: "",
|
||
stderrSummary: `security_blocked: ${redactText(reason)}`,
|
||
outputTruncated: false,
|
||
traceId
|
||
};
|
||
}
|
||
|
||
function assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runnerTrace, baseEvidence }) {
|
||
if (toolCall.status === "completed") return;
|
||
throw runnerError("security_blocked", toolCall.stderrSummary || `${toolCall.name} was blocked by the read-only runner policy`, {
|
||
workspace,
|
||
toolCalls: [toolCall],
|
||
skills,
|
||
runner,
|
||
runnerTrace,
|
||
capabilityLevel: "blocked",
|
||
blockers: [{
|
||
code: "security_blocked",
|
||
sourceIssue: "pikasTech/HWLAB#275",
|
||
summary: toolCall.stderrSummary || "Read-only runner blocked the requested file path."
|
||
}],
|
||
route: null,
|
||
toolName: toolCall.name,
|
||
...baseEvidence
|
||
});
|
||
}
|
||
|
||
async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs = 0 }) {
|
||
await maybeDelayForTest(delayMs);
|
||
const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact });
|
||
const sourceSummaries = [];
|
||
const items = [];
|
||
|
||
for (const skillsDir of checkedDirs) {
|
||
if (!(await pathReadable(skillsDir))) {
|
||
sourceSummaries.push({
|
||
path: skillsDir,
|
||
status: "missing_or_unreadable",
|
||
commit: null,
|
||
version: null
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const commit = await gitCommitFor(skillsDir, env);
|
||
const version = firstNonEmpty(env.HWLAB_SKILLS_VERSION, env.HWLAB_SKILL_VERSION, null);
|
||
sourceSummaries.push({
|
||
path: skillsDir,
|
||
status: "readable",
|
||
commit,
|
||
version
|
||
});
|
||
|
||
const manifests = await skillManifestPaths(skillsDir);
|
||
for (const manifestPath of manifests) {
|
||
const manifest = await readSkillManifest(manifestPath);
|
||
if (!manifest) continue;
|
||
items.push({
|
||
name: manifest.name ?? path.basename(path.dirname(manifestPath)),
|
||
summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.",
|
||
source: manifestPath,
|
||
sourceRoot: skillsDir,
|
||
version: manifest.version ?? version,
|
||
commit: manifest.commit ?? commit,
|
||
traceId
|
||
});
|
||
}
|
||
}
|
||
|
||
const uniqueItems = dedupeSkills(items)
|
||
.sort((a, b) => a.name.localeCompare(b.name, "en"));
|
||
const returned = uniqueItems.slice(0, MAX_SKILLS_RETURNED);
|
||
if (returned.length === 0) {
|
||
return {
|
||
status: "blocked",
|
||
code: "skills_unavailable",
|
||
items: [],
|
||
count: 0,
|
||
totalCount: 0,
|
||
checkedDirs,
|
||
sources: sourceSummaries,
|
||
blockers: [{
|
||
code: "skills_unavailable",
|
||
sourceIssue: "pikasTech/HWLAB#136",
|
||
linkedIssues: ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"],
|
||
summary: "No readable SKILL.md files were found in the configured runner skills directories.",
|
||
nextTask: "Mount or sync canonical ~/.agents/skills or repo-owned skills into the Code Agent runner image/runtime, then rerun /v1/agent/chat skills discovery."
|
||
}],
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: "ready",
|
||
code: "skills_ready",
|
||
items: returned,
|
||
count: returned.length,
|
||
totalCount: uniqueItems.length,
|
||
truncated: uniqueItems.length > returned.length,
|
||
checkedDirs,
|
||
sources: sourceSummaries,
|
||
blockers: [],
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
|
||
async function skillManifestPaths(skillsDir) {
|
||
const direct = path.join(skillsDir, "SKILL.md");
|
||
const manifests = [];
|
||
if (await pathReadable(direct)) manifests.push(direct);
|
||
let entries = [];
|
||
try {
|
||
entries = await readdir(skillsDir, { withFileTypes: true });
|
||
} catch {
|
||
return manifests;
|
||
}
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
const manifestPath = path.join(skillsDir, entry.name, "SKILL.md");
|
||
if (await pathReadable(manifestPath)) manifests.push(manifestPath);
|
||
}
|
||
return manifests;
|
||
}
|
||
|
||
async function readSkillManifest(manifestPath) {
|
||
let text = "";
|
||
try {
|
||
text = await readFile(manifestPath, "utf8");
|
||
} catch {
|
||
return null;
|
||
}
|
||
const frontmatter = parseFrontmatter(text);
|
||
const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, "");
|
||
const name = frontmatter.name ?? path.basename(path.dirname(manifestPath));
|
||
if (!name) return null;
|
||
return {
|
||
name,
|
||
description: frontmatter.description,
|
||
version: frontmatter.version,
|
||
commit: frontmatter.commit ?? frontmatter.commitId,
|
||
body
|
||
};
|
||
}
|
||
|
||
function parseFrontmatter(text) {
|
||
const match = String(text ?? "").match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/u);
|
||
if (!match) return {};
|
||
const data = {};
|
||
for (const line of match[1].split(/\r?\n/u)) {
|
||
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)\s*$/u);
|
||
if (!field) continue;
|
||
data[field[1]] = field[2].replace(/^["']|["']$/gu, "").trim();
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function firstMarkdownSummary(body) {
|
||
return String(body ?? "")
|
||
.split(/\r?\n/u)
|
||
.map((line) => line.trim())
|
||
.find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null;
|
||
}
|
||
|
||
function dedupeSkills(items) {
|
||
const seen = new Set();
|
||
const deduped = [];
|
||
for (const item of items) {
|
||
const key = item.name.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
deduped.push(item);
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
async function gitCommitFor(targetPath, env = process.env) {
|
||
if (isPathInside(targetPath, repoRoot)) {
|
||
return firstNonEmpty(env.HWLAB_SKILLS_COMMIT_ID, env.HWLAB_COMMIT_ID, env.HWLAB_GIT_SHA, await gitRevParse(repoRoot));
|
||
}
|
||
return firstNonEmpty(await gitRevParse(targetPath), null);
|
||
}
|
||
|
||
async function gitRevParse(targetPath) {
|
||
const result = await spawnWithInput("git", ["-C", targetPath, "rev-parse", "--short=12", "HEAD"], "", {
|
||
cwd: targetPath,
|
||
env: runnerCommandEnv(process.env),
|
||
timeoutMs: 3000
|
||
});
|
||
return result.code === 0 ? result.stdout.trim() : null;
|
||
}
|
||
|
||
function isPathInside(child, parent) {
|
||
const relative = path.relative(parent, child);
|
||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||
}
|
||
|
||
function resolveReadOnlyTarget(workspace, target, { mustExist = false, requireFile = false } = {}) {
|
||
const rawTarget = String(target || ".").trim();
|
||
if (!rawTarget) {
|
||
return { blocked: true, reason: "missing target path" };
|
||
}
|
||
if (isForbiddenPath(rawTarget)) {
|
||
return { blocked: true, reason: "security_blocked: target path may expose secrets or forbidden runtime material" };
|
||
}
|
||
const resolved = path.resolve(workspace, rawTarget);
|
||
if (!isPathInside(resolved, workspace)) {
|
||
return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" };
|
||
}
|
||
if (isForbiddenPath(path.relative(workspace, resolved))) {
|
||
return { blocked: true, reason: "security_blocked: target path is not allowed" };
|
||
}
|
||
const relative = path.relative(workspace, resolved) || ".";
|
||
return {
|
||
path: resolved,
|
||
relative,
|
||
async check() {
|
||
if (!mustExist && !requireFile) return null;
|
||
try {
|
||
const targetStat = await stat(resolved);
|
||
if (requireFile && !targetStat.isFile()) {
|
||
return "target is not a regular file";
|
||
}
|
||
return null;
|
||
} catch {
|
||
return "target path is not readable";
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function isForbiddenPath(value) {
|
||
const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase();
|
||
return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) ||
|
||
/(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized);
|
||
}
|
||
|
||
async function listFilesRecursively(rootPath, workspace) {
|
||
const results = [];
|
||
async function visit(currentPath, depth) {
|
||
if (results.length >= READONLY_FILE_ENTRY_LIMIT || depth > READONLY_FILE_TREE_DEPTH) return;
|
||
let entries = [];
|
||
try {
|
||
const currentStat = await stat(currentPath);
|
||
if (currentStat.isFile()) {
|
||
results.push(path.relative(workspace, currentPath));
|
||
return;
|
||
}
|
||
if (!currentStat.isDirectory()) return;
|
||
entries = await readdir(currentPath, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) {
|
||
if (results.length >= READONLY_FILE_ENTRY_LIMIT) break;
|
||
if (SKIPPED_READONLY_DIRS.has(entry.name) || isForbiddenPath(entry.name)) continue;
|
||
const nextPath = path.join(currentPath, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await visit(nextPath, depth + 1);
|
||
} else if (entry.isFile()) {
|
||
results.push(path.relative(workspace, nextPath));
|
||
}
|
||
}
|
||
}
|
||
await visit(rootPath, 0);
|
||
return results;
|
||
}
|
||
|
||
function safeDisplayPath(value) {
|
||
return redactText(String(value ?? ".")).replace(/\s+/gu, " ");
|
||
}
|
||
|
||
function skillsReply(skills) {
|
||
const lines = [
|
||
`当前只读 runner 已加载 ${skills.totalCount} 个 skill${skills.truncated ? `,本次显示前 ${skills.count} 个` : ""}。`
|
||
];
|
||
for (const skill of skills.items) {
|
||
const meta = [
|
||
`source=${skill.source}`,
|
||
skill.version ? `version=${skill.version}` : null,
|
||
skill.commit ? `commit=${skill.commit}` : null
|
||
].filter(Boolean).join(" / ");
|
||
lines.push(`- ${skill.name}: ${skill.summary} (${meta})`);
|
||
}
|
||
lines.push("说明:这是 read-only skills discovery;未读取 secret/token/kubeconfig,也未触发硬件写操作。");
|
||
return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text;
|
||
}
|
||
|
||
function notRequestedSkills() {
|
||
return {
|
||
status: "not_requested",
|
||
items: [],
|
||
count: 0,
|
||
blockers: []
|
||
};
|
||
}
|
||
|
||
function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated }) {
|
||
return {
|
||
provider: READONLY_RUNNER_PROVIDER,
|
||
model: READONLY_RUNNER_MODEL,
|
||
backend: READONLY_RUNNER_BACKEND,
|
||
content,
|
||
workspace,
|
||
sandbox: READONLY_RUNNER_SANDBOX,
|
||
session,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
sessionReuse: sessionReuseEvidence(session),
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
runnerLimitations: [...READONLY_LIMITATION_FLAGS],
|
||
codexStdioFeasibility,
|
||
longLivedSessionGate: longLivedSessionGate({
|
||
provider: READONLY_RUNNER_PROVIDER,
|
||
runnerKind: READONLY_RUNNER_KIND,
|
||
session,
|
||
sessionMode: READONLY_SESSION_MODE,
|
||
implementationType: READONLY_IMPLEMENTATION_TYPE,
|
||
codexStdioFeasibility
|
||
}),
|
||
toolCalls,
|
||
skills,
|
||
runner,
|
||
runnerTrace,
|
||
capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL,
|
||
providerTrace: {
|
||
runnerKind: READONLY_RUNNER_KIND,
|
||
toolCalls: toolCalls.length,
|
||
mode: READONLY_SESSION_MODE,
|
||
sessionId: session.sessionId,
|
||
turn: session.turn,
|
||
reused: session.reused,
|
||
outputTruncated: Boolean(outputTruncated)
|
||
}
|
||
};
|
||
}
|
||
|
||
function m3IoSkillArgsForIntent(intent, { env, traceId }) {
|
||
const args = [
|
||
"m3",
|
||
intent.action === "status" ? "status" : "io"
|
||
];
|
||
if (intent.action !== "status") {
|
||
args.push("--action", intent.action);
|
||
}
|
||
args.push(
|
||
"--trace-id",
|
||
traceId,
|
||
"--actor-id",
|
||
"usr_code_agent"
|
||
);
|
||
const apiBaseUrl = configuredCloudApiBaseUrl(env);
|
||
if (apiBaseUrl) {
|
||
args.push("--api-base-url", apiBaseUrl);
|
||
}
|
||
if (intent.action === "do.write") {
|
||
args.push(
|
||
"--value",
|
||
String(intent.value),
|
||
"--approved",
|
||
"--policy",
|
||
"hwlab-api-control-with-approval",
|
||
"--approval-reason",
|
||
"user explicitly requested res_boxsimu_1 DO1 write through HWLAB API"
|
||
);
|
||
} else {
|
||
args.push("--policy", "hwlab-api-readonly");
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function m3IoSkillApiBaseUrlSource(env = process.env) {
|
||
for (const name of HWLAB_M3_IO_API_BASE_URL_ENVS) {
|
||
if (firstNonEmpty(env[name])) return `env:${name}`;
|
||
}
|
||
return "missing-config";
|
||
}
|
||
|
||
function m3IoApiBaseUrlMissingBlocker({ route = HWLAB_M3_IO_API_ROUTE } = {}) {
|
||
return {
|
||
code: "skill_cli_api_base_missing",
|
||
layer: "skill-cli-config",
|
||
category: "needs_config",
|
||
retryable: false,
|
||
source: "code-agent-m3-skill-cli",
|
||
summary: `${HWLAB_M3_IO_API_BASE_URL_ENV} is required so the Skill CLI can reach cloud-api from inside the cloud-api runtime container.`,
|
||
message: `Set ${HWLAB_M3_IO_API_BASE_URL_ENV} or another supported HWLAB API base URL contract in the cloud-api runtime; the runner will not fall back to a loopback URL or direct hardware services.`,
|
||
zh: `cloud-api 运行时缺少 ${HWLAB_M3_IO_API_BASE_URL_ENV},Skill CLI 无法从容器内访问 HWLAB API;不会回退到 loopback URL 或直连硬件服务。`,
|
||
userMessage: "M3 Skill CLI 缺少 HWLAB API base URL 配置,需要补齐安全 env 或 contract。",
|
||
traceId: null,
|
||
route,
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
missingConfig: [
|
||
...HWLAB_M3_IO_API_BASE_URL_ENVS,
|
||
"contract:hwlab-agent-runtime.m3-io.apiBaseUrl"
|
||
]
|
||
};
|
||
}
|
||
|
||
function m3IoSkillReply(skillResult) {
|
||
const lines = [
|
||
`M3 IO Skill CLI result: status=${skillResult.status}; accepted=${skillResult.accepted}; route=${skillResult.route}; traceId=${skillResult.traceId}; operationId=${skillResult.operationId ?? "null"}.`
|
||
];
|
||
if (skillResult.audit?.summary) {
|
||
lines.push(`Audit: ${skillResult.audit.summary}.`);
|
||
}
|
||
if (skillResult.evidence?.summary) {
|
||
lines.push(`Evidence: ${skillResult.evidence.summary}.`);
|
||
}
|
||
if (skillResult.blocker) {
|
||
lines.push(`Blocker: ${skillResult.blocker.code}; ${skillResult.blocker.zh ?? skillResult.blocker.message}.`);
|
||
}
|
||
if (skillResult.action === "do.write") {
|
||
lines.push(`DO1 requested value=${String(skillResult.command?.value)}; DI1 readback=${String(skillResult.result?.targetReadback?.value ?? "unknown")}.`);
|
||
} else if (skillResult.action === "di.read") {
|
||
lines.push(`DI1 value=${String(skillResult.result?.value ?? "unknown")}.`);
|
||
} else if (skillResult.readonly === true || skillResult.route === HWLAB_M3_STATUS_API_ROUTE) {
|
||
lines.push(`M3 status readback DI1=${String(skillResult.readback?.value ?? "unknown")}; readiness=${skillResult.readiness?.status ?? "unknown"}.`);
|
||
}
|
||
lines.push(`Boundary: Code Agent -> Skill CLI -> HWLAB API ${skillResult.route}; no direct gateway/box/patch-panel call; no OpenAI fallback used. This is source/artifact control capability, not DEV-LIVE verification.`);
|
||
return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text;
|
||
}
|
||
|
||
function m3IoSkillRunnerDescriptor({ workspace, session = null, skillResult = null } = {}) {
|
||
const route = m3SkillRoute(skillResult);
|
||
const method = m3SkillMethod(skillResult);
|
||
const capabilityLevel = skillResult?.capabilityLevel ?? HWLAB_M3_IO_CAPABILITY_LEVELS.ready;
|
||
return {
|
||
kind: M3_IO_SKILL_RUNNER_KIND,
|
||
provider: M3_IO_SKILL_PROVIDER,
|
||
backend: M3_IO_SKILL_BACKEND,
|
||
workspace: workspace ?? repoRoot,
|
||
sandbox: M3_IO_SKILL_SANDBOX,
|
||
session: M3_IO_SKILL_SESSION_MODE,
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
codexStdio: false,
|
||
longLivedSession: false,
|
||
durableSession: false,
|
||
writeCapable: true,
|
||
readOnly: false,
|
||
capabilityLevel,
|
||
skill: {
|
||
name: HWLAB_M3_IO_SKILL_NAME,
|
||
contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
|
||
route
|
||
},
|
||
toolPolicy: {
|
||
allowed: [`${method} ${route}`],
|
||
blocked: ["gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "generic-hardware-rpc", "OpenAI-hardware-fallback", "M3/M4/M5-acceptance-claim"]
|
||
},
|
||
runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||
safety: {
|
||
secretsRead: false,
|
||
secretValuesPrinted: false,
|
||
kubeconfigRead: false,
|
||
cloudApiRouteOnly: true,
|
||
allowedRoute: route,
|
||
directGatewayCallsAllowed: false,
|
||
directBoxSimuCallsAllowed: false,
|
||
directPatchPanelCallsAllowed: false,
|
||
openAiFallbackAllowed: false,
|
||
m3m4m5AcceptanceClaimsAllowed: false,
|
||
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
|
||
}
|
||
};
|
||
}
|
||
|
||
function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = startedAt, outputTruncated, workspace = repoRoot, session = null, skillResult = null }) {
|
||
const capabilityLevel = skillResult?.capabilityLevel ?? (
|
||
skillResult?.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked
|
||
);
|
||
const route = m3SkillRoute(skillResult);
|
||
const method = m3SkillMethod(skillResult);
|
||
return {
|
||
traceId,
|
||
runnerKind: M3_IO_SKILL_RUNNER_KIND,
|
||
workspace,
|
||
sandbox: M3_IO_SKILL_SANDBOX,
|
||
sessionMode: M3_IO_SKILL_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
sessionStatus: session?.status ?? null,
|
||
idleTimeoutMs: session?.idleTimeoutMs ?? null,
|
||
lastTraceId: session?.lastTraceId ?? null,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE,
|
||
limitations: [...M3_IO_SKILL_LIMITATION_FLAGS],
|
||
startedAt,
|
||
finishedAt,
|
||
events,
|
||
route,
|
||
method,
|
||
skill: HWLAB_M3_IO_SKILL_NAME,
|
||
capabilityLevel,
|
||
controlReady: skillResult?.controlReady === true,
|
||
operationId: skillResult?.operationId ?? null,
|
||
auditId: skillResult?.auditId ?? skillResult?.audit?.auditId ?? null,
|
||
evidenceId: skillResult?.evidenceId ?? skillResult?.evidence?.evidenceId ?? null,
|
||
readback: skillResult?.readback ?? skillResult?.result?.targetReadback ?? null,
|
||
accepted: skillResult?.accepted ?? false,
|
||
status: skillResult?.status ?? "blocked",
|
||
blocker: skillResult?.blocker ?? null,
|
||
trustBlocker: skillResult?.trustBlocker ?? null,
|
||
blockers: skillResult?.blockers ?? [],
|
||
outputTruncated: Boolean(outputTruncated),
|
||
valuesPrinted: false,
|
||
directGatewayCalls: false,
|
||
directBoxCalls: false,
|
||
directPatchPanelCalls: false,
|
||
fallbackUsed: false,
|
||
note: `Controlled M3 IO uses Skill CLI -> HWLAB API ${route}; it is not OpenAI fallback and does not directly call gateway, box, or patch-panel.`
|
||
};
|
||
}
|
||
|
||
function structuredCompletionBlocker(result, context = {}) {
|
||
if (!result || typeof result !== "object") return null;
|
||
if (result.provider === OPENAI_FALLBACK_RUNNER_KIND || result.runner?.kind === OPENAI_FALLBACK_RUNNER_KIND || result.capabilityLevel === "text-chat-only") {
|
||
return structuredBlocker({
|
||
code: "text_chat_only_fallback",
|
||
layer: "provider",
|
||
message: "OpenAI Responses fallback is text chat only and cannot satisfy Code Agent runner/session/tool capability.",
|
||
userMessage: "当前仍是文本 fallback,只能回答普通问题,不能当作真实 Code Agent runner/session/tool 能力。",
|
||
retryable: false,
|
||
traceId: context.traceId,
|
||
provider: result.provider ?? context.provider,
|
||
backend: result.backend ?? context.backend,
|
||
runner: result.runner ?? context.runner,
|
||
capabilityLevel: result.capabilityLevel ?? context.capabilityLevel,
|
||
blockers: result.longLivedSessionGate?.blockers
|
||
});
|
||
}
|
||
if (result.provider === M3_IO_SKILL_PROVIDER && result.capabilityLevel === HWLAB_M3_IO_CAPABILITY_LEVELS.blocked) {
|
||
const primary = result.toolCalls?.[0]?.blocker ?? result.skills?.blockers?.[0] ?? result.runnerTrace?.blocker ?? null;
|
||
return structuredBlocker({
|
||
code: primary?.code === "hwlab_api_unavailable" ? "hwlab_api_unavailable" : "m3_readiness_blocked",
|
||
layer: primary?.code === "hwlab_api_unavailable" ? "hwlab-api" : "m3-readiness",
|
||
message: primary?.message ?? "M3 IO Skill CLI did not reach a ready HWLAB API control path.",
|
||
userMessage: primary?.zh ?? "M3 控制链路仍受阻,前端应显示为能力未就绪,而不是发送失败。",
|
||
retryable: primary?.code === "hwlab_api_unavailable",
|
||
traceId: context.traceId,
|
||
provider: result.provider,
|
||
backend: result.backend,
|
||
runner: result.runner,
|
||
capabilityLevel: result.capabilityLevel,
|
||
route: HWLAB_M3_IO_API_ROUTE,
|
||
toolName: HWLAB_M3_IO_SKILL_NAME,
|
||
blockers: result.skills?.blockers
|
||
});
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function sessionReuseEvidence(session) {
|
||
return {
|
||
conversationId: session.conversationId,
|
||
sessionId: session.sessionId,
|
||
mapped: true,
|
||
reused: session.reused,
|
||
turn: session.turn,
|
||
previousTurns: Math.max(0, session.turn - 1),
|
||
workspace: session.workspace,
|
||
createdAt: session.createdAt,
|
||
updatedAt: session.updatedAt,
|
||
idleTimeoutMs: session.idleTimeoutMs,
|
||
expiresAt: session.expiresAt,
|
||
lastTraceId: session.lastTraceId,
|
||
status: session.status
|
||
};
|
||
}
|
||
|
||
function sessionReplyLine(session) {
|
||
return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`;
|
||
}
|
||
|
||
function limitationReplyLine() {
|
||
return "边界:controlled-readonly-session-registry;long-lived read-only session;not-codex-stdio;not-write-capable;process-local-session-registry。";
|
||
}
|
||
|
||
function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) {
|
||
return registry.release(session.sessionId, {
|
||
now,
|
||
traceId,
|
||
conversationId,
|
||
reused: session.reused,
|
||
status: "idle"
|
||
}) ?? session;
|
||
}
|
||
|
||
function resolveCodeAgentSessionRegistry(options = {}) {
|
||
return options.sessionRegistry &&
|
||
typeof options.sessionRegistry.acquire === "function" &&
|
||
typeof options.sessionRegistry.release === "function" &&
|
||
typeof options.sessionRegistry.describe === "function"
|
||
? options.sessionRegistry
|
||
: defaultCodeAgentSessionRegistry;
|
||
}
|
||
|
||
function resolveCodexStdioSessionManager(options = {}) {
|
||
return options.codexStdioManager &&
|
||
typeof options.codexStdioManager.describe === "function" &&
|
||
typeof options.codexStdioManager.chat === "function" &&
|
||
typeof options.codexStdioManager.cancel === "function" &&
|
||
typeof options.codexStdioManager.reapIdle === "function"
|
||
? options.codexStdioManager
|
||
: defaultCodexStdioSessionManager;
|
||
}
|
||
|
||
function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) {
|
||
const allowed = kind === READONLY_RUNNER_KIND
|
||
? ["pwd", "skills.discover", "ls", "rg --files", "cat", `m3.io.skill-cli:${HWLAB_M3_IO_API_ROUTE}`]
|
||
: ["pwd", "skills.discover", "ls", "rg --files", "cat"];
|
||
return {
|
||
kind,
|
||
provider: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_PROVIDER : "codex-cli",
|
||
backend: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_BACKEND : "hwlab-cloud-api/codex-cli",
|
||
workspace: workspace ?? repoRoot,
|
||
sandbox: READONLY_RUNNER_SANDBOX,
|
||
session: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE,
|
||
sessionMode: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE,
|
||
sessionId: session?.sessionId ?? null,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: kind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral",
|
||
codexStdio: false,
|
||
longLivedSession: kind === READONLY_RUNNER_KIND,
|
||
durableSession: false,
|
||
writeCapable: false,
|
||
readOnly: true,
|
||
capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only",
|
||
toolPolicy: {
|
||
allowed,
|
||
blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"]
|
||
},
|
||
runnerLimitations: kind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"],
|
||
safety: runnerSafetyContract()
|
||
};
|
||
}
|
||
|
||
function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND, session = null }) {
|
||
return {
|
||
traceId,
|
||
runnerKind,
|
||
workspace,
|
||
sandbox: READONLY_RUNNER_SANDBOX,
|
||
sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot",
|
||
sessionId: session?.sessionId ?? null,
|
||
sessionStatus: session?.status ?? null,
|
||
idleTimeoutMs: session?.idleTimeoutMs ?? null,
|
||
lastTraceId: session?.lastTraceId ?? null,
|
||
turn: session?.turn ?? null,
|
||
sessionReused: session?.reused ?? false,
|
||
implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral",
|
||
limitations: runnerKind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"],
|
||
startedAt,
|
||
finishedAt: startedAt,
|
||
events,
|
||
outputTruncated: Boolean(outputTruncated),
|
||
valuesPrinted: false,
|
||
note: runnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND
|
||
? "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session."
|
||
: "controlled-readonly-session-registry stores conversation/session mapping, idle timeout, status, trace id, and turn counters for a reusable read-only session; it is not Codex stdio, write-capable, or DB-durable."
|
||
};
|
||
}
|
||
|
||
function runnerSafetyContract() {
|
||
return {
|
||
secretsRead: false,
|
||
secretValuesPrinted: false,
|
||
kubeconfigRead: false,
|
||
hardwareWritesAllowed: false,
|
||
directGatewayCallsAllowed: false,
|
||
directBoxSimuCallsAllowed: false,
|
||
directPatchPanelCallsAllowed: false,
|
||
m3IoSkillCliAllowedRoute: HWLAB_M3_IO_API_ROUTE,
|
||
m3StatusSkillCliAllowedRoute: HWLAB_M3_STATUS_API_ROUTE,
|
||
openAiHardwareFallbackAllowed: false,
|
||
m3m4m5AcceptanceClaimsAllowed: false,
|
||
outputLimitBytes: READONLY_TOOL_OUTPUT_LIMIT
|
||
};
|
||
}
|
||
|
||
async function inspectCodexStdioFeasibility(env = process.env, options = {}) {
|
||
const manager = resolveCodexStdioSessionManager(options);
|
||
const descriptor = {
|
||
env,
|
||
workspace: options.workspace,
|
||
command: options.command,
|
||
sandbox: options.sandbox
|
||
};
|
||
const feasibility = typeof manager.probe === "function"
|
||
? await manager.probe(descriptor)
|
||
: manager.describe(descriptor);
|
||
return {
|
||
...feasibility,
|
||
checked: true,
|
||
implementationRequired: "codex-stdio-or-equivalent-long-lived-runner",
|
||
currentImplementation: feasibility.ready ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE,
|
||
sourceIssue: CODEX_STDIO_SOURCE_ISSUE
|
||
};
|
||
}
|
||
|
||
function longLivedSessionGate({
|
||
provider,
|
||
runnerKind,
|
||
session,
|
||
sessionMode,
|
||
implementationType,
|
||
codexStdioFeasibility
|
||
} = {}) {
|
||
const normalizedProvider = String(provider ?? "").trim();
|
||
const normalizedRunnerKind = String(runnerKind ?? "").trim();
|
||
const normalizedSessionMode = String(sessionMode ?? "").trim();
|
||
const normalizedImplementation = String(implementationType ?? "").trim();
|
||
return codexLongLivedSessionGate({
|
||
provider: normalizedProvider,
|
||
runnerKind: normalizedRunnerKind,
|
||
session,
|
||
sessionMode: normalizedSessionMode,
|
||
implementationType: normalizedImplementation,
|
||
codexStdioFeasibility
|
||
});
|
||
}
|
||
|
||
function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }) {
|
||
return {
|
||
kind: OPENAI_FALLBACK_RUNNER_KIND,
|
||
provider: providerResult.provider ?? "openai-responses",
|
||
backend: providerResult.backend ?? "hwlab-cloud-api/openai-responses",
|
||
workspace: null,
|
||
sandbox: "none",
|
||
session: "provider-text-request",
|
||
sessionMode: "provider-text-request",
|
||
sessionId,
|
||
conversationId,
|
||
implementationType: OPENAI_FALLBACK_RUNNER_KIND,
|
||
codexStdio: false,
|
||
longLivedSession: false,
|
||
durableSession: false,
|
||
writeCapable: false,
|
||
readOnly: false,
|
||
capabilityLevel: "text-chat-only",
|
||
toolPolicy: {
|
||
allowed: [],
|
||
blocked: ["workspace-tools", "skills-discovery", "shell-tools", "file-tools", "hardware-write"]
|
||
},
|
||
capabilityGate: {
|
||
codexRunner: false,
|
||
reason: "OpenAI Responses fallback is text chat only and cannot satisfy the Codex runner capability gate."
|
||
},
|
||
runnerLimitations: [...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 未通过 readiness;OpenAI 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("'", "'\\''")}'`;
|
||
}
|