feat: add read-only Code Agent runner

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