feat: add read-only Code Agent runner
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -327,7 +327,10 @@ async function handleCodeAgentChatHttp(request, response, options) {
|
||||
{
|
||||
callProvider: options.callCodeAgentProvider,
|
||||
timeoutMs: options.codeAgentTimeoutMs,
|
||||
env: options.env
|
||||
env: options.env,
|
||||
workspace: options.workspace,
|
||||
skillsDirs: options.skillsDirs,
|
||||
skillsDirsExact: options.skillsDirsExact
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+217
-18
@@ -1,6 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { createServer as createTcpServer } from "node:net";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
@@ -65,9 +68,12 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthPayload.readiness.durability.dbLiveEvidenceObserved, false);
|
||||
assert.equal(healthPayload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(healthPayload.readiness.durability.blockedLayer, "adapter");
|
||||
assert.equal(healthPayload.codeAgent.status, "blocked");
|
||||
assert.equal(healthPayload.codeAgent.blocker, "凭证缺口");
|
||||
assert.equal(healthPayload.codeAgent.reason, "provider_unavailable");
|
||||
assert.equal(healthPayload.codeAgent.status, "available");
|
||||
assert.equal(healthPayload.codeAgent.blocker, null);
|
||||
assert.equal(healthPayload.codeAgent.reason, null);
|
||||
assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner");
|
||||
assert.equal(healthPayload.codeAgent.runner.ready, true);
|
||||
assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-tools");
|
||||
assert.deepEqual(healthPayload.codeAgent.missingEnv, ["OPENAI_API_KEY"]);
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(healthPayload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
@@ -86,8 +92,9 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthLivePayload.status, "degraded");
|
||||
assert.equal(healthLivePayload.commit.id.length > 0, true);
|
||||
assert.equal(healthLivePayload.db.ready, false);
|
||||
assert.equal(healthLivePayload.codeAgent.status, "blocked");
|
||||
assert.equal(healthLivePayload.codeAgent.ready, false);
|
||||
assert.equal(healthLivePayload.codeAgent.status, "available");
|
||||
assert.equal(healthLivePayload.codeAgent.ready, true);
|
||||
assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-tools");
|
||||
|
||||
const readiness = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(readiness.status, 200);
|
||||
@@ -785,19 +792,22 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret
|
||||
assert.equal(payload.codeAgent.model, "gpt-test");
|
||||
assert.equal(payload.codeAgent.backend, "hwlab-cloud-api/openai-responses");
|
||||
assert.equal(payload.codeAgent.mode, "openai");
|
||||
assert.equal(payload.codeAgent.status, "blocked");
|
||||
assert.match(payload.codeAgent.blocker, /DEV Code Agent OpenAI base URL is missing/u);
|
||||
assert.equal(payload.codeAgent.reason, "provider_unavailable");
|
||||
assert.equal(payload.codeAgent.status, "available");
|
||||
assert.equal(payload.codeAgent.blocker, null);
|
||||
assert.equal(payload.codeAgent.reason, null);
|
||||
assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner");
|
||||
assert.equal(payload.codeAgent.runner.ready, true);
|
||||
assert.equal(payload.codeAgent.capabilityLevel, "read-only-tools");
|
||||
assert.deepEqual(payload.codeAgent.missingEnv, ["OPENAI_API_KEY", "HWLAB_CODE_AGENT_OPENAI_BASE_URL"]);
|
||||
assert.equal(payload.codeAgent.secretRefs[0].env, "OPENAI_API_KEY");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(payload.codeAgent.secretRefs[0].redacted, true);
|
||||
assert.equal(payload.codeAgent.ready, false);
|
||||
assert.equal(payload.codeAgent.ready, true);
|
||||
assert.equal(payload.codeAgent.egress.present, false);
|
||||
assert.equal(payload.codeAgent.egress.valueRedacted, true);
|
||||
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
|
||||
assert.match(payload.codeAgent.summary, /真实后端已接入/u);
|
||||
assert.match(payload.codeAgent.summary, /受控只读 runner/u);
|
||||
assert.match(payload.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
} finally {
|
||||
@@ -823,9 +833,10 @@ test("cloud api /v1 describes Code Agent egress blocker without leaking API key"
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.codeAgent.status, "blocked");
|
||||
assert.equal(payload.codeAgent.status, "available");
|
||||
assert.equal(payload.codeAgent.egress.directPublicOpenAi, true);
|
||||
assert.match(payload.codeAgent.blocker, /public api\.openai\.com/u);
|
||||
assert.equal(payload.codeAgent.blocker, null);
|
||||
assert.equal(payload.codeAgent.runner.ready, true);
|
||||
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
@@ -884,6 +895,193 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload",
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-workspace-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_server-test-runner-pwd"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-runner-pwd",
|
||||
message: "用pwd列出你当前的工作目录"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-readonly-runner");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-readonly-runner");
|
||||
assert.equal(payload.capabilityLevel, "read-only-tools");
|
||||
assert.equal(payload.workspace, workspace);
|
||||
assert.equal(payload.sandbox, "read-only");
|
||||
assert.equal(payload.runner.kind, "hwlab-readonly-runner");
|
||||
assert.equal(payload.runner.longLivedSession, false);
|
||||
assert.equal(payload.toolCalls[0].name, "pwd");
|
||||
assert.equal(payload.toolCalls[0].status, "completed");
|
||||
assert.equal(payload.toolCalls[0].stdout, workspace);
|
||||
assert.equal(payload.skills.status, "not_requested");
|
||||
assert.equal(payload.runnerTrace.runnerKind, "hwlab-readonly-runner");
|
||||
assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")));
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat discovers skills manifest with source and version evidence", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-skills-"));
|
||||
const skillsDir = path.join(root, "skills");
|
||||
await mkdir(path.join(skillsDir, "alpha"), { recursive: true });
|
||||
await writeFile(path.join(skillsDir, "alpha", "SKILL.md"), [
|
||||
"---",
|
||||
"name: alpha-skill",
|
||||
"description: Alpha skill summary.",
|
||||
"version: 1.2.3",
|
||||
"commit: abc123def456",
|
||||
"---",
|
||||
"",
|
||||
"# Alpha"
|
||||
].join("\n"));
|
||||
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: root
|
||||
},
|
||||
skillsDirs: [skillsDir],
|
||||
skillsDirsExact: true
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-skills",
|
||||
message: "列出你可用的skills"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-readonly-runner");
|
||||
assert.equal(payload.capabilityLevel, "read-only-tools");
|
||||
assert.equal(payload.skills.status, "ready");
|
||||
assert.equal(payload.skills.items[0].name, "alpha-skill");
|
||||
assert.equal(payload.skills.items[0].summary, "Alpha skill summary.");
|
||||
assert.equal(payload.skills.items[0].version, "1.2.3");
|
||||
assert.equal(payload.skills.items[0].commit, "abc123def456");
|
||||
assert.equal(payload.toolCalls[0].name, "skills.discover");
|
||||
assert.match(payload.reply.content, /alpha-skill/u);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat reports structured skills_unavailable blocker", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-"));
|
||||
const skillsDir = path.join(root, "missing-skills");
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: root
|
||||
},
|
||||
skillsDirs: [skillsDir],
|
||||
skillsDirsExact: true
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-skills-missing",
|
||||
message: "列出你可用的skills"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.provider, "codex-readonly-runner");
|
||||
assert.equal(payload.error.code, "skills_unavailable");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.equal(payload.skills.status, "blocked");
|
||||
assert.equal(payload.skills.blockers[0].sourceIssue, "pikasTech/HWLAB#136");
|
||||
assert.ok(payload.skills.blockers[0].linkedIssues.includes("pikasTech/HWLAB#237"));
|
||||
assert.equal(payload.toolCalls[0].status, "blocked");
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-tool-blocked-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-tool-unavailable",
|
||||
message: "请用cat读取package.json"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.error.code, "tool_unavailable");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.equal(payload.toolCalls[0].name, "cat");
|
||||
assert.equal(payload.toolCalls[0].status, "blocked");
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE text", async () => {
|
||||
const providerRequests = [];
|
||||
const providerServer = createHttpServer((request, response) => {
|
||||
@@ -1020,7 +1218,7 @@ test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 45
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-agent-chat-delayed",
|
||||
message: "列出你能使用的所有skill"
|
||||
message: "请用一句话说明当前 HWLAB 工作台可以做什么,稍后回答"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
@@ -1029,7 +1227,7 @@ test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 45
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed");
|
||||
assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed");
|
||||
assert.match(payload.reply.content, /列出你能使用的所有skill/u);
|
||||
assert.match(payload.reply.content, /稍后回答/u);
|
||||
assert.equal(Object.hasOwn(payload, "error"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -1181,13 +1379,14 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as
|
||||
assert.match(payload.error.message, /Codex CLI command is not available/);
|
||||
assert.deepEqual(payload.error.missingCommands, ["codex"]);
|
||||
assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(payload.availability.status, "blocked");
|
||||
assert.equal(payload.availability.blocker, "凭证缺口");
|
||||
assert.equal(payload.availability.reason, "provider_unavailable");
|
||||
assert.equal(payload.availability.status, "available");
|
||||
assert.equal(payload.availability.blocker, null);
|
||||
assert.equal(payload.availability.reason, null);
|
||||
assert.equal(payload.availability.runner.ready, true);
|
||||
assert.equal(payload.availability.secretRefs[0].secretName, "hwlab-code-agent-provider");
|
||||
assert.equal(payload.availability.secretRefs[0].secretKey, "openai-api-key");
|
||||
assert.equal(payload.availability.secretRefs[0].redacted, true);
|
||||
assert.match(payload.availability.summary, /真实后端已接入/u);
|
||||
assert.match(payload.availability.summary, /受控只读 runner/u);
|
||||
assert.match(payload.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
validateCodeAgentChatSchema
|
||||
} from "../internal/cloud/code-agent-chat.mjs";
|
||||
import {
|
||||
classifyCodexRunnerCapability,
|
||||
classifyCodeAgentChatReadiness,
|
||||
isHttpNon2xxStatus,
|
||||
summarizeCodeAgentPayload
|
||||
@@ -116,10 +117,11 @@ async function runLocalContractSmoke() {
|
||||
assert.match(failed.error.message, /Codex CLI command is not available/);
|
||||
assert.deepEqual(failed.error.missingCommands, ["codex"]);
|
||||
assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY"));
|
||||
assert.equal(failed.availability.status, "blocked");
|
||||
assert.match(failed.availability.blocker, /凭证缺口|OPENAI_API_KEY/u);
|
||||
assert.equal(failed.availability.reason, "provider_unavailable");
|
||||
assert.match(failed.availability.summary, /真实后端已接入/u);
|
||||
assert.equal(failed.availability.status, "available");
|
||||
assert.equal(failed.availability.blocker, null);
|
||||
assert.equal(failed.availability.reason, null);
|
||||
assert.equal(failed.availability.runner.ready, true);
|
||||
assert.match(failed.availability.summary, /受控只读 runner/u);
|
||||
assert.match(failed.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
|
||||
assert.equal(JSON.stringify(failed).includes("sk-"), false);
|
||||
assert.equal(Object.hasOwn(failed, "reply"), false);
|
||||
@@ -157,6 +159,86 @@ async function runLocalContractSmoke() {
|
||||
model: "gpt-5.5",
|
||||
backend: "hwlab-cloud-api/openai-responses"
|
||||
};
|
||||
|
||||
const runnerPwd = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_code-agent-chat-runner-pwd",
|
||||
traceId: "trc_code-agent-chat-runner-pwd",
|
||||
message: "用pwd列出你当前的工作目录"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-22T00:02:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
validateCodeAgentChatSchema(runnerPwd);
|
||||
assert.equal(runnerPwd.status, "completed");
|
||||
assert.equal(runnerPwd.provider, "codex-readonly-runner");
|
||||
assert.equal(runnerPwd.workspace, process.cwd());
|
||||
assert.equal(runnerPwd.sandbox, "read-only");
|
||||
assert.equal(runnerPwd.capabilityLevel, "read-only-tools");
|
||||
assert.equal(runnerPwd.toolCalls[0].name, "pwd");
|
||||
assert.equal(runnerPwd.toolCalls[0].status, "completed");
|
||||
const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 });
|
||||
assert.equal(runnerCapability.status, "pass");
|
||||
assert.equal(runnerCapability.capabilityPass, true);
|
||||
logOk("read-only runner pwd capability");
|
||||
|
||||
const fallbackCapability = classifyCodexRunnerCapability(completedLivePayload, { httpStatus: 200 });
|
||||
assert.equal(fallbackCapability.status, "blocked");
|
||||
assert.equal(fallbackCapability.blocker, "openai-fallback-not-runner");
|
||||
assert.equal(fallbackCapability.capabilityPass, false);
|
||||
logOk("OpenAI fallback does not satisfy Codex runner capability");
|
||||
|
||||
const missingSkills = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_code-agent-chat-missing-skills",
|
||||
traceId: "trc_code-agent-chat-missing-skills",
|
||||
message: "列出你可用的skills"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-22T00:03:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_CODE_AGENT_SKILLS_DIRS: "/tmp/hwlab-code-agent-smoke-missing-skills"
|
||||
},
|
||||
skillsDirs: ["/tmp/hwlab-code-agent-smoke-missing-skills"],
|
||||
skillsDirsExact: true
|
||||
}
|
||||
);
|
||||
validateCodeAgentChatSchema(missingSkills);
|
||||
assert.equal(missingSkills.status, "failed");
|
||||
assert.equal(missingSkills.error.code, "skills_unavailable");
|
||||
assert.equal(missingSkills.skills.status, "blocked");
|
||||
assert.equal(missingSkills.skills.blockers[0].sourceIssue, "pikasTech/HWLAB#136");
|
||||
assert.equal(classifyCodexRunnerCapability(missingSkills, { httpStatus: 200 }).capabilityPass, false);
|
||||
logOk("missing skills is structured blocker");
|
||||
|
||||
const toolUnavailable = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_code-agent-chat-tool-unavailable",
|
||||
traceId: "trc_code-agent-chat-tool-unavailable",
|
||||
message: "请用cat读取 package.json"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-22T00:04:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
}
|
||||
}
|
||||
);
|
||||
validateCodeAgentChatSchema(toolUnavailable);
|
||||
assert.equal(toolUnavailable.status, "failed");
|
||||
assert.equal(toolUnavailable.error.code, "tool_unavailable");
|
||||
assert.equal(toolUnavailable.toolCalls[0].status, "blocked");
|
||||
assert.equal(classifyCodexRunnerCapability(toolUnavailable, { httpStatus: 200 }).capabilityPass, false);
|
||||
logOk("tool unavailable is blocked, not completed runner capability");
|
||||
|
||||
const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 });
|
||||
assert.equal(completedHttp200Readiness.status, "pass");
|
||||
assert.equal(completedHttp200Readiness.devLiveReplyPass, true);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const trustedLiveProviders = new Set(["openai-responses", "codex-cli"]);
|
||||
const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner"]);
|
||||
const trustedRunnerProviders = new Set(["codex-readonly-runner"]);
|
||||
const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
|
||||
const blockedCodeAgentUiLabels = new Set(["发送失败", "服务受阻", "BLOCKED 凭证缺口"]);
|
||||
const timeoutPattern = /\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu;
|
||||
@@ -56,6 +57,78 @@ export function classifyCodeAgentChatReadiness(payload, { realDevLive = false, h
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {}) {
|
||||
const providerBlock = classifyCodeAgentProviderBlock(payload, { httpStatus });
|
||||
if (providerBlock.blocked) {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: providerBlock.level,
|
||||
blocker: providerBlock.blocker,
|
||||
capabilityPass: false,
|
||||
reason: providerBlock.reason,
|
||||
...(providerBlock.providerStatus != null ? { providerStatus: providerBlock.providerStatus } : {})
|
||||
};
|
||||
}
|
||||
|
||||
const provider = stringOrNull(payload?.provider);
|
||||
const runnerKind = stringOrNull(payload?.runner?.kind);
|
||||
const capabilityLevel = stringOrNull(payload?.capabilityLevel);
|
||||
const workspace = stringOrNull(payload?.workspace ?? payload?.runner?.workspace);
|
||||
const sandbox = stringOrNull(payload?.sandbox ?? payload?.runner?.sandbox);
|
||||
const toolCalls = Array.isArray(payload?.toolCalls) ? payload.toolCalls : [];
|
||||
const runnerTrace = payload?.runnerTrace && typeof payload.runnerTrace === "object" ? payload.runnerTrace : null;
|
||||
const skills = payload?.skills && typeof payload.skills === "object" ? payload.skills : null;
|
||||
|
||||
if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: "BLOCKED/text-chat-only",
|
||||
blocker: "openai-fallback-not-runner",
|
||||
capabilityPass: false,
|
||||
reason: "OpenAI Responses fallback can answer text, but it has no workspace/tools/skills runner evidence."
|
||||
};
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
if (payload?.status !== "completed") missing.push("completed status");
|
||||
if (!trustedRunnerProviders.has(provider)) missing.push("runner provider");
|
||||
if (runnerKind !== "hwlab-readonly-runner") missing.push("runner.kind");
|
||||
if (capabilityLevel !== "read-only-tools") missing.push("capabilityLevel=read-only-tools");
|
||||
if (!workspace) missing.push("workspace");
|
||||
if (sandbox !== "read-only") missing.push("sandbox=read-only");
|
||||
if (toolCalls.length === 0) missing.push("toolCalls");
|
||||
if (!runnerTrace) missing.push("runnerTrace");
|
||||
if (!skills) missing.push("skills");
|
||||
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: "BLOCKED/runner-capability",
|
||||
blocker: "runner-evidence-missing",
|
||||
capabilityPass: false,
|
||||
reason: `Codex runner capability evidence is incomplete: ${missing.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
if (toolCalls.some((tool) => tool.status !== "completed")) {
|
||||
return {
|
||||
status: "blocked",
|
||||
level: "BLOCKED/tool",
|
||||
blocker: "tool-unavailable",
|
||||
capabilityPass: false,
|
||||
reason: "At least one runner tool call is not completed."
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "pass",
|
||||
level: "#275 read-only Codex runner capability pass",
|
||||
blocker: null,
|
||||
capabilityPass: true,
|
||||
reason: "completed read-only runner response includes workspace, sandbox, toolCalls, skills, and runnerTrace evidence"
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyCodeAgentProviderBlock(payload, { httpStatus = null } = {}) {
|
||||
const payloadStatus = stringOrNull(payload?.status);
|
||||
const errorCode = stringOrNull(payload?.error?.code);
|
||||
@@ -142,7 +215,14 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
|
||||
missingEnv: [],
|
||||
missingCommands: [],
|
||||
providerStatus: providerBlock.providerStatus
|
||||
}
|
||||
},
|
||||
runner: null,
|
||||
workspace: null,
|
||||
sandbox: null,
|
||||
capabilityLevel: null,
|
||||
toolCalls: [],
|
||||
skills: null,
|
||||
runnerTrace: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,7 +255,14 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
|
||||
backend,
|
||||
traceId: observedTraceId,
|
||||
hasReply: false,
|
||||
error: blockedError
|
||||
error: blockedError,
|
||||
runner: summarizeRunner(payload),
|
||||
workspace: stringOrNull(payload.workspace),
|
||||
sandbox: stringOrNull(payload.sandbox),
|
||||
capabilityLevel: stringOrNull(payload.capabilityLevel),
|
||||
toolCalls: summarizeToolCalls(payload.toolCalls),
|
||||
skills: summarizeSkills(payload.skills),
|
||||
runnerTrace: summarizeRunnerTrace(payload.runnerTrace)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,7 +275,14 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId
|
||||
backend,
|
||||
traceId: observedTraceId,
|
||||
hasReply: assistantReply.length > 0,
|
||||
error
|
||||
error,
|
||||
runner: summarizeRunner(payload),
|
||||
workspace: stringOrNull(payload.workspace),
|
||||
sandbox: stringOrNull(payload.sandbox),
|
||||
capabilityLevel: stringOrNull(payload.capabilityLevel),
|
||||
toolCalls: summarizeToolCalls(payload.toolCalls),
|
||||
skills: summarizeSkills(payload.skills),
|
||||
runnerTrace: summarizeRunnerTrace(payload.runnerTrace)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,6 +376,9 @@ export function inspectRealCompletionEvidence(payload) {
|
||||
if (!trustedLiveProviders.has(provider)) {
|
||||
return { ok: false, reason: `provider ${payload.provider} is not an accepted real provider` };
|
||||
}
|
||||
if (provider === "openai-responses" && payload?.capabilityLevel && payload.capabilityLevel !== "text-chat-only") {
|
||||
return { ok: false, reason: "openai-responses cannot claim runner capabilityLevel" };
|
||||
}
|
||||
if (untrustedProviderPattern.test(`${provider} ${backend}`)) {
|
||||
return { ok: false, reason: `provider/backend looks non-real: ${payload.provider}/${payload.backend}` };
|
||||
}
|
||||
@@ -324,6 +421,55 @@ function inspectSanitizedCompletionEvidence(summary) {
|
||||
return { ok: true, reason: "real provider/backend/model/trace/reply evidence present" };
|
||||
}
|
||||
|
||||
function summarizeRunner(payload) {
|
||||
if (!payload?.runner || typeof payload.runner !== "object") return null;
|
||||
return {
|
||||
kind: stringOrNull(payload.runner.kind),
|
||||
backend: stringOrNull(payload.runner.backend),
|
||||
workspace: stringOrNull(payload.runner.workspace),
|
||||
sandbox: stringOrNull(payload.runner.sandbox),
|
||||
session: stringOrNull(payload.runner.session),
|
||||
readOnly: payload.runner.readOnly === true,
|
||||
capabilityLevel: stringOrNull(payload.runner.capabilityLevel)
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeToolCalls(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((tool) => ({
|
||||
name: stringOrNull(tool?.name),
|
||||
type: stringOrNull(tool?.type),
|
||||
status: stringOrNull(tool?.status),
|
||||
exitCode: numericStatus(tool?.exitCode),
|
||||
outputTruncated: tool?.outputTruncated === true
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeSkills(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return {
|
||||
status: stringOrNull(value.status),
|
||||
count: typeof value.count === "number" ? value.count : Array.isArray(value.items) ? value.items.length : null,
|
||||
totalCount: typeof value.totalCount === "number" ? value.totalCount : null,
|
||||
blockers: Array.isArray(value.blockers)
|
||||
? value.blockers.map((blocker) => ({
|
||||
code: stringOrNull(blocker.code),
|
||||
sourceIssue: stringOrNull(blocker.sourceIssue)
|
||||
}))
|
||||
: []
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRunnerTrace(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
return {
|
||||
runnerKind: stringOrNull(value.runnerKind),
|
||||
sandbox: stringOrNull(value.sandbox),
|
||||
outputTruncated: value.outputTruncated === true,
|
||||
valuesPrinted: value.valuesPrinted === true
|
||||
};
|
||||
}
|
||||
|
||||
function stringOrNull(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,13 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([
|
||||
"provider",
|
||||
"model",
|
||||
"backend",
|
||||
"runner",
|
||||
"workspace",
|
||||
"sandbox",
|
||||
"capability",
|
||||
"toolCalls",
|
||||
"skills",
|
||||
"runnerTrace",
|
||||
"conversation",
|
||||
"trace",
|
||||
"message",
|
||||
@@ -1797,11 +1804,20 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) {
|
||||
/recordField\("provider",\s*message\.provider\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("model",\s*message\.model\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("backend",\s*message\.backend\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("runner",\s*message\.runner\?\.kind\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("workspace",\s*message\.workspace\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("sandbox",\s*message\.sandbox\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("capability",\s*message\.capabilityLevel\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("toolCalls",\s*toolCallsSummary\(message\.toolCalls\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("skills",\s*skillsSummary\(message\.skills\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("runnerTrace",\s*runnerTraceSummary\(message\.runnerTrace\)\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("conversation",\s*message\.conversationId\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("trace",\s*message\.traceId\)/u.test(messageEvidenceBody) &&
|
||||
/recordField\("message",\s*message\.messageId\)/u.test(messageEvidenceBody) &&
|
||||
/providerTraceSummary\(message\.providerTrace\)/u.test(messageEvidenceBody) &&
|
||||
/isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) &&
|
||||
/isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) &&
|
||||
/CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) &&
|
||||
/TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) &&
|
||||
/UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) &&
|
||||
/value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) &&
|
||||
@@ -2866,7 +2882,7 @@ async function inspectJourneyUi(page) {
|
||||
messageCount: document.querySelectorAll(".message-card").length,
|
||||
completedMessageHasNonSensitiveMeta: Boolean(
|
||||
[...document.querySelectorAll(".message-card.status-completed .message-meta")]
|
||||
.some((element) => /openai-responses|gpt-5\.5|trc_/u.test(element.textContent ?? ""))
|
||||
.some((element) => /openai-responses|codex-readonly-runner|gpt-5\.5|runner=|workspace=|toolCalls=|trc_/u.test(element.textContent ?? ""))
|
||||
),
|
||||
sourceMessageHasNonSensitiveMeta: Boolean(
|
||||
[...document.querySelectorAll(".message-card.status-source .message-meta")]
|
||||
|
||||
@@ -52,7 +52,8 @@ const M3_TRUSTED_ROUTE = Object.freeze({
|
||||
const LIVE_M3_ID_FIELDS = Object.freeze(["operationId", "traceId", "auditId", "evidenceId"]);
|
||||
const LIVE_M3_PASS_STATUSES = Object.freeze(["pass", "passed", "verified", "succeeded", "trusted", "completed"]);
|
||||
const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]);
|
||||
const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli"]);
|
||||
const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli", "codex-readonly-runner"]);
|
||||
const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-readonly-runner"]);
|
||||
const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
|
||||
|
||||
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
||||
@@ -329,6 +330,13 @@ function initCommandBar() {
|
||||
provider: result.provider,
|
||||
model: result.model,
|
||||
backend: result.backend,
|
||||
workspace: result.workspace,
|
||||
sandbox: result.sandbox,
|
||||
toolCalls: result.toolCalls,
|
||||
skills: result.skills,
|
||||
runner: result.runner,
|
||||
runnerTrace: result.runnerTrace,
|
||||
capabilityLevel: result.capabilityLevel,
|
||||
sourceKind: completion.sourceKind,
|
||||
providerTrace: result.providerTrace,
|
||||
updatedAt: result.updatedAt,
|
||||
@@ -1889,6 +1897,13 @@ function messageEvidenceFields(message) {
|
||||
recordField("provider", message.provider),
|
||||
recordField("model", message.model),
|
||||
recordField("backend", message.backend),
|
||||
recordField("runner", message.runner?.kind),
|
||||
recordField("workspace", message.workspace),
|
||||
recordField("sandbox", message.sandbox),
|
||||
recordField("capability", message.capabilityLevel),
|
||||
recordField("toolCalls", toolCallsSummary(message.toolCalls)),
|
||||
recordField("skills", skillsSummary(message.skills)),
|
||||
recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)),
|
||||
recordField("conversation", message.conversationId),
|
||||
recordField("trace", message.traceId),
|
||||
recordField("message", message.messageId),
|
||||
@@ -1909,6 +1924,25 @@ function providerTraceSummary(providerTrace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toolCallsSummary(toolCalls) {
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return null;
|
||||
const completed = toolCalls.filter((tool) => tool?.status === "completed").length;
|
||||
const names = toolCalls.map((tool) => tool?.name).filter(Boolean).slice(0, 3).join(",");
|
||||
return `${completed}/${toolCalls.length}${names ? `:${names}` : ""}`;
|
||||
}
|
||||
|
||||
function skillsSummary(skills) {
|
||||
if (!skills || typeof skills !== "object") return null;
|
||||
if (skills.status === "not_requested") return "not_requested";
|
||||
const count = skills.totalCount ?? skills.count ?? (Array.isArray(skills.items) ? skills.items.length : 0);
|
||||
return `${skills.status ?? "unknown"}:${count}`;
|
||||
}
|
||||
|
||||
function runnerTraceSummary(runnerTrace) {
|
||||
if (!runnerTrace || typeof runnerTrace !== "object") return null;
|
||||
return runnerTrace.runnerKind ?? "present";
|
||||
}
|
||||
|
||||
function statusCard(item) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "status-card";
|
||||
@@ -2060,6 +2094,14 @@ function sanitizeCodeAgentAvailability(availability) {
|
||||
}
|
||||
|
||||
function codeAgentStatusMessage(availability) {
|
||||
if (availability?.runner?.ready === true) {
|
||||
return {
|
||||
role: "system",
|
||||
title: "Code Agent 状态:只读 runner 已接入",
|
||||
text: "pwd 和 skills discovery 会走受控只读 runner;普通中文对话可走 OpenAI Responses fallback,但 fallback 不算 Codex runner 能力。",
|
||||
status: "source"
|
||||
};
|
||||
}
|
||||
if (availability?.status === "blocked") {
|
||||
return {
|
||||
role: "system",
|
||||
@@ -2128,6 +2170,9 @@ function untrustedCompletionMessage(result) {
|
||||
}
|
||||
|
||||
function codeAgentAvailabilitySummary() {
|
||||
if (state.codeAgentAvailability?.runner?.ready === true) {
|
||||
return "同源 API 可响应;pwd/skills 可走只读 runner,普通中文对话可降级到文本 fallback。";
|
||||
}
|
||||
if (state.codeAgentAvailability?.status === "blocked") {
|
||||
return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。";
|
||||
}
|
||||
@@ -2166,6 +2211,9 @@ function isSourceFixtureCompletedChatMessage(message) {
|
||||
}
|
||||
|
||||
function hasRealCodeAgentEvidence(value) {
|
||||
if (isCodexRunnerCapableEvidence(value)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
isTrustedCodeAgentProvider(value?.provider) &&
|
||||
Boolean(value?.model) &&
|
||||
@@ -2176,6 +2224,20 @@ function hasRealCodeAgentEvidence(value) {
|
||||
);
|
||||
}
|
||||
|
||||
function isCodexRunnerCapableEvidence(value) {
|
||||
return (
|
||||
CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) &&
|
||||
value?.runner?.kind === "hwlab-readonly-runner" &&
|
||||
value?.capabilityLevel === "read-only-tools" &&
|
||||
Boolean(value?.workspace || value?.runner?.workspace) &&
|
||||
(value?.sandbox || value?.runner?.sandbox) === "read-only" &&
|
||||
Array.isArray(value?.toolCalls) &&
|
||||
value.toolCalls.some((tool) => tool?.status === "completed") &&
|
||||
Boolean(value?.runnerTrace) &&
|
||||
Boolean(value?.skills)
|
||||
);
|
||||
}
|
||||
|
||||
function isTrustedCodeAgentProvider(provider) {
|
||||
const normalized = String(provider ?? "").trim().toLowerCase();
|
||||
return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized);
|
||||
|
||||
Reference in New Issue
Block a user