From 942fba0e9b0a7c225b5f7167bc9e839145f3bff3 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 09:23:26 +0000 Subject: [PATCH] feat: add controlled Code Agent session registry --- docs/reference/code-agent-chat-readiness.md | 20 + internal/cloud/code-agent-chat.mjs | 771 ++++++++++++++++-- internal/cloud/server.test.mjs | 114 ++- scripts/code-agent-chat-smoke.mjs | 116 ++- scripts/src/code-agent-response-contract.mjs | 84 +- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 20 + web/hwlab-cloud-web/app.mjs | 56 +- web/hwlab-cloud-web/scripts/check.mjs | 20 + 8 files changed, 1136 insertions(+), 65 deletions(-) diff --git a/docs/reference/code-agent-chat-readiness.md b/docs/reference/code-agent-chat-readiness.md index 9774adb8..0aa69b59 100644 --- a/docs/reference/code-agent-chat-readiness.md +++ b/docs/reference/code-agent-chat-readiness.md @@ -38,6 +38,8 @@ report、issue、PR 或截图。 | 观测结果 | readiness | | --- | --- | | `status: "failed"`,`error.code: "provider_unavailable"`,且 `error.missingEnv` 包含 `OPENAI_API_KEY` | `BLOCKED/credential`;provider 凭证缺失,不能标真实回复通过。 | +| `provider: "codex-readonly-runner"` 且 `sessionMode: "controlled-readonly-session-registry"`、`capabilityLevel: "read-only-tools"`、`runnerLimitations` 包含 `not-codex-stdio` / `not-write-capable` / `not-durable-session` | 只能标为 read-only session registry partial pass;不能关闭 #275 的 long-lived Codex stdio/session blocker。 | +| `codexStdioFeasibility.status: "blocked"`,或 blocker 包含 `codex_cli_binary_missing`、`runner_lifecycle_missing`、`stdio_protocol_not_wired`、`workspace_mount_missing`、`provider_token_boundary` | 真实 Codex stdio / 等价 long-lived runner 未具备;必须按 blocker 处理,不能表述为完整 Codex session。 | | `status: "completed"`,但来自 mock、fixture、本地 stub、source-only smoke、浏览器本地回显或人工拼接 | 不是 DEV-LIVE reply pass。 | | 真实 DEV `POST /v1/agent/chat` 返回 `status: "completed"`,且 `reply.content` 是非空 assistant 回复 | 可标 DEV-LIVE reply pass。 | | 传输失败、schema 不完整、HTTP 非预期、`reply.content` 为空或缺失 | `BLOCKED`,按 runtime/schema/transport 分析。 | @@ -45,6 +47,24 @@ report、issue、PR 或截图。 只有“真实 DEV 路由 + `completed` + 非空 assistant reply”能作为 DEV-LIVE 回复通过依据。 不得把 mock、fixture、本地 echo、source report、静态检查或前端状态当作通过。 +## Runner 能力边界 + +`/v1/agent/chat` 可以先落地受控只读能力,但必须诚实区分: + +- `controlled-readonly-session-registry`:由 cloud-api 进程内 registry 保存 + `conversationId/sessionId` 映射、`turn` 计数、workspace 与只读工具 trace。它可以覆盖 + `pwd`、`skills.discover`、`ls`、`rg --files` 和 bounded `cat`,输出必须限长和脱敏。 +- 该模式必须同时标记 `not-codex-stdio`、`not-write-capable`、`not-durable-session`。它不是 + long-lived Codex stdio session,不提供写文件、任意 shell、硬件写、Secret/kubeconfig/DB URL + 读取,也不证明 M3/M4/M5 trusted green。 +- OpenAI Responses fallback 只能标记为 `openai-responses-fallback` / + `text-chat-only`,不得满足 Codex runner capability gate。 + +当前 DEV/runtime 若要升级为完整 #275 runner,至少需要 repo-owned 的 Codex CLI/stdio 或等价 +runner 二进制/协议适配、session supervisor 生命周期、workspace mount 与 sandbox 合同、token/Secret +注入边界、trace/cancel/reap 机制,以及与 cloud-api/workbench 的持久 session 映射。缺任一项时,必须 +在 `codexStdioFeasibility` 中报告 blocker。 + ## Smoke Gate 本地合同检查: diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 1804951c..3751b6e6 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, existsSync } from "node:fs"; -import { access, mkdir, readFile, readdir, rm } from "node:fs/promises"; +import { access, mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -21,11 +21,33 @@ const READONLY_RUNNER_BACKEND = "hwlab-cloud-api/codex-readonly-runner"; const READONLY_RUNNER_MODEL = "read-only-tools"; const READONLY_RUNNER_KIND = "hwlab-readonly-runner"; const READONLY_RUNNER_SANDBOX = "read-only"; +const READONLY_SESSION_MODE = "controlled-readonly-session-registry"; +const READONLY_IMPLEMENTATION_TYPE = "controlled-readonly-session-registry"; const 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 READONLY_FILE_READ_LIMIT = 64 * 1024; +const READONLY_FILE_ENTRY_LIMIT = 200; +const READONLY_FILE_TREE_DEPTH = 8; +const MAX_READONLY_SESSIONS = 200; const MAX_SKILLS_RETURNED = 40; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); +const READONLY_LIMITATION_FLAGS = Object.freeze([ + "not-codex-stdio", + "not-write-capable", + "not-durable-session" +]); +const SKIPPED_READONLY_DIRS = new Set([ + ".git", + ".hg", + ".svn", + ".cache", + ".worktrees", + "node_modules", + "dist", + "build", + "coverage" +]); const CODE_AGENT_SYSTEM_PROMPT = [ "你是 HWLAB 云工作台的 Code Agent。", "请用中文直接回答用户的工作台问题。", @@ -36,11 +58,12 @@ const CODE_AGENT_SYSTEM_PROMPT = [ ].join("\n"); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const readOnlySessions = new Map(); +const readOnlyConversationSessions = new Map(); export async function handleCodeAgentChat(params = {}, options = {}) { const timestamp = nowIso(options.now); - const conversationId = cleanProtocolId(params.conversationId, "cnv") || `cnv_${randomUUID()}`; - const sessionId = conversationId; + const { conversationId, sessionId } = resolveConversationSessionIds(params); const messageId = `msg_${randomUUID()}`; const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`; const providerPlan = resolveProviderPlan(options.env ?? process.env, options); @@ -66,6 +89,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { intent: runnerIntent, conversationId, traceId, + sessionId, env: options.env ?? process.env, now: options.now, workspace: options.workspace, @@ -82,6 +106,11 @@ export async function handleCodeAgentChat(params = {}, options = {}) { backend: runnerResult.backend, workspace: runnerResult.workspace, sandbox: runnerResult.sandbox, + sessionMode: runnerResult.sessionMode, + sessionReuse: runnerResult.sessionReuse, + implementationType: runnerResult.implementationType, + runnerLimitations: runnerResult.runnerLimitations, + codexStdioFeasibility: runnerResult.codexStdioFeasibility, toolCalls: runnerResult.toolCalls, skills: runnerResult.skills, runner: runnerResult.runner, @@ -126,13 +155,23 @@ export async function handleCodeAgentChat(params = {}, options = {}) { backend: providerResult.backend ?? base.backend, workspace: providerResult.workspace ?? null, sandbox: providerResult.sandbox ?? "none", + sessionMode: providerResult.sessionMode ?? "provider-text-request", + sessionReuse: providerResult.sessionReuse ?? null, + implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, + runnerLimitations: providerResult.runnerLimitations ?? [ + "text-chat-only", + "not-codex-stdio", + "not-workspace-tools", + "not-durable-session" + ], + codexStdioFeasibility: providerResult.codexStdioFeasibility ?? inspectCodexStdioFeasibility(envForFeasibility(options.env ?? process.env)), toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], skills: providerResult.skills ?? { status: "not_requested", items: [], blockers: [] }, - runner: providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId }), + runner: providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }), runnerTrace: providerResult.runnerTrace ?? { traceId, runnerKind: OPENAI_FALLBACK_RUNNER_KIND, @@ -165,8 +204,13 @@ export async function handleCodeAgentChat(params = {}, options = {}) { 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.runnerTrace !== undefined) payload.runnerTrace = error.runnerTrace; + if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel; + if (error.sessionMode !== undefined) payload.sessionMode = error.sessionMode; + if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse; + if (error.implementationType !== undefined) payload.implementationType = error.implementationType; + if (error.runnerLimitations !== undefined) payload.runnerLimitations = error.runnerLimitations; + if (error.codexStdioFeasibility !== undefined) payload.codexStdioFeasibility = error.codexStdioFeasibility; if (error.code === "provider_unavailable") { payload.availability = describeCodeAgentAvailability(options.env ?? process.env, options); } else if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(error.code)) { @@ -247,6 +291,11 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { "skills", "runnerTrace", "capabilityLevel", + "sessionMode", + "sessionReuse", + "implementationType", + "runnerLimitations", + "codexStdioFeasibility", "error.message", "error.code", "error.missingEnv", @@ -264,6 +313,10 @@ export function describeCodeAgentAvailability(env = process.env, options = {}) { egress: providerContract.egress, safety: providerContract.safety, capabilityLevel: runnerAvailability.ready ? "read-only-tools" : blocked ? "blocked" : "text-chat-only", + sessionMode: runnerAvailability.sessionMode, + implementationType: runnerAvailability.implementationType, + runnerLimitations: runnerAvailability.runnerLimitations, + codexStdioFeasibility: runnerAvailability.codexStdioFeasibility, ready: !blocked || runnerAvailability.ready }; } @@ -327,32 +380,52 @@ function inspectReadOnlyRunnerAvailability(env, options = {}) { const workspaceReady = Boolean(workspace && existsSync(workspace)); const skillsDirs = resolveSkillDirs(env, options); const skillsDirsPresent = skillsDirs.filter((dir) => existsSync(dir)); + const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env)); 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", + mode: READONLY_SESSION_MODE, + sessionMode: READONLY_SESSION_MODE, + session: READONLY_SESSION_MODE, status: workspaceReady ? "available" : "blocked", ready: workspaceReady, capabilityLevel: workspaceReady ? "read-only-tools" : "blocked", + implementationType: READONLY_IMPLEMENTATION_TYPE, + longLivedSession: false, + durableSession: false, + codexStdio: false, + writeCapable: false, + runnerLimitations: [...READONLY_LIMITATION_FLAGS], + codexStdioFeasibility, skillsDirs, skillsDirsPresent, safety: runnerSafetyContract() }; } -async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skillsDirs, skillsDirsExact }) { +async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact }) { const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); - const runner = runnerDescriptor({ workspace: resolvedWorkspace }); + const session = getOrCreateReadOnlySession({ conversationId, sessionId, workspace: resolvedWorkspace, now }); + const runner = runnerDescriptor({ workspace: resolvedWorkspace, session }); const startedAt = nowIso(now); + const codexStdioFeasibility = inspectCodexStdioFeasibility(envForFeasibility(env)); const events = [ `intent:${intent.kind}`, "sandbox:read-only", - "session:stateless-one-shot" + `sessionMode:${READONLY_SESSION_MODE}`, + session.reused ? "session:reused" : "session:created", + `turn:${session.turn}` ]; + const baseEvidence = { + sessionMode: READONLY_SESSION_MODE, + sessionReuse: sessionReuseEvidence(session), + implementationType: READONLY_IMPLEMENTATION_TYPE, + runnerLimitations: [...READONLY_LIMITATION_FLAGS], + codexStdioFeasibility + }; if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) { throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, { @@ -360,8 +433,9 @@ async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skills toolCalls: [], skills: notRequestedSkills(), runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }), - capabilityLevel: "blocked" + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }), + capabilityLevel: "blocked", + ...baseEvidence }); } @@ -381,8 +455,9 @@ async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skills }], skills: notRequestedSkills(), runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }), + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }), capabilityLevel: "blocked", + ...baseEvidence, blockers: [{ code: "security_blocked", sourceIssue: "pikasTech/HWLAB#275", @@ -393,29 +468,107 @@ async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skills 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, + return readOnlyRunnerResult({ content: [ "当前受控只读 runner 工作目录:", toolCall.stdout, "", - "说明:这是一次 stateless/read-only runner 工具调用,不是长驻 stdio 会话,也不会触发硬件写操作。" + sessionReplyLine(session), + limitationReplyLine() ].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" - } - }; + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:pwd:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), + session, + codexStdioFeasibility, + outputTruncated: toolCall.outputTruncated + }); + } + + if (intent.kind === "ls") { + const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); + assertReadOnlyToolCompleted(toolCall, { + workspace: resolvedWorkspace, + skills: notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }), + baseEvidence + }); + return readOnlyRunnerResult({ + content: [ + "受控只读 ls 结果:", + toolCall.stdout || "(empty)", + "", + sessionReplyLine(session), + limitationReplyLine() + ].join("\n"), + workspace: resolvedWorkspace, + toolCalls: [toolCall], + skills: notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), + session, + codexStdioFeasibility, + outputTruncated: toolCall.outputTruncated + }); + } + + if (intent.kind === "rg_files") { + const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env }); + assertReadOnlyToolCompleted(toolCall, { + workspace: resolvedWorkspace, + skills: notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }), + baseEvidence + }); + return readOnlyRunnerResult({ + content: [ + "受控只读 rg --files 结果:", + toolCall.stdout || "(empty)", + "", + sessionReplyLine(session), + limitationReplyLine() + ].join("\n"), + workspace: resolvedWorkspace, + toolCalls: [toolCall], + skills: notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), + session, + codexStdioFeasibility, + outputTruncated: toolCall.outputTruncated + }); + } + + if (intent.kind === "cat") { + const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); + assertReadOnlyToolCompleted(toolCall, { + workspace: resolvedWorkspace, + skills: notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }), + baseEvidence + }); + return readOnlyRunnerResult({ + content: [ + "受控只读 cat 结果:", + toolCall.stdout || "(empty)", + "", + sessionReplyLine(session), + limitationReplyLine() + ].join("\n"), + workspace: resolvedWorkspace, + toolCalls: [toolCall], + skills: notRequestedSkills(), + runner, + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), + session, + codexStdioFeasibility, + outputTruncated: toolCall.outputTruncated + }); } if (intent.kind === "skills") { @@ -436,18 +589,15 @@ async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skills }], skills, runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }), + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }), capabilityLevel: "blocked", + ...baseEvidence, blockers: skills.blockers }); } - return { - provider: READONLY_RUNNER_PROVIDER, - model: READONLY_RUNNER_MODEL, - backend: READONLY_RUNNER_BACKEND, + return readOnlyRunnerResult({ content: skillsReply(skills), workspace: resolvedWorkspace, - sandbox: READONLY_RUNNER_SANDBOX, toolCalls: [{ id: `tool_${randomUUID()}`, type: "file-read", @@ -461,14 +611,11 @@ async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skills }], 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" - } - }; + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:completed"], startedAt, outputTruncated: Boolean(skills.truncated) }), + session, + codexStdioFeasibility, + outputTruncated: Boolean(skills.truncated) + }); } throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, { @@ -486,8 +633,9 @@ async function callReadOnlyRunner({ intent, traceId, env, now, workspace, skills }], skills: notRequestedSkills(), runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }), - capabilityLevel: "blocked" + runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }), + capabilityLevel: "blocked", + ...baseEvidence }); } @@ -515,13 +663,48 @@ function detectReadOnlyRunnerIntent(message) { 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"; + if (/\brg\s+--files\b/u.test(lower) || /(?:列出|查看).{0,12}(?:文件列表|所有文件|源码文件|代码文件)/u.test(text)) { + return { kind: "rg_files", toolName: "rg --files", target: extractReadOnlyTarget(text, { defaultTarget: "." }) }; + } + if (/\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text)) { + return { kind: "ls", toolName: "ls", target: extractReadOnlyTarget(text, { defaultTarget: "." }) }; + } + if (/\bcat\b/u.test(lower) || /(?:读取|查看).{0,10}(?:文件|源码|代码)/u.test(text)) { + const target = extractReadOnlyTarget(text, { preferFile: true }); + if (!target) { + return { kind: "unsupported", toolName: "cat" }; + } + return { kind: "cat", toolName: "cat", target }; + } + if (/\b(?:grep|find|sed|awk)\b/u.test(lower)) { + const tool = lower.match(/\b(?:grep|find|sed|awk)\b/u)?.[0] ?? "file.read"; return { kind: "unsupported", toolName: tool }; } return { kind: "none" }; } +function extractReadOnlyTarget(text, { defaultTarget = null, preferFile = false } = {}) { + const value = String(text ?? ""); + const quoted = value.match(/[`"']([^`"']{1,240})[`"']/u)?.[1]; + if (quoted) return quoted.trim(); + const tokenPattern = preferFile + ? /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+\/?[A-Za-z0-9._@:/+=-]*(?:\.[A-Za-z0-9._-]+)?)(?:\s|$)/gu + : /(?:^|\s)((?:\.{1,2}\/|\/)?[A-Za-z0-9._@:/+=-]+)(?:\s|$)/gu; + const stopWords = new Set(["cat", "ls", "rg", "--files", "读取", "查看", "列出", "文件", "目录", "源码", "代码", "你", "请用"]); + for (const match of value.matchAll(tokenPattern)) { + const candidate = match[1]?.trim(); + if (!candidate || stopWords.has(candidate)) continue; + if (/^(?:pwd|skills?|skill)$/iu.test(candidate)) continue; + if (!preferFile && !/[/.]/u.test(candidate)) continue; + return candidate; + } + const inlinePath = value.match(/((?:\.{1,2}\/|\/)[A-Za-z0-9._@:/+=-]+|[A-Za-z0-9._@+=-]+\.[A-Za-z0-9._-]+)/u)?.[1]; + if (inlinePath && !stopWords.has(inlinePath) && !/^(?:pwd|skills?|skill)$/iu.test(inlinePath)) { + return inlinePath; + } + return defaultTarget; +} + function isSecretReadRequest(text) { const asksToRead = /(?:print|show|cat|read|list|dump|echo|输出|显示|读取|列出|查看|打印)/iu.test(text); const secretTerm = /(?:secret|token|kubeconfig|OPENAI_API_KEY|DATABASE_URL|password|passwd|credential|private key|私钥|密码|凭证|环境变量|密钥)/iu.test(text); @@ -600,6 +783,161 @@ async function runPwdTool({ workspace, traceId, env }) { }; } +async function runLsTool({ workspace, target = ".", traceId }) { + const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true }); + const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); + if (blockedReason) { + return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: blockedReason }); + } + let entries = []; + try { + const targetStat = await stat(targetInfo.path); + if (targetStat.isDirectory()) { + entries = await readdir(targetInfo.path, { withFileTypes: true }); + } else { + entries = [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }]; + } + } catch (error) { + return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: redactText(error.message) }); + } + + const lines = entries + .sort((a, b) => a.name.localeCompare(b.name, "en")) + .slice(0, READONLY_FILE_ENTRY_LIMIT) + .map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`); + const overflow = entries.length > lines.length; + const bounded = boundToolOutput(redactText(lines.join("\n"))); + return { + id: `tool_${randomUUID()}`, + type: "file-list", + name: "ls", + status: "completed", + cwd: workspace, + command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`, + exitCode: 0, + stdout: bounded.text, + stderrSummary: overflow ? `entry limit ${READONLY_FILE_ENTRY_LIMIT} reached` : "", + outputTruncated: bounded.truncated || overflow, + traceId + }; +} + +async function runRgFilesTool({ workspace, target = ".", traceId, env }) { + const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true }); + const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); + if (blockedReason) { + return blockedToolCall({ name: "rg --files", type: "file-list", workspace, traceId, reason: blockedReason }); + } + + let files = []; + let usedFallback = true; + if (await commandExists("rg", runnerCommandEnv(env))) { + const result = await spawnWithInput("rg", ["--files", targetInfo.path], "", { + cwd: workspace, + env: runnerCommandEnv(env), + timeoutMs: 3000 + }); + if (result.code === 0) { + files = result.stdout.split(/\r?\n/u).filter(Boolean).map((filePath) => path.relative(workspace, path.resolve(filePath))); + usedFallback = false; + } + } + if (usedFallback) { + files = await listFilesRecursively(targetInfo.path, workspace); + } + + const returned = files + .filter((filePath) => filePath && !filePath.startsWith("..") && !path.isAbsolute(filePath)) + .sort((a, b) => a.localeCompare(b, "en")) + .slice(0, READONLY_FILE_ENTRY_LIMIT); + const bounded = boundToolOutput(redactText(returned.join("\n"))); + return { + id: `tool_${randomUUID()}`, + type: "file-list", + name: "rg --files", + status: "completed", + cwd: workspace, + command: `rg --files ${safeDisplayPath(targetInfo.relative || ".")}`, + exitCode: 0, + stdout: bounded.text, + stderrSummary: usedFallback ? "node-fallback-used" : "", + outputTruncated: bounded.truncated || files.length > returned.length, + traceId + }; +} + +async function runCatTool({ workspace, target, traceId }) { + const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true, requireFile: true }); + const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); + if (blockedReason) { + return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: blockedReason }); + } + + let content = ""; + let truncatedByReadLimit = false; + try { + const file = await open(targetInfo.path, "r"); + try { + const buffer = Buffer.alloc(READONLY_FILE_READ_LIMIT + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + truncatedByReadLimit = bytesRead > READONLY_FILE_READ_LIMIT; + content = buffer.subarray(0, Math.min(bytesRead, READONLY_FILE_READ_LIMIT)).toString("utf8"); + } finally { + await file.close(); + } + } catch (error) { + return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: redactText(error.message) }); + } + + const bounded = boundToolOutput(redactText(content)); + return { + id: `tool_${randomUUID()}`, + type: "file-read", + name: "cat", + status: "completed", + cwd: workspace, + command: `cat ${safeDisplayPath(targetInfo.relative)}`, + exitCode: 0, + stdout: bounded.text, + stderrSummary: truncatedByReadLimit ? `file read limited at ${READONLY_FILE_READ_LIMIT} bytes` : "", + outputTruncated: bounded.truncated || truncatedByReadLimit, + traceId + }; +} + +function blockedToolCall({ name, type, workspace, traceId, reason }) { + return { + id: `tool_${randomUUID()}`, + type, + name, + status: "blocked", + cwd: workspace, + exitCode: 1, + stdout: "", + stderrSummary: `security_blocked: ${redactText(reason)}`, + outputTruncated: false, + traceId + }; +} + +function assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runnerTrace, baseEvidence }) { + if (toolCall.status === "completed") return; + throw runnerError("security_blocked", toolCall.stderrSummary || `${toolCall.name} was blocked by the read-only runner policy`, { + workspace, + toolCalls: [toolCall], + skills, + runner, + runnerTrace, + capabilityLevel: "blocked", + blockers: [{ + code: "security_blocked", + sourceIssue: "pikasTech/HWLAB#275", + summary: toolCall.stderrSummary || "Read-only runner blocked the requested file path." + }], + ...baseEvidence + }); +} + async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId }) { const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact }); const sourceSummaries = []; @@ -768,6 +1106,81 @@ function isPathInside(child, parent) { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +function resolveReadOnlyTarget(workspace, target, { mustExist = false, requireFile = false } = {}) { + const rawTarget = String(target || ".").trim(); + if (!rawTarget) { + return { blocked: true, reason: "missing target path" }; + } + if (isForbiddenPath(rawTarget)) { + return { blocked: true, reason: "security_blocked: target path may expose secrets or forbidden runtime material" }; + } + const resolved = path.resolve(workspace, rawTarget); + if (!isPathInside(resolved, workspace)) { + return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" }; + } + if (isForbiddenPath(path.relative(workspace, resolved))) { + return { blocked: true, reason: "security_blocked: target path is not allowed" }; + } + const relative = path.relative(workspace, resolved) || "."; + return { + path: resolved, + relative, + async check() { + if (!mustExist && !requireFile) return null; + try { + const targetStat = await stat(resolved); + if (requireFile && !targetStat.isFile()) { + return "target is not a regular file"; + } + return null; + } catch { + return "target path is not readable"; + } + } + }; +} + +function isForbiddenPath(value) { + const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase(); + return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) || + /(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized); +} + +async function listFilesRecursively(rootPath, workspace) { + const results = []; + async function visit(currentPath, depth) { + if (results.length >= READONLY_FILE_ENTRY_LIMIT || depth > READONLY_FILE_TREE_DEPTH) return; + let entries = []; + try { + const currentStat = await stat(currentPath); + if (currentStat.isFile()) { + results.push(path.relative(workspace, currentPath)); + return; + } + if (!currentStat.isDirectory()) return; + entries = await readdir(currentPath, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) { + if (results.length >= READONLY_FILE_ENTRY_LIMIT) break; + if (SKIPPED_READONLY_DIRS.has(entry.name) || isForbiddenPath(entry.name)) continue; + const nextPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + await visit(nextPath, depth + 1); + } else if (entry.isFile()) { + results.push(path.relative(workspace, nextPath)); + } + } + } + await visit(rootPath, 0); + return results; +} + +function safeDisplayPath(value) { + return redactText(String(value ?? ".")).replace(/\s+/gu, " "); +} + function skillsReply(skills) { const lines = [ `当前只读 runner 已加载 ${skills.totalCount} 个 skill${skills.truncated ? `,本次显示前 ${skills.count} 个` : ""}。` @@ -793,31 +1206,148 @@ function notRequestedSkills() { }; } -function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND } = {}) { +function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated }) { + return { + provider: READONLY_RUNNER_PROVIDER, + model: READONLY_RUNNER_MODEL, + backend: READONLY_RUNNER_BACKEND, + content, + workspace, + sandbox: READONLY_RUNNER_SANDBOX, + sessionMode: READONLY_SESSION_MODE, + sessionReuse: sessionReuseEvidence(session), + implementationType: READONLY_IMPLEMENTATION_TYPE, + runnerLimitations: [...READONLY_LIMITATION_FLAGS], + codexStdioFeasibility, + toolCalls, + skills, + runner, + runnerTrace, + capabilityLevel: "read-only-tools", + providerTrace: { + runnerKind: READONLY_RUNNER_KIND, + toolCalls: toolCalls.length, + mode: READONLY_SESSION_MODE, + sessionId: session.sessionId, + turn: session.turn, + reused: session.reused, + outputTruncated: Boolean(outputTruncated) + } + }; +} + +function getOrCreateReadOnlySession({ conversationId, sessionId, workspace, now }) { + const requestedSessionId = cleanProtocolId(sessionId, "ses") || null; + const mappedSessionId = readOnlyConversationSessions.get(conversationId); + const effectiveSessionId = requestedSessionId || mappedSessionId || `ses_${randomUUID()}`; + const timestamp = nowIso(now); + let session = readOnlySessions.get(effectiveSessionId); + const reused = Boolean(session); + if (!session) { + session = { + sessionId: effectiveSessionId, + conversationIds: new Set(), + workspace, + createdAt: timestamp, + lastUsedAt: timestamp, + turn: 0 + }; + readOnlySessions.set(effectiveSessionId, session); + } + session.conversationIds.add(conversationId); + session.workspace = workspace; + session.lastUsedAt = timestamp; + session.turn += 1; + readOnlyConversationSessions.set(conversationId, effectiveSessionId); + pruneReadOnlySessions(); + return { + sessionId: effectiveSessionId, + conversationId, + workspace, + createdAt: session.createdAt, + lastUsedAt: session.lastUsedAt, + turn: session.turn, + reused, + conversationIds: [...session.conversationIds] + }; +} + +function sessionReuseEvidence(session) { + return { + conversationId: session.conversationId, + sessionId: session.sessionId, + mapped: true, + reused: session.reused, + turn: session.turn, + previousTurns: Math.max(0, session.turn - 1), + workspace: session.workspace, + createdAt: session.createdAt, + lastUsedAt: session.lastUsedAt + }; +} + +function pruneReadOnlySessions() { + if (readOnlySessions.size <= MAX_READONLY_SESSIONS) return; + const sorted = [...readOnlySessions.entries()] + .sort((a, b) => String(a[1].lastUsedAt).localeCompare(String(b[1].lastUsedAt))); + for (const [sessionId, session] of sorted.slice(0, readOnlySessions.size - MAX_READONLY_SESSIONS)) { + readOnlySessions.delete(sessionId); + for (const conversationId of session.conversationIds) { + if (readOnlyConversationSessions.get(conversationId) === sessionId) { + readOnlyConversationSessions.delete(conversationId); + } + } + } +} + +function sessionReplyLine(session) { + return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; reused=${session.reused}; turn=${session.turn}.`; +} + +function limitationReplyLine() { + return "边界:controlled-readonly-session-registry;not-codex-stdio;not-write-capable;not-durable-session。"; +} + +function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) { 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", + session: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE, + sessionMode: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE, + sessionId: session?.sessionId ?? null, + turn: session?.turn ?? null, + sessionReused: session?.reused ?? false, + implementationType: kind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", + codexStdio: false, longLivedSession: false, + durableSession: false, + writeCapable: false, readOnly: true, capabilityLevel: kind === READONLY_RUNNER_KIND ? "read-only-tools" : "text-chat-only", toolPolicy: { - allowed: ["pwd", "skills.discover"], + allowed: ["pwd", "skills.discover", "ls", "rg --files", "cat"], blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"] }, + runnerLimitations: kind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"], safety: runnerSafetyContract() }; } -function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND }) { +function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND, session = null }) { return { traceId, runnerKind, workspace, sandbox: READONLY_RUNNER_SANDBOX, + sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot", + sessionId: session?.sessionId ?? null, + turn: session?.turn ?? null, + sessionReused: session?.reused ?? false, + implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", + limitations: runnerKind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"], startedAt, finishedAt: startedAt, events, @@ -825,7 +1355,7 @@ function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = 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." + : "controlled-readonly-session-registry stores conversation/session mapping and turn counters only; it is not Codex stdio, write-capable, or durable." }; } @@ -843,7 +1373,87 @@ function runnerSafetyContract() { }; } -function openAiFallbackRunnerEvidence({ providerResult, traceId }) { +function inspectCodexStdioFeasibility(env = process.env) { + const workspace = resolveRunnerWorkspace(env); + const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); + const binaryOnPath = commandOnPathSync(command, env); + const blockers = []; + if (!binaryOnPath) { + blockers.push({ + code: "codex_cli_binary_missing", + sourceIssue: "pikasTech/HWLAB#275", + summary: `Codex CLI command ${command} is not present on the DEV cloud-api runtime PATH.` + }); + } + if (!existsSync(workspace)) { + blockers.push({ + code: "workspace_mount_missing", + sourceIssue: "pikasTech/HWLAB#275", + summary: `Configured runner workspace is not mounted/readable: ${workspace}.` + }); + } + if (!env.OPENAI_API_KEY) { + blockers.push({ + code: "provider_token_boundary", + sourceIssue: "pikasTech/HWLAB#275", + summary: "A long-lived Codex runner would need a repo-owned token/secret injection boundary; this endpoint does not read or print provider secrets." + }); + } + blockers.push({ + code: "runner_lifecycle_missing", + sourceIssue: "pikasTech/HWLAB#275", + summary: "cloud-api currently owns request/response HTTP handling only; no repo-owned supervisor exists to spawn, monitor, reap, and isolate long-lived Codex stdio sessions." + }); + blockers.push({ + code: "stdio_protocol_not_wired", + sourceIssue: "pikasTech/HWLAB#275", + summary: "No Codex stdio protocol adapter is implemented for multi-turn session IO, cancellation, trace capture, and bounded tool evidence." + }); + return { + status: blockers.length === 0 ? "ready" : "blocked", + canStartLongLivedCodexStdio: blockers.length === 0, + checked: true, + implementationRequired: "codex-stdio-or-equivalent-long-lived-runner", + currentImplementation: READONLY_IMPLEMENTATION_TYPE, + command, + binaryOnPath, + workspace, + sandbox: READONLY_RUNNER_SANDBOX, + blockers, + safety: { + secretsRead: false, + secretValuesPrinted: false, + prodTouched: false, + hardwareWritesAllowed: false + } + }; +} + +function envForFeasibility(env = process.env) { + return { + PATH: env.PATH || process.env.PATH || "/usr/local/bin:/usr/bin:/bin", + HWLAB_CODE_AGENT_CODEX_COMMAND: env.HWLAB_CODE_AGENT_CODEX_COMMAND, + HWLAB_CODE_AGENT_WORKSPACE: env.HWLAB_CODE_AGENT_WORKSPACE, + HWLAB_RUNNER_WORKSPACE: env.HWLAB_RUNNER_WORKSPACE, + WORKSPACE: env.WORKSPACE, + OPENAI_API_KEY: env.OPENAI_API_KEY ? "__present__" : "" + }; +} + +function commandOnPathSync(command, env = process.env) { + if (!command) return false; + if (command.includes("/") || command.includes("\\")) { + try { + return existsSync(command); + } catch { + return false; + } + } + const paths = String(env.PATH || process.env.PATH || "").split(path.delimiter).filter(Boolean); + return paths.some((dir) => existsSync(path.join(dir, command))); +} + +function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }) { return { kind: OPENAI_FALLBACK_RUNNER_KIND, provider: providerResult.provider ?? "openai-responses", @@ -851,7 +1461,14 @@ function openAiFallbackRunnerEvidence({ providerResult, traceId }) { workspace: null, sandbox: "none", session: "provider-text-request", + sessionMode: "provider-text-request", + sessionId, + conversationId, + implementationType: OPENAI_FALLBACK_RUNNER_KIND, + codexStdio: false, longLivedSession: false, + durableSession: false, + writeCapable: false, readOnly: false, capabilityLevel: "text-chat-only", toolPolicy: { @@ -862,6 +1479,12 @@ function openAiFallbackRunnerEvidence({ providerResult, traceId }) { codexRunner: false, reason: "OpenAI Responses fallback is text chat only and cannot satisfy the Codex runner capability gate." }, + runnerLimitations: [ + "text-chat-only", + "not-codex-stdio", + "not-workspace-tools", + "not-durable-session" + ], traceId }; } @@ -1064,6 +1687,19 @@ async function callCodexCli({ providerPlan, message, conversationId, traceId, ti usage: null, workspace: repoRoot, sandbox: READONLY_RUNNER_SANDBOX, + sessionMode: "ephemeral-one-shot", + sessionReuse: { + conversationId, + sessionId: conversationId, + mapped: true, + reused: false, + turn: 1, + previousTurns: 0, + workspace: repoRoot + }, + implementationType: "codex-cli-one-shot-ephemeral", + runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"], + codexStdioFeasibility: inspectCodexStdioFeasibility(envForFeasibility(env)), toolCalls: [], skills: notRequestedSkills(), runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }), @@ -1326,6 +1962,37 @@ function cleanProjectId(value) { return cleanProtocolId(value, "prj"); } +function resolveConversationSessionIds(params = {}) { + const requestedConversationId = cleanProtocolId(params.conversationId, "cnv"); + const requestedSessionId = cleanProtocolId(params.sessionId, "ses"); + if (requestedConversationId && requestedSessionId) { + readOnlyConversationSessions.set(requestedConversationId, requestedSessionId); + return { + conversationId: requestedConversationId, + sessionId: requestedSessionId + }; + } + if (requestedConversationId) { + return { + conversationId: requestedConversationId, + sessionId: readOnlyConversationSessions.get(requestedConversationId) || requestedConversationId + }; + } + if (requestedSessionId) { + const conversationId = `cnv_${requestedSessionId.replace(/^[a-z][a-z0-9]*_/u, "")}`; + readOnlyConversationSessions.set(conversationId, requestedSessionId); + return { + conversationId, + sessionId: requestedSessionId + }; + } + const conversationId = `cnv_${randomUUID()}`; + return { + conversationId, + sessionId: conversationId + }; +} + function nowIso(now) { return typeof now === "function" ? now() : new Date().toISOString(); } diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 58d49d08..30f8651c 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -983,11 +983,22 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence assert.equal(payload.sandbox, "read-only"); assert.equal(payload.runner.kind, "hwlab-readonly-runner"); assert.equal(payload.runner.longLivedSession, false); + assert.equal(payload.runner.codexStdio, false); + assert.equal(payload.runner.writeCapable, false); + assert.equal(payload.runner.durableSession, false); + assert.equal(payload.runner.session, "controlled-readonly-session-registry"); + assert.equal(payload.sessionMode, "controlled-readonly-session-registry"); + assert.equal(payload.implementationType, "controlled-readonly-session-registry"); + assert.equal(payload.sessionReuse.reused, false); + assert.equal(payload.sessionReuse.turn, 1); + assert.ok(payload.runnerLimitations.includes("not-codex-stdio")); + assert.ok(payload.codexStdioFeasibility.blockers.some((blocker) => blocker.code === "runner_lifecycle_missing")); 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.equal(payload.runnerTrace.sessionMode, "controlled-readonly-session-registry"); assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { @@ -997,6 +1008,92 @@ test("cloud api /v1/agent/chat runs read-only runner pwd with workspace evidence } }); +test("cloud api /v1/agent/chat reuses controlled read-only session and exposes bounded file tools", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-tools-")); + await writeFile(path.join(workspace, "alpha.txt"), "alpha\nbeta\n", "utf8"); + await writeFile(path.join(workspace, "package.json"), "{\"name\":\"sample\"}\n", "utf8"); + await mkdir(path.join(workspace, "src"), { recursive: true }); + await writeFile(path.join(workspace, "src", "main.mjs"), "export const value = 1;\n", "utf8"); + const env = { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: workspace + }; + const server = createCloudApiServer({ env }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const first = await postAgent(port, { + conversationId: "cnv_server-test-session-reuse", + traceId: "trc_server-test-session-reuse-ls", + message: "ls ." + }); + assert.equal(first.status, "completed"); + assert.equal(first.sessionReuse.reused, false); + assert.equal(first.sessionReuse.turn, 1); + assert.equal(first.toolCalls[0].name, "ls"); + assert.match(first.toolCalls[0].stdout, /alpha\.txt/u); + + const second = await postAgent(port, { + conversationId: "cnv_server-test-session-reuse", + traceId: "trc_server-test-session-reuse-cat", + message: "cat alpha.txt" + }); + assert.equal(second.status, "completed"); + assert.equal(second.sessionId, first.sessionId); + assert.equal(second.sessionReuse.reused, true); + assert.equal(second.sessionReuse.turn, 2); + assert.equal(second.toolCalls[0].name, "cat"); + assert.equal(second.toolCalls[0].stdout, "alpha\nbeta\n"); + + const third = await postAgent(port, { + conversationId: "cnv_server-test-session-reuse", + traceId: "trc_server-test-session-reuse-rg", + message: "rg --files ." + }); + assert.equal(third.status, "completed"); + assert.equal(third.sessionReuse.reused, true); + assert.equal(third.sessionReuse.turn, 3); + assert.equal(third.toolCalls[0].name, "rg --files"); + assert.match(third.toolCalls[0].stdout, /src\/main\.mjs/u); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + +test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-")); + 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 payload = await postAgent(port, { + conversationId: "cnv_server-test-security-block", + traceId: "trc_server-test-security-block", + message: "cat ../outside.txt" + }); + assert.equal(payload.status, "failed"); + assert.equal(payload.error.code, "security_blocked"); + assert.equal(payload.toolCalls[0].name, "cat"); + assert.equal(payload.toolCalls[0].status, "blocked"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(JSON.stringify(payload).includes("sk-"), 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"); @@ -1115,7 +1212,7 @@ test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", a }, body: JSON.stringify({ conversationId: "cnv_server-test-tool-unavailable", - message: "请用cat读取package.json" + message: "请用grep搜索 package.json" }) }); assert.equal(response.status, 200); @@ -1123,7 +1220,7 @@ test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", a 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].name, "grep"); assert.equal(payload.toolCalls[0].status, "blocked"); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { @@ -1605,3 +1702,16 @@ test("cloud api /v1/m3/io returns structured blocked payload when gateway is una }); } }); + +async function postAgent(port, body) { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(body.traceId ? { "x-trace-id": body.traceId } : {}) + }, + body: JSON.stringify(body) + }); + assert.equal(response.status, 200); + return response.json(); +} diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index 9b41cfd7..52383a6e 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -180,6 +180,16 @@ async function runLocalContractSmoke() { assert.equal(runnerPwd.workspace, process.cwd()); assert.equal(runnerPwd.sandbox, "read-only"); assert.equal(runnerPwd.capabilityLevel, "read-only-tools"); + assert.equal(runnerPwd.sessionMode, "controlled-readonly-session-registry"); + assert.equal(runnerPwd.implementationType, "controlled-readonly-session-registry"); + assert.equal(runnerPwd.runner.session, "controlled-readonly-session-registry"); + assert.equal(runnerPwd.runner.codexStdio, false); + assert.equal(runnerPwd.runner.writeCapable, false); + assert.equal(runnerPwd.runner.durableSession, false); + assert.equal(runnerPwd.sessionReuse.reused, false); + assert.equal(runnerPwd.sessionReuse.turn, 1); + assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio")); + assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked"); assert.equal(runnerPwd.toolCalls[0].name, "pwd"); assert.equal(runnerPwd.toolCalls[0].status, "completed"); const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 }); @@ -187,6 +197,30 @@ async function runLocalContractSmoke() { assert.equal(runnerCapability.capabilityPass, true); logOk("read-only runner pwd capability"); + const runnerSecondTurn = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-runner-pwd", + traceId: "trc_code-agent-chat-runner-session-reuse", + message: "ls ." + }, + { + now: () => "2026-05-22T00:02:01.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + validateCodeAgentChatSchema(runnerSecondTurn); + assert.equal(runnerSecondTurn.status, "completed"); + assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId); + assert.equal(runnerSecondTurn.sessionReuse.reused, true); + assert.equal(runnerSecondTurn.sessionReuse.turn, 2); + assert.equal(runnerSecondTurn.toolCalls[0].name, "ls"); + assert.equal(runnerSecondTurn.toolCalls[0].status, "completed"); + assert.match(runnerSecondTurn.reply.content, /package\.json/u); + logOk("same conversation reuses controlled read-only session registry"); + const fallbackCapability = classifyCodexRunnerCapability(completedLivePayload, { httpStatus: 200 }); assert.equal(fallbackCapability.status, "blocked"); assert.equal(fallbackCapability.blocker, "openai-fallback-not-runner"); @@ -218,10 +252,10 @@ async function runLocalContractSmoke() { assert.equal(classifyCodexRunnerCapability(missingSkills, { httpStatus: 200 }).capabilityPass, false); logOk("missing skills is structured blocker"); - const toolUnavailable = await handleCodeAgentChat( + const boundedCat = await handleCodeAgentChat( { - conversationId: "cnv_code-agent-chat-tool-unavailable", - traceId: "trc_code-agent-chat-tool-unavailable", + conversationId: "cnv_code-agent-chat-cat", + traceId: "trc_code-agent-chat-cat", message: "请用cat读取 package.json" }, { @@ -232,6 +266,49 @@ async function runLocalContractSmoke() { } } ); + validateCodeAgentChatSchema(boundedCat); + assert.equal(boundedCat.status, "completed"); + assert.equal(boundedCat.toolCalls[0].name, "cat"); + assert.equal(boundedCat.toolCalls[0].status, "completed"); + assert.ok(boundedCat.toolCalls[0].stdout.length <= 4100); + assert.match(boundedCat.reply.content, /"name": "hwlab"/u); + logOk("bounded cat read-only tool capability"); + + const boundedRgFiles = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-rg-files", + traceId: "trc_code-agent-chat-rg-files", + message: "rg --files ." + }, + { + now: () => "2026-05-22T00:04:30.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + validateCodeAgentChatSchema(boundedRgFiles); + assert.equal(boundedRgFiles.status, "completed"); + assert.equal(boundedRgFiles.toolCalls[0].name, "rg --files"); + assert.equal(boundedRgFiles.toolCalls[0].status, "completed"); + assert.match(boundedRgFiles.reply.content, /package\.json/u); + logOk("bounded rg --files read-only tool capability"); + + const toolUnavailable = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-tool-unavailable", + traceId: "trc_code-agent-chat-tool-unavailable", + message: "请用grep搜索 package.json" + }, + { + now: () => "2026-05-22T00:04:40.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"); @@ -239,6 +316,27 @@ async function runLocalContractSmoke() { assert.equal(classifyCodexRunnerCapability(toolUnavailable, { httpStatus: 200 }).capabilityPass, false); logOk("tool unavailable is blocked, not completed runner capability"); + const securityBlocked = await handleCodeAgentChat( + { + conversationId: "cnv_code-agent-chat-security-blocked", + traceId: "trc_code-agent-chat-security-blocked", + message: "请cat ../secret" + }, + { + now: () => "2026-05-22T00:04:50.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd() + } + } + ); + validateCodeAgentChatSchema(securityBlocked); + assert.equal(securityBlocked.status, "failed"); + assert.equal(securityBlocked.error.code, "security_blocked"); + assert.equal(securityBlocked.toolCalls[0].status, "blocked"); + assert.equal(classifyCodexRunnerCapability(securityBlocked, { httpStatus: 200 }).capabilityPass, false); + logOk("security blocked path is structured"); + const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 }); assert.equal(completedHttp200Readiness.status, "pass"); assert.equal(completedHttp200Readiness.devLiveReplyPass, true); @@ -333,6 +431,18 @@ function summarizePayload(payload, options = {}) { provider: responseSummary?.provider ?? null, model: responseSummary?.model ?? null, backend: responseSummary?.backend ?? null, + runner: responseSummary?.runner ?? null, + workspace: responseSummary?.workspace ?? null, + sandbox: responseSummary?.sandbox ?? null, + capabilityLevel: responseSummary?.capabilityLevel ?? null, + sessionMode: responseSummary?.sessionMode ?? null, + sessionReuse: responseSummary?.sessionReuse ?? null, + implementationType: responseSummary?.implementationType ?? null, + runnerLimitations: responseSummary?.runnerLimitations ?? [], + toolCalls: responseSummary?.toolCalls ?? [], + skills: responseSummary?.skills ?? null, + runnerTrace: responseSummary?.runnerTrace ?? null, + codexStdioFeasibility: responseSummary?.codexStdioFeasibility ?? null, assistantReplyNonEmpty: responseSummary?.hasReply === true, assistantReplyLength: assistantReply.length, error: responseSummary?.error ?? null diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index d9fa611c..5d676026 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -78,6 +78,14 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { 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; + const sessionMode = stringOrNull(payload?.sessionMode ?? payload?.runner?.sessionMode ?? payload?.runner?.session); + const implementationType = stringOrNull(payload?.implementationType ?? payload?.runner?.implementationType); + const runnerLimitations = Array.isArray(payload?.runnerLimitations) + ? payload.runnerLimitations.filter((item) => typeof item === "string") + : Array.isArray(payload?.runner?.runnerLimitations) + ? payload.runner.runnerLimitations.filter((item) => typeof item === "string") + : []; + const sessionReuse = payload?.sessionReuse && typeof payload.sessionReuse === "object" ? payload.sessionReuse : null; if (provider === "openai-responses" || runnerKind === "openai-responses-fallback") { return { @@ -96,9 +104,16 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { 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 (sessionMode !== "controlled-readonly-session-registry") missing.push("sessionMode=controlled-readonly-session-registry"); + if (implementationType !== "controlled-readonly-session-registry") missing.push("implementationType=controlled-readonly-session-registry"); + if (!sessionReuse) missing.push("sessionReuse"); if (toolCalls.length === 0) missing.push("toolCalls"); if (!runnerTrace) missing.push("runnerTrace"); if (!skills) missing.push("skills"); + if (!runnerLimitations.includes("not-codex-stdio")) missing.push("runnerLimitations.not-codex-stdio"); + if (payload?.runner?.codexStdio !== false) missing.push("runner.codexStdio=false"); + if (payload?.runner?.writeCapable !== false) missing.push("runner.writeCapable=false"); + if (payload?.runner?.durableSession !== false) missing.push("runner.durableSession=false"); if (missing.length > 0) { return { @@ -222,7 +237,12 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId capabilityLevel: null, toolCalls: [], skills: null, - runnerTrace: null + runnerTrace: null, + sessionMode: null, + sessionReuse: null, + implementationType: null, + runnerLimitations: [], + codexStdioFeasibility: null }; } @@ -262,7 +282,12 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId capabilityLevel: stringOrNull(payload.capabilityLevel), toolCalls: summarizeToolCalls(payload.toolCalls), skills: summarizeSkills(payload.skills), - runnerTrace: summarizeRunnerTrace(payload.runnerTrace) + runnerTrace: summarizeRunnerTrace(payload.runnerTrace), + sessionMode: stringOrNull(payload.sessionMode), + sessionReuse: summarizeSessionReuse(payload.sessionReuse), + implementationType: stringOrNull(payload.implementationType), + runnerLimitations: summarizeStringList(payload.runnerLimitations), + codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility) }; } @@ -282,7 +307,12 @@ export function summarizeCodeAgentPayload(payload, { httpStatus = null, traceId capabilityLevel: stringOrNull(payload.capabilityLevel), toolCalls: summarizeToolCalls(payload.toolCalls), skills: summarizeSkills(payload.skills), - runnerTrace: summarizeRunnerTrace(payload.runnerTrace) + runnerTrace: summarizeRunnerTrace(payload.runnerTrace), + sessionMode: stringOrNull(payload.sessionMode), + sessionReuse: summarizeSessionReuse(payload.sessionReuse), + implementationType: stringOrNull(payload.implementationType), + runnerLimitations: summarizeStringList(payload.runnerLimitations), + codexStdioFeasibility: summarizeCodexStdioFeasibility(payload.codexStdioFeasibility) }; } @@ -429,8 +459,16 @@ function summarizeRunner(payload) { workspace: stringOrNull(payload.runner.workspace), sandbox: stringOrNull(payload.runner.sandbox), session: stringOrNull(payload.runner.session), + sessionMode: stringOrNull(payload.runner.sessionMode), + sessionId: stringOrNull(payload.runner.sessionId), + implementationType: stringOrNull(payload.runner.implementationType), + codexStdio: payload.runner.codexStdio === true, + writeCapable: payload.runner.writeCapable === true, + durableSession: payload.runner.durableSession === true, + longLivedSession: payload.runner.longLivedSession === true, readOnly: payload.runner.readOnly === true, - capabilityLevel: stringOrNull(payload.runner.capabilityLevel) + capabilityLevel: stringOrNull(payload.runner.capabilityLevel), + runnerLimitations: summarizeStringList(payload.runner.runnerLimitations) }; } @@ -465,11 +503,49 @@ function summarizeRunnerTrace(value) { return { runnerKind: stringOrNull(value.runnerKind), sandbox: stringOrNull(value.sandbox), + sessionMode: stringOrNull(value.sessionMode), + implementationType: stringOrNull(value.implementationType), + sessionReused: value.sessionReused === true, + turn: typeof value.turn === "number" ? value.turn : null, outputTruncated: value.outputTruncated === true, valuesPrinted: value.valuesPrinted === true }; } +function summarizeSessionReuse(value) { + if (!value || typeof value !== "object") return null; + return { + conversationId: stringOrNull(value.conversationId), + sessionId: stringOrNull(value.sessionId), + mapped: value.mapped === true, + reused: value.reused === true, + turn: typeof value.turn === "number" ? value.turn : null, + previousTurns: typeof value.previousTurns === "number" ? value.previousTurns : null, + workspace: stringOrNull(value.workspace) + }; +} + +function summarizeCodexStdioFeasibility(value) { + if (!value || typeof value !== "object") return null; + return { + status: stringOrNull(value.status), + canStartLongLivedCodexStdio: value.canStartLongLivedCodexStdio === true, + currentImplementation: stringOrNull(value.currentImplementation), + implementationRequired: stringOrNull(value.implementationRequired), + binaryOnPath: value.binaryOnPath === true, + blockers: Array.isArray(value.blockers) + ? value.blockers.map((blocker) => ({ + code: stringOrNull(blocker.code), + sourceIssue: stringOrNull(blocker.sourceIssue) + })) + : [] + }; +} + +function summarizeStringList(value) { + return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; +} + 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 8d3a8eda..034d4dc5 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -173,6 +173,11 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "workspace", "sandbox", "capability", + "sessionMode", + "sessionReuse", + "implementation", + "limitations", + "codexStdio", "toolCalls", "skills", "runnerTrace", @@ -182,6 +187,10 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "providerTrace", "openai-responses", "codex-cli", + "controlled-readonly-session-registry", + "not-codex-stdio", + "not-write-capable", + "not-durable-session", "echo/mock/stub", "untrusted_completion", "SOURCE fixture", @@ -2145,6 +2154,7 @@ function hasCodeAgentReadinessVisibility({ html, app }) { function hasCodeAgentCompletedEvidenceVisibility({ app }) { const messageEvidenceBody = functionBody(app, "messageEvidenceFields"); const realEvidenceBody = functionBody(app, "hasRealCodeAgentEvidence"); + const runnerEvidenceBody = functionBody(app, "isCodexRunnerCapableEvidence"); return ( requiredCodeAgentEvidenceTerms.every((term) => app.includes(term)) && /conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) && @@ -2157,6 +2167,11 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) { /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\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) && + /recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/u.test(messageEvidenceBody) && + /recordField\("implementation",\s*message\.implementationType\)/u.test(messageEvidenceBody) && + /recordField\("limitations",\s*limitationsSummary\(message\.runnerLimitations\)\)/u.test(messageEvidenceBody) && + /recordField\("codexStdio",\s*codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/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) && @@ -2167,6 +2182,11 @@ function hasCodeAgentCompletedEvidenceVisibility({ app }) { /isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) && /isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) && /CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) && + /controlled-readonly-session-registry/u.test(runnerEvidenceBody) && + /not-codex-stdio/u.test(runnerEvidenceBody) && + /value\?\.runner\?\.codexStdio === false/u.test(runnerEvidenceBody) && + /value\?\.runner\?\.writeCapable === false/u.test(runnerEvidenceBody) && + /value\?\.runner\?\.durableSession === false/u.test(runnerEvidenceBody) && /TRUSTED_CODE_AGENT_PROVIDERS/u.test(app) && /UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN/u.test(realEvidenceBody) && /value\?\.conversationId \|\| value\?\.sessionId/u.test(realEvidenceBody) && diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 4634e177..41f594e6 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -106,6 +106,7 @@ const el = { const state = { conversationId: null, + sessionId: null, codeAgentAvailability: null, chatMessages: [], chatPending: false, @@ -335,6 +336,7 @@ function initCommandBar() { try { const result = await sendAgentMessage(value, state.conversationId, traceId); state.conversationId = result.conversationId || result.sessionId || state.conversationId; + state.sessionId = result.sessionId || state.sessionId || state.conversationId; const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); const completion = classifyCodeAgentCompletion(result); const status = completion.status; @@ -355,6 +357,11 @@ function initCommandBar() { backend: result.backend, workspace: result.workspace, sandbox: result.sandbox, + sessionMode: result.sessionMode, + sessionReuse: result.sessionReuse, + implementationType: result.implementationType, + runnerLimitations: result.runnerLimitations, + codexStdioFeasibility: result.codexStdioFeasibility, toolCalls: result.toolCalls, skills: result.skills, runner: result.runner, @@ -406,6 +413,7 @@ function initCommandBar() { el.commandClear.addEventListener("click", () => { state.chatMessages = []; state.conversationId = null; + state.sessionId = null; state.chatPending = false; el.commandInput.value = ""; renderAgentChatStatus("idle"); @@ -453,6 +461,7 @@ function renderProbePending() { } async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) { + const sessionId = state.conversationId === conversationId ? state.sessionId : undefined; const response = await fetchJson("/v1/agent/chat", { method: "POST", headers: { @@ -464,6 +473,7 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI body: JSON.stringify({ message, conversationId, + sessionId, traceId, projectId: gateSummary.topology.projectId }) @@ -2152,6 +2162,11 @@ function messageEvidenceFields(message) { recordField("workspace", message.workspace), recordField("sandbox", message.sandbox), recordField("capability", message.capabilityLevel), + recordField("sessionMode", message.sessionMode), + recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)), + recordField("implementation", message.implementationType), + recordField("limitations", limitationsSummary(message.runnerLimitations)), + recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)), recordField("toolCalls", toolCallsSummary(message.toolCalls)), recordField("skills", skillsSummary(message.skills)), recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)), @@ -2191,7 +2206,29 @@ function skillsSummary(skills) { function runnerTraceSummary(runnerTrace) { if (!runnerTrace || typeof runnerTrace !== "object") return null; - return runnerTrace.runnerKind ?? "present"; + const parts = [ + runnerTrace.runnerKind ?? "present", + runnerTrace.sessionMode, + typeof runnerTrace.turn === "number" ? `turn${runnerTrace.turn}` : null + ].filter(Boolean); + return parts.join(":"); +} + +function sessionReuseSummary(sessionReuse) { + if (!sessionReuse || typeof sessionReuse !== "object") return null; + const state = sessionReuse.reused ? "reused" : "new"; + return `${state}:turn${sessionReuse.turn ?? "?"}`; +} + +function limitationsSummary(limitations) { + if (!Array.isArray(limitations) || limitations.length === 0) return null; + return limitations.slice(0, 3).join(","); +} + +function codexStdioFeasibilitySummary(feasibility) { + if (!feasibility || typeof feasibility !== "object") return null; + const blockers = Array.isArray(feasibility.blockers) ? feasibility.blockers.length : 0; + return `${feasibility.status ?? "unknown"}:${blockers}`; } function statusCard(item) { @@ -2297,6 +2334,9 @@ function isBlockedAgentResponse(payload, httpStatus) { isHttpNon2xxStatus(payload.error?.providerStatus) || payload.status === "blocked" || payload.error?.code === "provider_unavailable" || + payload.error?.code === "security_blocked" || + payload.error?.code === "tool_unavailable" || + payload.error?.code === "skills_unavailable" || payload.availability?.status === "blocked" ) ); @@ -2348,8 +2388,8 @@ 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 能力。", + title: "Code Agent 状态:受控只读 session registry 已接入", + text: "pwd、skills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;这是 not-codex-stdio、not-write-capable、not-durable-session 的安全增量,普通中文对话可走 OpenAI Responses fallback。", status: "source" }; } @@ -2422,7 +2462,7 @@ function untrustedCompletionMessage(result) { function codeAgentAvailabilitySummary() { if (state.codeAgentAvailability?.runner?.ready === true) { - return "同源 API 可响应;pwd/skills 可走只读 runner,普通中文对话可降级到文本 fallback。"; + return "同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读 session registry,但它不是 Codex stdio、不可写、非持久 session。"; } if (state.codeAgentAvailability?.status === "blocked") { return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; @@ -2480,6 +2520,14 @@ function isCodexRunnerCapableEvidence(value) { CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && value?.runner?.kind === "hwlab-readonly-runner" && value?.capabilityLevel === "read-only-tools" && + value?.sessionMode === "controlled-readonly-session-registry" && + value?.implementationType === "controlled-readonly-session-registry" && + Boolean(value?.sessionReuse) && + value?.runner?.codexStdio === false && + value?.runner?.writeCapable === false && + value?.runner?.durableSession === false && + Array.isArray(value?.runnerLimitations) && + value.runnerLimitations.includes("not-codex-stdio") && Boolean(value?.workspace || value?.runner?.workspace) && (value?.sandbox || value?.runner?.sandbox) === "read-only" && Array.isArray(value?.toolCalls) && diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 554e77f1..317d958f 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -469,10 +469,30 @@ assert.match(app, /Boolean\(value\?\.model\)/); assert.match(app, /Boolean\(value\?\.backend\)/); assert.match(app, /Boolean\(value\?\.traceId\)/); assert.match(app, /Boolean\(value\?\.conversationId \|\| value\?\.sessionId\)/); +assert.match(app, /value\?\.sessionMode === "controlled-readonly-session-registry"/); +assert.match(app, /value\?\.implementationType === "controlled-readonly-session-registry"/); +assert.match(app, /Boolean\(value\?\.sessionReuse\)/); +assert.match(app, /value\?\.runner\?\.codexStdio === false/); +assert.match(app, /value\?\.runner\?\.writeCapable === false/); +assert.match(app, /value\?\.runner\?\.durableSession === false/); assert.match(app, /providerTrace:\s*result\.providerTrace/); +assert.match(app, /sessionMode:\s*result\.sessionMode/); +assert.match(app, /sessionReuse:\s*result\.sessionReuse/); +assert.match(app, /implementationType:\s*result\.implementationType/); +assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/); +assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/); assert.match(app, /providerTraceSummary\(message\.providerTrace\)/); assert.match(app, /recordField\("conversation", message\.conversationId\)/); assert.match(app, /recordField\("provider", message\.provider\)/); +assert.match(app, /recordField\("sessionMode", message\.sessionMode\)/); +assert.match(app, /recordField\("sessionReuse", sessionReuseSummary\(message\.sessionReuse\)\)/); +assert.match(app, /recordField\("implementation", message\.implementationType\)/); +assert.match(app, /recordField\("limitations", limitationsSummary\(message\.runnerLimitations\)\)/); +assert.match(app, /recordField\("codexStdio", codexStdioFeasibilitySummary\(message\.codexStdioFeasibility\)\)/); +assert.match(app, /controlled-readonly-session-registry/); +assert.match(app, /not-codex-stdio/); +assert.match(app, /not-write-capable/); +assert.match(app, /not-durable-session/); assert.match(app, /Code Agent 完成证据不足/); assert.match(app, /本次不会标记为真实完成/); assert.match(app, /untrusted_completion/);