diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 45e7ce9b..1804951c 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -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); }); } diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 7307d715..1ec93af2 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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 } ); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 63373ac7..40de6bc1 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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); diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 6b4b8d32..9b41cfd7 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -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); diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 4d0c9328..d9fa611c 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -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; } diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 3e2607d5..94c3c793 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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")] diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index e0d6458e..da7e48a4 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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);