From e166a6ff2016e80ea1c79386d8964fd53a3485ab Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sun, 24 May 2026 02:49:11 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=20C?= =?UTF-8?q?odex=20stdio=20trace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cloud/code-agent-chat.mjs | 2494 +++-------------- internal/cloud/code-agent-trace-store.mjs | 395 +++ internal/cloud/codex-stdio-session.mjs | 385 +-- internal/cloud/health-contract.mjs | 8 +- internal/cloud/server.mjs | 59 +- internal/cloud/server.test.mjs | 676 +++-- package.json | 2 +- scripts/code-agent-chat-smoke.mjs | 640 +---- scripts/src/dev-cloud-workbench-smoke-lib.mjs | 159 +- web/hwlab-cloud-web/app.mjs | 508 ++-- web/hwlab-cloud-web/scripts/check.mjs | 96 +- web/hwlab-cloud-web/styles.css | 71 +- 12 files changed, 1791 insertions(+), 3702 deletions(-) create mode 100644 internal/cloud/code-agent-trace-store.mjs diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 6691bd3f..92b8f0fc 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -1,7 +1,5 @@ -import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { constants as fsConstants, existsSync } from "node:fs"; -import { access, mkdir, open, readFile, readdir, rm, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -21,6 +19,10 @@ import { createCodexStdioSessionManager, longLivedSessionGate as codexLongLivedSessionGate } from "./codex-stdio-session.mjs"; +import { + createCodeAgentTraceRecorder, + defaultCodeAgentTraceStore +} from "./code-agent-trace-store.mjs"; import { DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS, createCodeAgentSessionRegistry @@ -38,8 +40,6 @@ import { runM3IoSkillCommand } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs"; -const DEFAULT_CODE_AGENT_TIMEOUT_MS = 150000; -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"; @@ -64,14 +64,9 @@ const M3_IO_SKILL_LIMITATION_FLAGS = Object.freeze([ "not-durable-session" ]); const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; -const CODEX_CLI_ONE_SHOT_RUNNER_KIND = "codex-cli-one-shot-ephemeral"; const DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu; const READONLY_TOOL_OUTPUT_LIMIT = 4000; -const READONLY_FILE_READ_LIMIT = 64 * 1024; -const READONLY_FILE_ENTRY_LIMIT = 200; -const READONLY_FILE_TREE_DEPTH = 8; const MAX_READONLY_SESSIONS = 200; -const MAX_SKILLS_RETURNED = 40; const CODE_AGENT_PROVIDER_SECRET_REF = codeAgentSecretRefPlaceholder().replace("secretRef:", ""); const CODEX_STDIO_SOURCE_ISSUE = "pikasTech/HWLAB#275"; const READONLY_LIMITATION_FLAGS = Object.freeze([ @@ -86,23 +81,6 @@ const CODEX_STDIO_LIMITATION_FLAGS = Object.freeze([ "no-direct-patch-panel-link", "secret-values-redacted" ]); -const OPENAI_FALLBACK_LIMITATION_FLAGS = Object.freeze([ - "openai-responses-fallback", - "text-chat-only", - "not-workspace-tools", - "not-session-runner" -]); -const SKIPPED_READONLY_DIRS = new Set([ - ".git", - ".hg", - ".svn", - ".cache", - ".worktrees", - "node_modules", - "dist", - "build", - "coverage" -]); const CODE_AGENT_SYSTEM_PROMPT = [ "你是 HWLAB 云工作台的 Code Agent。", "请用中文直接回答用户的工作台问题。", @@ -126,6 +104,17 @@ export async function handleCodeAgentChat(params = {}, options = {}) { const traceId = cleanProtocolId(params.traceId, "trc") || `trc_${randomUUID()}`; const providerPlan = resolveProviderPlan(options.env ?? process.env, options); const sessionRegistry = resolveCodeAgentSessionRegistry(options); + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + const traceRecorder = createCodeAgentTraceRecorder({ + traceStore, + traceId, + now: options.now, + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace: options.workspace, + sandbox: "workspace-write", + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE + }); const base = { conversationId, sessionId, @@ -143,184 +132,154 @@ export async function handleCodeAgentChat(params = {}, options = {}) { try { const message = normalizeUserMessage(params.message); const runnerIntent = detectReadOnlyRunnerIntent(message); + traceRecorder.append({ + type: "request", + status: "accepted", + label: "request:accepted", + promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message, + waitingFor: "codex-stdio-readiness" + }); const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; if (securityIntent) { - const runnerResult = await callReadOnlyRunner({ - intent: securityIntent, - conversationId, - traceId, - sessionId: requestedSessionId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - skillsDirs: options.skillsDirs, - skillsDirsExact: options.skillsDirsExact, - skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, - sessionRegistry, - codexStdioManager: options.codexStdioManager + traceRecorder.append({ + type: "error", + status: "blocked", + label: "security:blocked", + errorCode: "security_blocked", + message: securityIntent.reason, + terminal: true + }); + throw runnerError("security_blocked", securityIntent.reason, { + provider: CODEX_STDIO_PROVIDER, + model: providerPlan.model, + backend: CODEX_STDIO_BACKEND, + workspace: options.workspace ?? repoRoot, + sandbox: "workspace-write", + toolCalls: [], + skills: notRequestedSkills(), + runner: codexStdioBlockedRunnerDescriptor({ workspace: options.workspace, session: null }), + runnerTrace: traceRecorder.runnerTrace({ + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace: options.workspace ?? repoRoot, + sandbox: "workspace-write", + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + limitations: ["security-blocked-before-stdio"] + }), + capabilityLevel: "blocked", + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["security-blocked-before-stdio"], + blockers: [{ + code: "security_blocked", + sourceIssue: "pikasTech/HWLAB#275", + summary: securityIntent.reason + }], + route: null, + toolName: securityIntent.toolName ?? null }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } if (runnerIntent.kind === "session_context") { - const runnerResult = await callReadOnlyRunner({ - intent: runnerIntent, - conversationId, - traceId, - sessionId: requestedSessionId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - skillsDirs: options.skillsDirs, - skillsDirsExact: options.skillsDirsExact, - skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, - sessionRegistry, - codexStdioManager: options.codexStdioManager + traceRecorder.append({ + type: "session", + status: "context_requested", + label: "session:context_requested", + waitingFor: "codex-stdio" }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); - } - - if (runnerIntent.kind === "m3_io") { - const runnerResult = await callM3IoSkillRunner({ - intent: runnerIntent, - conversationId, - traceId, - sessionId: requestedSessionId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - sessionRegistry, - requestJson: options.m3IoSkillRequestJson, - codexStdioManager: options.codexStdioManager, - codexStdioFeasibility: m3IoSkippedCodexStdioFeasibility() - }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } const codexStdioAvailability = await inspectCodexStdioFeasibility(options.env ?? process.env, options); - const shouldUseCodexStdio = - providerPlan.mode === "codex-stdio" || - (providerPlan.mode !== "openai" && codexStdioAvailability.canStartLongLivedCodexStdio === true); - if (shouldUseCodexStdio) { - const stdioResult = await callCodexStdioRunner({ - message, - conversationId, - sessionId: requestedSessionId, - traceId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - timeoutMs: options.timeoutMs, - model: providerPlan.model, - codexStdioManager: options.codexStdioManager, - conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId) - }); + if (codexStdioLongLivedReady(codexStdioAvailability)) { + const stdioResult = runnerIntent.kind === "m3_io" + ? await callCodexStdioWithM3IoSkillRunner({ + message, + intent: runnerIntent, + conversationId, + sessionId: requestedSessionId, + traceId, + env: options.env ?? process.env, + now: options.now, + workspace: options.workspace, + timeoutMs: options.timeoutMs, + model: providerPlan.model, + codexStdioManager: options.codexStdioManager, + traceRecorder, + conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), + requestJson: options.m3IoSkillRequestJson + }) + : await callCodexStdioRunner({ + message, + conversationId, + sessionId: requestedSessionId, + traceId, + env: options.env ?? process.env, + now: options.now, + workspace: options.workspace, + timeoutMs: options.timeoutMs, + model: providerPlan.model, + codexStdioManager: options.codexStdioManager, + traceRecorder, + conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId) + }); return completedRunnerPayload({ base, runnerResult: stdioResult, messageId, now: options.now, sessionRegistry }); } - if (runnerIntent.kind !== "none") { - const runnerResult = await callReadOnlyRunner({ - intent: runnerIntent, - conversationId, - traceId, - sessionId: requestedSessionId, - env: options.env ?? process.env, - now: options.now, - workspace: options.workspace, - skillsDirs: options.skillsDirs, - skillsDirsExact: options.skillsDirsExact, - skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, - sessionRegistry, - codexStdioManager: options.codexStdioManager - }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); - } - - const priorConversationFacts = conversationFactsForPrompt(sessionRegistry, conversationId); - const providerResult = await callConfiguredProvider({ - providerPlan, - message, - conversationId, - traceId, - timeoutMs: options.timeoutMs, - env: options.env ?? process.env, - now: options.now, - conversationFacts: priorConversationFacts, - callProvider: options.callProvider + const primaryBlocker = codexStdioAvailability.blockers?.[0] ?? null; + traceRecorder.append({ + type: "error", + status: "blocked", + label: "codex-stdio:blocked", + errorCode: primaryBlocker?.code ?? "codex_stdio_blocked", + message: primaryBlocker?.summary ?? "Codex stdio is not available; no local fallback will be used.", + waitingFor: "codex-stdio-readiness", + terminal: true }); - const content = typeof providerResult.content === "string" ? providerResult.content.trim() : ""; - if (!content) { - throw providerUnavailable("Code Agent provider returned no assistant text", { - provider: providerResult.provider ?? base.provider, - model: providerResult.model ?? base.model, - backend: providerResult.backend ?? base.backend - }); - } - const completedAt = nowIso(options.now); - const providerRunner = providerResult.runner ?? openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }); - const providerLongLivedGate = providerResult.longLivedSessionGate ?? longLivedSessionGate({ - provider: providerResult.provider ?? base.provider, - runnerKind: providerRunner?.kind ?? OPENAI_FALLBACK_RUNNER_KIND, - session: providerResult.session ?? null, - sessionMode: providerResult.sessionMode ?? "provider-text-request", - implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options) + throw runnerError(primaryBlocker?.code ?? "codex_stdio_blocked", "Codex stdio long-lived session is unavailable; no controlled-readonly, skills, shell, or text fallback will answer this request.", { + provider: CODEX_STDIO_PROVIDER, + model: providerPlan.model, + backend: CODEX_STDIO_BACKEND, + workspace: codexStdioAvailability.workspace ?? options.workspace ?? repoRoot, + sandbox: codexStdioAvailability.sandbox ?? "workspace-write", + session: null, + toolCalls: [], + skills: notRequestedSkills(), + runner: codexStdioBlockedRunnerDescriptor({ workspace: codexStdioAvailability.workspace ?? options.workspace, session: null }), + runnerTrace: traceRecorder.runnerTrace({ + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace: codexStdioAvailability.workspace ?? options.workspace ?? repoRoot, + sandbox: codexStdioAvailability.sandbox ?? "workspace-write", + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + limitations: ["no-fallback", "codex-stdio-required"], + outputTruncated: false + }), + capabilityLevel: "blocked", + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionReuse: null, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"], + codexStdioFeasibility: codexStdioAvailability, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + session: null, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: codexStdioAvailability + }), + blockers: codexStdioAvailability.blockers?.length + ? codexStdioAvailability.blockers + : [{ + code: "codex_stdio_blocked", + sourceIssue: "pikasTech/HWLAB#275", + summary: "Codex stdio readiness did not pass." + }], + route: "/v1/agent/chat", + toolName: runnerIntent.toolName ?? "codex-stdio.session", + availability: await describeCodeAgentAvailability(options.env ?? process.env, options) }); - const providerCompletionBlocker = structuredCompletionBlocker({ - provider: providerResult.provider ?? base.provider, - backend: providerResult.backend ?? base.backend, - runner: providerRunner, - capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only", - longLivedSessionGate: providerLongLivedGate - }, { - traceId, - provider: providerResult.provider ?? base.provider, - backend: providerResult.backend ?? base.backend, - runner: providerRunner, - capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only" - }); - const providerPayload = finalizeCodeAgentChatPayload({ - ...base, - status: "completed", - updatedAt: completedAt, - provider: providerResult.provider ?? base.provider, - model: providerResult.model ?? base.model, - backend: providerResult.backend ?? base.backend, - workspace: providerResult.workspace ?? null, - sandbox: providerResult.sandbox ?? "none", - session: providerResult.session ?? null, - sessionMode: providerResult.sessionMode ?? "provider-text-request", - sessionReuse: providerResult.sessionReuse ?? null, - implementationType: providerResult.implementationType ?? OPENAI_FALLBACK_RUNNER_KIND, - runnerLimitations: providerResult.runnerLimitations ?? [...OPENAI_FALLBACK_LIMITATION_FLAGS], - codexStdioFeasibility: providerResult.codexStdioFeasibility ?? await inspectCodexStdioFeasibility(options.env ?? process.env, options), - longLivedSessionGate: providerLongLivedGate, - toolCalls: Array.isArray(providerResult.toolCalls) ? providerResult.toolCalls : [], - skills: providerResult.skills ?? { - status: "not_requested", - items: [], - blockers: [] - }, - runner: providerRunner, - runnerTrace: providerResult.runnerTrace ?? { - traceId, - runnerKind: OPENAI_FALLBACK_RUNNER_KIND, - events: ["fallback:text-chat-only"], - note: "OpenAI Responses fallback can answer ordinary chat, but it does not satisfy the Codex runner capability gate." - }, - capabilityLevel: providerResult.capabilityLevel ?? "text-chat-only", - reply: { - messageId, - role: "assistant", - content, - createdAt: completedAt - }, - usage: providerResult.usage ?? null, - providerTrace: providerResult.providerTrace ?? null, - ...(providerCompletionBlocker ? { blocker: providerCompletionBlocker, blockers: [providerCompletionBlocker] } : {}) - }); - return attachConversationFacts(providerPayload, sessionRegistry, { now: options.now }); } catch (error) { const failedAt = nowIso(options.now); const payload = { @@ -350,6 +309,16 @@ export async function handleCodeAgentChat(params = {}, options = {}) { 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 (payload.runnerTrace === undefined) { + payload.runnerTrace = traceRecorder.runnerTrace({ + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace: error.workspace ?? options.workspace ?? repoRoot, + sandbox: error.sandbox ?? "workspace-write", + sessionMode: error.sessionMode ?? CODEX_STDIO_SESSION_MODE, + implementationType: error.implementationType ?? CODEX_STDIO_IMPLEMENTATION_TYPE, + limitations: error.runnerLimitations ?? ["codex-stdio-required"] + }); + } if (error.capabilityLevel !== undefined) payload.capabilityLevel = error.capabilityLevel; if (error.sessionMode !== undefined) payload.sessionMode = error.sessionMode; if (error.sessionReuse !== undefined) payload.sessionReuse = error.sessionReuse; @@ -556,7 +525,7 @@ export async function describeCodeAgentAvailability(env = process.env, options = const codexStdioActive = providerPlan.mode !== "openai" && codexStdioReady; const activeRunnerAvailability = codexStdioActive ? codexStdioRunnerAvailability(codexStdio) - : runnerAvailability; + : codexStdioBlockedRunnerAvailability(codexStdio); const blocked = providerPlan.mode === "openai" ? !providerContract.ready : providerPlan.mode === "codex-stdio" @@ -584,29 +553,11 @@ export async function describeCodeAgentAvailability(env = process.env, options = implementationType: codexStdioActive ? CODEX_STDIO_IMPLEMENTATION_TYPE : READONLY_IMPLEMENTATION_TYPE, codexStdioFeasibility: codexStdio }); - const agentKind = codexStdioActive - ? "codex-stdio-feasible" - : runnerAvailability.ready - ? "controlled-readonly-session-registry" - : blocked - ? "openai-fallback-blocked" - : "openai-fallback"; - const capabilityStatus = codexStdioActive - ? "codex-stdio-feasible" - : runnerAvailability.ready - ? "controlled-readonly-session-registry" - : blocked - ? "blocked" - : "openai-fallback"; + const agentKind = codexStdioActive ? "codex-stdio-feasible" : "codex-stdio-blocked"; + const capabilityStatus = codexStdioActive ? "codex-stdio-feasible" : "blocked"; const codeAgentReady = codexStdioActive; - const status = codexStdioActive ? "codex-stdio-feasible" : blocked && !runnerAvailability.ready ? "blocked" : "partial"; - const capabilityLevel = codexStdioActive - ? "long-lived-codex-stdio-session" - : runnerAvailability.ready - ? READONLY_SESSION_CAPABILITY_LEVEL - : blocked - ? "blocked" - : "text-chat-only"; + const status = codexStdioActive ? "codex-stdio-feasible" : "blocked"; + const capabilityLevel = codexStdioActive ? "long-lived-codex-stdio-session" : "blocked"; return { endpoint: "POST /v1/agent/chat", provider: providerPlan.provider, @@ -682,7 +633,7 @@ export async function describeCodeAgentAvailability(env = process.env, options = ...(blocked && providerPlan.mode !== "codex-stdio" ? ["openai_fallback_blocked"] : []) ], ready: codeAgentReady, - partialReady: runnerAvailability.ready || !blocked + partialReady: false }; } @@ -746,6 +697,51 @@ function codexStdioRunnerAvailability(codexStdio = {}) { }; } +function codexStdioBlockedRunnerAvailability(codexStdio = {}) { + const workspace = codexStdio.workspace ?? repoRoot; + const sandbox = codexStdio.sandbox ?? "workspace-write"; + return { + kind: CODEX_STDIO_RUNNER_KIND, + backend: CODEX_STDIO_BACKEND, + provider: CODEX_STDIO_PROVIDER, + workspace, + sandbox, + mode: CODEX_STDIO_SESSION_MODE, + sessionMode: CODEX_STDIO_SESSION_MODE, + session: CODEX_STDIO_SESSION_MODE, + status: "blocked", + ready: false, + capabilityLevel: "blocked", + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + longLivedSession: false, + durableSession: false, + durable: false, + codexStdio: true, + writeCapable: false, + readOnly: false, + runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"], + codexStdioFeasibility: codexStdio, + longLivedSessionGate: longLivedSessionGate({ + provider: CODEX_STDIO_PROVIDER, + runnerKind: CODEX_STDIO_RUNNER_KIND, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdioFeasibility: codexStdio + }), + sessionRegistry: codexStdioSessionRegistrySummary(codexStdio), + safety: { + secretsRead: false, + secretValuesPrinted: false, + kubeconfigRead: false, + hardwareControlViaCloudApiOnly: true, + directGatewayCallsAllowed: false, + directBoxSimuCallsAllowed: false, + directPatchPanelCallsAllowed: false, + m3m4m5AcceptanceClaimsAllowed: false + } + }; +} + function codexStdioSessionRegistrySummary(codexStdio = {}) { return { kind: "codex-stdio-session-registry", @@ -769,14 +765,14 @@ function codexStdioSessionRegistrySummary(codexStdio = {}) { function primaryCodeAgentBlocker({ blocked, providerContract, providerPlan, codexStdio, runnerAvailability }) { if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].summary; if (blocked && !runnerAvailability.ready) return providerPlan.mode === "openai" ? providerContract.blocker : "凭证缺口"; - if (runnerAvailability.ready) return "controlled-readonly-session-registry is available, but long-lived Codex stdio is blocked."; + if (runnerAvailability.ready) return "Codex stdio is blocked; controlled read-only runner fallback is disabled for /v1/agent/chat."; return null; } function primaryCodeAgentReason({ blocked, codexStdio, runnerAvailability }) { if (codexStdio.blockers?.length > 0) return codexStdio.blockers[0].code; if (blocked && !runnerAvailability.ready) return "provider_unavailable"; - if (runnerAvailability.ready) return "codex_stdio_blocked_readonly_session_available"; + if (runnerAvailability.ready) return "codex_stdio_blocked_fallback_disabled"; return null; } @@ -784,11 +780,7 @@ function codeAgentAvailabilitySummary({ blocked, codexStdio, runnerAvailability if (codexStdio.ready) { return "Codex stdio long-lived session adapter is feasible; /v1/agent/chat can create/reuse/cancel/reap sessions with trace capture. Hardware control still must use cloud-api/HWLAB API/skill CLI."; } - if (runnerAvailability.ready) { - return blocked - ? `受控只读 runner 可用于 pwd/skills/ls/rg/cat,M3 IO 只允许 Skill CLI 调用 ${HWLAB_M3_IO_API_ROUTE};OpenAI fallback 受 DEV provider Secret ${CODE_AGENT_PROVIDER_SECRET_REF} 或 egress/base-url contract 影响;long-lived Codex stdio 仍 blocked。` - : `普通聊天可走 OpenAI Responses fallback,pwd/skills 走受控只读 runner,M3 IO 走 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE};这些能力都不能冒充完整 long-lived Codex stdio Code Agent。`; - } + if (runnerAvailability.ready) return "Codex stdio long-lived session is blocked; /v1/agent/chat will not use controlled-readonly-session-registry, local skills discovery, shell/file shortcuts, or text fallback."; return blocked ? "OpenAI fallback 和 Codex stdio long-lived session 都 blocked;不返回伪完成。" : "Only text-chat fallback is available; it does not satisfy the Codex stdio session gate."; @@ -837,26 +829,6 @@ function resolveProviderPlan(env, options = {}) { }; } -async function callConfiguredProvider({ - providerPlan, - message, - conversationId, - traceId, - timeoutMs, - env, - now, - conversationFacts, - callProvider -}) { - if (callProvider) { - return callProvider({ providerPlan, message, conversationId, traceId, timeoutMs, env, now, conversationFacts }); - } - if (providerPlan.mode === "openai") { - return callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }); - } - return callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }); -} - async function inspectReadOnlyRunnerAvailability(env, options = {}) { const workspace = resolveRunnerWorkspace(env, options); const workspaceReady = Boolean(workspace && existsSync(workspace)); @@ -922,7 +894,7 @@ async function inspectReadOnlyRunnerAvailability(env, options = {}) { }; } -async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, conversationFacts }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, model, codexStdioManager, traceRecorder, conversationFacts }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ @@ -935,6 +907,7 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI workspace, timeoutMs, model, + traceRecorder, conversationFacts }); } catch (error) { @@ -999,381 +972,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI } } -async function callReadOnlyRunner({ intent, conversationId, sessionId, traceId, env, now, workspace, skillsDirs, skillsDirsExact, skillsDiscoveryDelayMs, sessionRegistry, codexStdioManager }) { - const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); - const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); - const sessionAcquire = registry.acquire({ - conversationId, - sessionId, - workspace: resolvedWorkspace, - sandbox: READONLY_RUNNER_SANDBOX, - runnerKind: READONLY_RUNNER_KIND, - sessionMode: READONLY_SESSION_MODE, - capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, - implementationType: READONLY_IMPLEMENTATION_TYPE, - traceId, - now - }); - if (!sessionAcquire.ok) { - const blockedSession = sessionAcquire.session; - const conversationFacts = registry.getConversationFacts(conversationId); - const blockedCodexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace }); - const blockedTrace = runnerTrace({ - traceId, - workspace: resolvedWorkspace ?? repoRoot, - session: blockedSession, - events: [`blocked:${sessionAcquire.code}`], - startedAt: nowIso(now), - outputTruncated: false - }); - throw runnerError(sessionAcquire.code, sessionAcquire.message, { - workspace: resolvedWorkspace ?? null, - session: blockedSession, - toolCalls: [], - skills: notRequestedSkills(), - runner: runnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), - runnerTrace: blockedTrace, - capabilityLevel: "blocked", - sessionMode: READONLY_SESSION_MODE, - sessionReuse: sessionReuseEvidence(blockedSession), - implementationType: READONLY_IMPLEMENTATION_TYPE, - runnerLimitations: [...READONLY_LIMITATION_FLAGS], - codexStdioFeasibility: blockedCodexStdioFeasibility, - conversationFacts, - longLivedSessionGate: longLivedSessionGate({ - provider: READONLY_RUNNER_PROVIDER, - runnerKind: READONLY_RUNNER_KIND, - session: blockedSession, - sessionMode: READONLY_SESSION_MODE, - implementationType: READONLY_IMPLEMENTATION_TYPE, - codexStdioFeasibility: blockedCodexStdioFeasibility - }), - blockers: [sessionAcquire.blocker], - route: null, - toolName: intent.toolName ?? null - }); - } - let session = sessionAcquire.session; - const runner = runnerDescriptor({ workspace: resolvedWorkspace, session }); - const startedAt = nowIso(now); - const codexStdioFeasibility = await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); - const events = [ - `intent:${intent.kind}`, - "sandbox:read-only", - `sessionMode:${READONLY_SESSION_MODE}`, - session.reused ? "session:reused" : "session:created", - `turn:${session.turn}` - ]; - const baseEvidence = { - sessionMode: READONLY_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - implementationType: READONLY_IMPLEMENTATION_TYPE, - runnerLimitations: [...READONLY_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: READONLY_RUNNER_PROVIDER, - runnerKind: READONLY_RUNNER_KIND, - session, - sessionMode: READONLY_SESSION_MODE, - implementationType: READONLY_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }) - }; - - if (!resolvedWorkspace || !(await pathReadable(resolvedWorkspace))) { - session = registry.fail(session.sessionId, { - now, - traceId, - conversationId, - reused: session.reused, - statusReason: "workspace_unreadable" - }) ?? session; - throw runnerError("runner_unavailable", `Read-only runner workspace is not readable: ${resolvedWorkspace || "missing"}`, { - workspace: resolvedWorkspace ?? null, - session, - toolCalls: [], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace ?? repoRoot, session, events: [...events, "blocked:workspace_unreadable"], startedAt, outputTruncated: false }), - capabilityLevel: "blocked", - route: null, - toolName: intent.toolName ?? null, - ...baseEvidence - }); - } - - if (intent.kind === "security") { - session = registry.fail(session.sessionId, { - now, - traceId, - conversationId, - reused: session.reused, - statusReason: "security_blocked" - }) ?? session; - throw runnerError("security_blocked", intent.reason ?? "The read-only runner blocked a request that could expose secrets or mutate hardware", { - workspace: resolvedWorkspace, - session, - toolCalls: [{ - id: `tool_${randomUUID()}`, - type: "security", - name: intent.toolName ?? "security.guard", - status: "blocked", - cwd: resolvedWorkspace, - exitCode: 1, - stdout: "", - stderrSummary: "security_blocked", - outputTruncated: false - }], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `blocked:${intent.toolName ?? "security.guard"}`], startedAt, outputTruncated: false }), - capabilityLevel: "blocked", - route: null, - toolName: intent.toolName ?? "security.guard", - ...baseEvidence, - blockers: [{ - code: "security_blocked", - sourceIssue: "pikasTech/HWLAB#275", - summary: intent.reason ?? "Read-only runner guardrail blocked the request." - }] - }); - } - - if (intent.kind === "pwd") { - const toolCall = await runPwdTool({ workspace: resolvedWorkspace, traceId, env }); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - return readOnlyRunnerResult({ - content: [ - "当前受控只读 runner 工作目录:", - toolCall.stdout, - "", - sessionReplyLine(session), - limitationReplyLine() - ].join("\n"), - workspace: resolvedWorkspace, - toolCalls: [toolCall], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:pwd:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), - session, - codexStdioFeasibility, - outputTruncated: toolCall.outputTruncated - }); - } - - if (intent.kind === "ls") { - const toolCall = await runLsTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); - assertReadOnlyToolCompleted(toolCall, { - session, - workspace: resolvedWorkspace, - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:blocked"], startedAt, outputTruncated: false }), - baseEvidence - }); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - return readOnlyRunnerResult({ - content: [ - "受控只读 ls 结果:", - toolCall.stdout || "(empty)", - "", - sessionReplyLine(session), - limitationReplyLine() - ].join("\n"), - workspace: resolvedWorkspace, - toolCalls: [toolCall], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:ls:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), - session, - codexStdioFeasibility, - outputTruncated: toolCall.outputTruncated - }); - } - - if (intent.kind === "rg_files") { - const toolCall = await runRgFilesTool({ workspace: resolvedWorkspace, target: intent.target, traceId, env }); - assertReadOnlyToolCompleted(toolCall, { - session, - workspace: resolvedWorkspace, - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:blocked"], startedAt, outputTruncated: false }), - baseEvidence - }); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - return readOnlyRunnerResult({ - content: [ - "受控只读 rg --files 结果:", - toolCall.stdout || "(empty)", - "", - sessionReplyLine(session), - limitationReplyLine() - ].join("\n"), - workspace: resolvedWorkspace, - toolCalls: [toolCall], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:rg --files:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), - session, - codexStdioFeasibility, - outputTruncated: toolCall.outputTruncated - }); - } - - if (intent.kind === "cat") { - const toolCall = await runCatTool({ workspace: resolvedWorkspace, target: intent.target, traceId }); - assertReadOnlyToolCompleted(toolCall, { - session, - workspace: resolvedWorkspace, - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:blocked"], startedAt, outputTruncated: false }), - baseEvidence - }); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - return readOnlyRunnerResult({ - content: [ - "受控只读 cat 结果:", - toolCall.stdout || "(empty)", - "", - sessionReplyLine(session), - limitationReplyLine() - ].join("\n"), - workspace: resolvedWorkspace, - toolCalls: [toolCall], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:cat:completed"], startedAt, outputTruncated: toolCall.outputTruncated }), - session, - codexStdioFeasibility, - outputTruncated: toolCall.outputTruncated - }); - } - - if (intent.kind === "skills") { - const skills = await discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs: skillsDiscoveryDelayMs }); - if (skills.status === "blocked") { - session = registry.fail(session.sessionId, { - now, - traceId, - conversationId, - reused: session.reused, - statusReason: "skills_unavailable" - }) ?? session; - throw runnerError("skills_unavailable", "No usable SKILL.md manifest was found for the read-only runner", { - workspace: resolvedWorkspace, - session, - toolCalls: [{ - id: `tool_${randomUUID()}`, - type: "file-read", - name: "skills.discover", - status: "blocked", - cwd: resolvedWorkspace, - exitCode: 1, - stdout: "", - stderrSummary: "skills_unavailable", - outputTruncated: false - }], - skills, - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:blocked"], startedAt, outputTruncated: false }), - capabilityLevel: "blocked", - route: null, - toolName: "skills.discover", - ...baseEvidence, - blockers: skills.blockers - }); - } - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - return readOnlyRunnerResult({ - content: skillsReply(skills), - workspace: resolvedWorkspace, - toolCalls: [{ - id: `tool_${randomUUID()}`, - type: "file-read", - name: "skills.discover", - status: "completed", - cwd: resolvedWorkspace, - exitCode: 0, - stdout: `skills=${skills.items.length}`, - stderrSummary: "", - outputTruncated: Boolean(skills.truncated) - }], - skills, - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, "tool:skills.discover:completed"], startedAt, outputTruncated: Boolean(skills.truncated) }), - session, - codexStdioFeasibility, - outputTruncated: Boolean(skills.truncated) - }); - } - - if (intent.kind === "session_context") { - const conversationFacts = registry.getConversationFacts(conversationId); - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - const contextToolCall = sessionContextToolCall({ conversationFacts, workspace: resolvedWorkspace, traceId }); - return readOnlyRunnerResult({ - content: sessionContextReply({ conversationFacts, session }), - workspace: resolvedWorkspace, - toolCalls: [contextToolCall], - skills: conversationFacts.latestSkills - ? { - status: conversationFacts.latestSkills.status ?? "ready", - items: conversationFacts.latestSkills.names.map((name) => ({ name })), - count: conversationFacts.latestSkills.count ?? conversationFacts.latestSkills.names.length, - totalCount: conversationFacts.latestSkills.totalCount ?? conversationFacts.latestSkills.names.length, - blockers: [] - } - : notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ - traceId, - workspace: resolvedWorkspace, - session, - events: [...events, "tool:session.context:completed"], - startedAt, - outputTruncated: false - }), - session, - codexStdioFeasibility, - outputTruncated: false, - conversationFacts - }); - } - - session = registry.fail(session.sessionId, { - now, - traceId, - conversationId, - reused: session.reused, - statusReason: "tool_unavailable" - }) ?? session; - throw runnerError("tool_unavailable", `Read-only runner does not expose requested tool: ${intent.toolName ?? "unknown"}`, { - workspace: resolvedWorkspace, - session, - toolCalls: [{ - id: `tool_${randomUUID()}`, - type: "tool", - name: intent.toolName ?? "unsupported", - status: "blocked", - cwd: resolvedWorkspace, - exitCode: 1, - stdout: "", - stderrSummary: "tool_unavailable", - outputTruncated: false - }], - skills: notRequestedSkills(), - runner, - runnerTrace: runnerTrace({ traceId, workspace: resolvedWorkspace, session, events: [...events, `tool:${intent.toolName ?? "unsupported"}:blocked`], startedAt, outputTruncated: false }), - capabilityLevel: "blocked", - route: null, - toolName: intent.toolName ?? "unsupported", - ...baseEvidence - }); -} - -async function callM3IoSkillRunner({ +async function callCodexStdioWithM3IoSkillRunner({ + message, intent, conversationId, sessionId, @@ -1381,155 +981,58 @@ async function callM3IoSkillRunner({ env, now, workspace, - sessionRegistry, - requestJson, + timeoutMs, + model, codexStdioManager, - codexStdioFeasibility: providedCodexStdioFeasibility = null + traceRecorder, + conversationFacts, + requestJson }) { - const resolvedWorkspace = resolveRunnerWorkspace(env, { workspace }); - const registry = resolveCodeAgentSessionRegistry({ sessionRegistry }); - const m3ApiBaseUrl = configuredCloudApiBaseUrl(env); - const sessionCapabilityLevel = m3ApiBaseUrl - ? m3SessionCapabilityLevelForIntent(intent) - : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked; - const sessionAcquire = registry.acquire({ + const stdioResult = await callCodexStdioRunner({ + message, conversationId, sessionId, - workspace: resolvedWorkspace, - sandbox: M3_IO_SKILL_SANDBOX, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - sessionMode: M3_IO_SKILL_SESSION_MODE, - capabilityLevel: sessionCapabilityLevel, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, traceId, - now + env, + now, + workspace, + timeoutMs, + model, + codexStdioManager, + traceRecorder, + conversationFacts + }); + traceRecorder?.append({ + type: "tool_call", + status: "started", + label: "tool:hwlab-m3-io:started", + toolName: HWLAB_M3_IO_SKILL_NAME, + waitingFor: HWLAB_M3_IO_API_ROUTE }); - const startedAt = nowIso(now); - const codexStdioFeasibility = providedCodexStdioFeasibility ?? - await inspectCodexStdioFeasibility(env, { codexStdioManager, workspace: resolvedWorkspace }); - if (!sessionAcquire.ok) { - const blockedSession = sessionAcquire.session; - throw runnerError(sessionAcquire.code, sessionAcquire.message, { - provider: M3_IO_SKILL_PROVIDER, - model: M3_IO_SKILL_MODEL, - backend: M3_IO_SKILL_BACKEND, - workspace: resolvedWorkspace ?? null, - sandbox: M3_IO_SKILL_SANDBOX, - session: blockedSession, - toolCalls: [], - skills: notRequestedSkills(), - runner: m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session: blockedSession }), - runnerTrace: m3IoSkillRunnerTrace({ - traceId, - workspace: resolvedWorkspace ?? repoRoot, - session: blockedSession, - events: [`blocked:${sessionAcquire.code}`], - startedAt, - outputTruncated: false - }), - capabilityLevel: "blocked", - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: sessionReuseEvidence(blockedSession), - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - session: blockedSession, - sessionMode: M3_IO_SKILL_SESSION_MODE, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - blockers: [sessionAcquire.blocker], - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME - }); - } - - let session = sessionAcquire.session; - if (intent.blocker) { - const skillResult = m3IoSkillIntentBlockedResult({ - traceId, - requestId: `req_${randomUUID()}`, - actorId: "usr_code_agent", - blocker: intent.blocker, - startedAt, - finishedAt: nowIso(now) - }); - return m3IoSkillRunnerResult({ - skillResult, - commandArgs: m3IoSkillArgsForIntent(intent, { env, traceId }), - resolvedWorkspace, - registry, - session, - now, - traceId, - conversationId, - codexStdioFeasibility, - startedAt - }); - } - - if (!m3ApiBaseUrl) { - const route = intent.action === "status" ? HWLAB_M3_STATUS_API_ROUTE : HWLAB_M3_IO_API_ROUTE; - const blocker = m3IoApiBaseUrlMissingBlocker({ route }); - const skillResult = m3IoSkillMissingApiBaseUrlResult({ - traceId, - requestId: `req_${randomUUID()}`, - actorId: "usr_code_agent", - blocker, - route, - startedAt, - finishedAt: nowIso(now) - }); - return m3IoSkillRunnerResult({ - skillResult, - commandArgs: m3IoSkillArgsForIntent(intent, { env, traceId }), - resolvedWorkspace, - registry, - session, - now, - traceId, - conversationId, - codexStdioFeasibility, - startedAt - }); - } - const commandArgs = m3IoSkillArgsForIntent(intent, { env, traceId }); const skillResult = await runM3IoSkillCommand(commandArgs, { env, now, requestJson }); - return m3IoSkillRunnerResult({ + traceRecorder?.append({ + type: "tool_call", + status: skillResult.ok ? "completed" : "blocked", + label: `tool:hwlab-m3-io:${skillResult.ok ? "completed" : "blocked"}`, + toolName: HWLAB_M3_IO_SKILL_NAME, + outputSummary: `route=${skillResult.route}; status=${skillResult.status}; operationId=${skillResult.operationId ?? "null"}`, + terminal: false + }); + return codexStdioM3IoSkillRunnerResult({ + stdioResult, skillResult, commandArgs, - resolvedWorkspace, - registry, - session, - now, traceId, - conversationId, - codexStdioFeasibility, - startedAt + now }); } -function m3IoSkillRunnerResult({ - skillResult, - commandArgs, - resolvedWorkspace, - registry, - session, - now, - traceId, - conversationId, - codexStdioFeasibility, - startedAt -}) { - const finishedAt = nowIso(now); +function codexStdioM3IoSkillRunnerResult({ stdioResult, skillResult, commandArgs, traceId, now }) { const capabilityLevel = skillResult.capabilityLevel ?? ( skillResult.ok ? HWLAB_M3_IO_CAPABILITY_LEVELS.ready : HWLAB_M3_IO_CAPABILITY_LEVELS.blocked ); @@ -1538,65 +1041,87 @@ function m3IoSkillRunnerResult({ const toolCall = m3IoSkillToolCall({ skillResult, commandArgs, - cwd: resolvedWorkspace, + cwd: stdioResult.workspace, capabilityLevel, route, method }); - - session = releaseReadOnlySession(registry, session, { now, traceId, conversationId }); - const runner = m3IoSkillRunnerDescriptor({ workspace: resolvedWorkspace, session, skillResult }); - const runnerTracePayload = m3IoSkillRunnerTrace({ - traceId, - workspace: resolvedWorkspace, - session, + const blockers = skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []); + const runnerTrace = { + ...stdioResult.runnerTrace, events: [ - "intent:m3_io", + ...(stdioResult.runnerTrace?.events ?? []), "tool:skill-cli:started", `route:${route}`, `tool:${toolCall.status}` ], - startedAt, - finishedAt, - outputTruncated: toolCall.outputTruncated, - skillResult - }); + route, + method, + skill: HWLAB_M3_IO_SKILL_NAME, + capabilityLevel, + controlReady: skillResult.controlReady === true, + operationId: skillResult.operationId, + auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, + evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, + readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, + accepted: skillResult.accepted, + status: skillResult.status, + blocker: skillResult.blocker ?? null, + trustBlocker: skillResult.trustBlocker ?? null, + blockers, + directGatewayCalls: false, + directBoxCalls: false, + directPatchPanelCalls: false, + fallbackUsed: false, + finishedAt: nowIso(now) + }; + const runner = { + ...stdioResult.runner, + skill: { + name: HWLAB_M3_IO_SKILL_NAME, + contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, + route + }, + toolPolicy: { + ...(stdioResult.runner?.toolPolicy ?? {}), + allowed: [ + ...new Set([ + ...(stdioResult.runner?.toolPolicy?.allowed ?? []), + `${method} ${route}` + ]) + ] + } + }; return { - provider: M3_IO_SKILL_PROVIDER, - model: M3_IO_SKILL_MODEL, - backend: M3_IO_SKILL_BACKEND, - content: m3IoSkillReply(skillResult), - workspace: resolvedWorkspace, - sandbox: M3_IO_SKILL_SANDBOX, - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - runnerLimitations: [...M3_IO_SKILL_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: M3_IO_SKILL_PROVIDER, - runnerKind: M3_IO_SKILL_RUNNER_KIND, - session, - sessionMode: M3_IO_SKILL_SESSION_MODE, - implementationType: M3_IO_SKILL_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - toolCalls: [toolCall], + ...stdioResult, + content: `${stdioResult.content}\n\n${m3IoSkillReply(skillResult)}`, + toolCalls: [ + ...(stdioResult.toolCalls ?? []), + toolCall + ], skills: { status: "used", - items: [m3IoSkillItem({ capabilityLevel, route })], - count: 1, - blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []) + items: [ + ...(stdioResult.skills?.items ?? []), + m3IoSkillItem({ capabilityLevel, route }) + ], + count: (stdioResult.skills?.count ?? 0) + 1, + blockers }, runner, - runnerTrace: runnerTracePayload, + runnerTrace, capabilityLevel, - blockers: skillResult.blockers ?? (skillResult.blocker ? [skillResult.blocker] : []), + blockers, route, toolName: HWLAB_M3_IO_SKILL_NAME, - providerTrace: m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }) + providerTrace: { + ...(stdioResult.providerTrace ?? {}), + ...m3IoSkillProviderTrace({ skillResult, route, method, capabilityLevel }), + runnerKind: stdioResult.runner?.kind ?? CODEX_STDIO_RUNNER_KIND, + codexStdio: true, + transport: "stdio+skill-cli" + } }; } @@ -1832,118 +1357,14 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker }; } -function m3IoSkillIntentBlockedResult({ traceId, requestId, actorId, blocker, startedAt, finishedAt }) { - return { - ok: false, - service: HWLAB_M3_IO_SKILL_NAME, - contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, - route: HWLAB_M3_IO_API_ROUTE, - method: "POST", - hwlabApi: { - route: HWLAB_M3_IO_API_ROUTE, - redactedUrl: null, - source: "intent-validation", - baseUrlConfigured: false, - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false - }, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - controlReady: false, - action: "m3.io", - accepted: false, - status: "blocked", - traceId, - requestId, - actorId, - operationId: null, - auditId: null, - evidenceId: null, - audit: { - auditId: null, - status: "not_written", - durableStatus: null, - summary: "blocked before HWLAB API request" - }, - evidence: { - evidenceId: null, - status: "blocked", - sourceKind: "BLOCKED", - blocker: blocker.code, - writeStatus: "not_written", - summary: "blocked before HWLAB API request" - }, - durable: { - status: "blocked", - durable: false, - blocker: blocker.code, - category: blocker.category, - summary: blocker.message - }, - blocker, - capabilityBlocker: blocker, - trustBlocker: null, - blockers: [blocker], - readiness: { - status: "blocked", - controlReady: false, - capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, - route: HWLAB_M3_IO_API_ROUTE, - blocker, - trustBlocker: null - }, - command: null, - result: { - value: null, - targetReadback: null - }, - readback: null, - controlPath: { - cloudApi: false, - gatewaySimu: false, - boxSimu: false, - patchPanel: false, - frontendBypass: false - }, - safety: { - cloudApiRouteOnly: true, - allowedRoute: HWLAB_M3_IO_API_ROUTE, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - openAiFallbackUsed: false - }, - httpStatus: 0, - error: { - code: blocker.code, - layer: blocker.layer, - category: blocker.category, - blocker, - retryable: blocker.retryable, - userMessage: blocker.userMessage, - message: blocker.message, - traceId, - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME - }, - rawStatus: null, - startedAt, - finishedAt, - response: null - }; -} - function detectReadOnlyRunnerIntent(message) { const text = String(message ?? "").trim(); - const lower = text.toLowerCase(); if (isSecretReadRequest(text)) { return { kind: "security", toolName: "security.secret-redaction", - reason: "只读 runner 不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" + reason: "Code Agent 安全边界不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" }; } const m3IoIntent = detectM3IoIntent(text); @@ -1954,35 +1375,12 @@ function detectReadOnlyRunnerIntent(message) { return { kind: "security", toolName: "security.hardware-boundary", - reason: `只读 runner 不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` + reason: `Code Agent 安全边界不直接调用 gateway/box-simu/patch-panel、泛化硬件写接口或宣称 M3/M4/M5 验收通过;M3 DO1/DI1 只能通过 Codex stdio -> Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE}。` }; } if (isSessionContextRequest(text)) { return { kind: "session_context", toolName: "session.context" }; } - if (/\bpwd\b/u.test(lower) || /当前.*(?:工作目录|目录)|工作目录|当前路径|workspace path|工作区路径/iu.test(text)) { - return { kind: "pwd", toolName: "pwd" }; - } - if (/(?:可用|能使用|加载|列出|所有).{0,16}(?:skills?|skill|技能)|(?:skills?|skill|技能).{0,16}(?:可用|能使用|加载|列出|所有)/iu.test(text)) { - return { kind: "skills", toolName: "skills.discover" }; - } - if (/\brg\s+--files\b/u.test(lower) || /(?:列出|查看).{0,12}(?:文件列表|所有文件|源码文件|代码文件)/u.test(text)) { - return { kind: "rg_files", toolName: "rg --files", target: extractReadOnlyTarget(text, { defaultTarget: "." }) }; - } - if (/\bls\b/u.test(lower) || /(?:列出|查看).{0,12}(?:目录|文件)/u.test(text)) { - return { kind: "ls", toolName: "ls", target: extractReadOnlyTarget(text, { defaultTarget: "." }) }; - } - if (/\bcat\b/u.test(lower) || /(?:读取|查看).{0,10}(?:文件|源码|代码)/u.test(text)) { - const target = extractReadOnlyTarget(text, { preferFile: true }); - if (!target) { - return { kind: "unsupported", toolName: "cat" }; - } - return { kind: "cat", toolName: "cat", target }; - } - if (/\b(?:grep|find|sed|awk)\b/u.test(lower)) { - const tool = lower.match(/\b(?:grep|find|sed|awk)\b/u)?.[0] ?? "file.read"; - return { kind: "unsupported", toolName: tool }; - } return { kind: "none" }; } @@ -2005,9 +1403,8 @@ function detectM3IoIntent(text) { return null; } - const m3ControlMentioned = /(?:HWLAB\s*API|Skill\s*CLI|M3\s*IO|控制\s*M3\s*IO|M3\s*控制|控制链路|受控链路)/iu.test(normalized); - const wantsRead = /(?:DI1|数字输入).{0,24}(?:read|readback|读取|回读|读回|查询|状态|当前)|(?:read|readback|读取|回读|读回|查询|状态|当前).{0,24}(?:DI1|数字输入)/iu.test(normalized); - const wantsWrite = /(?:DO1|数字输出).{0,30}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,30}(?:DO1|数字输出)/iu.test(normalized); + const wantsRead = /(?:DI1|数字输入).{0,16}(?:read|readback|读取|回读|读回|查询)|(?:read|readback|读取|回读|读回|查询).{0,16}(?:DI1|数字输入)/iu.test(normalized); + const wantsWrite = /(?:DO1|数字输出).{0,20}(?:write|set|置|写|打开|关闭|true|false|高|低)|(?:write|set|置|写|打开|关闭).{0,20}(?:DO1|数字输出)/iu.test(normalized); const wantsStatus = /(?:M3).{0,16}(?:status|状态|聚合|health|readiness)|(?:status|状态|聚合|health|readiness).{0,16}(?:M3)/iu.test(normalized); if (wantsStatus && !wantsRead && !wantsWrite) { return { @@ -2017,17 +1414,7 @@ function detectM3IoIntent(text) { }; } if (!wantsRead && !wantsWrite) { - if (!m3ControlMentioned) return null; - return { - kind: "m3_io", - toolName: HWLAB_M3_IO_SKILL_NAME, - action: "intent.blocked", - blocker: m3IoIntentBlocker({ - code: "m3_io_intent_action_missing", - message: "M3 IO request must specify DO1 true/false write or DI1 read.", - zh: "M3 IO 请求缺少明确动作;请指定“把 DO1 置为 true/false”或“读取 DI1”。" - }) - }; + return null; } if (wantsRead && !wantsWrite) { @@ -2041,14 +1428,9 @@ function detectM3IoIntent(text) { const value = parseM3WriteValue(normalized); if (typeof value !== "boolean") { return { - kind: "m3_io", + kind: "unsupported", toolName: HWLAB_M3_IO_SKILL_NAME, - action: "intent.blocked", - blocker: m3IoIntentBlocker({ - code: "m3_do1_value_missing", - message: "M3 DO1 write requires an explicit true/false value.", - zh: "M3 DO1 写入缺少明确 true/false 值;不会进入 Codex stdio 或用文本 fallback 冒充成功。" - }) + reason: "M3 DO1 write requires an explicit true/false value." }; } return { @@ -2065,44 +1447,6 @@ function parseM3WriteValue(text) { return null; } -function m3IoIntentBlocker({ code, message, zh }) { - return { - code, - layer: "intent", - category: "needs_user_action", - retryable: false, - source: "code-agent-m3-intent-router", - summary: message, - message, - zh, - userMessage: zh, - route: HWLAB_M3_IO_API_ROUTE, - toolName: HWLAB_M3_IO_SKILL_NAME - }; -} - -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); @@ -2149,455 +1493,12 @@ function resolveSkillDirs(env = process.env, options = {}) { .map((dir) => path.resolve(dir.trim())))]; } -async function pathReadable(targetPath) { - try { - await access(targetPath, fsConstants.R_OK); - return true; - } catch { - return false; - } -} - -async function runPwdTool({ workspace, traceId, env }) { - const result = await spawnWithInput("pwd", [], "", { - cwd: workspace, - env: runnerCommandEnv(env), - timeoutMs: 3000 - }); - const output = redactText((result.stdout || workspace).trim() || workspace); - const bounded = boundToolOutput(output); - return { - id: `tool_${randomUUID()}`, - type: "shell", - name: "pwd", - status: result.code === 0 ? "completed" : "blocked", - cwd: workspace, - command: "pwd", - exitCode: result.code, - stdout: bounded.text, - stderrSummary: redactText(tailText(result.stderr, 300)), - outputTruncated: bounded.truncated, - traceId - }; -} - -async function runLsTool({ workspace, target = ".", traceId }) { - const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true }); - const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); - if (blockedReason) { - return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: blockedReason }); - } - let entries = []; - try { - const targetStat = await stat(targetInfo.path); - if (targetStat.isDirectory()) { - entries = await readdir(targetInfo.path, { withFileTypes: true }); - } else { - entries = [{ name: path.basename(targetInfo.path), isDirectory: () => false, isSymbolicLink: () => false }]; - } - } catch (error) { - return blockedToolCall({ name: "ls", type: "file-list", workspace, traceId, reason: redactText(error.message) }); - } - - const lines = entries - .sort((a, b) => a.name.localeCompare(b.name, "en")) - .slice(0, READONLY_FILE_ENTRY_LIMIT) - .map((entry) => `${entry.isDirectory() ? "dir " : entry.isSymbolicLink() ? "link" : "file"} ${entry.name}`); - const overflow = entries.length > lines.length; - const bounded = boundToolOutput(redactText(lines.join("\n"))); - return { - id: `tool_${randomUUID()}`, - type: "file-list", - name: "ls", - status: "completed", - cwd: workspace, - command: `ls ${safeDisplayPath(targetInfo.relative || ".")}`, - exitCode: 0, - stdout: bounded.text, - stderrSummary: overflow ? `entry limit ${READONLY_FILE_ENTRY_LIMIT} reached` : "", - outputTruncated: bounded.truncated || overflow, - traceId - }; -} - -async function runRgFilesTool({ workspace, target = ".", traceId, env }) { - const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true }); - const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); - if (blockedReason) { - return blockedToolCall({ name: "rg --files", type: "file-list", workspace, traceId, reason: blockedReason }); - } - - let files = []; - let usedFallback = true; - if (await commandExists("rg", runnerCommandEnv(env))) { - const result = await spawnWithInput("rg", ["--files", targetInfo.path], "", { - cwd: workspace, - env: runnerCommandEnv(env), - timeoutMs: 3000 - }); - if (result.code === 0) { - files = result.stdout.split(/\r?\n/u).filter(Boolean).map((filePath) => path.relative(workspace, path.resolve(filePath))); - usedFallback = false; - } - } - if (usedFallback) { - files = await listFilesRecursively(targetInfo.path, workspace); - } - - const returned = files - .filter((filePath) => filePath && !filePath.startsWith("..") && !path.isAbsolute(filePath)) - .sort((a, b) => a.localeCompare(b, "en")) - .slice(0, READONLY_FILE_ENTRY_LIMIT); - const bounded = boundToolOutput(redactText(returned.join("\n"))); - return { - id: `tool_${randomUUID()}`, - type: "file-list", - name: "rg --files", - status: "completed", - cwd: workspace, - command: `rg --files ${safeDisplayPath(targetInfo.relative || ".")}`, - exitCode: 0, - stdout: bounded.text, - stderrSummary: usedFallback ? "node-fallback-used" : "", - outputTruncated: bounded.truncated || files.length > returned.length, - traceId - }; -} - -async function runCatTool({ workspace, target, traceId }) { - const targetInfo = resolveReadOnlyTarget(workspace, target, { mustExist: true, requireFile: true }); - const blockedReason = targetInfo.blocked ? targetInfo.reason : await targetInfo.check(); - if (blockedReason) { - return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: blockedReason }); - } - - let content = ""; - let truncatedByReadLimit = false; - try { - const file = await open(targetInfo.path, "r"); - try { - const buffer = Buffer.alloc(READONLY_FILE_READ_LIMIT + 1); - const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); - truncatedByReadLimit = bytesRead > READONLY_FILE_READ_LIMIT; - content = buffer.subarray(0, Math.min(bytesRead, READONLY_FILE_READ_LIMIT)).toString("utf8"); - } finally { - await file.close(); - } - } catch (error) { - return blockedToolCall({ name: "cat", type: "file-read", workspace, traceId, reason: redactText(error.message) }); - } - - const bounded = boundToolOutput(redactText(content)); - return { - id: `tool_${randomUUID()}`, - type: "file-read", - name: "cat", - status: "completed", - cwd: workspace, - command: `cat ${safeDisplayPath(targetInfo.relative)}`, - exitCode: 0, - stdout: bounded.text, - stderrSummary: truncatedByReadLimit ? `file read limited at ${READONLY_FILE_READ_LIMIT} bytes` : "", - outputTruncated: bounded.truncated || truncatedByReadLimit, - traceId - }; -} - -function blockedToolCall({ name, type, workspace, traceId, reason }) { - return { - id: `tool_${randomUUID()}`, - type, - name, - status: "blocked", - cwd: workspace, - exitCode: 1, - stdout: "", - stderrSummary: `security_blocked: ${redactText(reason)}`, - outputTruncated: false, - traceId - }; -} - -function assertReadOnlyToolCompleted(toolCall, { workspace, skills, runner, runnerTrace, baseEvidence }) { - if (toolCall.status === "completed") return; - throw runnerError("security_blocked", toolCall.stderrSummary || `${toolCall.name} was blocked by the read-only runner policy`, { - workspace, - toolCalls: [toolCall], - skills, - runner, - runnerTrace, - capabilityLevel: "blocked", - blockers: [{ - code: "security_blocked", - sourceIssue: "pikasTech/HWLAB#275", - summary: toolCall.stderrSummary || "Read-only runner blocked the requested file path." - }], - route: null, - toolName: toolCall.name, - ...baseEvidence - }); -} - -async function discoverSkills({ env, skillsDirs, skillsDirsExact, traceId, delayMs = 0 }) { - await maybeDelayForTest(delayMs); - const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact }); - const sourceSummaries = []; - const items = []; - - for (const skillsDir of checkedDirs) { - if (!(await pathReadable(skillsDir))) { - sourceSummaries.push({ - path: skillsDir, - status: "missing_or_unreadable", - commit: null, - version: null - }); - continue; - } - - const commit = await gitCommitFor(skillsDir, env); - const version = firstNonEmpty(env.HWLAB_SKILLS_VERSION, env.HWLAB_SKILL_VERSION, null); - sourceSummaries.push({ - path: skillsDir, - status: "readable", - commit, - version - }); - - const manifests = await skillManifestPaths(skillsDir); - for (const manifestPath of manifests) { - const manifest = await readSkillManifest(manifestPath); - if (!manifest) continue; - items.push({ - name: manifest.name ?? path.basename(path.dirname(manifestPath)), - summary: manifest.description ?? firstMarkdownSummary(manifest.body) ?? "No description provided.", - source: manifestPath, - sourceRoot: skillsDir, - version: manifest.version ?? version, - commit: manifest.commit ?? commit, - traceId - }); - } - } - - const uniqueItems = dedupeSkills(items) - .sort((a, b) => a.name.localeCompare(b.name, "en")); - const returned = uniqueItems.slice(0, MAX_SKILLS_RETURNED); - if (returned.length === 0) { - return { - status: "blocked", - code: "skills_unavailable", - items: [], - count: 0, - totalCount: 0, - checkedDirs, - sources: sourceSummaries, - blockers: [{ - code: "skills_unavailable", - sourceIssue: "pikasTech/HWLAB#136", - linkedIssues: ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"], - summary: "No readable SKILL.md files were found in the configured runner skills directories.", - nextTask: "Mount or sync canonical ~/.agents/skills or repo-owned skills into the Code Agent runner image/runtime, then rerun /v1/agent/chat skills discovery." - }], - valuesPrinted: false - }; - } - - return { - status: "ready", - code: "skills_ready", - items: returned, - count: returned.length, - totalCount: uniqueItems.length, - truncated: uniqueItems.length > returned.length, - checkedDirs, - sources: sourceSummaries, - blockers: [], - valuesPrinted: false - }; -} - -async function skillManifestPaths(skillsDir) { - const direct = path.join(skillsDir, "SKILL.md"); - const manifests = []; - if (await pathReadable(direct)) manifests.push(direct); - let entries = []; - try { - entries = await readdir(skillsDir, { withFileTypes: true }); - } catch { - return manifests; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const manifestPath = path.join(skillsDir, entry.name, "SKILL.md"); - if (await pathReadable(manifestPath)) manifests.push(manifestPath); - } - return manifests; -} - -async function readSkillManifest(manifestPath) { - let text = ""; - try { - text = await readFile(manifestPath, "utf8"); - } catch { - return null; - } - const frontmatter = parseFrontmatter(text); - const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, ""); - const name = frontmatter.name ?? path.basename(path.dirname(manifestPath)); - if (!name) return null; - return { - name, - description: frontmatter.description, - version: frontmatter.version, - commit: frontmatter.commit ?? frontmatter.commitId, - body - }; -} - -function parseFrontmatter(text) { - const match = String(text ?? "").match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/u); - if (!match) return {}; - const data = {}; - for (const line of match[1].split(/\r?\n/u)) { - const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)\s*$/u); - if (!field) continue; - data[field[1]] = field[2].replace(/^["']|["']$/gu, "").trim(); - } - return data; -} - -function firstMarkdownSummary(body) { - return String(body ?? "") - .split(/\r?\n/u) - .map((line) => line.trim()) - .find((line) => line && !line.startsWith("#") && !line.startsWith("-")) ?? null; -} - -function dedupeSkills(items) { - const seen = new Set(); - const deduped = []; - for (const item of items) { - const key = item.name.toLowerCase(); - if (seen.has(key)) continue; - seen.add(key); - deduped.push(item); - } - return deduped; -} - -async function gitCommitFor(targetPath, env = process.env) { - if (isPathInside(targetPath, repoRoot)) { - return firstNonEmpty(env.HWLAB_SKILLS_COMMIT_ID, env.HWLAB_COMMIT_ID, env.HWLAB_GIT_SHA, await gitRevParse(repoRoot)); - } - return firstNonEmpty(await gitRevParse(targetPath), null); -} - -async function gitRevParse(targetPath) { - const result = await spawnWithInput("git", ["-C", targetPath, "rev-parse", "--short=12", "HEAD"], "", { - cwd: targetPath, - env: runnerCommandEnv(process.env), - timeoutMs: 3000 - }); - return result.code === 0 ? result.stdout.trim() : null; -} - -function isPathInside(child, parent) { - const relative = path.relative(parent, child); - return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); -} - -function resolveReadOnlyTarget(workspace, target, { mustExist = false, requireFile = false } = {}) { - const rawTarget = String(target || ".").trim(); - if (!rawTarget) { - return { blocked: true, reason: "missing target path" }; - } - if (isForbiddenPath(rawTarget)) { - return { blocked: true, reason: "security_blocked: target path may expose secrets or forbidden runtime material" }; - } - const resolved = path.resolve(workspace, rawTarget); - if (!isPathInside(resolved, workspace)) { - return { blocked: true, reason: "security_blocked: target path is outside the runner workspace" }; - } - if (isForbiddenPath(path.relative(workspace, resolved))) { - return { blocked: true, reason: "security_blocked: target path is not allowed" }; - } - const relative = path.relative(workspace, resolved) || "."; - return { - path: resolved, - relative, - async check() { - if (!mustExist && !requireFile) return null; - try { - const targetStat = await stat(resolved); - if (requireFile && !targetStat.isFile()) { - return "target is not a regular file"; - } - return null; - } catch { - return "target path is not readable"; - } - } - }; -} - function isForbiddenPath(value) { const normalized = String(value ?? "").replaceAll("\\", "/").toLowerCase(); return /(^|\/)(?:\.env(?:\.|$)|\.npmrc$|\.pypirc$|id_rsa$|id_ed25519$|kubeconfig$|k3s\.yaml$|credentials?$|secrets?$|token(?:s)?$|database-url$)/u.test(normalized) || /(?:secret|token|password|passwd|private[_-]?key|openai_api_key|database_url|kubeconfig)/u.test(normalized); } -async function listFilesRecursively(rootPath, workspace) { - const results = []; - async function visit(currentPath, depth) { - if (results.length >= READONLY_FILE_ENTRY_LIMIT || depth > READONLY_FILE_TREE_DEPTH) return; - let entries = []; - try { - const currentStat = await stat(currentPath); - if (currentStat.isFile()) { - results.push(path.relative(workspace, currentPath)); - return; - } - if (!currentStat.isDirectory()) return; - entries = await readdir(currentPath, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) { - if (results.length >= READONLY_FILE_ENTRY_LIMIT) break; - if (SKIPPED_READONLY_DIRS.has(entry.name) || isForbiddenPath(entry.name)) continue; - const nextPath = path.join(currentPath, entry.name); - if (entry.isDirectory()) { - await visit(nextPath, depth + 1); - } else if (entry.isFile()) { - results.push(path.relative(workspace, nextPath)); - } - } - } - await visit(rootPath, 0); - return results; -} - -function safeDisplayPath(value) { - return redactText(String(value ?? ".")).replace(/\s+/gu, " "); -} - -function skillsReply(skills) { - const lines = [ - `当前只读 runner 已加载 ${skills.totalCount} 个 skill${skills.truncated ? `,本次显示前 ${skills.count} 个` : ""}。` - ]; - for (const skill of skills.items) { - const meta = [ - `source=${skill.source}`, - skill.version ? `version=${skill.version}` : null, - skill.commit ? `commit=${skill.commit}` : null - ].filter(Boolean).join(" / "); - lines.push(`- ${skill.name}: ${skill.summary} (${meta})`); - } - lines.push("说明:这是 read-only skills discovery;未读取 secret/token/kubeconfig,也未触发硬件写操作。"); - return boundToolOutput(lines.join("\n"), READONLY_TOOL_OUTPUT_LIMIT).text; -} - function notRequestedSkills() { return { status: "not_requested", @@ -2607,100 +1508,6 @@ function notRequestedSkills() { }; } -function readOnlyRunnerResult({ content, workspace, toolCalls, skills, runner, runnerTrace, session, codexStdioFeasibility, outputTruncated, conversationFacts }) { - return { - provider: READONLY_RUNNER_PROVIDER, - model: READONLY_RUNNER_MODEL, - backend: READONLY_RUNNER_BACKEND, - content, - workspace, - sandbox: READONLY_RUNNER_SANDBOX, - session, - sessionMode: READONLY_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - implementationType: READONLY_IMPLEMENTATION_TYPE, - runnerLimitations: [...READONLY_LIMITATION_FLAGS], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: READONLY_RUNNER_PROVIDER, - runnerKind: READONLY_RUNNER_KIND, - session, - sessionMode: READONLY_SESSION_MODE, - implementationType: READONLY_IMPLEMENTATION_TYPE, - codexStdioFeasibility - }), - toolCalls, - skills, - runner, - runnerTrace, - capabilityLevel: READONLY_SESSION_CAPABILITY_LEVEL, - ...(conversationFacts ? { conversationFacts } : {}), - 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 sessionContextToolCall({ conversationFacts, workspace, traceId }) { - const bounded = boundToolOutput(JSON.stringify({ - conversationId: conversationFacts.conversationId, - sessionId: conversationFacts.sessionId, - turnCount: conversationFacts.turnCount, - provider: conversationFacts.latestProvider, - backend: conversationFacts.latestBackend, - runnerKind: conversationFacts.runnerKind, - capabilityLevel: conversationFacts.capabilityLevel, - workspace: conversationFacts.workspace, - skills: { - status: conversationFacts.latestSkills?.status ?? "not_requested", - count: conversationFacts.latestSkills?.totalCount ?? conversationFacts.latestSkills?.count ?? 0, - names: conversationFacts.latestSkills?.names?.slice(0, 8) ?? [] - }, - latestTraceId: conversationFacts.latestTraceId, - recentToolCalls: conversationFacts.recentToolCalls?.slice(-6) ?? [], - valuesRedacted: true - })); - return { - id: `tool_${randomUUID()}`, - type: "session-facts", - name: "session.context", - status: "completed", - cwd: workspace, - command: "session.context", - exitCode: 0, - stdout: bounded.text, - stderrSummary: "", - outputTruncated: bounded.truncated, - traceId - }; -} - -function sessionContextReply({ conversationFacts, session }) { - const skills = conversationFacts.latestSkills; - const skillNames = skills?.names?.length ? skills.names.join(", ") : "上一轮没有可用 skill 清单"; - const skillCount = skills ? (skills.totalCount ?? skills.count ?? skills.names?.length ?? 0) : 0; - const toolSummary = conversationFacts.recentToolCalls?.length - ? conversationFacts.recentToolCalls.map((toolCall) => `${toolCall.name ?? "tool"}:${toolCall.status ?? "unknown"}`).join(", ") - : "none"; - const traceSummary = conversationFacts.traceIds?.length ? conversationFacts.traceIds.join(", ") : "none"; - return [ - "根据同一个 conversation/session 中已记录的 runner facts:", - `- conversationId=${conversationFacts.conversationId}; sessionId=${conversationFacts.sessionId}; sessionStatus=${conversationFacts.sessionStatus ?? session.status}; sessionMode=${conversationFacts.sessionMode ?? session.sessionMode}; turnCount=${conversationFacts.turnCount}.`, - `- provider=${conversationFacts.latestProvider ?? "unknown"}; backend=${conversationFacts.latestBackend ?? "unknown"}; runner=${conversationFacts.runnerKind ?? "unknown"}; capabilityLevel=${conversationFacts.capabilityLevel ?? "unknown"}.`, - `- 当前工作目录/workspace=${conversationFacts.workspace ?? session.workspace ?? "unknown"}.`, - `- 可用 skills=${skillCount}; ${skillNames}.`, - `- 最近 toolCalls=${toolSummary}; traceIds=${traceSummary}.`, - "证明:本轮走 session.context 的 Code Agent session fact 路径,复用同一 conversation/session 的 bounded facts;不是 openai-responses-fallback,不是 stateless-one-shot,也不是无法访问之前状态;不会输出原始 secret/evidence。", - limitationReplyLine() - ].join("\n"); -} - function m3IoSkillArgsForIntent(intent, { env, traceId }) { const args = [ "m3", @@ -2938,14 +1745,6 @@ function sessionReuseEvidence(session) { }; } -function sessionReplyLine(session) { - return `Session registry: mode=${READONLY_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}; idleTimeoutMs=${session.idleTimeoutMs}; lastTraceId=${session.lastTraceId}.`; -} - -function limitationReplyLine() { - return "边界:controlled-readonly-session-registry;long-lived read-only session;not-codex-stdio;not-write-capable;process-local-session-registry。"; -} - function releaseReadOnlySession(registry, session, { now, traceId, conversationId } = {}) { return registry.release(session.sessionId, { now, @@ -2975,90 +1774,6 @@ function resolveCodexStdioSessionManager(options = {}) { : defaultCodexStdioSessionManager; } -function m3IoSkippedCodexStdioFeasibility() { - return { - kind: CODEX_STDIO_RUNNER_KIND, - provider: CODEX_STDIO_PROVIDER, - backend: CODEX_STDIO_BACKEND, - status: "skipped", - ready: false, - checked: false, - skipped: true, - reason: "m3_io_skill_cli_deterministic_route", - canStartLongLivedCodexStdio: false, - implementationRequired: "not-required-for-m3-io-skill-cli", - currentImplementation: M3_IO_SKILL_IMPLEMENTATION_TYPE, - sourceIssue: CODEX_STDIO_SOURCE_ISSUE, - blockers: [], - blockerCodes: [], - safety: { - hardwareControlViaCloudApiOnly: true, - directGatewayCallsAllowed: false, - directBoxSimuCallsAllowed: false, - directPatchPanelCallsAllowed: false, - openAiHardwareFallbackAllowed: false - }, - note: `M3 IO requests are handled by deterministic Code Agent -> Skill CLI -> HWLAB API ${HWLAB_M3_IO_API_ROUTE} routing before Codex stdio probe/chat, so stdio readiness cannot cause the 150s M3 control timeout.` - }; -} - -function runnerDescriptor({ workspace, kind = READONLY_RUNNER_KIND, session = null } = {}) { - const allowed = kind === READONLY_RUNNER_KIND - ? ["pwd", "skills.discover", "ls", "rg --files", "cat", `m3.io.skill-cli:${HWLAB_M3_IO_API_ROUTE}`] - : ["pwd", "skills.discover", "ls", "rg --files", "cat"]; - return { - kind, - provider: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_PROVIDER : "codex-cli", - backend: kind === READONLY_RUNNER_KIND ? READONLY_RUNNER_BACKEND : "hwlab-cloud-api/codex-cli", - workspace: workspace ?? repoRoot, - sandbox: READONLY_RUNNER_SANDBOX, - session: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE, - sessionMode: kind === CODEX_CLI_ONE_SHOT_RUNNER_KIND ? "ephemeral-one-shot" : READONLY_SESSION_MODE, - sessionId: session?.sessionId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: kind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", - codexStdio: false, - longLivedSession: kind === READONLY_RUNNER_KIND, - durableSession: false, - writeCapable: false, - readOnly: true, - capabilityLevel: kind === READONLY_RUNNER_KIND ? READONLY_SESSION_CAPABILITY_LEVEL : "text-chat-only", - toolPolicy: { - allowed, - blocked: ["secret-read", "kubeconfig-read", "hardware-write", "gateway-direct-call", "box-simu-direct-call", "patch-panel-direct-call", "M3/M4/M5-acceptance-claim"] - }, - runnerLimitations: kind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"], - safety: runnerSafetyContract() - }; -} - -function runnerTrace({ traceId, events, startedAt, outputTruncated, workspace = repoRoot, runnerKind = READONLY_RUNNER_KIND, session = null }) { - return { - traceId, - runnerKind, - workspace, - sandbox: READONLY_RUNNER_SANDBOX, - sessionMode: runnerKind === READONLY_RUNNER_KIND ? READONLY_SESSION_MODE : "ephemeral-one-shot", - sessionId: session?.sessionId ?? null, - sessionStatus: session?.status ?? null, - idleTimeoutMs: session?.idleTimeoutMs ?? null, - lastTraceId: session?.lastTraceId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: runnerKind === READONLY_RUNNER_KIND ? READONLY_IMPLEMENTATION_TYPE : "codex-cli-one-shot-ephemeral", - limitations: runnerKind === READONLY_RUNNER_KIND ? [...READONLY_LIMITATION_FLAGS] : ["one-shot", "not-durable-session"], - startedAt, - finishedAt: startedAt, - events, - outputTruncated: Boolean(outputTruncated), - valuesPrinted: false, - note: runnerKind === CODEX_CLI_ONE_SHOT_RUNNER_KIND - ? "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session." - : "controlled-readonly-session-registry stores conversation/session mapping, idle timeout, status, trace id, and turn counters for a reusable read-only session; it is not Codex stdio, write-capable, or DB-durable." - }; -} - function runnerSafetyContract() { return { secretsRead: false, @@ -3118,34 +1833,29 @@ function longLivedSessionGate({ }); } -function openAiFallbackRunnerEvidence({ providerResult, traceId, conversationId, sessionId }) { +function codexStdioBlockedRunnerDescriptor({ workspace, session = null } = {}) { return { - kind: OPENAI_FALLBACK_RUNNER_KIND, - provider: providerResult.provider ?? "openai-responses", - backend: providerResult.backend ?? "hwlab-cloud-api/openai-responses", - workspace: null, - sandbox: "none", - session: "provider-text-request", - sessionMode: "provider-text-request", - sessionId, - conversationId, - implementationType: OPENAI_FALLBACK_RUNNER_KIND, - codexStdio: false, + kind: CODEX_STDIO_RUNNER_KIND, + provider: CODEX_STDIO_PROVIDER, + backend: CODEX_STDIO_BACKEND, + workspace: workspace ?? repoRoot, + sandbox: "workspace-write", + session: CODEX_STDIO_SESSION_MODE, + sessionMode: CODEX_STDIO_SESSION_MODE, + sessionId: session?.sessionId ?? null, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, + codexStdio: true, longLivedSession: false, durableSession: false, writeCapable: false, readOnly: false, - capabilityLevel: "text-chat-only", + capabilityLevel: "blocked", toolPolicy: { - allowed: [], - blocked: ["workspace-tools", "skills-discovery", "shell-tools", "file-tools", "hardware-write"] + allowed: ["codex", "codex-reply"], + blocked: ["controlled-readonly-session-registry", "skills.discover-local-fallback", "pwd-local-fallback", "ls-local-fallback", "rg-local-fallback", "cat-local-fallback", "openai-text-fallback"] }, - capabilityGate: { - codexRunner: false, - reason: "OpenAI Responses fallback is text chat only and cannot satisfy the Codex runner capability gate." - }, - runnerLimitations: [...OPENAI_FALLBACK_LIMITATION_FLAGS], - traceId + runnerLimitations: ["codex-stdio-required", "no-controlled-readonly-fallback", "no-text-fallback"], + safety: runnerSafetyContract() }; } @@ -3162,19 +1872,6 @@ function runnerError(code, message, details = {}) { return error; } -function runnerCommandEnv(env = process.env) { - return { - PATH: Object.hasOwn(env, "PATH") ? env.PATH : process.env.PATH || "/usr/local/bin:/usr/bin:/bin", - HOME: env.HOME || process.env.HOME || os.homedir() - }; -} - -async function maybeDelayForTest(value) { - const delayMs = Number.parseInt(value ?? "", 10); - if (!Number.isInteger(delayMs) || delayMs <= 0) return; - await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, 10000))); -} - function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) { const text = String(value ?? ""); if (text.length <= maxLength) return { text, truncated: false }; @@ -3184,328 +1881,6 @@ function boundToolOutput(value, maxLength = READONLY_TOOL_OUTPUT_LIMIT) { }; } -async function callOpenAiResponses({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }) { - if (env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "1" && env.HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK !== "true") { - return { - provider: "openai-responses", - model: providerPlan.model, - backend: "hwlab-cloud-api/openai-responses-fallback", - content: "当前完整 Codex stdio Code Agent 未通过 readiness;OpenAI Responses fallback 保留但默认不自动调用。请查看 blocker 后补齐运行底座。", - usage: null, - capabilityLevel: "text-chat-only", - implementationType: OPENAI_FALLBACK_RUNNER_KIND, - runnerLimitations: [...OPENAI_FALLBACK_LIMITATION_FLAGS], - providerTrace: { - fallbackUsed: true, - fallbackKind: OPENAI_FALLBACK_RUNNER_KIND, - networkCallAttempted: false, - valuesPrinted: false - } - }; - } - const providerContract = inspectCodeAgentProviderEnv(env); - if (!providerContract.ready) { - throw providerUnavailable(providerContract.blocker, { - missingEnv: providerContract.missingEnv, - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - egress: providerContract.egress - }); - } - - const endpoint = env.HWLAB_CODE_AGENT_OPENAI_BASE_URL; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), effectiveTimeout(timeoutMs)); - let response; - let payload; - - try { - response = await fetch(endpoint, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "text/event-stream", - authorization: `Bearer ${env.OPENAI_API_KEY}` - }, - body: JSON.stringify({ - model: providerPlan.model, - instructions: CODE_AGENT_SYSTEM_PROMPT, - input: [ - { - role: "user", - content: [ - { - type: "input_text", - text: buildAgentPrompt({ message, conversationId, traceId, conversationFacts }) - } - ] - } - ], - store: false, - stream: true - }), - signal: controller.signal - }); - payload = parseOpenAiResponseBody(await response.text()); - } catch (error) { - if (error.name === "AbortError") { - throw providerUnavailable(`OpenAI Responses request timed out after ${effectiveTimeout(timeoutMs)}ms`, { - code: "provider_timeout", - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - timeoutMs: effectiveTimeout(timeoutMs) - }); - } - throw providerUnavailable(`OpenAI Responses request failed: ${error.message}`, { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend - }); - } finally { - clearTimeout(timer); - } - - const providerError = payload.error ?? payload.raw?.error ?? null; - if (!response.ok) { - throw providerUnavailable(`OpenAI Responses returned HTTP ${response.status}: ${providerError?.message || "request rejected"}`, { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - providerStatus: response.status - }); - } - - if (providerError) { - throw providerUnavailable(`OpenAI Responses stream error: ${providerError.message || "request rejected"}`, { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend - }); - } - - const content = payload.content || extractOpenAiOutputText(payload.raw); - if (!content) { - throw providerUnavailable("OpenAI Responses returned no assistant text", { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend - }); - } - - return { - provider: providerPlan.provider, - model: payload.model || providerPlan.model, - backend: providerPlan.backend, - content, - usage: payload.usage ?? null, - providerTrace: { - responseId: payload.responseId ?? null - } - }; -} - -async function callCodexCli({ providerPlan, message, conversationId, traceId, timeoutMs, env, conversationFacts }) { - const command = firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_COMMAND, DEFAULT_CODEX_COMMAND); - if (!(await commandExists(command, env))) { - throw providerUnavailable(`Codex CLI command is not available: ${command}`, { - code: "codex_cli_binary_missing", - missingCommands: [command], - missingEnv: env.OPENAI_API_KEY ? [] : ["OPENAI_API_KEY"], - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - command, - nextEvidence: `Install/provide a repo-controlled Codex CLI binary on PATH or set HWLAB_CODE_AGENT_CODEX_COMMAND to an approved binary path; checked command=${command}.` - }); - } - - const outputDir = await makeTempDir(); - const outputFile = path.join(outputDir, "last-message.txt"); - const args = [ - "exec", - "--cd", - repoRoot, - "--skip-git-repo-check", - "--sandbox", - "read-only", - "--ephemeral", - "--output-last-message", - outputFile - ]; - if (providerPlan.model) { - args.push("--model", providerPlan.model); - } - args.push("-"); - - const result = await spawnWithInput(command, args, buildAgentPrompt({ message, conversationId, traceId, conversationFacts }), { - env, - timeoutMs: effectiveTimeout(timeoutMs) - }); - - try { - if (result.code !== 0) { - throw providerUnavailable(`Codex CLI exited with code ${result.code}`, { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - command: commandLine(command, args), - exitCode: result.code, - stderrSummary: redactText(tailText(result.stderr || result.stdout)) - }); - } - - const content = (await readFile(outputFile, "utf8")).trim(); - if (!content) { - throw providerUnavailable("Codex CLI completed without an assistant message", { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - command: commandLine(command, args), - stderrSummary: redactText(tailText(result.stderr || result.stdout)) - }); - } - - return { - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content, - usage: null, - workspace: repoRoot, - sandbox: READONLY_RUNNER_SANDBOX, - sessionMode: "ephemeral-one-shot", - session: { - sessionId: conversationId, - conversationId, - status: "expired", - workspace: repoRoot, - sandbox: READONLY_RUNNER_SANDBOX, - runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, - capabilityLevel: "text-chat-only", - implementationType: "codex-cli-one-shot-ephemeral", - createdAt: nowIso(), - updatedAt: nowIso(), - idleTimeoutMs: 0, - expiresAt: nowIso(), - lastTraceId: traceId, - turn: 1, - longLivedSession: false, - codexStdio: false, - durable: false, - secretMaterialStored: false, - valuesRedacted: true - }, - sessionReuse: { - conversationId, - sessionId: conversationId, - mapped: true, - reused: false, - turn: 1, - previousTurns: 0, - workspace: repoRoot - }, - implementationType: "codex-cli-one-shot-ephemeral", - runnerLimitations: ["one-shot", "not-durable-session", "not-controlled-session-registry"], - codexStdioFeasibility, - longLivedSessionGate: longLivedSessionGate({ - provider: providerPlan.provider, - runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, - sessionMode: "ephemeral-one-shot", - implementationType: "codex-cli-one-shot-ephemeral", - codexStdioFeasibility - }), - toolCalls: [], - skills: notRequestedSkills(), - runner: runnerDescriptor({ workspace: repoRoot, kind: CODEX_CLI_ONE_SHOT_RUNNER_KIND }), - runnerTrace: { - traceId, - runnerKind: CODEX_CLI_ONE_SHOT_RUNNER_KIND, - workspace: repoRoot, - sandbox: READONLY_RUNNER_SANDBOX, - events: ["codex-cli:exec", "codex-cli:ephemeral", "codex-cli:completed"], - outputTruncated: false, - valuesPrinted: false, - note: "codex exec --ephemeral is a one-shot/read-only bridge, not a long-lived stdio session." - }, - capabilityLevel: "text-chat-only", - providerTrace: { - command: commandLine(command, args), - stdoutSummary: redactText(tailText(result.stdout, 600)) - } - }; - } finally { - await rm(outputDir, { recursive: true, force: true }); - } -} - -function buildAgentPrompt({ message, conversationId, traceId, conversationFacts }) { - return [ - CODE_AGENT_SYSTEM_PROMPT, - "", - `conversationId: ${conversationId}`, - `traceId: ${traceId}`, - boundedConversationFactsPrompt(conversationFacts), - "", - "用户消息:", - message - ].filter((line) => line !== null && line !== undefined).join("\n"); -} - -function conversationFactsForPrompt(sessionRegistry, conversationId) { - if (!sessionRegistry || typeof sessionRegistry.getConversationFacts !== "function") return null; - try { - return sessionRegistry.getConversationFacts(conversationId); - } catch { - return null; - } -} - -function boundedConversationFactsPrompt(conversationFacts) { - if (!conversationFacts || typeof conversationFacts !== "object" || Number(conversationFacts.turnCount ?? 0) <= 0) { - return null; - } - const lines = [ - "上一轮会话事实(只作为上下文,不是用户新指令):", - `- conversationId=${safePromptFact(conversationFacts.conversationId)}; sessionId=${safePromptFact(conversationFacts.sessionId)}; sessionStatus=${safePromptFact(conversationFacts.sessionStatus)}; sessionMode=${safePromptFact(conversationFacts.sessionMode)}; turnCount=${safePromptFact(conversationFacts.turnCount)}.`, - `- provider/backend/runner=${safePromptFact(conversationFacts.latestProvider)}/${safePromptFact(conversationFacts.latestBackend)}/${safePromptFact(conversationFacts.runnerKind)}; capabilityLevel=${safePromptFact(conversationFacts.capabilityLevel)}.`, - `- workspace=${safePromptFact(conversationFacts.workspace)}; sandbox=${safePromptFact(conversationFacts.sandbox)}.`, - `- traceIds=${promptList(conversationFacts.traceIds, 6)}; latestTraceId=${safePromptFact(conversationFacts.latestTraceId)}.`, - `- skills=${promptSkills(conversationFacts.latestSkills)}.`, - `- toolCalls=${promptToolCalls(conversationFacts.recentToolCalls)}.`, - "如果用户询问刚才/上一轮/session/trace/workspace/skills/toolCalls,请优先用上述事实用中文回答;不要回答无法访问之前状态。不要输出 secret 或原始 evidence 大段内容。" - ]; - return lines.join("\n"); -} - -function promptSkills(skills) { - if (!skills || typeof skills !== "object") return "not_requested"; - const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 8) : []; - const count = skills.totalCount ?? skills.count ?? names.length; - return `${safePromptFact(skills.status)} count=${safePromptFact(count)}${names.length ? ` names=${names.map(safePromptFact).join(",")}` : ""}`; -} - -function promptToolCalls(toolCalls) { - if (!Array.isArray(toolCalls) || toolCalls.length === 0) return "none"; - return toolCalls - .slice(-6) - .map((toolCall) => [toolCall?.name, toolCall?.status, toolCall?.route].filter(Boolean).map(safePromptFact).join(":")) - .filter(Boolean) - .join(",") || "none"; -} - -function promptList(values, limit) { - return Array.isArray(values) && values.length > 0 - ? values.filter(Boolean).slice(-limit).map(safePromptFact).join(",") - : "none"; -} - -function safePromptFact(value) { - if (value === undefined || value === null || value === "") return "none"; - return redactText(String(value).replace(/\s+/gu, " ").trim()).slice(0, 180); -} - function normalizeUserMessage(value) { if (typeof value !== "string") { throw badRequest("message must be a string"); @@ -3520,6 +1895,15 @@ function normalizeUserMessage(value) { return message; } +function conversationFactsForPrompt(sessionRegistry, conversationId) { + if (!sessionRegistry || typeof sessionRegistry.getConversationFacts !== "function") return null; + try { + return sessionRegistry.getConversationFacts(conversationId); + } catch { + return null; + } +} + export function createCodeAgentErrorPayload({ code = "code_agent_failed", message = "Code Agent request failed", @@ -3863,176 +2247,6 @@ function badRequest(message) { return error; } -function providerUnavailable(message, details = {}) { - const error = new Error(message); - error.code = details.code ?? "provider_unavailable"; - Object.assign(error, details); - return error; -} - -function extractOpenAiOutputText(payload) { - if (typeof payload?.output_text === "string" && payload.output_text.trim()) { - return payload.output_text.trim(); - } - const chunks = []; - for (const item of payload?.output ?? []) { - for (const content of item.content ?? []) { - if (typeof content.text === "string") chunks.push(content.text); - else if (typeof content.output_text === "string") chunks.push(content.output_text); - } - } - return chunks.join("\n").trim(); -} - -function parseOpenAiResponseBody(text) { - const raw = parseJsonOrNull(text); - if (raw) { - return { - raw, - content: extractOpenAiOutputText(raw), - responseId: raw.id ?? null, - model: raw.model ?? null, - usage: raw.usage ?? null, - error: raw.error ?? null - }; - } - - const stream = parseOpenAiResponsesSse(text); - return { - raw: stream.finalResponse, - content: stream.content, - responseId: stream.responseId, - model: stream.model, - usage: stream.usage, - error: stream.error - }; -} - -function parseOpenAiResponsesSse(text) { - const deltas = []; - let finalResponse = null; - let responseId = null; - let model = null; - let usage = null; - let error = null; - - for (const event of parseSseDataMessages(text)) { - const payload = parseJsonOrNull(event); - if (!payload) continue; - const response = payload.response && typeof payload.response === "object" ? payload.response : null; - if (response) { - finalResponse = response; - responseId = response.id ?? responseId; - model = response.model ?? model; - usage = response.usage ?? usage; - if (response.error) error = response.error; - } - if (payload.response_id) responseId = payload.response_id; - if (payload.item?.id) responseId = responseId ?? payload.item.id; - if (payload.error) error = payload.error; - if (payload.type === "error" && !payload.error) error = payload; - if (typeof payload.delta === "string") deltas.push(payload.delta); - else if (typeof payload.text === "string" && payload.type === "response.output_text.done" && deltas.length === 0) { - deltas.push(payload.text); - } - } - - const content = deltas.join("").trim() || extractOpenAiOutputText(finalResponse); - return { - finalResponse, - content, - responseId, - model, - usage, - error - }; -} - -function parseSseDataMessages(text) { - const messages = []; - for (const block of String(text ?? "").split(/\r?\n\r?\n/u)) { - const data = []; - for (const line of block.split(/\r?\n/u)) { - if (line.startsWith("data:")) data.push(line.slice(5).trimStart()); - } - const message = data.join("\n").trim(); - if (message && message !== "[DONE]") messages.push(message); - } - return messages; -} - -function parseJsonOrNull(value) { - try { - return JSON.parse(value); - } catch { - return null; - } -} - -async function commandExists(command, env) { - if (command.includes("/") || command.includes("\\")) { - try { - await access(command, fsConstants.X_OK); - return true; - } catch { - return false; - } - } - - const result = await spawnWithInput("sh", ["-lc", `command -v ${shellQuote(command)}`], "", { - env, - timeoutMs: 3000 - }); - return result.code === 0; -} - -function spawnWithInput(command, args, input, { env, timeoutMs, cwd = repoRoot }) { - return new Promise((resolve) => { - const child = spawn(command, args, { - cwd, - env, - stdio: ["pipe", "pipe", "pipe"] - }); - let stdout = ""; - let stderr = ""; - let settled = false; - const timer = setTimeout(() => { - if (!settled) { - child.kill("SIGTERM"); - } - }, timeoutMs); - - child.stdout.on("data", (chunk) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk) => { - stderr += chunk; - }); - child.on("error", (error) => { - settled = true; - clearTimeout(timer); - resolve({ code: 127, stdout, stderr: `${stderr}\n${error.message}` }); - }); - child.on("close", (code) => { - settled = true; - clearTimeout(timer); - resolve({ code: code ?? 1, stdout, stderr }); - }); - child.stdin.on("error", (error) => { - if (error?.code !== "EPIPE") { - stderr += `\n${error.message}`; - } - }); - child.stdin.end(input); - }); -} - -async function makeTempDir() { - const dir = path.join(os.tmpdir(), `hwlab-code-agent-${randomUUID()}`); - await mkdir(dir, { recursive: true }); - return dir; -} - function cleanProtocolId(value, prefix) { if (typeof value !== "string") return null; const trimmed = value.trim(); @@ -4082,10 +2296,6 @@ function nowIso(now) { return typeof now === "function" ? now() : new Date().toISOString(); } -function effectiveTimeout(timeoutMs) { - return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_CODE_AGENT_TIMEOUT_MS; -} - function firstNonEmpty(...values) { for (const value of values) { if (typeof value === "string" && value.trim()) return value.trim(); @@ -4093,16 +2303,6 @@ function firstNonEmpty(...values) { return ""; } -function commandLine(command, args) { - return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); -} - -function tailText(value, maxLength = 1200) { - const text = String(value ?? "").trim(); - if (text.length <= maxLength) return text; - return text.slice(text.length - maxLength); -} - function redactText(value) { return String(value ?? "") .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") @@ -4121,7 +2321,3 @@ function redactUrl(value) { return redactText(String(value ?? "")).replace(/\/\/[^/@]+@/u, "//***@"); } } - -function shellQuote(value) { - return `'${String(value).replaceAll("'", "'\\''")}'`; -} diff --git a/internal/cloud/code-agent-trace-store.mjs b/internal/cloud/code-agent-trace-store.mjs new file mode 100644 index 00000000..f66ca784 --- /dev/null +++ b/internal/cloud/code-agent-trace-store.mjs @@ -0,0 +1,395 @@ +import { randomUUID } from "node:crypto"; + +const DEFAULT_MAX_TRACES = 256; +const DEFAULT_MAX_EVENTS = 600; +const TEXT_LIMIT = 1200; +const SECRET_PATTERN = /\b(?:sk-[A-Za-z0-9_-]+|OPENAI_API_KEY|DATABASE_URL|secretRef:[^\s,;]+|secret|token|password|passwd|credential|private[_ -]?key|kubeconfig|BEGIN [A-Z ]*PRIVATE KEY)\b/giu; + +export function createCodeAgentTraceStore(options = {}) { + const maxTraces = positiveInteger(options.maxTraces, DEFAULT_MAX_TRACES); + const maxEvents = positiveInteger(options.maxEvents, DEFAULT_MAX_EVENTS); + const traces = new Map(); + + function ensure(traceId, meta = {}) { + const id = cleanTraceId(traceId) || `trc_${randomUUID()}`; + let trace = traces.get(id); + if (!trace) { + const timestamp = timestampFor(meta.now); + trace = { + traceId: id, + status: "running", + createdAt: timestamp, + updatedAt: timestamp, + startedAt: timestamp, + finishedAt: null, + nextSeq: 1, + events: [], + listeners: new Set(), + meta: { + runnerKind: meta.runnerKind ?? null, + workspace: meta.workspace ?? null, + sandbox: meta.sandbox ?? null, + sessionMode: meta.sessionMode ?? null, + implementationType: meta.implementationType ?? null + } + }; + traces.set(id, trace); + prune(); + } else { + trace.meta = { + ...trace.meta, + ...dropEmpty({ + runnerKind: meta.runnerKind, + workspace: meta.workspace, + sandbox: meta.sandbox, + sessionMode: meta.sessionMode, + implementationType: meta.implementationType + }) + }; + } + return trace; + } + + function append(traceId, event = {}, meta = {}) { + const trace = ensure(traceId, meta); + const normalized = normalizeTraceEvent(event, { + traceId: trace.traceId, + seq: trace.nextSeq, + now: meta.now, + fallbackRunnerKind: trace.meta.runnerKind + }); + trace.nextSeq += 1; + trace.events.push(normalized); + if (trace.events.length > maxEvents) { + trace.events.splice(0, trace.events.length - maxEvents); + } + trace.updatedAt = normalized.createdAt; + if (normalized.terminal === true) { + trace.status = normalized.status === "completed" ? "completed" : normalized.type; + trace.finishedAt = normalized.createdAt; + } else if (normalized.status === "failed" || normalized.type === "error" || normalized.type === "timeout") { + trace.status = normalized.type; + trace.finishedAt = normalized.createdAt; + } + notify(trace, normalized); + return normalized; + } + + function snapshot(traceId, extra = {}) { + const trace = traces.get(cleanTraceId(traceId)); + if (!trace) return emptySnapshot(traceId, extra); + const events = trace.events.map((event) => ({ ...event })); + const lastEvent = events.at(-1) ?? null; + return { + traceId: trace.traceId, + status: trace.status, + createdAt: trace.createdAt, + updatedAt: trace.updatedAt, + startedAt: trace.startedAt, + finishedAt: trace.finishedAt, + eventCount: events.length, + events, + eventLabels: events.map((event) => event.label).filter(Boolean), + lastEvent, + elapsedMs: elapsedMs(trace.startedAt, trace.finishedAt ?? trace.updatedAt), + waitingFor: lastWaitingFor(events), + runnerKind: extra.runnerKind ?? trace.meta.runnerKind ?? null, + workspace: extra.workspace ?? trace.meta.workspace ?? null, + sandbox: extra.sandbox ?? trace.meta.sandbox ?? null, + sessionMode: extra.sessionMode ?? trace.meta.sessionMode ?? null, + implementationType: extra.implementationType ?? trace.meta.implementationType ?? null, + sessionId: extra.sessionId ?? lastEvent?.sessionId ?? null, + sessionStatus: extra.sessionStatus ?? lastEvent?.sessionStatus ?? null, + turn: extra.turn ?? lastEvent?.turn ?? null, + outputTruncated: events.some((event) => event.outputTruncated === true), + valuesPrinted: false + }; + } + + function subscribe(traceId, listener, meta = {}) { + const trace = ensure(traceId, meta); + trace.listeners.add(listener); + return () => { + trace.listeners.delete(listener); + }; + } + + function clear() { + traces.clear(); + } + + function prune() { + if (traces.size <= maxTraces) return; + const stale = [...traces.values()] + .sort((left, right) => String(left.updatedAt).localeCompare(String(right.updatedAt))) + .slice(0, traces.size - maxTraces); + for (const trace of stale) { + traces.delete(trace.traceId); + } + } + + return { + ensure, + append, + snapshot, + subscribe, + clear + }; +} + +export const defaultCodeAgentTraceStore = createCodeAgentTraceStore(); + +export function createCodeAgentTraceRecorder({ + traceStore = defaultCodeAgentTraceStore, + traceId, + now, + runnerKind = null, + workspace = null, + sandbox = null, + sessionMode = null, + implementationType = null +} = {}) { + const startedAt = timestampFor(now); + const startedEpochMs = Date.now(); + const baseMeta = { now, runnerKind, workspace, sandbox, sessionMode, implementationType }; + traceStore.ensure(traceId, baseMeta); + + function append(event = {}) { + return traceStore.append(traceId, { + elapsedMs: Date.now() - startedEpochMs, + ...event + }, baseMeta); + } + + function snapshot(extra = {}) { + return traceStore.snapshot(traceId, { runnerKind, workspace, sandbox, sessionMode, implementationType, ...extra }); + } + + function runnerTrace(extra = {}) { + const current = snapshot(extra); + return { + traceId: current.traceId, + runnerKind: extra.runnerKind ?? current.runnerKind, + workspace: extra.workspace ?? current.workspace, + sandbox: extra.sandbox ?? current.sandbox, + sessionMode: extra.sessionMode ?? current.sessionMode, + sessionId: extra.sessionId ?? current.sessionId, + sessionStatus: extra.sessionStatus ?? current.sessionStatus, + idleTimeoutMs: extra.idleTimeoutMs ?? null, + lastTraceId: extra.lastTraceId ?? current.traceId, + turn: extra.turn ?? current.turn, + sessionReused: extra.sessionReused ?? false, + implementationType: extra.implementationType ?? current.implementationType, + limitations: Array.isArray(extra.limitations) ? extra.limitations : [], + startedAt: extra.startedAt ?? startedAt, + finishedAt: extra.finishedAt ?? current.finishedAt ?? timestampFor(now), + updatedAt: current.updatedAt, + events: current.events, + eventLabels: current.eventLabels, + lastEvent: current.lastEvent, + elapsedMs: current.elapsedMs, + waitingFor: current.waitingFor, + outputTruncated: current.outputTruncated || extra.outputTruncated === true, + valuesPrinted: false, + note: extra.note ?? "Real-time runnerTrace is appended as Codex stdio/session events are captured." + }; + } + + return { + traceId, + append, + snapshot, + runnerTrace + }; +} + +export function runnerTraceFromSnapshot(snapshot = {}, extra = {}) { + return { + traceId: snapshot.traceId ?? extra.traceId ?? null, + runnerKind: extra.runnerKind ?? snapshot.runnerKind ?? null, + workspace: extra.workspace ?? snapshot.workspace ?? null, + sandbox: extra.sandbox ?? snapshot.sandbox ?? null, + sessionMode: extra.sessionMode ?? snapshot.sessionMode ?? null, + sessionId: extra.sessionId ?? snapshot.sessionId ?? null, + sessionStatus: extra.sessionStatus ?? snapshot.sessionStatus ?? null, + idleTimeoutMs: extra.idleTimeoutMs ?? null, + lastTraceId: extra.lastTraceId ?? snapshot.traceId ?? null, + turn: extra.turn ?? snapshot.turn ?? null, + sessionReused: extra.sessionReused ?? false, + implementationType: extra.implementationType ?? snapshot.implementationType ?? null, + limitations: Array.isArray(extra.limitations) ? extra.limitations : [], + startedAt: extra.startedAt ?? snapshot.startedAt ?? null, + finishedAt: extra.finishedAt ?? snapshot.finishedAt ?? snapshot.updatedAt ?? null, + updatedAt: snapshot.updatedAt ?? null, + events: Array.isArray(snapshot.events) ? snapshot.events : [], + eventLabels: Array.isArray(snapshot.eventLabels) ? snapshot.eventLabels : [], + lastEvent: snapshot.lastEvent ?? null, + elapsedMs: snapshot.elapsedMs ?? null, + waitingFor: snapshot.waitingFor ?? null, + outputTruncated: snapshot.outputTruncated === true || extra.outputTruncated === true, + valuesPrinted: false, + note: extra.note ?? "runnerTrace snapshot" + }; +} + +function normalizeTraceEvent(event, { traceId, seq, now, fallbackRunnerKind } = {}) { + const type = safeToken(event.type ?? event.kind ?? "event"); + const status = safeToken(event.status ?? "observed"); + const toolName = safeText(event.toolName ?? event.name, 120); + const label = safeText(event.label, 180) || labelFor({ type, status, toolName }); + return dropUndefined({ + seq, + traceId, + type, + stage: safeToken(event.stage ?? type), + status, + label, + createdAt: timestampFor(now), + elapsedMs: typeof event.elapsedMs === "number" ? Math.max(0, Math.trunc(event.elapsedMs)) : null, + runnerKind: safeText(event.runnerKind ?? fallbackRunnerKind, 140), + sessionId: safeText(event.sessionId, 180), + sessionStatus: safeText(event.sessionStatus, 80), + sessionReused: typeof event.sessionReused === "boolean" ? event.sessionReused : undefined, + turn: typeof event.turn === "number" ? event.turn : undefined, + toolName, + promptSummary: safeText(event.promptSummary, 240), + outputSummary: safeText(event.outputSummary, 400), + chunk: safeText(event.chunk, 400), + message: safeText(event.message, 500), + errorCode: safeText(event.errorCode, 120), + waitingFor: safeText(event.waitingFor, 220), + timeoutMs: typeof event.timeoutMs === "number" ? Math.trunc(event.timeoutMs) : undefined, + outputTruncated: event.outputTruncated === true, + terminal: event.terminal === true, + valuesPrinted: false + }); +} + +function labelFor({ type, status, toolName }) { + if (type === "session") return `session:${status}`; + if (type === "prompt") return `prompt:${status}`; + if (type === "tool_call") return `tool:${toolName || "codex"}:${status}`; + if (type === "assistant_message") return `assistant:${status}`; + if (["timeout", "cancel", "error"].includes(type)) return type; + if (type === "request") return `request:${status}`; + if (type === "stdio") return `stdio:${status}`; + return `${type}:${status}`; +} + +function notify(trace, event) { + const snapshot = { + ...emptySnapshot(trace.traceId), + ...{ + traceId: trace.traceId, + status: trace.status, + createdAt: trace.createdAt, + updatedAt: trace.updatedAt, + startedAt: trace.startedAt, + finishedAt: trace.finishedAt, + eventCount: trace.events.length, + events: trace.events.map((item) => ({ ...item })), + eventLabels: trace.events.map((item) => item.label).filter(Boolean), + lastEvent: event, + elapsedMs: elapsedMs(trace.startedAt, trace.finishedAt ?? trace.updatedAt), + waitingFor: lastWaitingFor(trace.events), + runnerKind: trace.meta.runnerKind, + workspace: trace.meta.workspace, + sandbox: trace.meta.sandbox, + sessionMode: trace.meta.sessionMode, + implementationType: trace.meta.implementationType, + outputTruncated: trace.events.some((item) => item.outputTruncated === true), + valuesPrinted: false + } + }; + for (const listener of trace.listeners) { + try { + listener(event, snapshot); + } catch { + trace.listeners.delete(listener); + } + } +} + +function emptySnapshot(traceId, extra = {}) { + const id = cleanTraceId(traceId); + return { + traceId: id, + status: "missing", + createdAt: null, + updatedAt: null, + startedAt: null, + finishedAt: null, + eventCount: 0, + events: [], + eventLabels: [], + lastEvent: null, + elapsedMs: null, + waitingFor: null, + runnerKind: extra.runnerKind ?? null, + workspace: extra.workspace ?? null, + sandbox: extra.sandbox ?? null, + sessionMode: extra.sessionMode ?? null, + implementationType: extra.implementationType ?? null, + sessionId: null, + sessionStatus: null, + turn: null, + outputTruncated: false, + valuesPrinted: false + }; +} + +function lastWaitingFor(events) { + for (const event of [...events].reverse()) { + if (event.waitingFor) return event.waitingFor; + if (event.type === "tool_call" && ["started", "output_chunk"].includes(event.status)) { + return `tool:${event.toolName ?? "codex"}`; + } + if (event.type === "prompt" && event.status === "sent") return "assistant-message"; + } + return null; +} + +function elapsedMs(start, end) { + const startMs = Date.parse(start ?? ""); + const endMs = Date.parse(end ?? ""); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null; + return Math.max(0, endMs - startMs); +} + +function safeText(value, limit = TEXT_LIMIT) { + if (value === undefined || value === null || value === "") return undefined; + const redacted = String(value).replace(SECRET_PATTERN, "[redacted]").replace(/\s+/gu, " ").trim(); + if (!redacted) return undefined; + return redacted.length > limit ? `${redacted.slice(0, limit - 3)}...` : redacted; +} + +function safeToken(value) { + return String(value ?? "") + .trim() + .replace(/[^A-Za-z0-9_.:-]/gu, "_") + .slice(0, 80) || "event"; +} + +function cleanTraceId(value) { + const id = String(value ?? "").trim(); + return /^trc_[A-Za-z0-9_.:-]+$/u.test(id) ? id : null; +} + +function timestampFor(now) { + const value = typeof now === "function" ? now() : now; + const date = value ? new Date(value) : new Date(); + return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function dropEmpty(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== null && item !== "")); +} + +function dropUndefined(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index b64d5ea1..999d9341 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -6,6 +6,12 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + createCodeAgentTraceRecorder, + defaultCodeAgentTraceStore, + runnerTraceFromSnapshot +} from "./code-agent-trace-store.mjs"; + export const CODEX_STDIO_PROVIDER = "codex-stdio"; export const CODEX_STDIO_BACKEND = "hwlab-cloud-api/codex-mcp-stdio"; export const CODEX_STDIO_RUNNER_KIND = "codex-mcp-stdio-runner"; @@ -41,6 +47,7 @@ export function createCodexStdioSessionManager(options = {}) { const probeCacheTtlMs = positiveInteger(options.probeCacheTtlMs, DEFAULT_CODEX_STDIO_PROBE_TTL_MS); const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`; const nowDefault = options.now; + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; const sessions = new Map(); const conversations = new Map(); let rpcClient = options.rpcClient ?? null; @@ -230,9 +237,34 @@ export function createCodexStdioSessionManager(options = {}) { const conversationId = requiredId(params.conversationId, "cnv"); const workspace = resolveCodexWorkspace(env, params); const sandbox = resolveCodexSandbox(env, params); + const traceRecorder = params.traceRecorder ?? createCodeAgentTraceRecorder({ + traceStore, + traceId, + now, + runnerKind: CODEX_STDIO_RUNNER_KIND, + workspace, + sandbox, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE + }); + traceRecorder.append({ + type: "request", + status: "received", + label: "request:received", + promptSummary: summarizePrompt(params.message), + waitingFor: "runtime-readiness" + }); let availability = describe({ ...params, env, workspace, sandbox }); const startupBlockers = availability.blockers.filter((blocker) => blocker.code !== "stdio_protocol_not_wired"); if (startupBlockers.length > 0) { + traceRecorder.append({ + type: "error", + status: "blocked", + label: "runtime-readiness:blocked", + errorCode: startupBlockers[0]?.code ?? "codex_stdio_blocked", + message: startupBlockers[0]?.summary ?? "Codex stdio session is blocked by runtime readiness gates.", + terminal: true + }); throw codexStdioError("codex_stdio_blocked", "Codex stdio session is blocked by runtime readiness gates.", { availability: { ...availability, @@ -252,6 +284,16 @@ export function createCodexStdioSessionManager(options = {}) { now }); if (!acquire.ok) { + traceRecorder.append({ + type: "session", + status: "blocked", + label: `session:${acquire.code}`, + errorCode: acquire.code, + message: acquire.message, + sessionId: acquire.session?.sessionId, + sessionStatus: acquire.session?.status, + terminal: true + }); throw codexStdioError(acquire.code, acquire.message, { availability, session: acquire.session, @@ -260,18 +302,30 @@ export function createCodexStdioSessionManager(options = {}) { } let session = acquire.session; - const events = [ - "stdio:acquire", - session.reused ? "session:reused" : "session:created", - session.threadId ? "codex-thread:reused" : "codex-thread:create", - `turn:${session.turn}` - ]; const startedAt = timestamp; + traceRecorder.append({ + type: "session", + status: session.reused ? "reused" : "created", + label: session.reused ? "session:reused" : "session:created", + sessionId: session.sessionId, + sessionStatus: session.status, + sessionReused: session.reused, + turn: session.turn, + waitingFor: "stdio-client" + }); try { const client = await ensureRpcClient({ env, availability, timeoutMs: params.timeoutMs }); availability = describe({ ...params, env, workspace, sandbox }); - events.push("stdio:ready"); + traceRecorder.append({ + type: "stdio", + status: "ready", + label: "stdio:ready", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "prompt-send" + }); const sidecar = await collectWorkspaceSidecarEvidence({ message: params.message, workspace, @@ -279,41 +333,7 @@ export function createCodexStdioSessionManager(options = {}) { env, now }); - if (shouldReturnSidecarOnly(sidecar)) { - if (sidecar.skills.status === "blocked") { - throw codexStdioError("skills_unavailable", "No usable SKILL.md manifest was found for the Codex stdio runner.", { - availability, - session, - toolCalls: sidecar.toolCalls, - skills: sidecar.skills, - blockers: sidecar.skills.blockers, - route: null, - toolName: "skills.discover" - }); - } - session = releaseSession(session.sessionId, { - now, - traceId, - conversationId, - reused: session.reused, - status: "idle" - }) ?? session; - events.push(...sidecar.events); - return codexStdioSidecarResult({ - message: params.message, - workspace, - sandbox, - session, - sidecar, - availability, - traceId, - startedAt, - events, - model: params.model, - env, - providerTraceToolName: "workspace-sidecar" - }); - } + for (const event of sidecar.events) traceRecorder.append(event); const toolName = session.threadId ? "codex-reply" : "codex"; const toolArguments = session.threadId ? { @@ -328,7 +348,39 @@ export function createCodexStdioSessionManager(options = {}) { "approval-policy": "never", "developer-instructions": CODEX_STDIO_BOUNDARY_INSTRUCTIONS }; + traceRecorder.append({ + type: "prompt", + status: "sent", + label: "prompt:sent", + toolName, + promptSummary: summarizePrompt(params.message), + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: `tool:${toolName}` + }); + traceRecorder.append({ + type: "tool_call", + status: "started", + label: `tool:${toolName}:started`, + toolName, + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: `tool:${toolName}:output` + }); const toolResult = await client.callTool(toolName, dropEmpty(toolArguments), effectiveTimeout(params.timeoutMs)); + traceRecorder.append({ + type: "tool_call", + status: "output_chunk", + label: `tool:${toolName}:output_chunk`, + toolName, + outputSummary: summarizeToolResult(toolResult), + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "assistant-message" + }); const codexOutput = extractCodexToolOutput(toolResult); if (!codexOutput.content) { throw codexStdioError("codex_stdio_empty_response", "Codex stdio tool call completed without assistant content.", { @@ -342,9 +394,37 @@ export function createCodexStdioSessionManager(options = {}) { conversationId, reused: session.reused, threadId: codexOutput.threadId ?? session.threadId, - status: "idle" - }) ?? session; - events.push(`tool:${toolName}:completed`); + status: "idle" + }) ?? session; + traceRecorder.append({ + type: "tool_call", + status: "completed", + label: `tool:${toolName}:completed`, + toolName, + outputSummary: codexOutput.threadId ? `threadId=${codexOutput.threadId}` : "threadId=captured", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn + }); + traceRecorder.append({ + type: "assistant_message", + status: "chunk", + label: "assistant:chunk", + chunk: codexOutput.content.slice(0, 400), + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: "assistant-message-complete" + }); + traceRecorder.append({ + type: "assistant_message", + status: "completed", + label: "assistant:completed", + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + terminal: true + }); return { provider: CODEX_STDIO_PROVIDER, model: firstNonEmpty(params.model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), @@ -382,11 +462,11 @@ export function createCodexStdioSessionManager(options = {}) { skills: sidecar.skills, runner: runnerDescriptor({ workspace, sandbox, session }), runnerTrace: runnerTrace({ + traceRecorder, traceId, workspace, sandbox, session, - events: [...events, ...sidecar.events], startedAt, outputTruncated: sidecar.outputTruncated }), @@ -409,15 +489,29 @@ export function createCodexStdioSessionManager(options = {}) { reused: session.reused, statusReason: error.code ?? "codex_stdio_failed" }) ?? session; + const timeout = /timed out|timeout|超时/iu.test(String(error.message ?? "")); + traceRecorder.append({ + type: timeout ? "timeout" : "error", + status: "failed", + label: timeout ? "timeout" : `error:${error.code ?? "codex_stdio_failed"}`, + errorCode: error.code ?? (timeout ? "codex_stdio_timeout" : "codex_stdio_failed"), + message: error.message || "Codex stdio session failed.", + timeoutMs: timeout ? effectiveTimeout(params.timeoutMs) : undefined, + sessionId: session.sessionId, + sessionStatus: session.status, + turn: session.turn, + waitingFor: timeout ? "codex-stdio-tool-response" : null, + terminal: true + }); if (error.code && (error.code.startsWith("codex_stdio") || ["skills_unavailable"].includes(error.code))) { error.session = session; error.availability = error.availability ?? describe({ ...params, env, workspace, sandbox }); error.runnerTrace = runnerTrace({ + traceRecorder, traceId, workspace, sandbox, session, - events: [...events, `blocked:${error.code}`], startedAt, outputTruncated: false }); @@ -427,11 +521,11 @@ export function createCodexStdioSessionManager(options = {}) { availability, session, runnerTrace: runnerTrace({ + traceRecorder, traceId, workspace, sandbox, session, - events: [...events, "blocked:codex_stdio_failed"], startedAt, outputTruncated: false }) @@ -1149,9 +1243,24 @@ function runnerDescriptor({ workspace, sandbox, session }) { }; } -function runnerTrace({ traceId, workspace, sandbox, session, events, startedAt, outputTruncated }) { - return { +function runnerTrace({ traceRecorder, traceId, workspace, sandbox, session, startedAt, outputTruncated }) { + const snapshot = traceRecorder?.snapshot({ + sessionId: session?.sessionId, + sessionStatus: session?.status, + turn: session?.turn, + workspace, + sandbox, + runnerKind: CODEX_STDIO_RUNNER_KIND, + sessionMode: CODEX_STDIO_SESSION_MODE, + implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE + }); + return runnerTraceFromSnapshot(snapshot ?? { traceId, + events: [], + eventLabels: [], + updatedAt: timestampFor(), + startedAt + }, { runnerKind: CODEX_STDIO_RUNNER_KIND, workspace, sandbox, @@ -1159,129 +1268,15 @@ function runnerTrace({ traceId, workspace, sandbox, session, events, startedAt, sessionId: session?.sessionId ?? null, sessionStatus: session?.status ?? null, idleTimeoutMs: session?.idleTimeoutMs ?? null, - lastTraceId: session?.lastTraceId ?? null, + lastTraceId: session?.lastTraceId ?? traceId, turn: session?.turn ?? null, sessionReused: session?.reused ?? false, implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, limitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], startedAt, - finishedAt: timestampFor(), - events, outputTruncated: Boolean(outputTruncated), - valuesPrinted: false, - note: "Repo-owned Codex MCP stdio supervisor manages create/reuse/cancel/reap/idle timeout and trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries." - }; -} - -function codexStdioSidecarResult({ - message, - workspace, - sandbox, - session, - sidecar, - availability, - traceId, - startedAt, - events, - model, - env, - providerTraceToolName -}) { - return { - provider: CODEX_STDIO_PROVIDER, - model: firstNonEmpty(model, env.HWLAB_CODE_AGENT_MODEL, env.OPENAI_MODEL, "codex-default"), - backend: CODEX_STDIO_BACKEND, - content: sidecarOnlyReply({ message, sidecar, session }), - workspace, - sandbox, - session, - sessionMode: CODEX_STDIO_SESSION_MODE, - sessionReuse: sessionReuseEvidence(session), - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - runnerLimitations: ["hardware-control-via-cloud-api-only", "secret-values-redacted"], - codexStdioFeasibility: availability, - longLivedSessionGate: longLivedSessionGate({ - provider: CODEX_STDIO_PROVIDER, - runnerKind: CODEX_STDIO_RUNNER_KIND, - session, - sessionMode: CODEX_STDIO_SESSION_MODE, - implementationType: CODEX_STDIO_IMPLEMENTATION_TYPE, - codexStdioFeasibility: availability - }), - toolCalls: sidecar.toolCalls, - skills: sidecar.skills, - runner: runnerDescriptor({ workspace, sandbox, session }), - runnerTrace: runnerTrace({ - traceId, - workspace, - sandbox, - session, - events, - startedAt, - outputTruncated: sidecar.outputTruncated - }), - capabilityLevel: CODEX_STDIO_CAPABILITY_LEVEL, - providerTrace: { - transport: "stdio", - protocol: "mcp", - command: `${availability.command} mcp-server`, - toolName: providerTraceToolName, - threadId: session.threadId ?? null, - sidecarOnly: true, - valuesPrinted: false - } - }; -} - -function shouldReturnSidecarOnly(sidecar = {}) { - const intent = sidecar.intent ?? {}; - const requested = ["pwd", "ls", "skills", "smoke"].filter((name) => intent[name] === true); - if (requested.length === 0) return false; - return sidecar.toolCalls.length > 0; -} - -function sidecarOnlyReply({ message, sidecar, session }) { - const intent = sidecar.intent ?? {}; - const lines = []; - if (intent.pwd) { - const pwd = sidecar.toolCalls.find((toolCall) => toolCall.name === "pwd"); - lines.push("当前 Codex stdio 受控 workspace 目录:", pwd?.stdout ?? ""); - } - if (intent.ls) { - const ls = sidecar.toolCalls.find((toolCall) => toolCall.name === "ls"); - lines.push(lines.length ? "" : "", "目录列表:", ls?.stdout || "(empty)"); - } - if (sidecar.skills.status === "ready") { - lines.push(lines.length ? "" : "", stdioSkillsReply(sidecar.skills)); - } - if (intent.smoke) { - const smokeCalls = sidecar.toolCalls - .filter((toolCall) => toolCall.name.startsWith("workspace.smoke.")) - .map((toolCall) => `${toolCall.name}:${toolCall.status}`); - if (smokeCalls.length > 0) lines.push(lines.length ? "" : "", `workspace smoke: ${smokeCalls.join(", ")}`); - } - lines.push( - lines.length ? "" : "", - `Session: mode=${CODEX_STDIO_SESSION_MODE}; sessionId=${session.sessionId}; status=${session.status}; reused=${session.reused}; turn=${session.turn}.`, - "边界:provider=codex-stdio;backend=hwlab-cloud-api/codex-mcp-stdio;硬件控制仅允许 cloud-api/HWLAB API/skill CLI;未读取 secret/token/kubeconfig。" - ); - return boundToolOutput(lines.filter((line, index) => line !== "" || index > 0).join("\n")).text || String(message ?? "").trim(); -} - -function stdioSkillsReply(skills) { - const lines = [ - `已发现 ${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 ? ` (${meta})` : ""}`); - } - lines.push("说明:这是 Codex stdio runner 的受控 skills discovery,未读取 secret/token/kubeconfig。"); - return lines.join("\n"); + note: "Repo-owned Codex MCP stdio supervisor manages create/reuse/cancel/reap/idle timeout and real-time trace capture; hardware control remains routed through cloud-api/HWLAB API/skill CLI boundaries." + }); } function sessionReuseEvidence(session) { @@ -1341,6 +1336,24 @@ function extractCodexToolOutput(toolResult) { }; } +function summarizePrompt(message) { + const value = String(message ?? "").replace(/\s+/gu, " ").trim(); + if (!value) return "empty prompt"; + return value.length > 160 ? `${value.slice(0, 157)}...` : value; +} + +function summarizeToolResult(toolResult) { + const content = extractCodexToolOutput(toolResult); + if (content.threadId || content.content) { + return [ + content.threadId ? `threadId=${content.threadId}` : "threadId=not_observed", + content.content ? `assistantChars=${content.content.length}` : null + ].filter(Boolean).join(" "); + } + if (Array.isArray(toolResult?.content)) return `contentItems=${toolResult.content.length}`; + return "tool result captured"; +} + async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) { const intent = detectWorkspaceSidecarIntent(message); const toolCalls = []; @@ -1351,13 +1364,13 @@ async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, en if (intent.pwd) { const toolCall = await pwdToolCall({ workspace, traceId }); toolCalls.push(toolCall); - events.push(`tool:workspace.pwd:${toolCall.status}`); + events.push(toolTraceEvent(toolCall, { labelPrefix: "sidecar" })); } if (intent.ls) { const toolCall = await lsToolCall({ workspace, target: intent.target, traceId }); toolCalls.push(toolCall); - events.push(`tool:workspace.ls:${toolCall.status}`); + events.push(toolTraceEvent(toolCall, { labelPrefix: "sidecar" })); outputTruncated = outputTruncated || toolCall.outputTruncated; } @@ -1375,14 +1388,21 @@ async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, en outputTruncated: Boolean(skills.truncated), traceId }); - events.push(`tool:skills.discover:${skills.status === "ready" ? "completed" : "blocked"}`); + events.push({ + type: "tool_call", + status: skills.status === "ready" ? "completed" : "blocked", + label: `sidecar:skills.discover:${skills.status === "ready" ? "completed" : "blocked"}`, + toolName: "skills.discover", + outputSummary: skills.status === "ready" ? `skills=${skills.count}` : "skills_unavailable", + outputTruncated: Boolean(skills.truncated) + }); outputTruncated = outputTruncated || Boolean(skills.truncated); } if (intent.smoke) { const smokeCalls = await workspaceSmokeToolCalls({ workspace, traceId, now }); toolCalls.push(...smokeCalls); - for (const call of smokeCalls) events.push(`tool:${call.name}:${call.status}`); + for (const call of smokeCalls) events.push(toolTraceEvent(call, { labelPrefix: "sidecar" })); outputTruncated = outputTruncated || smokeCalls.some((call) => call.outputTruncated); } @@ -1395,6 +1415,17 @@ async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, en }; } +function toolTraceEvent(toolCall, { labelPrefix = "tool" } = {}) { + return { + type: "tool_call", + status: toolCall.status, + label: `${labelPrefix}:${toolCall.name}:${toolCall.status}`, + toolName: toolCall.name, + outputSummary: firstNonEmpty(toolCall.stderrSummary, toolCall.stdout ? `${toolCall.name} output captured` : null), + outputTruncated: toolCall.outputTruncated === true + }; +} + function detectWorkspaceSidecarIntent(message) { const text = String(message ?? ""); const lower = text.toLowerCase(); @@ -1704,16 +1735,8 @@ function notRequestedSkills() { }; } -function codexReplyWithSidecar(content, sidecar) { - const lines = [String(content ?? "").trim()].filter(Boolean); - const callNames = sidecar.toolCalls.map((call) => `${call.name}:${call.status}`); - if (callNames.length > 0) { - lines.push("", `runner tool evidence: ${callNames.join(", ")}`); - } - if (sidecar.skills.status === "ready") { - lines.push(`skills discovered: ${sidecar.skills.count}/${sidecar.skills.totalCount}`); - } - return lines.join("\n"); +function codexReplyWithSidecar(content) { + return String(content ?? "").trim(); } function resolveWorkspaceTarget(workspace, target, { mustExist = false } = {}) { diff --git a/internal/cloud/health-contract.mjs b/internal/cloud/health-contract.mjs index 4248522b..600b448d 100644 --- a/internal/cloud/health-contract.mjs +++ b/internal/cloud/health-contract.mjs @@ -234,8 +234,8 @@ function buildSessionRunnerReadiness(codeAgent = {}) { const stdioCommandReady = stdio.commandProbe?.ready === true; const stdioReady = (stdio.ready === true || stdio.canStartLongLivedCodexStdio === true) && stdioCommandReady; const runner = codeAgent?.runner ?? {}; - const runnerReady = stdioReady || runner.ready === true || codeAgent?.sessionRegistry?.status === "available"; - const status = stdioReady ? "codex_stdio_ready" : runnerReady ? "read_only_long_lived_ready" : "blocked"; + const runnerReady = stdioReady; + const status = stdioReady ? "codex_stdio_ready" : "blocked"; const blocker = runnerReady ? null : firstCode( runner.blocker, codeAgent?.reason, @@ -251,11 +251,11 @@ function buildSessionRunnerReadiness(codeAgent = {}) { backend: stdioReady ? stdio.backend ?? "hwlab-cloud-api/codex-mcp-stdio" : runner.backend ?? codeAgent?.backend ?? "unknown", mode: stdioReady ? "codex-mcp-stdio-long-lived" : runner.sessionMode ?? runner.mode ?? codeAgent?.sessionMode ?? "unknown", capabilityLevel: stdioReady ? "long-lived-codex-stdio-session" : runner.capabilityLevel ?? codeAgent?.capabilityLevel ?? "unknown", - longLivedSession: stdioReady || runner.longLivedSession === true || runner.sessionMode === "controlled-readonly-session-registry", + longLivedSession: stdioReady || runner.longLivedSession === true, durableSession: stdioReady || runner.durableSession === true || runner.durable === true, codexStdio: stdioReady || runner.codexStdio === true, writeCapable: stdioReady || runner.writeCapable === true, - readOnly: !stdioReady && runner.writeCapable === false, + readOnly: !stdioReady && runner.codexStdio !== true && runner.writeCapable === false, blocker, runnerLimitations: Array.isArray(codeAgent?.runnerLimitations) ? [...codeAgent.runnerLimitations] : [], secretMaterialRead: false diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index c6575651..ca1c353c 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -26,6 +26,7 @@ import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; +import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs"; import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; import { buildGateDiagnosticsRows } from "./gate-diagnostics.mjs"; import { @@ -81,6 +82,7 @@ export function createCloudApiServer(options = {}) { const env = options.env ?? process.env; ensureCodeAgentRuntimeBase(env); const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry(); + const traceStore = options.traceStore || defaultCodeAgentTraceStore; const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env }); const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), @@ -88,7 +90,7 @@ export function createCloudApiServer(options = {}) { }); return createServer(async (request, response) => { try { - await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry }); + await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore }); } catch (error) { sendJson(response, 500, { error: { @@ -328,6 +330,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) { + await handleCodeAgentTraceHttp(request, response, url, options); + return; + } + if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) { sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", { operation: `${request.method || "GET"} ${url.pathname}`, @@ -1243,13 +1250,61 @@ async function handleCodeAgentChatHttp(request, response, options) { skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sessionRegistry: options.sessionRegistry, m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options), - codexStdioManager: options.codexStdioManager + codexStdioManager: options.codexStdioManager, + traceStore: options.traceStore } ); sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload); } +async function handleCodeAgentTraceHttp(request, response, url, options) { + const parts = url.pathname.split("/").filter(Boolean); + const traceId = decodeURIComponent(parts[4] ?? ""); + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(traceId)) { + sendJson(response, 400, { + error: { + code: "invalid_trace_id", + message: "traceId must start with trc_ and contain only safe identifier characters" + } + }); + return; + } + + if (parts[5] === "stream") { + sendTraceSse(response, traceStore, traceId, options); + return; + } + + sendJson(response, 200, traceStore.snapshot(traceId)); +} + +function sendTraceSse(response, traceStore, traceId, options = {}) { + response.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store", + connection: "keep-alive", + "x-accel-buffering": "no" + }); + const writeEvent = (eventName, payload) => { + response.write(`event: ${eventName}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); + }; + writeEvent("snapshot", traceStore.snapshot(traceId)); + const unsubscribe = traceStore.subscribe(traceId, (event, snapshot) => { + writeEvent("runnerTrace", { event, snapshot }); + }); + const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_HEARTBEAT_MS, 15000); + const heartbeat = setInterval(() => { + writeEvent("heartbeat", traceStore.snapshot(traceId)); + }, heartbeatMs); + response.on("close", () => { + clearInterval(heartbeat); + unsubscribe(); + }); +} + function createCodeAgentM3HwlabApiRequestJson(options = {}) { return async (targetUrl, request = {}) => { const url = new URL(targetUrl); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 0c7c81fa..8c047e9c 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -91,17 +91,17 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.readiness.provider.blocker, "provider_config_blocked"); assert.equal(healthPayload.readiness.dbDurable.status, "blocked"); assert.equal(healthPayload.readiness.dbDurable.ready, false); - assert.equal(healthPayload.readiness.sessionRunner.status, "read_only_long_lived_ready"); - assert.equal(healthPayload.readiness.sessionRunner.ready, true); - assert.equal(healthPayload.readiness.sessionRunner.codexStdio, false); + assert.equal(healthPayload.readiness.sessionRunner.status, "blocked"); + assert.equal(healthPayload.readiness.sessionRunner.ready, false); + assert.equal(healthPayload.readiness.sessionRunner.codexStdio, true); assert.equal(healthPayload.readiness.sessionRunner.durableSession, false); - assert.equal(healthPayload.readiness.sessionRunner.longLivedSession, true); + assert.equal(healthPayload.readiness.sessionRunner.longLivedSession, false); assert.equal(healthPayload.readiness.codexStdio.status, "blocked"); assert.equal(healthPayload.readiness.codexStdio.ready, false); assertCodexStdioFeasibilityBlocker(healthPayload.readiness.codexStdio.blocker); assert.equal(healthPayload.readiness.codeAgent.providerReady, false); assert.equal(healthPayload.readiness.codeAgent.durableDbReady, false); - assert.equal(healthPayload.readiness.codeAgent.sessionRunnerReady, true); + assert.equal(healthPayload.readiness.codeAgent.sessionRunnerReady, false); assert.equal(healthPayload.readiness.codeAgent.codexStdioFeasible, false); assert.deepEqual(healthPayload.readiness.codeAgent.currentBlockers.slice(0, 2), [ "provider_config_blocked", @@ -109,14 +109,14 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { ]); assertCodexStdioFeasibilityBlocker(healthPayload.readiness.codeAgent.currentBlockers[2], "readiness current blocker"); assert.equal(healthPayload.readiness.codeAgent.secretMaterialRead, false); - assert.equal(healthPayload.codeAgent.status, "partial"); - assert.equal(healthPayload.codeAgent.agentKind, "controlled-readonly-session-registry"); - assert.equal(healthPayload.codeAgent.partialReady, true); + assert.equal(healthPayload.codeAgent.status, "blocked"); + assert.equal(healthPayload.codeAgent.agentKind, "codex-stdio-blocked"); + assert.equal(healthPayload.codeAgent.partialReady, false); assert.match(healthPayload.codeAgent.blocker, /Codex CLI command/u); assert.equal(healthPayload.codeAgent.reason, "codex_cli_binary_missing"); - assert.equal(healthPayload.codeAgent.runner.kind, "hwlab-readonly-runner"); - assert.equal(healthPayload.codeAgent.runner.ready, true); - assert.equal(healthPayload.codeAgent.capabilityLevel, "read-only-session-tools"); + assert.equal(healthPayload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(healthPayload.codeAgent.runner.ready, false); + assert.equal(healthPayload.codeAgent.capabilityLevel, "blocked"); assert.equal(healthPayload.codeAgent.sessionRegistry.status, "available"); assert.equal(healthPayload.codeAgent.longLivedSessionGate.status, "blocked"); assert.ok(healthPayload.codeAgent.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_cli_binary_missing")); @@ -147,10 +147,10 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLivePayload.commit.id.length > 0, true); assert.equal(typeof healthLivePayload.build, "object"); assert.equal(healthLivePayload.db.ready, false); - assert.equal(healthLivePayload.codeAgent.status, "partial"); + assert.equal(healthLivePayload.codeAgent.status, "blocked"); assert.equal(healthLivePayload.codeAgent.ready, false); - assert.equal(healthLivePayload.codeAgent.partialReady, true); - assert.equal(healthLivePayload.codeAgent.capabilityLevel, "read-only-session-tools"); + assert.equal(healthLivePayload.codeAgent.partialReady, false); + assert.equal(healthLivePayload.codeAgent.capabilityLevel, "blocked"); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(readiness.status, 200); @@ -573,7 +573,7 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(payload.readiness.dbDurable.status, "blocked"); assert.equal(payload.readiness.dbDurable.dbLiveEvidenceObserved, true); assert.equal(payload.readiness.dbDurable.blocker, "runtime_durable_adapter_missing"); - assert.equal(payload.readiness.sessionRunner.status, "read_only_long_lived_ready"); + assert.equal(payload.readiness.sessionRunner.status, "blocked"); assert.equal(payload.readiness.codexStdio.status, "blocked"); assert.equal(payload.db.redaction.valuesRedacted, true); assert.equal(payload.db.redaction.endpointRedacted, true); @@ -1495,7 +1495,7 @@ function codexStdioChatFixture({ workspace, codexHome, params }) { provider: "codex-stdio", model: "gpt-test", backend: "hwlab-cloud-api/codex-mcp-stdio", - content: `stdio pwd\nrunner tool evidence: pwd:completed\n${workspace}`, + content: `stdio pwd\n${workspace}`, workspace, sandbox: "workspace-write", session, @@ -1607,12 +1607,12 @@ 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/codex-mcp-stdio"); assert.equal(payload.codeAgent.mode, "codex-stdio"); - assert.equal(payload.codeAgent.status, "partial"); + assert.equal(payload.codeAgent.status, "blocked"); assert.match(payload.codeAgent.blocker, /Codex CLI command/u); assert.equal(payload.codeAgent.reason, "codex_cli_binary_missing"); - assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner"); - assert.equal(payload.codeAgent.runner.ready, true); - assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); + assert.equal(payload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.codeAgent.runner.ready, false); + assert.equal(payload.codeAgent.capabilityLevel, "blocked"); assert.equal(payload.codeAgent.sessionRegistry.status, "available"); assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); assert.deepEqual(payload.codeAgent.missingEnv, []); @@ -1621,12 +1621,12 @@ test("cloud api /v1 describes Code Agent provider blocker without leaking secret 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.partialReady, true); + assert.equal(payload.codeAgent.partialReady, false); 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, /受控只读 runner/u); - assert.match(payload.codeAgent.summary, /long-lived Codex stdio/u); + assert.match(payload.codeAgent.summary, /will not use controlled-readonly-session-registry/u); + assert.match(payload.codeAgent.summary, /Codex stdio long-lived session is blocked/u); assert.equal(JSON.stringify(payload).includes("sk-"), false); } finally { await new Promise((resolve, reject) => { @@ -1651,10 +1651,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, "partial"); + assert.equal(payload.codeAgent.status, "blocked"); assert.equal(payload.codeAgent.egress.directPublicOpenAi, true); assert.match(payload.codeAgent.blocker, /Codex stdio|long-lived|Codex CLI command/u); - assert.equal(payload.codeAgent.runner.ready, true); + assert.equal(payload.codeAgent.runner.ready, false); assert.equal(payload.codeAgent.safety.secretMaterialRead, false); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { @@ -1855,18 +1855,13 @@ test("cloud api health blocks full Code Agent readiness when Codex stdio command } }); -test("cloud api /v1/agent/chat returns structured completed Code Agent payload", async () => { +test("cloud api /v1/agent/chat refuses provider stub when Codex stdio is unavailable", async () => { + let providerCalled = false; const server = createCloudApiServer({ - callCodeAgentProvider: async ({ providerPlan, message, traceId }) => ({ - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content: `真实 provider stub: ${message} / ${traceId}`, - usage: null, - providerTrace: { - source: "test-provider" - } - }) + callCodeAgentProvider: async () => { + providerCalled = true; + throw new Error("provider stub must not be used"); + } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -1888,15 +1883,17 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload", assert.equal(payload.conversationId, "cnv_server-test-agent-chat"); assert.equal(payload.sessionId, "cnv_server-test-agent-chat"); assert.match(payload.messageId, /^msg_/); - assert.equal(payload.status, "completed"); + assert.equal(payload.status, "failed"); assert.equal(payload.traceId, "trc_server-test-agent-chat"); - assert.equal(payload.provider.length > 0, true); + assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.model.length > 0, true); - assert.equal(payload.backend.length > 0, true); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); - assert.match(payload.reply.content, /HWLAB 工作台/); - assert.equal(Object.hasOwn(payload, "error"), false); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.error.layer, "runner"); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(providerCalled, false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -1904,7 +1901,8 @@ test("cloud api /v1/agent/chat returns structured completed Code Agent payload", } }); -test("OpenAI fallback text chat does not satisfy Codex stdio capability gate", async () => { +test("OpenAI fallback text chat is not used when Codex stdio is unavailable", async () => { + let providerCalled = false; const server = createCloudApiServer({ env: { PATH: process.env.PATH, @@ -1913,16 +1911,10 @@ test("OpenAI fallback text chat does not satisfy Codex stdio capability gate", a HWLAB_CODE_AGENT_MODEL: "gpt-test", HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, - callCodeAgentProvider: async ({ providerPlan }) => ({ - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content: "普通文本回复,不包含 runner/tool/session 能力。", - usage: null, - providerTrace: { - source: "test-provider" - } - }) + callCodeAgentProvider: async () => { + providerCalled = true; + throw new Error("OpenAI fallback must not be used"); + } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -1935,17 +1927,17 @@ test("OpenAI fallback text chat does not satisfy Codex stdio capability gate", a }); validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "openai-responses"); - assert.equal(payload.backend, "hwlab-cloud-api/openai-responses"); - assert.equal(payload.capabilityLevel, "text-chat-only"); - assert.equal(payload.runner.kind, "openai-responses-fallback"); - assert.equal(payload.runner.codexStdio, false); + assert.equal(payload.status, "failed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.runner.codexStdio, true); assert.equal(payload.longLivedSessionGate.status, "blocked"); - assert.equal(payload.blocker.code, "text_chat_only_fallback"); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(providerCalled, false); const capability = classifyCodexRunnerCapability(payload, { httpStatus: 200 }); assert.equal(capability.capabilityPass, false); - assert.equal(capability.blocker, "openai-fallback-not-runner"); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { @@ -1991,9 +1983,9 @@ test("cloud api health does not pass long-lived session gate for explicit OpenAI assert.equal(payload.codeAgent.provider, "openai-responses"); assert.equal(payload.codeAgent.mode, "openai"); assert.equal(payload.codeAgent.ready, false); - assert.equal(payload.codeAgent.capabilityLevel, "read-only-session-tools"); - assert.equal(payload.codeAgent.runner.kind, "hwlab-readonly-runner"); - assert.equal(payload.codeAgent.runner.codexStdio, false); + assert.equal(payload.codeAgent.capabilityLevel, "blocked"); + assert.equal(payload.codeAgent.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.codeAgent.runner.codexStdio, true); assert.equal(payload.codeAgent.longLivedSessionGate.status, "blocked"); assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready"); assert.equal(payload.readiness.codeAgent.ready, false); @@ -2209,14 +2201,14 @@ test("cloud api health reports Codex stdio runner facts without readonly limitat } }); -test("cloud api /v1/agent/chat runs Codex stdio pwd through controlled command path without OpenAI fallback", async () => { +test("cloud api /v1/agent/chat sends pwd prompt through real Codex stdio instead of local sidecar", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-pwd-")); const codexHome = path.join(workspace, "codex-home"); const fakeCodex = await createFakeCodexCommand(); await mkdir(codexHome, { recursive: true }); const codexToolCalls = []; const manager = createCodexStdioSessionManager({ - idFactory: () => "ses_server_stdio_pwd_sidecar", + idFactory: () => "ses_server_stdio_pwd_real", createRpcClient: async () => ({ async initialize() { return { tools: ["codex", "codex-reply"] }; @@ -2226,7 +2218,12 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd through controlled command p }, async callTool(name, args) { codexToolCalls.push({ name, args }); - throw new Error("model turn must not be needed for pwd"); + return { + structuredContent: { + threadId: "thread_server_stdio_pwd_real", + content: `真实 Codex stdio 回复:当前工作目录是 ${workspace}` + } + }; }, close() {} }) @@ -2268,11 +2265,16 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd through controlled command p assert.equal(payload.workspace, workspace); assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); assert.equal(payload.longLivedSessionGate.status, "pass"); - assert.equal(payload.providerTrace.sidecarOnly, true); - assert.equal(payload.providerTrace.toolName, "workspace-sidecar"); - assert.deepEqual(codexToolCalls, []); + assert.equal(payload.providerTrace.sidecarOnly, undefined); + assert.equal(payload.providerTrace.toolName, "codex"); + assert.equal(codexToolCalls.length, 1); + assert.equal(codexToolCalls[0].name, "codex"); + assert.match(codexToolCalls[0].args.prompt, /用pwd列出/u); assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "pwd" && toolCall.status === "completed" && toolCall.stdout === workspace)); - assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); + assert.match(payload.reply.content, /真实 Codex stdio 回复/u); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex:started")); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "assistant:completed")); assert.equal(JSON.stringify(payload).includes("openai-responses-fallback"), false); assert.equal(JSON.stringify(payload).includes("text-chat-only"), false); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); @@ -2285,7 +2287,7 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd through controlled command p } }); -test("cloud api /v1/agent/chat discovers skills through Codex stdio command path", async () => { +test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio and retains trace", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-")); const codexHome = path.join(workspace, "codex-home"); const skillsDir = path.join(workspace, "skills"); @@ -2325,8 +2327,13 @@ test("cloud api /v1/agent/chat discovers skills through Codex stdio command path async listTools() { return ["codex", "codex-reply"]; }, - async callTool() { - throw new Error("model turn must not be needed for skills discovery"); + async callTool(name, args) { + return { + structuredContent: { + threadId: "thread_server_stdio_skills", + content: `真实 Codex stdio skill 回复:${args.prompt.includes("skills") ? "hwlab-test-skill" : "unknown"}` + } + }; }, close() {} }) @@ -2347,11 +2354,14 @@ test("cloud api /v1/agent/chat discovers skills through Codex stdio command path assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); - assert.equal(payload.providerTrace.sidecarOnly, true); + assert.equal(payload.providerTrace.sidecarOnly, undefined); + assert.equal(payload.providerTrace.toolName, "codex"); assert.equal(payload.skills.status, "ready"); assert.ok(payload.skills.items.some((skill) => skill.name === "hwlab-test-skill")); assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed")); assert.match(payload.reply.content, /hwlab-test-skill/u); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent")); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex:started")); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { @@ -2362,7 +2372,7 @@ test("cloud api /v1/agent/chat discovers skills through Codex stdio command path } }); -test("cloud api /v1/agent/chat reports Codex stdio skills_unavailable as structured blocker", async () => { +test("cloud api /v1/agent/chat can answer skills prompt without local skills manifest when Codex stdio replies", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-")); const codexHome = path.join(workspace, "codex-home"); const fakeCodex = await createFakeCodexCommand(); @@ -2392,7 +2402,12 @@ test("cloud api /v1/agent/chat reports Codex stdio skills_unavailable as structu return ["codex", "codex-reply"]; }, async callTool() { - throw new Error("model turn must not be needed for skills discovery"); + return { + structuredContent: { + threadId: "thread_server_stdio_skills_missing", + content: "真实 Codex stdio 回复:当前运行时未提供可展开的本地 skill manifest,但这是模型/工具链的真实回答。" + } + }; }, close() {} }) @@ -2409,17 +2424,15 @@ test("cloud api /v1/agent/chat reports Codex stdio skills_unavailable as structu }); validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "failed"); + assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); - assert.equal(payload.error.code, "skills_unavailable"); - assert.equal(payload.error.userMessage, "Code Agent Skill 清单未挂载或不可读,需要补齐运行时配置。"); - assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); assert.equal(payload.skills.status, "blocked"); assert.equal(payload.skills.blockers[0].code, "skills_unavailable"); assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "blocked")); - assert.equal(payload.session.status, "failed"); - assert.equal(payload.session.statusReason, "skills_unavailable"); + assert.equal(payload.session.status, "idle"); + assert.match(payload.reply.content, /真实 Codex stdio 回复/u); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); } finally { await new Promise((resolve, reject) => { @@ -2430,7 +2443,7 @@ test("cloud api /v1/agent/chat reports Codex stdio skills_unavailable as structu } }); -test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only", async () => { +test("cloud api /v1/agent/chat blocks M3 IO when Codex stdio is unavailable", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-skill-")); const calls = []; const server = createCloudApiServer({ @@ -2440,9 +2453,6 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", OPENAI_API_KEY: "must-not-be-used" }, - callCodeAgentProvider: async () => { - throw new Error("OpenAI fallback must not handle M3 IO"); - }, m3IoSkillRequestJson: async (url, request) => { calls.push({ url, request }); return { @@ -2509,35 +2519,13 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" assert.equal(response.status, 200); const payload = await response.json(); validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].route, "/v1/m3/io"); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].accepted, true); - assert.equal(payload.toolCalls[0].operationId, "op_m3_do_write_server_skill"); - assert.equal(payload.toolCalls[0].traceId, "trc_server-test-m3-skill"); - assert.equal(payload.toolCalls[0].auditId, "aud_m3_do_write_server_skill_succeeded"); - assert.equal(payload.toolCalls[0].evidenceId, "evd_m3_do_write_server_skill_succeeded"); - assert.equal(payload.toolCalls[0].readback.value, false); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(payload.providerTrace.method, "POST"); - assert.equal(payload.providerTrace.readback.value, false); - assert.match(payload.reply.content, /HWLAB API \/v1\/m3\/io/u); - assert.match(payload.reply.content, /res_boxsimu_1:DO1=false/u); - assert.match(payload.reply.content, /res_boxsimu_2:DI1=false/u); - assert.equal(payload.runner.safety.directGatewayCallsAllowed, false); - assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false); - assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false); - assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); - assert.equal(calls.length, 1); - assert.equal(calls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); - assert.equal(calls[0].request.body.action, "do.write"); - assert.equal(calls[0].request.body.value, false); - assert.equal(calls[0].url.includes("gateway-simu"), false); - assert.equal(calls[0].url.includes("box-simu"), false); - assert.equal(calls[0].url.includes("patch-panel"), false); + assert.equal(payload.status, "failed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.deepEqual(payload.toolCalls, []); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(calls.length, 0); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -2545,7 +2533,7 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" } }); -test("cloud api /v1/agent/chat bypasses Codex stdio for M3 and routes through in-process HWLAB API handler", async () => { +test("cloud api /v1/agent/chat routes M3 through Codex stdio then in-process HWLAB API handler", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-m3-skill-")); const fakeCodex = await createFakeCodexCommand(); const gatewayCalls = []; @@ -2607,17 +2595,16 @@ test("cloud api /v1/agent/chat bypasses Codex stdio for M3 and routes through in validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); - assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); - assert.equal(payload.runner.kind, "hwlab-m3-io-skill-cli"); - assert.equal(payload.sessionMode, "controlled-m3-io-skill-cli"); - assert.equal(payload.codexStdioFeasibility.skipped, true); - assert.equal(payload.codexStdioFeasibility.reason, "m3_io_skill_cli_deterministic_route"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); + assert.equal(payload.runner.codexStdio, true); + assert.equal(payload.codexStdioFeasibility.ready, true); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); - assert.equal(payload.toolCalls.length, 1); - const skillTool = payload.toolCalls[0]; + const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); + assert.ok(skillTool); assert.equal(skillTool.type, "skill-cli"); - assert.equal(skillTool.name, "hwlab-agent-runtime.m3-io"); assert.equal(skillTool.route, "/v1/m3/io"); assert.equal(skillTool.method, "POST"); assert.equal(skillTool.accepted, true); @@ -2627,7 +2614,12 @@ test("cloud api /v1/agent/chat bypasses Codex stdio for M3 and routes through in assert.match(skillTool.auditId, /^aud_m3_do_write_/u); assert.match(skillTool.evidenceId, /^evd_m3_do_write_/u); assert.equal(payload.runnerTrace.route, "/v1/m3/io"); + assert.ok(payload.runnerTrace.eventLabels.includes("prompt:sent")); + assert.ok(payload.runnerTrace.events.includes("tool:skill-cli:started")); + assert.ok(payload.runnerTrace.events.includes("route:/v1/m3/io")); assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.providerTrace.codexStdio, true); + assert.equal(payload.providerTrace.transport, "stdio+skill-cli"); assert.match(payload.reply.content, /HWLAB API \/v1\/m3\/io/u); assert.match(payload.reply.content, /res_boxsimu_1:DO1=true/u); assert.match(payload.reply.content, /res_boxsimu_2:DI1=true/u); @@ -2639,7 +2631,8 @@ test("cloud api /v1/agent/chat bypasses Codex stdio for M3 and routes through in "/status", "/invoke" ]); - assert.deepEqual(codexStdioCalls, []); + assert.ok(codexStdioCalls.includes("initialize")); + assert.ok(codexStdioCalls.includes("callTool")); assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); } finally { await new Promise((resolve, reject) => { @@ -2650,15 +2643,47 @@ test("cloud api /v1/agent/chat bypasses Codex stdio for M3 and routes through in } }); -test("cloud api /v1/agent/chat blocks direct M3 API base targets before request and without toolCall URL leakage", async () => { +test("cloud api /v1/agent/chat blocks direct M3 API base targets only after Codex stdio session is ready", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-direct-target-")); + const codexHome = path.join(workspace, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); const calls = []; + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_server_stdio_m3_direct_block", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + return { + structuredContent: { + threadId: "thread_server_stdio_m3_direct_block", + content: "stdio session ready for blocked M3 direct target." + } + }; + }, + close() {} + }) + }); const server = createCloudApiServer({ env: { PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101" }, + codexStdioManager: manager, m3IoSkillRequestJson: async (url, request) => { calls.push({ url, request }); throw new Error("direct gateway target must be blocked before requestJson"); @@ -2675,32 +2700,68 @@ test("cloud api /v1/agent/chat blocks direct M3 API base targets before request }); validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].route, "/v1/m3/io"); - assert.equal(payload.toolCalls[0].method, "POST"); - assert.equal(payload.toolCalls[0].blocker.code, "direct_hardware_target_blocked"); - assert.equal(payload.toolCalls[0].hwlabApi.redactedUrl, null); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.toolCalls[0].name, "codex"); + assert.equal(payload.toolCalls[0].status, "completed"); + const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); + assert.equal(skillTool.status, "blocked"); + assert.equal(skillTool.route, "/v1/m3/io"); + assert.equal(skillTool.method, "POST"); + assert.equal(skillTool.blocker.code, "direct_hardware_target_blocked"); + assert.equal(skillTool.hwlabApi.redactedUrl, null); + assert.equal(payload.skills.blockers[0].code, "direct_hardware_target_blocked"); assert.equal(calls.length, 0); assert.equal(/https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel)|:7101\b|:7201\b|:7301\b|\/invoke\b|\/sync\/tick\b/iu.test(JSON.stringify(payload.toolCalls)), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + await rm(workspace, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); } }); -test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base as structured blocker", async () => { +test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base after Codex stdio session is ready", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-m3-api-base-missing-")); + const codexHome = path.join(workspace, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_server_stdio_m3_api_base_missing", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + return { + structuredContent: { + threadId: "thread_server_stdio_m3_api_base_missing", + content: "stdio session ready for missing M3 API base." + } + }; + }, + close() {} + }) + }); const server = createCloudApiServer({ env: { PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", HWLAB_CODE_AGENT_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, HWLAB_CODE_AGENT_REQUIRE_HWLAB_API_BASE_URL: "1" }, + codexStdioManager: manager, m3IoSkillRequestJson: async () => { throw new Error("missing API base must not call requestJson"); } @@ -2715,11 +2776,14 @@ test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base as structur message: "读取 M3 DI1" }); assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); - assert.equal(payload.blocker.code, "m3_readiness_blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); - assert.deepEqual(payload.toolCalls[0].blocker.missingConfig, [ + assert.equal(payload.skills.blockers[0].code, "skill_cli_api_base_missing"); + assert.equal(payload.toolCalls[0].name, "codex"); + assert.equal(payload.toolCalls[0].status, "completed"); + const skillTool = payload.toolCalls.find((toolCall) => toolCall.name === "hwlab-agent-runtime.m3-io"); + assert.equal(skillTool.blocker.code, "skill_cli_api_base_missing"); + assert.deepEqual(skillTool.blocker.missingConfig, [ "HWLAB_CODE_AGENT_HWLAB_API_BASE_URL", "HWLAB_API_BASE_URL", "HWLAB_CLOUD_API_BASE_URL", @@ -2730,10 +2794,12 @@ test("cloud api /v1/agent/chat reports M3 Skill CLI missing API base as structur await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + await rm(workspace, { recursive: true, force: true }); + await rm(fakeCodex.root, { recursive: true, force: true }); } }); -test("cloud api /v1/agent/chat reuses controlled read-only session and exposes bounded file tools", async () => { +test("cloud api /v1/agent/chat refuses local file-tool shortcuts when Codex stdio is unavailable", 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"); @@ -2753,34 +2819,35 @@ test("cloud api /v1/agent/chat reuses controlled read-only session and exposes b 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); + assert.equal(first.status, "failed"); + assert.equal(first.provider, "codex-stdio"); + assert.equal(first.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(first.capabilityLevel, "blocked"); + assert.equal(first.sessionMode, "codex-mcp-stdio-long-lived"); + assert.deepEqual(first.toolCalls, []); + assert.equal(Object.hasOwn(first, "reply"), false); + assert.ok(first.runnerTrace.events.some((event) => event.label === "request:accepted")); + assert.ok(first.runnerLimitations.includes("no-controlled-readonly-fallback")); 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"); + assert.equal(second.status, "failed"); + assert.equal(second.provider, "codex-stdio"); + assert.deepEqual(second.toolCalls, []); + assert.equal(Object.hasOwn(second, "reply"), false); 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); + assert.equal(third.status, "failed"); + assert.equal(third.provider, "codex-stdio"); + assert.deepEqual(third.toolCalls, []); + assert.equal(Object.hasOwn(third, "reply"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -2788,7 +2855,7 @@ test("cloud api /v1/agent/chat reuses controlled read-only session and exposes b } }); -test("cloud api /v1/agent/chat answers third-turn context from same conversation session facts", async () => { +test("cloud api /v1/agent/chat does not use read-only conversation facts as fallback", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-session-continuity-")); const workspace = path.join(root, "workspace"); const skillsDir = path.join(root, "skills"); @@ -2832,59 +2899,39 @@ test("cloud api /v1/agent/chat answers third-turn context from same conversation traceId: "trc_server-session-continuity-pwd", message: "用pwd列出你当前的工作目录" }); - assert.equal(first.status, "completed"); - assert.equal(first.provider, "codex-readonly-runner"); - assert.equal(first.runner.kind, "hwlab-readonly-runner"); - assert.equal(first.capabilityLevel, "read-only-session-tools"); - assert.equal(first.sessionMode, "controlled-readonly-session-registry"); - assert.equal(first.toolCalls[0].name, "pwd"); - assert.equal(first.workspace, workspace); - assert.equal(first.conversationFacts.turnCount, 1); - assert.equal(first.conversationFacts.workspace, workspace); + assert.equal(first.status, "failed"); + assert.equal(first.provider, "codex-stdio"); + assert.equal(first.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(first.capabilityLevel, "blocked"); + assert.equal(first.conversationFacts.turnCount, 0); const second = await postAgent(port, { conversationId: "cnv_server-session-continuity", traceId: "trc_server-session-continuity-skills", message: "列出你能使用的所有skill" }); - 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, "skills.discover"); - assert.equal(second.skills.totalCount, 2); - assert.deepEqual(second.skills.items.map((skill) => skill.name), ["alpha-skill", "beta-skill"]); - assert.equal(second.conversationFacts.turnCount, 2); - assert.deepEqual(second.conversationFacts.latestSkills.names, ["alpha-skill", "beta-skill"]); + assert.equal(second.status, "failed"); + assert.equal(second.provider, "codex-stdio"); + assert.equal(second.skills.status, "not_requested"); + assert.equal(second.conversationFacts.turnCount, 0); const third = await postAgent(port, { conversationId: "cnv_server-session-continuity", traceId: "trc_server-session-continuity-context", message: "根据前两轮结果说明你现在在哪个工作目录、能使用哪些skill" }); - assert.equal(third.status, "completed"); - assert.equal(third.sessionId, first.sessionId); - assert.equal(third.sessionReuse.reused, true); - assert.equal(third.sessionReuse.turn, 3); - assert.equal(third.provider, "codex-readonly-runner"); - assert.equal(third.backend, "hwlab-cloud-api/codex-readonly-runner"); - assert.equal(third.runner.kind, "hwlab-readonly-runner"); - assert.equal(third.capabilityLevel, "read-only-session-tools"); - assert.equal(third.toolCalls[0].name, "session.context"); - assert.equal(third.toolCalls[0].status, "completed"); - assert.equal(third.conversationFacts.turnCount, 3); - assert.equal(third.conversationFacts.workspace, workspace); - assert.deepEqual(third.conversationFacts.latestSkills.names, ["alpha-skill", "beta-skill"]); - assert.match(third.reply.content, new RegExp(escapeRegExp(workspace), "u")); - assert.match(third.reply.content, /alpha-skill/u); - assert.match(third.reply.content, /beta-skill/u); - assert.match(third.reply.content, /不是 openai-responses-fallback/u); - assert.match(third.reply.content, /不是 stateless-one-shot/u); + assert.equal(third.status, "failed"); + assert.equal(third.provider, "codex-stdio"); + assert.equal(third.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(third.runner.kind, "codex-mcp-stdio-runner"); + assert.equal(third.capabilityLevel, "blocked"); + assert.deepEqual(third.toolCalls, []); + assert.equal(third.conversationFacts.turnCount, 0); assert.notEqual(third.provider, "openai-responses"); assert.notEqual(third.runner.kind, "openai-responses-fallback"); assert.notEqual(third.runner.kind, "codex-cli-one-shot-ephemeral"); assert.notEqual(third.sessionMode, "ephemeral-one-shot"); - assert.ok(third.runnerTrace.events.includes("tool:session.context:completed")); + assert.equal(Object.hasOwn(third, "reply"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -2911,10 +2958,10 @@ test("cloud api /v1/agent/chat blocks forbidden file paths without leaking value 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.error.code, "codex_cli_binary_missing"); + assert.deepEqual(payload.toolCalls, []); assert.equal(payload.capabilityLevel, "blocked"); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "codex-stdio:blocked")); assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(JSON.stringify(payload).includes("sk-"), false); } finally { @@ -2924,7 +2971,7 @@ test("cloud api /v1/agent/chat blocks forbidden file paths without leaking value } }); -test("cloud api /v1/agent/chat discovers skills manifest with source and version evidence", async () => { +test("cloud api /v1/agent/chat does not answer skills from local manifest when Codex stdio is blocked", 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 }); @@ -2963,16 +3010,14 @@ test("cloud api /v1/agent/chat discovers skills manifest with source and version }); 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-session-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); + assert.equal(payload.status, "failed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.skills.status, "not_requested"); + assert.deepEqual(payload.toolCalls, []); + assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(JSON.stringify(payload).includes("alpha-skill"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -2980,7 +3025,7 @@ test("cloud api /v1/agent/chat discovers skills manifest with source and version } }); -test("cloud api /v1/agent/chat keeps slow skills discovery running past legacy 4500ms timeout", async () => { +test("cloud api /v1/agent/chat exposes prompt trace immediately while Codex stdio model call is slow", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-slow-skills-")); const workspace = path.join(root, "workspace"); const skillsDir = path.join(root, "skills"); @@ -2997,21 +3042,51 @@ test("cloud api /v1/agent/chat keeps slow skills discovery running past legacy 4 "# Slow" ].join("\n")); + const codexHome = path.join(root, "codex-home"); + const fakeCodex = await createFakeCodexCommand(); + await mkdir(codexHome, { recursive: true }); const server = createCloudApiServer({ env: { PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: workspace + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir, + HWLAB_CODE_AGENT_SKILLS_STRICT: "1", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" }, - skillsDirs: [skillsDir], - skillsDirsExact: true, - skillsDiscoveryDelayMs: 4600 + codexStdioManager: createCodexStdioSessionManager({ + idFactory: () => "ses_server_test_slow_skills", + createRpcClient: async () => ({ + async initialize() { + return { tools: ["codex", "codex-reply"] }; + }, + async listTools() { + return ["codex", "codex-reply"]; + }, + async callTool() { + await delay(120); + return { + structuredContent: { + threadId: "thread_server_test_slow_skills", + content: "真实 Codex stdio 回复:slow-skill" + } + }; + }, + close() {} + }) + }) }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); try { const { port } = server.address(); - const startedAt = Date.now(); - const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + const chatPromise = fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { method: "POST", headers: { "content-type": "application/json", @@ -3022,32 +3097,40 @@ test("cloud api /v1/agent/chat keeps slow skills discovery running past legacy 4 message: "请列出你可用的所有 skills" }) }); + await delay(40); + const traceResponse = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/trace/trc_server-test-slow-skills`); + assert.equal(traceResponse.status, 200); + const tracePayload = await traceResponse.json(); + assert.equal(tracePayload.traceId, "trc_server-test-slow-skills"); + assert.equal(tracePayload.eventCount > 0, true); + assert.ok(tracePayload.events.some((event) => event.label === "request:accepted" || event.label === "prompt:sent")); + + const response = await chatPromise; assert.equal(response.status, 200); const payload = await response.json(); - assert.equal(Date.now() - startedAt >= 4500, true); 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.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(payload.traceId, "trc_server-test-slow-skills"); - assert.equal(payload.capabilityLevel, "read-only-session-tools"); - assert.equal(payload.sessionMode, "controlled-readonly-session-registry"); - assert.equal(payload.toolCalls[0].name, "skills.discover"); - assert.equal(payload.toolCalls[0].status, "completed"); + assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(payload.sessionMode, "codex-mcp-stdio-long-lived"); assert.equal(payload.skills.status, "ready"); assert.equal(payload.skills.items[0].name, "slow-skill"); assert.equal(payload.skills.items[0].traceId, "trc_server-test-slow-skills"); - assert.equal(payload.runner.kind, "hwlab-readonly-runner"); + assert.equal(payload.runner.kind, "codex-mcp-stdio-runner"); assert.equal(payload.runnerTrace.traceId, "trc_server-test-slow-skills"); - assert.ok(payload.runnerTrace.events.includes("tool:skills.discover:completed")); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex:started")); + assert.ok(payload.runnerTrace.events.some((event) => event.label === "assistant:completed")); assert.equal(Object.hasOwn(payload, "error"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + await rm(fakeCodex.root, { recursive: true, force: true }); } }); -test("cloud api /v1/agent/chat reports structured skills_unavailable blocker", async () => { +test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-")); const skillsDir = path.join(root, "missing-skills"); const server = createCloudApiServer({ @@ -3075,13 +3158,12 @@ test("cloud api /v1/agent/chat reports structured skills_unavailable blocker", a 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.provider, "codex-stdio"); + assert.notEqual(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(payload.skills.status, "not_requested"); + assert.deepEqual(payload.toolCalls, []); + assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback")); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { @@ -3090,7 +3172,7 @@ test("cloud api /v1/agent/chat reports structured skills_unavailable blocker", a } }); -test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", async () => { +test("cloud api /v1/agent/chat does not run unsupported local grep fallback", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-tool-blocked-")); const server = createCloudApiServer({ env: { @@ -3115,10 +3197,11 @@ test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", a assert.equal(response.status, 200); const payload = await response.json(); assert.equal(payload.status, "failed"); - assert.equal(payload.error.code, "tool_unavailable"); + assert.notEqual(payload.error.code, "tool_unavailable"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.toolCalls[0].name, "grep"); - assert.equal(payload.toolCalls[0].status, "blocked"); + assert.deepEqual(payload.toolCalls, []); + assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback")); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { @@ -3127,7 +3210,7 @@ test("cloud api /v1/agent/chat reports unsupported read-only tool as blocked", a } }); -test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE text", async () => { +test("cloud api /v1/agent/chat ignores OpenAI fallback and blocks when Codex stdio is unavailable", async () => { const providerRequests = []; const providerServer = createHttpServer((request, response) => { const chunks = []; @@ -3207,24 +3290,18 @@ test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE te }); assert.equal(response.status, 200); const payload = await response.json(); - assert.equal(payload.status, "completed"); + assert.equal(payload.status, "failed"); assert.equal(payload.conversationId, "cnv_server-test-agent-chat-stream"); assert.equal(payload.traceId, "trc_server-test-agent-chat-stream"); - assert.equal(payload.provider, "openai-responses"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.model, "gpt-test"); - assert.equal(payload.providerTrace.responseId, "resp_server_test_stream"); - assert.equal(payload.reply.content, "HWLAB Code Agent streaming ready."); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(Object.hasOwn(payload, "reply"), false); assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false); - assert.equal(providerRequests.length, 1); - assert.equal(providerRequests[0].method, "POST"); - assert.equal(providerRequests[0].url, "/v1/responses"); - assert.equal(providerRequests[0].authorizationPresent, true); - assert.match(providerRequests[0].accept, /text\/event-stream/u); - assert.equal(providerRequests[0].contentType, "application/json"); - assert.equal(providerRequests[0].body.model, "gpt-test"); - assert.equal(providerRequests[0].body.store, false); - assert.equal(providerRequests[0].body.stream, true); + assert.equal(providerRequests.length, 0); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -3235,9 +3312,11 @@ test("cloud api /v1/agent/chat uses streaming OpenAI Responses and parses SSE te } }); -test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 4500ms UI timeout", async () => { +test("cloud api /v1/agent/chat does not call delayed provider fallback beyond legacy 4500ms UI timeout", async () => { + let providerCalled = false; const server = createCloudApiServer({ callCodeAgentProvider: async ({ providerPlan, message, traceId }) => { + providerCalled = true; await delay(4700); return { provider: providerPlan.provider, @@ -3269,12 +3348,14 @@ test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 45 }); assert.equal(response.status, 200); const payload = await response.json(); - assert.equal(Date.now() - startedAt >= 4500, true); - assert.equal(payload.status, "completed"); + assert.equal(Date.now() - startedAt < 4500, true); + assert.equal(payload.status, "failed"); 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, /稍后回答/u); - assert.equal(Object.hasOwn(payload, "error"), false); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.capabilityLevel, "blocked"); + assert.equal(providerCalled, false); + assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -3282,7 +3363,8 @@ test("cloud api /v1/agent/chat accepts provider success delayed beyond legacy 45 } }); -test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond legacy 4500ms UI timeout", async () => { +test("cloud api /v1/agent/chat skips delayed provider fallback when Codex stdio is unavailable", async () => { + let providerCalled = false; const server = createCloudApiServer({ env: { OPENAI_API_KEY: "test-openai-key-material", @@ -3292,6 +3374,7 @@ test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1/provider-fixture" }, callCodeAgentProvider: async ({ providerPlan }) => { + providerCalled = true; await delay(4600); const error = new Error("OpenAI Responses returned HTTP 503: slow upstream rejected"); error.code = "provider_unavailable"; @@ -3320,16 +3403,15 @@ test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond }); assert.equal(response.status, 200); const payload = await response.json(); - assert.equal(Date.now() - startedAt >= 4500, true); + assert.equal(Date.now() - startedAt < 4500, true); assert.equal(payload.status, "failed"); assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed-failure"); - assert.equal(payload.provider, "openai-responses"); - assert.equal(payload.error.code, "provider_unavailable"); - assert.equal(payload.error.providerStatus, 503); - assert.match(payload.error.message, /HTTP 503/u); - assert.equal(payload.backend, "hwlab-cloud-api/openai-responses"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(payload.availability.fallback.backend, "hwlab-cloud-api/openai-responses"); assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(providerCalled, false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -3337,7 +3419,7 @@ test("cloud api /v1/agent/chat keeps delayed provider failure structured beyond } }); -test("cloud api /v1/agent/chat reports provider timeout as failed without a reply", async () => { +test("cloud api /v1/agent/chat reports Codex stdio blocker instead of provider timeout fallback", async () => { const providerServer = createHttpServer((request, response) => { request.resume(); setTimeout(() => { @@ -3377,15 +3459,13 @@ test("cloud api /v1/agent/chat reports provider timeout as failed without a repl const payload = await response.json(); assert.equal(payload.status, "failed"); assert.equal(payload.traceId, "trc_server-test-agent-chat-provider-timeout"); - assert.equal(payload.error.code, "provider_timeout"); - assert.equal(payload.error.layer, "provider"); - assert.equal(payload.error.retryable, true); - assert.match(payload.error.userMessage, /超时/u); - assert.match(payload.error.message, /timed out after 50ms/u); - assert.equal(payload.provider, "openai-responses"); - assert.equal(payload.backend, "hwlab-cloud-api/openai-responses"); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.error.layer, "runner"); + assert.equal(payload.error.retryable, false); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(payload.availability.endpoint, "POST /v1/agent/chat"); - assert.equal(payload.availability.runner.kind, "hwlab-readonly-runner"); + assert.equal(payload.availability.runner.kind, "codex-mcp-stdio-runner"); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { @@ -3397,7 +3477,7 @@ test("cloud api /v1/agent/chat reports provider timeout as failed without a repl } }); -test("cloud api /v1/agent/chat reports OpenAI provider 502 and 503 as failed blocked payloads", async () => { +test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", async () => { for (const status of [502, 503]) { const providerServer = createHttpServer((request, response) => { request.resume(); @@ -3439,15 +3519,13 @@ test("cloud api /v1/agent/chat reports OpenAI provider 502 and 503 as failed blo const payload = await response.json(); assert.equal(payload.status, "failed"); assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`); - assert.equal(payload.error.code, "provider_unavailable"); - assert.equal(payload.error.layer, "provider"); - assert.equal(payload.error.retryable, true); - assert.match(payload.error.userMessage, /provider/u); - assert.equal(payload.error.providerStatus, status); - assert.match(payload.error.message, new RegExp(`HTTP ${status}`)); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.error.layer, "runner"); + assert.equal(payload.error.retryable, false); assert.equal(payload.error.blocker.traceId, `trc_server-test-agent-chat-provider-${status}`); - assert.equal(payload.error.blocker.retryable, true); + assert.equal(payload.error.blocker.retryable, false); assert.equal(payload.error.blocker.capabilityLevel, "blocked"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { await new Promise((resolve, reject) => { @@ -3487,33 +3565,27 @@ test("cloud api /v1/agent/chat reports provider gaps without faking a reply", as assert.match(payload.conversationId, /^cnv_/); assert.match(payload.messageId, /^msg_/); assert.equal(payload.status, "failed"); - assert.equal(payload.provider, "codex-cli"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(payload.model, "gpt-test"); - assert.equal(payload.backend, "hwlab-cloud-api/codex-cli"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-mcp-stdio"); assert.equal(Number.isNaN(Date.parse(payload.createdAt)), false); assert.equal(Number.isNaN(Date.parse(payload.updatedAt)), false); assert.equal(payload.error.code, "codex_cli_binary_missing"); assert.equal(payload.error.layer, "runner"); assert.equal(payload.error.retryable, false); assert.match(payload.error.userMessage, /Codex CLI binary/u); - assert.match(payload.error.message, /Codex CLI command is not available/); - assert.match(payload.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u); - assert.deepEqual(payload.error.missingCommands, ["/tmp/hwlab-missing-codex"]); - assert.ok(payload.error.missingEnv.includes("OPENAI_API_KEY")); - assert.ok(payload.error.missingConfig.includes("OPENAI_API_KEY")); - assert.ok(payload.error.missingConfig.includes("command:/tmp/hwlab-missing-codex")); - assert.equal(payload.availability.status, "partial"); + assert.match(payload.error.message, /Codex stdio long-lived session is unavailable/u); + assert.equal(payload.availability.status, "blocked"); assert.match(payload.availability.blocker, /Codex CLI command/u); assert.equal(payload.availability.reason, "codex_cli_binary_missing"); - assert.equal(payload.availability.runner.ready, true); + assert.equal(payload.availability.runner.ready, false); assert.equal(payload.availability.codexStdio.runtimeContract.binary.status, "missing"); assert.equal(payload.availability.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); assert.ok(payload.availability.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); 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, /受控只读 runner/u); - assert.match(payload.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u); + assert.match(payload.availability.summary, /will not use controlled-readonly-session-registry/u); assert.equal(JSON.stringify(payload).includes("sk-"), false); assert.equal(Object.hasOwn(payload, "reply"), false); } finally { @@ -3527,18 +3599,13 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -test("cloud api /v1/agent/chat does not complete on empty provider text", async () => { +test("cloud api /v1/agent/chat does not call empty provider text fallback", async () => { + let providerCalled = false; const server = createCloudApiServer({ - callCodeAgentProvider: async ({ providerPlan }) => ({ - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content: " ", - usage: null, - providerTrace: { - source: "empty-test-provider" - } - }) + callCodeAgentProvider: async () => { + providerCalled = true; + throw new Error("empty provider fallback must not be used"); + } }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -3558,9 +3625,10 @@ test("cloud api /v1/agent/chat does not complete on empty provider text", async const payload = await response.json(); assert.equal(payload.conversationId, "cnv_empty-provider-text"); assert.equal(payload.status, "failed"); - assert.equal(payload.error.code, "provider_unavailable"); - assert.match(payload.error.message, /no assistant text/); + assert.equal(payload.error.code, "codex_cli_binary_missing"); + assert.equal(payload.provider, "codex-stdio"); assert.equal(Object.hasOwn(payload, "reply"), false); + assert.equal(providerCalled, false); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); diff --git a/package.json b/package.json index 0f755d8e..e8dcc51a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/repo-reports-guard.mjs && node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs", - "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs tools/hwlab-cli/lib/cli.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node scripts/repo-reports-guard.mjs && node --check scripts/repo-reports-guard.mjs && node --check scripts/src/report-paths.mjs && node --check internal/protocol/index.mjs && node --check internal/build-metadata.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/dev-entrypoint/http.mjs && node --check internal/dev-entrypoint/http.test.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/code-agent-session-registry.mjs && node --check internal/cloud/code-agent-session-registry.test.mjs && node --check internal/cloud/code-agent-trace-store.mjs && node --check internal/cloud/codex-stdio-session.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/m3-io-control.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/model.test.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-edge-proxy/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-contract-plan.test.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/artifact-runtime-readiness-guard.mjs && node --check scripts/src/artifact-runtime-readiness-guard.mjs && node --check scripts/artifact-runtime-readiness-guard.test.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.mjs && node --check scripts/src/dev-cd-apply.test.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/m3-io-control-e2e.mjs && node --check scripts/src/m3-io-control-e2e.mjs && node --check scripts/m3-io-control-e2e.test.mjs && node --check scripts/dev-cloud-workbench-smoke.test.mjs && node --check scripts/dev-cloud-workbench-layout-smoke.mjs && node --check scripts/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.mjs && node --check scripts/src/rpt004-mvp-e2e-harness.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-dev-m3-cardinality.test.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.test.mjs && node --check scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.mjs && node --check scripts/src/dev-runtime-provisioning.test.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.mjs && node --check scripts/src/dev-runtime-postflight.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check skills/hwlab-agent-runtime/scripts/hwlab-agent-runtime-cli.mjs && node --check skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs && node --check skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check tools/hwlab-cli/lib/cli.test.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/code-agent-facts.mjs && node --check web/hwlab-cloud-web/code-agent-facts.test.mjs && node --check web/hwlab-cloud-web/code-agent-status.mjs && node --check web/hwlab-cloud-web/code-agent-status.test.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.mjs && node --check web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs && node --check web/hwlab-cloud-web/live-status.mjs && node --check web/hwlab-cloud-web/wiring-status.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/live-status-contract.test.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-provisioning.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-runtime-postflight.mjs --check && node scripts/rpt004-mvp-e2e-harness.mjs --check --no-write && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test web/hwlab-cloud-web/code-agent-facts.test.mjs web/hwlab-cloud-web/code-agent-status.test.mjs web/hwlab-cloud-web/wiring-status.test.mjs web/hwlab-cloud-web/code-agent-m3-evidence.test.mjs scripts/artifact-runtime-readiness-guard.test.mjs scripts/deploy-contract-plan.test.mjs scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/validate-dev-m3-cardinality.test.mjs scripts/dev-cloud-workbench-smoke.test.mjs web/hwlab-cloud-web/scripts/live-status-contract.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-cd-apply.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-provisioning.test.mjs scripts/src/dev-runtime-migration.test.mjs scripts/src/dev-runtime-postflight.test.mjs scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs scripts/src/dev-evidence-blocker-aggregator.test.mjs skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs tools/hwlab-cli/lib/cli.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/m3-io-control.test.mjs internal/cloud/code-agent-session-registry.test.mjs internal/cloud/server.test.mjs internal/dev-entrypoint/http.test.mjs internal/sim/model.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index dd67cd91..66bd2902 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -37,52 +37,30 @@ function logOk(name) { process.stdout.write(`[code-agent-chat-smoke] ok ${name}\n`); } +function runnerTraceLabels(payload) { + return Array.isArray(payload?.runnerTrace?.events) + ? payload.runnerTrace.events.map((event) => event?.label ?? event).filter(Boolean) + : []; +} + async function runLocalContractSmoke() { - const completed = await handleCodeAgentChat( - { - conversationId: "cnv_code-agent-chat-smoke", - traceId: "trc_code-agent-chat-smoke", - projectId: "prj_code-agent-chat-smoke", - message: "请用一句话说明当前 HWLAB 工作台可以做什么。" - }, - { - now: () => "2026-05-22T00:00:00.000Z", - callProvider: async ({ providerPlan }) => ({ - provider: providerPlan.provider, - model: providerPlan.model, - backend: providerPlan.backend, - content: "HWLAB 工作台可以用 Code Agent 对话来整理任务、查看资源并保持硬件变更受控。", - usage: null, - providerTrace: { - source: "schema-smoke-provider" - } - }) - } - ); - - validateCodeAgentChatSchema(completed); - assert.equal(completed.conversationId, "cnv_code-agent-chat-smoke"); - assert.equal(completed.sessionId, "cnv_code-agent-chat-smoke"); - assert.match(completed.messageId, /^msg_/); - assert.equal(completed.status, "completed"); - assert.equal(completed.createdAt, "2026-05-22T00:00:00.000Z"); - assert.equal(completed.updatedAt, "2026-05-22T00:00:00.000Z"); - assert.equal(completed.traceId, "trc_code-agent-chat-smoke"); - assert.equal(completed.error, undefined); - assert.match(completed.reply.content, /Code Agent/); - logOk("completed schema"); - - const sourceReadiness = classifyCodeAgentChatReadiness(completed, { realDevLive: false }); - assert.equal(sourceReadiness.status, "blocked"); - assert.equal(sourceReadiness.level, "SOURCE"); - assert.equal(sourceReadiness.devLiveReplyPass, false); - logOk("local stub completion is not #143 DEV-LIVE pass"); - const echoReadiness = classifyCodeAgentChatReadiness({ - ...completed, + conversationId: "cnv_code-agent-chat-smoke-echo", + sessionId: "cnv_code-agent-chat-smoke-echo", + messageId: "msg_code-agent-chat-smoke-echo", + status: "completed", + createdAt: "2026-05-22T00:00:00.000Z", + updatedAt: "2026-05-22T00:00:00.000Z", + traceId: "trc_code-agent-chat-smoke-echo", provider: "echo-mock", model: "mock-model", - backend: "hwlab-cloud-api/echo-mock" + backend: "hwlab-cloud-api/echo-mock", + reply: { + messageId: "msg_code-agent-chat-smoke-echo", + role: "assistant", + content: "mock reply", + createdAt: "2026-05-22T00:00:00.000Z" + } }, { realDevLive: true, httpStatus: 200 }); assert.equal(echoReadiness.status, "blocked"); assert.equal(echoReadiness.level, "BLOCKED/untrusted-completion"); @@ -91,138 +69,48 @@ async function runLocalContractSmoke() { assert.match(echoReadiness.reason, /echo\/mock\/stub/u); logOk("echo/mock completion is not real DEV-LIVE pass"); - const failed = await handleCodeAgentChat( - { - traceId: "trc_code-agent-chat-smoke-failed", - message: "你好" - }, - { - now: () => "2026-05-22T00:01:00.000Z", - env: { - PATH: "", - HWLAB_CODE_AGENT_PROVIDER: "codex-cli", - HWLAB_CODE_AGENT_MODEL: "gpt-test", - HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" + for (const [index, message] of [ + "列出可以使用的skill", + "pwd", + "ls .", + "rg --files .", + "请用cat读取 package.json" + ].entries()) { + const blocked = await handleCodeAgentChat( + { + conversationId: `cnv_code-agent-chat-blocked-${index}`, + traceId: `trc_code-agent-chat-blocked-${index}`, + message + }, + { + now: () => "2026-05-22T00:01:00.000Z", + env: { + PATH: "", + HWLAB_CODE_AGENT_PROVIDER: "codex-cli", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex" + } } - } - ); - - validateCodeAgentChatSchema(failed); - assert.match(failed.conversationId, /^cnv_/); - assert.match(failed.sessionId, /^cnv_/); - assert.match(failed.messageId, /^msg_/); - assert.equal(failed.status, "failed"); - assert.equal(failed.createdAt, "2026-05-22T00:01:00.000Z"); - assert.equal(failed.updatedAt, "2026-05-22T00:01:00.000Z"); - assert.equal(failed.traceId, "trc_code-agent-chat-smoke-failed"); - assert.equal(failed.provider, "codex-cli"); - assert.equal(failed.model, "gpt-test"); - assert.equal(failed.backend, "hwlab-cloud-api/codex-cli"); - assert.equal(failed.error.code, "codex_cli_binary_missing"); - assert.equal(failed.error.blocker.code, "codex_cli_binary_missing"); - assert.match(failed.error.nextEvidence, /HWLAB_CODE_AGENT_CODEX_COMMAND|PATH/u); - assert.match(failed.error.message, /Codex CLI command is not available/); - assert.deepEqual(failed.error.missingCommands, ["/tmp/hwlab-missing-codex"]); - assert.ok(failed.error.missingEnv.includes("OPENAI_API_KEY")); - assert.equal(failed.availability.status, "partial"); - assert.match(failed.availability.blocker, /Codex CLI command/u); - assert.equal(failed.availability.reason, "codex_cli_binary_missing"); - assert.equal(failed.availability.runner.ready, true); - assert.equal(failed.availability.codexStdio.runtimeContract.binary.status, "missing"); - assert.equal(failed.availability.codexStdio.runtimeContract.lifecycleSupervisor.status, "blocked"); - assert.equal(failed.availability.codexStdio.runtimeContract.stdioProtocol.status, "blocked"); - assert.ok(failed.availability.codexStdio.blockerCodes.includes("codex_cli_binary_missing")); - assert.ok(failed.availability.codexStdio.blockerCodes.includes("runner_lifecycle_missing")); - assert.ok(failed.availability.codexStdio.blockerCodes.includes("stdio_protocol_not_wired")); - 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); - logOk("failed provider-gap schema"); - - const credentialReadiness = classifyCodeAgentChatReadiness(failed, { realDevLive: true }); - assert.equal(credentialReadiness.status, "blocked"); - assert.equal(credentialReadiness.level, "BLOCKED"); - assert.equal(credentialReadiness.blocker, "runtime"); - assert.equal(credentialReadiness.devLiveReplyPass, false); - logOk("codex binary missing blocker readiness"); - - const upstreamBlocked = classifyCodeAgentChatReadiness({ - ...failed, - error: { - code: "provider_unavailable", - message: "OpenAI Responses returned HTTP 502: request rejected", - providerStatus: 502, - missingEnv: [] - }, - availability: { - status: "available" - } - }, { realDevLive: true, httpStatus: 200 }); - assert.equal(upstreamBlocked.status, "blocked"); - assert.equal(upstreamBlocked.level, "BLOCKED/provider"); - assert.equal(upstreamBlocked.blocker, "provider-upstream"); - assert.equal(upstreamBlocked.providerStatus, 502); - assert.equal(upstreamBlocked.devLiveReplyPass, false); - logOk("provider 502 blocker readiness"); - - const completedLivePayload = { - ...completed, - provider: "openai-responses", - 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-session-tools"); - assert.equal(runnerPwd.session.status, "idle"); - assert.equal(runnerPwd.session.workspace, process.cwd()); - assert.equal(runnerPwd.session.sandbox, "read-only"); - assert.equal(runnerPwd.session.idleTimeoutMs, 1800000); - assert.equal(runnerPwd.session.lastTraceId, "trc_code-agent-chat-runner-pwd"); - 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.longLivedSession, true); - 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.equal(runnerPwd.sessionReuse.status, "idle"); - assert.ok(runnerPwd.runnerLimitations.includes("not-codex-stdio")); - assert.equal(runnerPwd.codexStdioFeasibility.status, "blocked"); - assert.ok(runnerPwd.codexStdioFeasibility.blockerCodes.includes("stdio_protocol_not_wired")); - assert.equal(runnerPwd.codexStdioFeasibility.runtimeContract.stdioProtocol.status, "blocked"); - assert.equal(runnerPwd.codexStdioFeasibility.runtimeContract.lifecycleSupervisor.status, "blocked"); - assert.equal(runnerPwd.longLivedSessionGate.status, "blocked"); - assert.equal(runnerPwd.longLivedSessionGate.pass, false); - assert.ok(runnerPwd.longLivedSessionGate.blockers.some((blocker) => blocker.code === "codex_stdio_blocked_readonly_session_available")); - assert.equal(runnerPwd.toolCalls[0].name, "pwd"); - assert.equal(runnerPwd.toolCalls[0].status, "completed"); - const runnerCapability = classifyCodexRunnerCapability(runnerPwd, { httpStatus: 200 }); - assert.equal(runnerCapability.status, "blocked"); - assert.equal(runnerCapability.capabilityPass, false); - assert.equal(runnerCapability.blocker, "controlled-readonly-not-long-lived"); - logOk("read-only runner pwd uses a reusable session but remains partial capability"); + ); + validateCodeAgentChatSchema(blocked); + assert.equal(blocked.status, "failed"); + assert.equal(blocked.provider, "codex-stdio"); + assert.equal(blocked.backend, "hwlab-cloud-api/codex-mcp-stdio"); + assert.equal(blocked.error.code, "codex_cli_binary_missing"); + assert.equal(Object.hasOwn(blocked, "reply"), false); + assert.deepEqual(blocked.toolCalls, []); + assert.ok(blocked.runnerLimitations.includes("no-controlled-readonly-fallback")); + assert.ok(blocked.runnerLimitations.includes("no-text-fallback")); + assert.equal(blocked.availability.ready, false); + assert.equal(blocked.availability.partialReady, false); + assert.equal(blocked.runnerTrace.traceId, `trc_code-agent-chat-blocked-${index}`); + assert.ok(runnerTraceLabels(blocked).includes("request:accepted")); + assert.ok(runnerTraceLabels(blocked).includes("codex-stdio:blocked")); + assert.equal(blocked.runnerTrace.lastEvent.errorCode, "codex_cli_binary_missing"); + assert.equal(blocked.runnerTrace.lastEvent.waitingFor, "codex-stdio-readiness"); + assert.equal(JSON.stringify(blocked).includes("sk-"), false); + } + logOk("skills/pwd/ls/rg/cat prompts fail with Codex stdio blocker instead of local fallback"); const stdioWorkspaceRoot = await mkdtemp(path.join(os.tmpdir(), `hwlab-code-agent-stdio-smoke-${process.pid}-`)); const stdioWorkspace = path.join(stdioWorkspaceRoot, "workspace"); @@ -243,6 +131,7 @@ async function runLocalContractSmoke() { let fakeCodex = null; try { fakeCodex = await createFakeCodexCommand(); + const rpcCalls = []; const stdioManager = createCodexStdioSessionManager({ idFactory: () => "ses_code_agent_chat_smoke_stdio", createRpcClient: async () => ({ @@ -253,6 +142,7 @@ async function runLocalContractSmoke() { return ["codex", "codex-reply"]; }, async callTool(name, toolArgs) { + rpcCalls.push({ name, toolArgs }); return { structuredContent: { threadId: "thread_code_agent_chat_smoke_stdio", @@ -281,12 +171,14 @@ async function runLocalContractSmoke() { { conversationId: "cnv_code-agent-chat-stdio", traceId: "trc_code-agent-chat-stdio-pwd", - message: "请 pwd、ls .、列出可用 skills,并做一次 workspace 写入读取清理 smoke" + message: "列出可以使用的skill" }, { now: () => "2026-05-22T00:02:10.000Z", codexStdioManager: stdioManager, - env: stdioEnv + env: stdioEnv, + skillsDirs: [stdioSkillsDir], + skillsDirsExact: true } ); validateCodeAgentChatSchema(stdioPwd); @@ -313,20 +205,24 @@ async function runLocalContractSmoke() { assert.equal(stdioPwd.codexStdioFeasibility.runtimeContract.codexHome.status, "ready"); assert.equal(stdioPwd.longLivedSessionGate.status, "pass"); assert.equal(stdioPwd.longLivedSessionGate.pass, true); - assert.equal(stdioPwd.providerTrace.sidecarOnly, true); - assert.equal(stdioPwd.providerTrace.toolName, "workspace-sidecar"); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "pwd" && call.status === "completed")); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "ls" && call.status === "completed")); + assert.equal(stdioPwd.providerTrace.toolName, "codex"); + assert.equal(stdioPwd.providerTrace.threadId, "thread_code_agent_chat_smoke_stdio"); + assert.equal(Object.hasOwn(stdioPwd.providerTrace, "sidecarOnly"), false); + assert.equal(rpcCalls[0].name, "codex"); + assert.match(rpcCalls[0].toolArgs.prompt, /列出可以使用的skill/u); + assert.ok(stdioPwd.toolCalls.some((call) => call.name === "codex" && call.status === "completed")); assert.ok(stdioPwd.toolCalls.some((call) => call.name === "skills.discover" && call.status === "completed")); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "workspace.smoke.write" && call.status === "completed")); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "workspace.smoke.read" && call.status === "completed")); - assert.ok(stdioPwd.toolCalls.some((call) => call.name === "workspace.smoke.cleanup" && call.status === "completed")); assert.equal(stdioPwd.skills.status, "ready"); assert.ok(stdioPwd.skills.items.some((skill) => skill.name === "aaa-stdio-smoke-skill")); assert.equal(stdioPwd.runnerTrace.runnerKind, "codex-mcp-stdio-runner"); assert.equal(stdioPwd.runnerTrace.sessionMode, "codex-mcp-stdio-long-lived"); - assert.ok(stdioPwd.runnerTrace.events.includes("tool:workspace.pwd:completed")); - assert.ok(stdioPwd.runnerTrace.events.includes("tool:workspace.smoke.cleanup:completed")); + assert.ok(runnerTraceLabels(stdioPwd).includes("session:created")); + assert.ok(runnerTraceLabels(stdioPwd).includes("prompt:sent")); + assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex:started")); + assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex:output_chunk")); + assert.ok(runnerTraceLabels(stdioPwd).includes("tool:codex:completed")); + assert.ok(runnerTraceLabels(stdioPwd).includes("assistant:chunk")); + assert.ok(runnerTraceLabels(stdioPwd).includes("assistant:completed")); assert.equal(classifyCodexRunnerCapability(stdioPwd, { httpStatus: 200 }).capabilityPass, true); const stdioSecondTurn = await handleCodeAgentChat( @@ -338,7 +234,9 @@ async function runLocalContractSmoke() { { now: () => "2026-05-22T00:02:11.000Z", codexStdioManager: stdioManager, - env: stdioEnv + env: stdioEnv, + skillsDirs: [stdioSkillsDir], + skillsDirsExact: true } ); validateCodeAgentChatSchema(stdioSecondTurn); @@ -346,12 +244,15 @@ async function runLocalContractSmoke() { assert.equal(stdioSecondTurn.provider, "codex-stdio"); assert.equal(stdioSecondTurn.sessionId, stdioPwd.sessionId); assert.equal(stdioSecondTurn.session.sessionId, stdioPwd.session.sessionId); - assert.equal(stdioSecondTurn.session.threadId, null); + assert.equal(stdioSecondTurn.session.threadId, "thread_code_agent_chat_smoke_stdio"); assert.equal(stdioSecondTurn.sessionReuse.reused, true); assert.equal(stdioSecondTurn.sessionReuse.turn, 2); - assert.equal(stdioSecondTurn.providerTrace.sidecarOnly, true); - assert.equal(stdioSecondTurn.providerTrace.toolName, "workspace-sidecar"); - assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "ls" && call.status === "completed")); + assert.equal(stdioSecondTurn.providerTrace.toolName, "codex-reply"); + assert.equal(Object.hasOwn(stdioSecondTurn.providerTrace, "sidecarOnly"), false); + assert.ok(stdioSecondTurn.toolCalls.some((call) => call.name === "codex-reply" && call.status === "completed")); + assert.ok(runnerTraceLabels(stdioSecondTurn).includes("session:reused")); + assert.ok(runnerTraceLabels(stdioSecondTurn).includes("prompt:sent")); + assert.ok(runnerTraceLabels(stdioSecondTurn).includes("tool:codex-reply:completed")); assert.equal(classifyCodexRunnerCapability(stdioSecondTurn, { httpStatus: 200 }).capabilityPass, true); assert.equal(JSON.stringify(stdioSecondTurn).includes("test-openai-key-material"), false); logOk("codex stdio runner passes long-lived workspace-write session capability"); @@ -361,349 +262,30 @@ async function runLocalContractSmoke() { await rm(fakeCodex.root, { recursive: true, force: true }); } } - - 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() - } + const completedCodexPayload = { + conversationId: "cnv_code-agent-chat-stdio-http", + sessionId: "ses_code-agent-chat-stdio-http", + messageId: "msg_code-agent-chat-stdio-http", + status: "completed", + createdAt: "2026-05-22T00:05:00.000Z", + updatedAt: "2026-05-22T00:05:00.000Z", + traceId: "trc_code-agent-chat-stdio-http", + provider: "codex-stdio", + model: "gpt-5.5", + backend: "hwlab-cloud-api/codex-mcp-stdio", + runner: { kind: "codex-mcp-stdio-runner", codexStdio: true, writeCapable: true, durableSession: true }, + sessionMode: "codex-mcp-stdio-long-lived", + implementationType: "repo-owned-codex-mcp-stdio-session", + capabilityLevel: "long-lived-codex-stdio-session", + reply: { + messageId: "msg_code-agent-chat-stdio-http", + role: "assistant", + content: "real stdio reply", + createdAt: "2026-05-22T00:05:00.000Z" } - ); - validateCodeAgentChatSchema(runnerSecondTurn); - assert.equal(runnerSecondTurn.status, "completed"); - assert.equal(runnerSecondTurn.sessionId, runnerPwd.sessionId); - assert.equal(runnerSecondTurn.session.sessionId, runnerPwd.session.sessionId); - assert.equal(runnerSecondTurn.sessionReuse.reused, true); - assert.equal(runnerSecondTurn.sessionReuse.turn, 2); - assert.equal(runnerSecondTurn.session.lastTraceId, "trc_code-agent-chat-runner-session-reuse"); - 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"); - 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 boundedCat = await handleCodeAgentChat( - { - conversationId: "cnv_code-agent-chat-cat", - traceId: "trc_code-agent-chat-cat", - message: "请用cat读取 package.json" - }, - { - now: () => "2026-05-22T00:04:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() - } - } - ); - 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.ok(boundedRgFiles.toolCalls[0].stdout.length <= 4100); - assert.match(boundedRgFiles.reply.content, /受控只读 rg --files 结果/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"); - 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 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 m3SkillCalls = []; - const m3SkillWrite = await handleCodeAgentChat( - { - conversationId: "cnv_code-agent-chat-m3-skill", - traceId: "trc_code-agent-chat-m3-skill", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 true,然后读取 res_boxsimu_2 的 DI1。" - }, - { - now: () => "2026-05-22T00:04:55.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO"); - }, - m3IoSkillRequestJson: async (url, request) => { - m3SkillCalls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_code-agent-chat-m3-skill", - operationId: "op_m3_do_write_smoke", - auditId: "aud_m3_do_write_smoke_succeeded", - evidenceId: "evd_m3_do_write_smoke_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: true, - targetReadback: { - status: "succeeded", - value: true - } - }, - controlPath: { - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - validateCodeAgentChatSchema(m3SkillWrite); - assert.equal(m3SkillWrite.status, "completed"); - assert.equal(m3SkillWrite.provider, "hwlab-skill-cli"); - assert.equal(m3SkillWrite.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); - assert.equal(m3SkillWrite.toolCalls[0].route, "/v1/m3/io"); - assert.equal(m3SkillWrite.toolCalls[0].method, "POST"); - assert.equal(m3SkillWrite.toolCalls[0].operationId, "op_m3_do_write_smoke"); - assert.equal(m3SkillWrite.toolCalls[0].auditId, "aud_m3_do_write_smoke_succeeded"); - assert.equal(m3SkillWrite.toolCalls[0].evidenceId, "evd_m3_do_write_smoke_succeeded"); - assert.equal(m3SkillWrite.toolCalls[0].readback.value, true); - assert.equal(m3SkillWrite.providerTrace.fallbackUsed, false); - assert.equal(m3SkillWrite.providerTrace.method, "POST"); - assert.equal(m3SkillWrite.providerTrace.readback.value, true); - assert.equal(m3SkillWrite.runner.safety.directGatewayCallsAllowed, false); - assert.match(m3SkillWrite.reply.content, /HWLAB API \/v1\/m3\/io/u); - assert.match(m3SkillWrite.reply.content, /res_boxsimu_1:DO1=true/u); - assert.match(m3SkillWrite.reply.content, /res_boxsimu_2:DI1=true/u); - assert.equal(m3SkillCalls.length, 1); - assert.equal(m3SkillCalls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); - assert.equal(m3SkillCalls[0].request.body.action, "do.write"); - assert.equal(m3SkillCalls[0].request.body.value, true); - assert.equal(m3SkillCalls[0].url.includes("gateway-simu"), false); - assert.equal(m3SkillCalls[0].url.includes("box-simu"), false); - assert.equal(m3SkillCalls[0].url.includes("patch-panel"), false); - logOk("M3 Skill CLI routes through HWLAB API only"); - - const m3SkillFalseCalls = []; - const m3SkillWriteFalse = await handleCodeAgentChat( - { - conversationId: "cnv_code-agent-chat-m3-skill-false", - traceId: "trc_code-agent-chat-m3-skill-false", - message: "通过 HWLAB API 把 res_boxsimu_1 的 DO1 写成 false,然后读取 res_boxsimu_2 的 DI1。" - }, - { - now: () => "2026-05-22T00:04:56.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), - HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", - OPENAI_API_KEY: "must-not-be-used" - }, - callProvider: async () => { - throw new Error("OpenAI fallback must not be used for M3 IO"); - }, - m3IoSkillRequestJson: async (url, request) => { - m3SkillFalseCalls.push({ url, request }); - return { - ok: true, - status: 200, - body: { - status: "succeeded", - accepted: true, - action: "do.write", - traceId: "trc_code-agent-chat-m3-skill-false", - operationId: "op_m3_do_write_false_smoke", - auditId: "aud_m3_do_write_false_smoke_succeeded", - evidenceId: "evd_m3_do_write_false_smoke_succeeded", - auditState: { - status: "written_non_durable" - }, - evidenceState: { - status: "blocked", - sourceKind: "BLOCKED", - blocker: "runtime_durable_not_green", - writeStatus: "written_non_durable" - }, - durableStatus: { - status: "degraded", - durable: false, - blocker: "runtime_durable_not_green" - }, - result: { - value: false, - targetReadback: { - status: "succeeded", - value: false - } - }, - controlPath: { - cloudApi: true, - gatewaySimu: true, - boxSimu: true, - patchPanel: true, - frontendBypass: false - } - } - }; - } - } - ); - validateCodeAgentChatSchema(m3SkillWriteFalse); - assert.equal(m3SkillWriteFalse.status, "completed"); - assert.equal(m3SkillWriteFalse.provider, "hwlab-skill-cli"); - assert.equal(m3SkillWriteFalse.toolCalls[0].route, "/v1/m3/io"); - assert.equal(m3SkillWriteFalse.toolCalls[0].method, "POST"); - assert.equal(m3SkillWriteFalse.toolCalls[0].operationId, "op_m3_do_write_false_smoke"); - assert.equal(m3SkillWriteFalse.toolCalls[0].readback.value, false); - assert.equal(m3SkillWriteFalse.providerTrace.fallbackUsed, false); - assert.match(m3SkillWriteFalse.reply.content, /HWLAB API \/v1\/m3\/io/u); - assert.match(m3SkillWriteFalse.reply.content, /res_boxsimu_1:DO1=false/u); - assert.match(m3SkillWriteFalse.reply.content, /res_boxsimu_2:DI1=false/u); - assert.equal(m3SkillFalseCalls.length, 1); - assert.equal(m3SkillFalseCalls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); - assert.equal(m3SkillFalseCalls[0].request.body.action, "do.write"); - assert.equal(m3SkillFalseCalls[0].request.body.value, false); - logOk("M3 Skill CLI handles user-direct false request through HWLAB API only"); - - const directToolCallPayload = JSON.parse(JSON.stringify(m3SkillWrite)); - directToolCallPayload.toolCalls[0].hwlabApi = { - route: "/v1/m3/io", - redactedUrl: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101/v1/m3/io" }; - assert.throws( - () => validateCodeAgentChatSchema(directToolCallPayload), - /direct gateway\/box\/patch-panel target/u - ); - - const missingRouteToolCallPayload = JSON.parse(JSON.stringify(m3SkillWrite)); - delete missingRouteToolCallPayload.toolCalls[0].route; - missingRouteToolCallPayload.toolCalls[0].blocker = null; - missingRouteToolCallPayload.toolCalls[0].status = "completed"; - assert.throws( - () => validateCodeAgentChatSchema(missingRouteToolCallPayload), - /must show route \/v1\/m3\/io/u - ); - logOk("M3 toolCall schema rejects direct hardware URLs and missing route evidence"); - - const completedHttp200Readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: 200 }); - assert.equal(completedHttp200Readiness.status, "blocked"); - assert.equal(completedHttp200Readiness.devLiveReplyPass, false); - assert.equal(completedHttp200Readiness.blocker, "untrusted-completion"); - logOk("HTTP 200 OpenAI fallback completion is not full Code Agent pass"); - for (const status of [400, 401, 403, 429, 500, 502, 503, 504]) { - const readiness = classifyCodeAgentChatReadiness(completedLivePayload, { realDevLive: true, httpStatus: status }); + const readiness = classifyCodeAgentChatReadiness(completedCodexPayload, { realDevLive: true, httpStatus: status }); assert.equal(readiness.status, "blocked", `HTTP ${status} must block`); assert.equal(readiness.blocker, "provider-upstream", `HTTP ${status} blocker`); assert.equal(readiness.providerStatus, status, `HTTP ${status} providerStatus`); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index e335f318..a970c912 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -281,22 +281,9 @@ const workbenchMarkers = Object.freeze([ ]); const requiredCodeAgentEvidenceTerms = Object.freeze([ - "消息归因", - "字段缺失:证据不足", - "只是文本回答", - "当前不能执行", - "provider", - "model", - "backend", - "runner", - "workspace", - "sandbox", - "capability", - "sessionMode", - "sessionReuse", - "implementation", - "limitations", - "codexStdio", + "运行 trace", + "message-trace", + "message-trace-events", "toolCalls", "skills", "runnerTrace", @@ -304,15 +291,9 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "trace", "message", "providerTrace", - "openai-responses", - "openai-responses-fallback", - "text-chat-only / 只是文本回答", "codex-stdio", - "codex-cli", "codex-mcp-stdio-long-lived", "repo-owned-codex-mcp-stdio-session", - "controlled-readonly-session-registry", - "read-only-session-tools:只读长会话", "not-codex-stdio", "not-write-capable", "process-local-session-registry", @@ -320,8 +301,6 @@ const requiredCodeAgentEvidenceTerms = Object.freeze([ "untrusted_completion", "SOURCE fixture", "Code Agent SOURCE 回复", - "message-evidence-chip", - "message-attribution-chip", "hwlab-skill-cli", "hwlab-m3-io-skill-cli", "message-m3-evidence" @@ -635,22 +614,22 @@ function runStaticSmoke() { addCheck(checks, blockers, "route-control-stable-dimensions", hasStableRouteControls(files), "Route controls and status tags have stable dimensions, readable wrapping, and usable labels on small screens.", { blocker: "runtime_blocker", - evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile message evidence column"] + evidence: ["rail-button min-width/min-height", "state-tag wrapping", "side-tab stable labels", "mobile trace column"] }); - addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio provider/model/trace/conversation/session evidence and reject echo/mock/stub, OpenAI fallback, and read-only runner completions.", { + addCheck(checks, blockers, "code-agent-completed-evidence-visible", hasCodeAgentCompletedEvidenceVisibility(files), "Completed full Code Agent replies expose Codex stdio runnerTrace and reject echo/mock/stub, OpenAI fallback, and local shortcut completions.", { blocker: "observability_blocker", evidence: requiredCodeAgentEvidenceTerms }); - addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded evidence.", { + addCheck(checks, blockers, "code-agent-conversation-ux-states", hasCodeAgentConversationUxStates(files), "Code Agent thread renders trusted completed, SOURCE fixture, failed, and blocked provider states as distinct conversation replies with bounded trace.", { blocker: "observability_blocker", evidence: [ "DEV-LIVE 回复", "SOURCE 回复", "等待超时/API 错误/发送失败", "服务受阻 or legacy BLOCKED 凭证缺口", - "message-evidence/details/chips" + "message-trace/details/events" ] }); @@ -2347,7 +2326,7 @@ function hasStableRouteControls({ html, styles }) { /\.(?:status-dot|state-tag|badge)[^{]*\{[^}]*max-width:\s*100%;[^}]*line-height:\s*1\.25;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && /\.probe-card\s*\{[^}]*min-width:\s*0;/su.test(styles) && /\.probe-card strong\s*\{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/su.test(styles) && - /@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-evidence\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles) + /@media\s*\(max-width:\s*860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence\s*\{[\s\S]*?grid-column:\s*1;/u.test(styles) ); } @@ -2489,7 +2468,6 @@ function hasCodeAgentReadinessVisibility({ html, app, liveStatus = "" }) { } function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { - const messageEvidenceBody = functionBody(app, "messageEvidenceFields"); const realEvidenceBody = functionBody(app, "hasRealCodeAgentEvidence"); const runnerEvidenceBody = functionBody(app, "isCodexRunnerCapableEvidence"); const codeAgentEvidenceSource = `${app}\n${codeAgentM3Evidence}`; @@ -2497,34 +2475,19 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { requiredCodeAgentEvidenceTerms.every((term) => codeAgentEvidenceSource.includes(term)) && /conversationId:\s*result\.conversationId \|\| result\.sessionId \|\| state\.conversationId/u.test(app) && /providerTrace:\s*result\.providerTrace/u.test(app) && - /function\s+providerTraceSummary\s*\(/u.test(app) && - /function\s+messageAttributionPanel\s*\(/u.test(app) && - /codeAgentAttributionFromMessage/u.test(app) && - /attribution\.fields\.map\(\(field\) => field\.chip\)/u.test(messageEvidenceBody) && - /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\("session",\s*sessionSummary\(message\.session\)\s*\?\?\s*message\.sessionId\)/u.test(messageEvidenceBody) && - /recordField\("sessionMode",\s*message\.sessionMode\)/u.test(messageEvidenceBody) && - /recordField\("sessionReuse",\s*sessionReuseSummary\(message\.sessionReuse\)\)/u.test(messageEvidenceBody) && - /recordField\("longLivedGate",\s*longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/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) && - /recordField\("operationId",\s*operationIdSummary\(message\)\)/u.test(messageEvidenceBody) && - /recordField\("audit",\s*auditSummary\(message\)\)/u.test(messageEvidenceBody) && - /recordField\("evidence",\s*evidenceSummary\(message\)\)/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) && + /function\s+messageTracePanel\s*\(/u.test(app) && + /function\s+subscribeRunnerTrace\s*\(/u.test(app) && + /new EventSource\(`\/v1\/agent\/chat\/trace\/\$\{encodeURIComponent\(traceId\)\}\/stream`\)/u.test(app) && + /source\.addEventListener\("runnerTrace"/u.test(app) && + /function\s+pollRunnerTrace\s*\(/u.test(app) && + /function\s+updateMessageTrace\s*\(/u.test(app) && + /function\s+runnerTraceFromSnapshot\s*\(/u.test(app) && + /function\s+runnerTraceHeadline\s*\(/u.test(app) && + /function\s+traceEventMeta\s*\(/u.test(app) && + !/function\s+messageAttributionPanel\s*\(/u.test(app) && + !/function\s+messageEvidencePanel\s*\(/u.test(app) && + !/codeAgentAttributionFromMessage/u.test(app) && + !/codeAgentFactsFromMessage/u.test(app) && /extractCodeAgentM3Evidence\(result\)/u.test(app) && /messageM3EvidencePanel/u.test(app) && /m3EvidenceRows/u.test(app) && @@ -2540,11 +2503,9 @@ function hasCodeAgentCompletedEvidenceVisibility({ app, codeAgentM3Evidence }) { /isTrustedCodeAgentProvider\(value\?\.provider\)/u.test(realEvidenceBody) && /isCodexRunnerCapableEvidence\(value\)/u.test(realEvidenceBody) && /CODEX_RUNNER_CAPABLE_PROVIDERS/u.test(app) && - /CODEX_READONLY_PARTIAL_PROVIDERS/u.test(app) && /codex-mcp-stdio-long-lived/u.test(runnerEvidenceBody) && /repo-owned-codex-mcp-stdio-session/u.test(runnerEvidenceBody) && /long-lived-codex-stdio-session/u.test(runnerEvidenceBody) && - /isReadOnlyRunnerPartialEvidence/u.test(app) && /isTextFallbackChatResult/u.test(app) && /value\?\.session\?\.status === "idle"/u.test(runnerEvidenceBody) && /value\?\.longLivedSessionGate\?\.status === "pass"/u.test(runnerEvidenceBody) && @@ -2614,14 +2575,13 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /function\s+isSourceFixtureChatResult\s*\(/u.test(app) && /function\s+isSourceFixtureCompletion\s*\(/u.test(app) && /function\s+isSourceFixtureCompletedChatMessage\s*\(/u.test(app) && - /function\s+messageEvidencePanel\s*\(/u.test(app) && - /function\s+messageAttributionPanel\s*\(/u.test(app) && + /function\s+messageTracePanel\s*\(/u.test(app) && + /function\s+subscribeRunnerTrace\s*\(/u.test(app) && + /function\s+updateMessageTrace\s*\(/u.test(app) && /function\s+messagePendingContextPanel\s*\(/u.test(app) && - /function\s+messageEvidenceSummary\s*\(/u.test(app) && - /function\s+boundedEvidenceField\s*\(/u.test(app) && - /codeAgentAttributionFromMessage/u.test(app) && - /字段缺失:证据不足/u.test(app) && - /当前不能执行/u.test(app) && + !/function\s+messageEvidencePanel\s*\(/u.test(app) && + !/function\s+messageAttributionPanel\s*\(/u.test(app) && + !/codeAgentAttributionFromMessage/u.test(app) && /DEFAULT_API_TIMEOUT_MS\s*=\s*4500/u.test(app) && /DEFAULT_CODE_AGENT_TIMEOUT_MS\s*=\s*180000/u.test(app) && /timeoutMs:\s*CODE_AGENT_TIMEOUT_MS/u.test(app) && @@ -2640,12 +2600,12 @@ function hasCodeAgentConversationUxStates({ app, styles }) { /message-agent/u.test(styles) && /message-pending-context\s*\{/u.test(styles) && /message-m3-evidence\s*\{/u.test(styles) && - /message-attribution\s*\{/u.test(styles) && - /message-attribution-chip/u.test(styles) && /message-m3-rows\s*\{/u.test(styles) && /copy-chip\s*\{/u.test(styles) && - /message-evidence\s*\{/u.test(styles) && - /message-evidence-chip/u.test(styles) && + /message-trace\s*\{/u.test(styles) && + /message-trace-events\s*\{/u.test(styles) && + !/message-attribution\s*\{/u.test(styles) && + !/message-evidence\s*\{/u.test(styles) && /width:\s*min\(100%,\s*780px\)/u.test(styles) && /status === "completed" \? "dev-live" : status === "failed" \|\| status === "blocked" \? "blocked" : status === "running" \? "pending" : "source"/u.test(app) && !/sourceKind:\s*"SOURCE"[\s\S]{0,160}status:\s*"completed"/u.test(app) @@ -4237,29 +4197,27 @@ async function inspectJourneyUi(page) { messageCount: document.querySelectorAll(".message-card").length, completedMessageHasNonSensitiveMeta: Boolean( [...document.querySelectorAll(".message-card.status-completed .message-meta")] - .some((element) => /openai-responses|codex-readonly-runner|gpt-5\.5|runner=|workspace=|toolCalls=|trc_/u.test(element.textContent ?? "")) + .some((element) => /codex-stdio|gpt-5\.5|runner=|workspace=|toolCalls=|trc_|trace\s+trc_/u.test(element.textContent ?? "")) ), sourceMessageHasNonSensitiveMeta: Boolean( [...document.querySelectorAll(".message-card.status-source .message-meta")] .some((element) => /SOURCE|source-fixture|trc_/u.test(element.textContent ?? "")) ), - attributionText: [...document.querySelectorAll(".message-card.message-agent .message-attribution")] + traceText: [...document.querySelectorAll(".message-card.message-agent .message-trace")] .map((element) => element.textContent ?? "") .join("\n"), - attributionHasTraceId: [...document.querySelectorAll(".message-card.message-agent .message-attribution [data-field='traceId']")] + traceHasTraceId: [...document.querySelectorAll(".message-card.message-agent .message-trace")] .some((element) => /trc_/u.test(element.textContent ?? "")), - attributionShowsToolCalls: [...document.querySelectorAll(".message-card.message-agent .message-attribution [data-field='toolCalls']")] - .some((element) => /toolCalls=/u.test(element.textContent ?? "")), - attributionMissingFieldsStructured: [...document.querySelectorAll(".message-card.message-agent .message-attribution-chip.is-missing")] - .some((element) => /字段缺失:证据不足/u.test(element.textContent ?? "")), + traceShowsEvents: [...document.querySelectorAll(".message-card.message-agent .message-trace-events li")] + .some((element) => /#\d+|session|prompt|tool|assistant|timeout|blocked/u.test(element.textContent ?? "")), + noMainAttributionNoise: document.querySelectorAll(".message-card.message-agent .message-attribution, .message-card.message-agent .message-evidence, .message-card.message-agent .code-agent-facts").length === 0, attributionFallbackNotRunnerControl: !/openai-responses-fallback[\s\S]{0,80}(真实 runner|Codex stdio 长会话|workspace-write)/u.test( - [...document.querySelectorAll(".message-card.message-agent .message-attribution")] + [...document.querySelectorAll(".message-card.message-agent .message-trace")] .map((element) => element.textContent ?? "") .join("\n") ), conversationFactsVisible: /conversationFacts|fact traces|fact skills|fact tools|turns=|sessionId=|workspace=/u.test( [ - ...document.querySelectorAll(".message-card.message-agent .code-agent-facts"), document.querySelector("#code-agent-summary") ] .map((element) => element?.textContent ?? "") @@ -4703,9 +4661,9 @@ async function runLocalAgentFixtureSmoke({ ui.agentChatStatus === "SOURCE 回复" && ui.sourceMessageVisible && ui.sourceMessageHasNonSensitiveMeta && - ui.attributionHasTraceId && - ui.attributionShowsToolCalls && - ui.attributionMissingFieldsStructured && + ui.traceHasTraceId && + ui.traceShowsEvents && + ui.noMainAttributionNoise && ui.attributionFallbackNotRunnerControl && ui.conversationFactsVisible && mobilePending?.pass === true && @@ -6102,21 +6060,21 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} createdAt: timestamp, updatedAt: timestamp, traceId, - provider: "codex-readonly-runner", - model: "read-only-tools", - backend: "local-source-fixture/slow-structured-blocker", + provider: "codex-stdio", + model: "codex-stdio", + backend: "local-source-fixture/codex-stdio-slow-structured-blocker", projectId: stringOrFallback(body?.projectId, gateSummary.topology.projectId), workspace: "/workspace/hwlab", sandbox: "read-only", runner: { - kind: "hwlab-readonly-runner", - writeCapable: false, - codexStdio: false, + kind: "codex-mcp-stdio-runner", + writeCapable: true, + codexStdio: true, longLivedSession: true, - durableSession: false + durableSession: true }, capabilityLevel: "blocked", - sessionMode: "controlled-readonly-session-registry", + sessionMode: "codex-mcp-stdio-long-lived", session: { sessionId: conversationId, conversationId, @@ -6191,16 +6149,21 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} }, runnerTrace: { traceId, - runnerKind: "hwlab-readonly-runner", - sessionMode: "controlled-readonly-session-registry", + runnerKind: "codex-mcp-stdio-runner", + sessionMode: "codex-mcp-stdio-long-lived", status: "blocked", - events: ["tool:skills.discover:blocked"] + events: [ + { seq: 1, traceId, type: "session", stage: "created", status: "completed", label: "session:created" }, + { seq: 2, traceId, type: "prompt", stage: "sent", status: "completed", label: "prompt:sent" }, + { seq: 3, traceId, type: "error", stage: "blocked", status: "blocked", label: "codex-stdio:blocked", errorCode: "skills_unavailable", waitingFor: "codex-stdio" } + ], + lastEvent: { seq: 3, traceId, type: "error", stage: "blocked", status: "blocked", label: "codex-stdio:blocked", errorCode: "skills_unavailable", waitingFor: "codex-stdio" } }, conversationFacts: { conversationId, sessionId: conversationId, sessionStatus: "idle", - sessionMode: "controlled-readonly-session-registry", + sessionMode: "codex-mcp-stdio-long-lived", turnCount: 1, latestTraceId: traceId, traceIds: [traceId], @@ -6270,7 +6233,11 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {} runnerTrace: { traceId, runnerKind: "openai-responses-fallback", - events: ["fixture:text-chat-only"] + events: [ + { seq: 1, traceId, type: "session", stage: "fixture", status: "completed", label: "source-fixture:session" }, + { seq: 2, traceId, type: "assistant", stage: "completed", status: "completed", label: "assistant:completed" } + ], + lastEvent: { seq: 2, traceId, type: "assistant", stage: "completed", status: "completed", label: "assistant:completed" } }, conversationFacts: { conversationId, diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 0abbf6a2..d9ab9d71 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1,9 +1,6 @@ import { gateSummary } from "./gate-summary.mjs"; import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.mjs"; import { - codeAgentAttributionFromMessage, - codeAgentFactsFromMessage, - compactHwlabApiFact, runnerTraceSummary as compactRunnerTraceSummary, skillsSummary as compactSkillsSummary, toolCallsSummary as compactToolCallsSummary @@ -70,9 +67,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", "codex-readonly-runner", "codex-stdio", "hwlab-skill-cli"]); +const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["codex-stdio"]); const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]); -const CODEX_READONLY_PARTIAL_PROVIDERS = Object.freeze(["codex-readonly-runner"]); const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([ "codex_cli_binary_missing", "runner_lifecycle_missing", @@ -152,6 +148,7 @@ const state = { sessionId: null, codeAgentAvailability: null, chatMessages: [], + traceStreams: new Map(), chatPending: false, liveSurface: null, gateDiagnostics: { @@ -643,8 +640,11 @@ function initCommandBar() { renderDrafts(); renderRecords(state.liveSurface); + const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); + try { const result = await sendAgentMessage(value, activeConversationId, traceId); + stopTraceStream(); 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); @@ -707,6 +707,7 @@ function initCommandBar() { renderAgentChatStatus(status, result); renderCodeAgentSummary(); } catch (error) { + stopTraceStream(); const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id); const presentation = agentFailurePresentation(error, { traceId }); state.chatMessages[index] = { @@ -729,13 +730,15 @@ function initCommandBar() { missingConfig: error.missingConfig, route: error.route, toolName: error.toolName - } + }, + runnerTrace: latestTraceSnapshot(traceId) }; el.commandInput.value = value; renderAgentChatStatus("failed", state.chatMessages[index]); renderCodeAgentSummary(); } finally { state.chatPending = false; + stopTraceStream(); renderCodeAgentSummary(); renderConversation(); renderDrafts(); @@ -757,6 +760,113 @@ function initCommandBar() { }); } +function subscribeRunnerTrace(traceId, messageId) { + if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId); + const existing = state.traceStreams.get(traceId); + if (existing) existing(); + const source = new EventSource(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}/stream`); + const close = () => { + source.close(); + if (state.traceStreams.get(traceId) === close) state.traceStreams.delete(traceId); + }; + source.addEventListener("snapshot", (event) => { + updateMessageTrace(messageId, parseTraceEventData(event.data)); + }); + source.addEventListener("runnerTrace", (event) => { + const payload = parseTraceEventData(event.data); + updateMessageTrace(messageId, payload?.snapshot ?? payload); + }); + source.addEventListener("heartbeat", (event) => { + updateMessageTrace(messageId, parseTraceEventData(event.data), { quiet: true }); + }); + source.onerror = () => { + close(); + pollRunnerTrace(traceId, messageId); + }; + state.traceStreams.set(traceId, close); + return close; +} + +function pollRunnerTrace(traceId, messageId) { + let stopped = false; + let timer = null; + const tick = async () => { + if (stopped) return; + const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent trace" + }); + if (response.ok) updateMessageTrace(messageId, response.data); + if (!stopped) timer = window.setTimeout(tick, 1000); + }; + tick(); + const stop = () => { + stopped = true; + if (timer) window.clearTimeout(timer); + if (state.traceStreams.get(traceId) === stop) state.traceStreams.delete(traceId); + }; + state.traceStreams.set(traceId, stop); + return stop; +} + +function parseTraceEventData(data) { + try { + return data ? JSON.parse(data) : null; + } catch { + return null; + } +} + +function updateMessageTrace(messageId, snapshot, options = {}) { + if (!snapshot || typeof snapshot !== "object" || !Array.isArray(snapshot.events)) return; + const index = state.chatMessages.findIndex((message) => message.id === messageId); + if (index < 0) return; + const current = state.chatMessages[index]; + const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace); + state.chatMessages[index] = { + ...current, + runnerTrace, + traceId: snapshot.traceId ?? current.traceId, + updatedAt: snapshot.updatedAt ?? current.updatedAt + }; + if (options.quiet !== true) { + renderCodeAgentSummary(); + renderConversation(); + renderRecords(state.liveSurface); + } +} + +function latestTraceSnapshot(traceId) { + for (const message of [...state.chatMessages].reverse()) { + if (message.traceId === traceId && message.runnerTrace) return message.runnerTrace; + } + return null; +} + +function runnerTraceFromSnapshot(snapshot, previous = null) { + return { + ...(previous && typeof previous === "object" ? previous : {}), + traceId: snapshot.traceId, + runnerKind: snapshot.runnerKind ?? previous?.runnerKind, + workspace: snapshot.workspace ?? previous?.workspace, + sandbox: snapshot.sandbox ?? previous?.sandbox, + sessionMode: snapshot.sessionMode ?? previous?.sessionMode, + sessionId: snapshot.sessionId ?? previous?.sessionId, + sessionStatus: snapshot.sessionStatus ?? previous?.sessionStatus, + implementationType: snapshot.implementationType ?? previous?.implementationType, + startedAt: snapshot.startedAt ?? previous?.startedAt, + finishedAt: snapshot.finishedAt ?? previous?.finishedAt, + updatedAt: snapshot.updatedAt ?? previous?.updatedAt, + elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs, + waitingFor: snapshot.waitingFor ?? previous?.waitingFor, + events: snapshot.events, + eventLabels: snapshot.eventLabels ?? snapshot.events.map((item) => item.label).filter(Boolean), + lastEvent: snapshot.lastEvent ?? snapshot.events.at(-1) ?? null, + outputTruncated: snapshot.outputTruncated === true, + valuesPrinted: false + }; +} + function initM3Control() { el.m3ControlForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -1457,26 +1567,19 @@ function hardwareOverviewGroups(status) { const di1 = box2?.ports?.DI1; const patch = status.patchPanel ?? {}; const trust = status.trust ?? {}; - const io = status.io ?? {}; - const operation = status.operation ?? {}; - const audit = status.audit ?? {}; - const runtimeDurable = status.runtimeDurable ?? {}; - const runtimeDurableStatus = runtimeDurable.status ?? trust.durableStatus ?? "blocked"; return [ hardwareKvGroup("总览", [ ["状态", hardwareStatusLabel(status), statusTone(status)], ["观测时间", status.observedAt ?? "未验证", "source"], ["Gateway 在线", `${onlineCount(status.gateways)} / ${status.gateways?.length ?? 0}`, onlineCount(status.gateways) === 2 ? "live" : "blocked"], ["BOX 在线", `${onlineCount(status.boxes)} / ${status.boxes?.length ?? 0}`, onlineCount(status.boxes) === 2 ? "live" : "blocked"], - ["链路状态", io.status ?? m3LinkStatus(status, patch), m3LinkTone(status, patch)], + ["链路状态", m3LinkStatus(status, patch), m3LinkTone(status, patch)], ["链路", `${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort}`, patch.connectionActive ? "live" : "blocked"], ["DO1", valueLabel(do1), portTone(do1)], ["DI1", valueLabel(di1), portTone(di1)], ["接线盘连接", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"], - ["operation", operation.operationId ?? operation.blocker ?? "未持久化", operation.operationId ? "source" : "blocked"], - ["audit", audit.auditId ?? audit.blocker ?? "未持久化", audit.auditId ? "source" : "blocked"], - ["runtime durable", runtimeDurableStatus, runtimeDurableStatus === "green" ? "dev-live" : "blocked"], - ["blocker", runtimeDurable.blocker ?? trust.blocker ?? status.blocker?.code ?? "none", runtimeDurable.blocker || trust.blocker || status.blocker ? "blocked" : "source"] + ["runtime durable", trust.durableStatus ?? "blocked", trust.durableStatus === "green" ? "dev-live" : "blocked"], + ["blocker", trust.blocker ?? status.blocker?.code ?? "none", trust.blocker || status.blocker ? "blocked" : "source"] ]), hardwareKvGroup("可信证据", [ ["operationId", trust.operationId ?? "未持久化", trust.operationId ? "source" : "blocked"], @@ -2578,7 +2681,7 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) return { category: "runner_busy", title: "Code Agent Runner 忙碌", - text: `受控只读 runner 正在处理上一轮请求;输入已保留,稍后重试即可。${traceSuffix}` + text: `Codex stdio runner 正在处理上一轮请求;输入已保留,稍后重试即可。${traceSuffix}` }; } @@ -2586,7 +2689,7 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) return { category: "session_blocked", title: "Code Agent Session 受阻", - text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的受控只读 session。${traceSuffix}` + text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}` }; } @@ -2594,7 +2697,7 @@ function agentFailurePresentation(error, { result = null, traceId = null } = {}) return { category: "runner_blocked", title: "Code Agent Runner 受阻", - text: `受控只读 runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}` + text: `Codex stdio runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}` }; } @@ -2661,81 +2764,13 @@ function messageCard(message) { article.append(textSpan(message.text, "message-copy")); const pendingContext = messagePendingContextPanel(message); if (pendingContext) article.append(pendingContext); - const attribution = messageAttributionPanel(message); - if (attribution) article.append(attribution); - const capabilityFacts = codeAgentCapabilityFactsPanel(message); - if (capabilityFacts) article.append(capabilityFacts); const m3Evidence = messageM3EvidencePanel(message); if (m3Evidence) article.append(m3Evidence); - const messageEvidence = messageEvidencePanel(message); - if (messageEvidence) article.append(messageEvidence); + const tracePanel = messageTracePanel(message); + if (tracePanel) article.append(tracePanel); return article; } -function messageAttributionPanel(message) { - if (message.status === "running") return null; - const attribution = codeAgentAttributionFromMessage(message); - if (!attribution) return null; - const section = document.createElement("section"); - section.className = `message-attribution tone-border-${toneClass(attribution.tone)}`; - section.setAttribute( - "aria-label", - "消息归因:字段缺失:证据不足;fallback 只是文本回答;blocker 当前不能执行;text-chat-only / 只是文本回答;read-only-session-tools:只读长会话。" - ); - section.dataset.codeAgentAttributionKind = attribution.kind; - - const header = document.createElement("div"); - header.className = "message-attribution-head"; - header.append(badge(attribution.label, attribution.tone), textSpan(attribution.summary, "message-attribution-summary")); - - const chips = document.createElement("div"); - chips.className = "message-attribution-chips"; - for (const field of attribution.fields) { - const chip = textSpan(field.chip, field.missing ? "message-attribution-chip is-missing" : "message-attribution-chip"); - chip.dataset.field = field.key; - chip.dataset.missing = field.missing ? "true" : "false"; - chips.append(chip); - } - - section.append(header, chips); - return section; -} - -function codeAgentCapabilityFactsPanel(message) { - if (message.status === "running") return null; - const facts = codeAgentFactsFromMessage(message); - if (!facts) return null; - const section = document.createElement("section"); - section.className = `code-agent-facts tone-border-${toneClass(facts.tone)}`; - section.dataset.codeAgentFactKind = facts.kind; - section.append(codeAgentFactsHeader(facts)); - - const grid = document.createElement("dl"); - grid.className = "code-agent-fact-grid"; - for (const row of [...facts.rows, ...conversationFactRows(message.conversationFacts)]) { - const item = document.createElement("div"); - item.className = "code-agent-fact-row"; - item.append(textSpan(row.label, "code-agent-fact-key"), textSpan(row.value ?? "none", "code-agent-fact-value mono")); - grid.append(item); - } - section.append(grid); - - if (facts.hwlabApiFacts.length > 0) { - const apiList = document.createElement("div"); - apiList.className = "hwlab-api-facts"; - apiList.append(textSpan("HWLAB API", "hwlab-api-facts-title")); - for (const fact of facts.hwlabApiFacts) { - const code = document.createElement("code"); - code.className = "hwlab-api-fact-code"; - code.textContent = compactHwlabApiFact(fact); - apiList.append(code); - } - section.append(apiList); - } - - return section; -} - function messagePendingContextPanel(message) { if (message.status !== "running") return null; const fields = [ @@ -2766,14 +2801,6 @@ function messagePendingContextPanel(message) { return section; } -function codeAgentFactsHeader(facts) { - const header = document.createElement("div"); - header.className = "code-agent-facts-head"; - header.append(badge(facts.statusLabel, facts.tone)); - header.append(textSpan(facts.summary, "code-agent-facts-summary")); - return header; -} - function messageM3EvidencePanel(message) { const evidence = message.m3Evidence ?? extractCodeAgentM3Evidence(message); if (!evidence) return null; @@ -2810,178 +2837,54 @@ function m3EvidenceRowElement(row) { return item; } -function messageEvidencePanel(message) { - const fields = messageEvidenceFields(message).map(boundedEvidenceField); - if (fields.length === 0) return null; +function messageTracePanel(message) { + const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; + if (!trace && !message.traceId) return null; const details = document.createElement("details"); - details.className = "message-evidence"; + details.className = "message-trace"; + details.open = message.status === "running" || message.status === "completed" || message.status === "source" || message.status === "failed"; const summary = document.createElement("summary"); summary.className = "message-meta"; - summary.textContent = messageEvidenceSummary(message, fields); - const chips = document.createElement("div"); - chips.className = "message-evidence-chips"; - chips.append(...fields.map((field) => textSpan(field, "message-evidence-chip"))); - details.append(summary, chips); + summary.textContent = runnerTraceHeadline(message, trace); + const list = document.createElement("ol"); + list.className = "message-trace-events"; + const events = Array.isArray(trace?.events) ? trace.events : []; + if (events.length === 0) { + const item = document.createElement("li"); + item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`; + list.append(item); + } else { + for (const event of events.slice(-14)) { + const item = document.createElement("li"); + item.append( + textSpan(`#${event.seq ?? "?"}`, "message-trace-seq"), + textSpan(event.label ?? `${event.type}:${event.status}`, "message-trace-label"), + textSpan(traceEventMeta(event), "message-trace-meta") + ); + list.append(item); + } + } + details.append(summary, list); return details; } -function messageEvidenceSummary(message, fields) { - const sourceKind = message.sourceKind ?? ( - message.status === "running" - ? "PENDING" - : message.status === "completed" - ? "DEV-LIVE" - : message.status === "failed" - ? "BLOCKED" - : "SOURCE" - ); - return `证据 ${sourceKind} / ${fields.slice(0, 3).join(" / ")}`; +function runnerTraceHeadline(message, trace) { + const count = Array.isArray(trace?.events) ? trace.events.length : 0; + const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件"; + const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : ""; + const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : ""; + return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}`; } -function messageEvidenceFields(message) { - if (message.status === "running") { - return [ - recordField("source", message.sourceKind ?? "PENDING"), - recordField("status", "running"), - recordField("conversation", message.conversationId), - recordField("trace", message.traceId), - recordField("session", message.sessionId ?? "pending"), - recordField("timeout", `${CODE_AGENT_TIMEOUT_MS}ms`) - ].filter(Boolean); - } - const attribution = codeAgentAttributionFromMessage(message); +function traceEventMeta(event) { return [ - ...(attribution ? attribution.fields.map((field) => field.chip) : []), - recordField("source", message.sourceKind), - 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("session", sessionSummary(message.session) ?? message.sessionId), - recordField("sessionMode", message.sessionMode), - recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)), - recordField("implementation", message.implementationType), - recordField("limitations", limitationsSummary(message.runnerLimitations)), - recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)), - recordField("longLivedGate", longLivedSessionGateSummary(message.longLivedSessionGate)), - recordField("toolCalls", toolCallsSummary(message.toolCalls)), - recordField("skills", skillsSummary(message.skills)), - recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)), - recordField("conversationFacts", conversationFactsSummary(message.conversationFacts)), - recordField("factTraces", conversationFactTracesSummary(message.conversationFacts)), - recordField("operationId", operationIdSummary(message)), - recordField("audit", auditSummary(message)), - recordField("evidence", evidenceSummary(message)), - recordField("conversation", message.conversationId), - recordField("trace", message.traceId), - recordField("message", message.messageId), - providerTraceSummary(message.providerTrace) - ].filter(Boolean); -} - -function boundedEvidenceField(field) { - const text = String(field ?? "").replace(/\s+/gu, " ").trim(); - return text.length > 96 ? `${text.slice(0, 93)}...` : text; -} - -function providerTraceSummary(providerTrace) { - if (!providerTrace || typeof providerTrace !== "object") return null; - const responseId = providerTrace.responseId ?? providerTrace.id; - if (responseId) return recordField("providerTrace", responseId); - if (providerTrace.command) return "providerTrace=codex-cli"; - return null; -} - -function operationIdSummary(message) { - return operationAuditEvidenceSummary(message).operationId; -} - -function auditSummary(message) { - const ids = operationAuditEvidenceSummary(message); - return ids.auditId ?? ids.auditStatus; -} - -function evidenceSummary(message) { - const ids = operationAuditEvidenceSummary(message); - return ids.evidenceId ?? ids.evidenceStatus; -} - -function operationAuditEvidenceSummary(message) { - const providerTrace = objectOrNull(message.providerTrace); - const runnerTrace = objectOrNull(message.runnerTrace); - const toolCalls = Array.isArray(message.toolCalls) ? message.toolCalls : []; - const parsedToolPayloads = toolCalls.map((toolCall) => parseJsonFromText(toolCall?.stdout)).filter(Boolean); - return { - operationId: firstEvidenceText( - message.operationId, - message.operation?.operationId, - providerTrace?.operationId, - runnerTrace?.operationId, - ...toolCalls.map((toolCall) => toolCall?.operationId ?? toolCall?.hwlabApi?.operationId), - ...parsedToolPayloads.map((payload) => payload?.operationId ?? payload?.operation?.operationId) - ), - auditId: firstEvidenceText( - message.auditId, - message.audit?.auditId, - providerTrace?.auditId, - runnerTrace?.auditId, - ...toolCalls.map((toolCall) => toolCall?.auditId ?? toolCall?.audit?.auditId ?? toolCall?.hwlabApi?.auditId), - ...parsedToolPayloads.map((payload) => payload?.auditId ?? payload?.audit?.auditId) - ), - evidenceId: firstEvidenceText( - message.evidenceId, - message.evidence?.evidenceId, - providerTrace?.evidenceId, - runnerTrace?.evidenceId, - ...toolCalls.map((toolCall) => toolCall?.evidenceId ?? toolCall?.evidence?.evidenceId ?? toolCall?.hwlabApi?.evidenceId), - ...parsedToolPayloads.map((payload) => payload?.evidenceId ?? payload?.evidence?.evidenceId) - ), - auditStatus: firstEvidenceText( - message.audit?.status, - providerTrace?.auditStatus, - ...toolCalls.map((toolCall) => toolCall?.audit?.status), - ...parsedToolPayloads.map((payload) => payload?.audit?.status) - ), - evidenceStatus: firstEvidenceText( - message.evidence?.status, - providerTrace?.evidenceStatus, - ...toolCalls.map((toolCall) => toolCall?.evidence?.status), - ...parsedToolPayloads.map((payload) => payload?.evidence?.status) - ) - }; -} - -function firstEvidenceText(...values) { - return values.map((value) => { - if (value === undefined || value === null || value === "") return null; - return String(value).replace(/\s+/gu, " ").trim(); - }).find(Boolean) ?? null; -} - -function objectOrNull(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : null; -} - -function parseJsonFromText(stdout) { - if (typeof stdout !== "string" || !stdout.trim()) return null; - const text = stdout.trim(); - const direct = parseJsonObject(text); - if (direct) return direct; - const start = text.indexOf("{"); - const end = text.lastIndexOf("}"); - return start >= 0 && end > start ? parseJsonObject(text.slice(start, end + 1)) : null; -} - -function parseJsonObject(text) { - try { - const parsed = JSON.parse(text); - return parsed && typeof parsed === "object" ? parsed : null; - } catch { - return null; - } + event.toolName ? `tool=${event.toolName}` : null, + event.waitingFor ? `waiting=${event.waitingFor}` : null, + typeof event.elapsedMs === "number" ? `${event.elapsedMs}ms` : null, + event.errorCode ? `error=${event.errorCode}` : null, + event.outputSummary, + event.message + ].filter(Boolean).join(" / "); } function toolCallsSummary(toolCalls) { @@ -2999,28 +2902,6 @@ function runnerTraceSummary(runnerTrace) { return summary === "none" ? null : summary; } -function conversationFactRows(conversationFacts) { - if (!conversationFacts || typeof conversationFacts !== "object") return []; - return [ - { - label: "conversationFacts", - value: conversationFactsSummary(conversationFacts) - }, - { - label: "fact traces", - value: conversationFactTracesSummary(conversationFacts) - }, - { - label: "fact skills", - value: conversationFactSkillsSummary(conversationFacts) - }, - { - label: "fact tools", - value: conversationFactToolsSummary(conversationFacts) - } - ]; -} - function conversationFactsSummary(conversationFacts) { if (!conversationFacts || typeof conversationFacts !== "object") return "none"; return [ @@ -3059,13 +2940,6 @@ function conversationFactToolsSummary(conversationFacts) { .join(","); } -function sessionReuseSummary(sessionReuse) { - if (!sessionReuse || typeof sessionReuse !== "object") return null; - const state = sessionReuse.reused ? "reused" : "new"; - const status = sessionReuse.status ? `:${sessionReuse.status}` : ""; - return `${state}:turn${sessionReuse.turn ?? "?"}${status}`; -} - function sessionSummary(session) { if (!session || typeof session !== "object") return null; const parts = [ @@ -3077,26 +2951,6 @@ function sessionSummary(session) { return parts.join(":"); } -function limitationsSummary(limitations) { - if (!Array.isArray(limitations) || limitations.length === 0) return null; - return limitations.slice(0, 3).join(","); -} - -function longLivedSessionGateSummary(gate) { - if (!gate || typeof gate !== "object") return null; - const status = gate.status ?? "unknown"; - const blockers = Array.isArray(gate.blockers) - ? gate.blockers.map((blocker) => blocker?.code).filter(Boolean).slice(0, 2).join(",") - : ""; - return blockers ? `${status}:${blockers}` : status; -} - -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) { const article = document.createElement("article"); article.className = "status-card"; @@ -3297,9 +3151,9 @@ function codeAgentStatusMessage(availability) { if (availability?.runner?.ready === true) { return { role: "system", - title: "Code Agent 状态:只读长会话 registry 可用,Codex stdio 受阻", - text: `pwd、skills、ls、rg --files 和 cat 会走可复用 controlled-readonly-session-registry;它不是完整 Codex stdio Code Agent。${codeAgentBlockerDetail(availability)}`, - status: "source" + title: "Code Agent 状态:Codex stdio gate 未通过", + text: `同源服务可响应,但完整 Codex stdio 长会话仍未通过 gate;不会用本地技能发现、shell/file 快捷命令或文本 fallback 冒充回复。${codeAgentBlockerDetail(availability)}`, + status: "blocked" }; } if (availability?.status === "blocked") { @@ -3328,7 +3182,7 @@ function codeAgentStatusMessage(availability) { } return { role: "system", - title: "Code Agent 状态:等待只读探测", + title: "Code Agent 状态:等待 Codex stdio 探测", text: "正在确认服务状态;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容当成真实回复。", status: "source" }; @@ -3354,7 +3208,7 @@ function codeAgentControlSummary(availability) { return codeAgentBlockedSummary(availability); } if (availability?.partialReady === true || availability?.runner?.ready === true) { - return `输入区会调用受控接口;当前只有只读长会话 registry 或 text fallback,不能标成完整 Codex stdio Code Agent。${codeAgentBlockerDetail(availability)}`; + return `输入区会调用受控接口;完整 Codex stdio 长会话尚未可用时只返回结构化失败,不能用本地 shortcut 或 text fallback 冒充回复。${codeAgentBlockerDetail(availability)}`; } return "输入区会调用受控 Code Agent 接口;只有真实完成回复才显示为完成,不能因为只有会话编号就当成实况完成。"; } @@ -3399,8 +3253,8 @@ function codeAgentBlockerChineseLabel(code) { runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor", stdio_protocol_not_wired: "Codex stdio 协议尚未接入", codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用", - codex_stdio_blocked_readonly_session_available: "只读长会话 registry 可用但 Codex stdio 受阻", - controlled_readonly_not_long_lived_stdio: "只读 registry 不是 Codex stdio", + codex_stdio_blocked_readonly_session_available: "旧 partial runner 不能作为 Codex stdio", + controlled_readonly_not_long_lived_stdio: "非 Codex stdio 长会话", openai_responses_fallback_not_session: "文本 fallback 不是长会话", one_shot_runner_not_long_lived: "一次性执行不是可复用 session", provider_token_boundary: "token 边界未配置", @@ -3422,7 +3276,7 @@ function codeAgentAvailabilitySummary() { return "同源 API 可响应;Codex stdio 长会话 gate 已通过,支持真实 session 复用和 trace capture。"; } if (state.codeAgentAvailability?.runner?.ready === true) { - return `同源 API 可响应;pwd/skills/ls/rg/cat 可走受控只读长会话 registry,但它不是 Codex stdio、不可写、非 DB 持久 session。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; + return `同源 API 可响应;完整 Codex stdio 长会话仍受阻,本地 shortcut 与文本 fallback 不会冒充真实 Code Agent 回复。${codeAgentBlockerDetail(state.codeAgentAvailability)}`; } if (state.codeAgentAvailability?.status === "blocked") { return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。"; @@ -3433,7 +3287,7 @@ function codeAgentAvailabilitySummary() { if (state.codeAgentAvailability?.status === "available") { return "同源 API 可响应;Code Agent 真实通道已接入,只有真实完成回复才显示为完成。"; } - return "同源只读接口可响应。技术复核详情已放在二级页面。"; + return "同源接口可响应。技术复核详情已放在二级页面。"; } function latestCompletedAgentMessage() { @@ -3477,7 +3331,7 @@ function hasRealCodeAgentEvidence(value) { if (isCodexRunnerCapableEvidence(value)) { return true; } - if (isReadOnlyRunnerPartialEvidence(value) || isTextFallbackChatResult(value)) { + if (isTextFallbackChatResult(value)) { return false; } return ( @@ -3516,22 +3370,6 @@ function isCodexRunnerCapableEvidence(value) { ); } -function isReadOnlyRunnerPartialEvidence(value) { - return ( - CODEX_READONLY_PARTIAL_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) && - value?.runner?.kind === "hwlab-readonly-runner" && - (value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") && - value?.sessionMode === "controlled-readonly-session-registry" && - value?.implementationType === "controlled-readonly-session-registry" && - value?.runner?.codexStdio === false && - value?.runner?.writeCapable === false && - value?.session?.longLivedSession === true && - value?.runner?.longLivedSession === true && - value?.runner?.durableSession === false && - value?.longLivedSessionGate?.status === "blocked" - ); -} - function isTrustedCodeAgentProvider(provider) { const normalized = String(provider ?? "").trim().toLowerCase(); return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index d3d7fdb4..5eaf8b8e 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -366,14 +366,6 @@ assert.match(functionBody(app, "sourceFallbackM3Status"), /unverified_cloud_api_ assert.match(functionBody(app, "sourceFallbackM3Status"), /SOURCE 拓扑不能冒充实况硬件状态/); assert.doesNotMatch(functionBody(app, "renderHardwareStatus"), /hardwareRow\(\s*\{/u); assert.match(functionBody(app, "renderHardwareStatus"), /Key\/Value groups/u); -assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.io \?\? \{\}/); -assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.operation \?\? \{\}/); -assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.audit \?\? \{\}/); -assert.match(functionBody(app, "hardwareOverviewGroups"), /status\.runtimeDurable \?\? \{\}/); -assert.match(functionBody(app, "hardwareOverviewGroups"), /\["operation", operation\.operationId \?\? operation\.blocker/u); -assert.match(functionBody(app, "hardwareOverviewGroups"), /\["audit", audit\.auditId \?\? audit\.blocker/u); -assert.match(functionBody(app, "hardwareOverviewGroups"), /\["runtime durable", runtimeDurableStatus/u); -assert.match(functionBody(app, "hardwareOverviewGroups"), /runtimeDurable\.blocker \?\? trust\.blocker/u); assert.doesNotMatch(app, /sourceKind:\s*counts\s*\?\s*"DEV-LIVE"/); assert.doesNotMatch(app, /counts\s*&&\s*(?:liveLink|sourceLink|m3Link)[\s\S]{0,80}\?\s*"DEV-LIVE"/); assert.match(app, /function renderRecords/); @@ -751,17 +743,17 @@ 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, /value\?\.capabilityLevel === "read-only-session-tools"/); assert.match(app, /value\?\.session\?\.status === "idle"/); assert.match(app, /typeof value\?\.session\?\.idleTimeoutMs === "number"/); assert.match(app, /Boolean\(value\?\.session\?\.lastTraceId\)/); -assert.match(app, /value\?\.longLivedSessionGate\?\.status === "blocked"/); 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, /value\?\.sessionMode === "codex-mcp-stdio-long-lived"/); +assert.match(app, /value\?\.implementationType === "repo-owned-codex-mcp-stdio-session"/); +assert.match(app, /value\?\.capabilityLevel === "long-lived-codex-stdio-session"/); +assert.match(app, /value\?\.session\?\.codexStdio === true/); +assert.match(app, /value\?\.runner\?\.codexStdio === true/); +assert.match(app, /value\?\.runner\?\.writeCapable === true/); +assert.match(app, /value\?\.runner\?\.durableSession === true/); assert.match(app, /providerTrace:\s*result\.providerTrace/); assert.match(app, /session:\s*result\.session/); assert.match(app, /sessionMode:\s*result\.sessionMode/); @@ -771,21 +763,17 @@ assert.match(app, /runnerLimitations:\s*result\.runnerLimitations/); assert.match(app, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/); assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/); assert.match(app, /conversationFacts:\s*result\.conversationFacts/); -assert.match(app, /providerTraceSummary\(message\.providerTrace\)/); -assert.match(app, /recordField\("conversation", message\.conversationId\)/); -assert.match(app, /recordField\("provider", message\.provider\)/); -assert.match(app, /recordField\("operationId", operationIdSummary\(message\)\)/); -assert.match(app, /recordField\("audit", auditSummary\(message\)\)/); -assert.match(app, /recordField\("evidence", evidenceSummary\(message\)\)/); -assert.match(app, /recordField\("session", sessionSummary\(message\.session\)\s*\?\?\s*message\.sessionId\)/); -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, /recordField\("longLivedGate", longLivedSessionGateSummary\(message\.longLivedSessionGate\)\)/); -assert.match(app, /recordField\("conversationFacts", conversationFactsSummary\(message\.conversationFacts\)\)/); -assert.match(app, /recordField\("factTraces", conversationFactTracesSummary\(message\.conversationFacts\)\)/); +assert.match(app, /function messageTracePanel/); +assert.match(app, /function runnerTraceHeadline/); +assert.match(app, /function traceEventMeta/); +assert.match(app, /function subscribeRunnerTrace/); +assert.match(app, /function pollRunnerTrace/); +assert.match(app, /function updateMessageTrace/); +assert.match(app, /function runnerTraceFromSnapshot/); +assert.match(app, /new EventSource\(`\/v1\/agent\/chat\/trace\/\$\{encodeURIComponent\(traceId\)\}\/stream`\)/); +assert.match(app, /source\.addEventListener\("runnerTrace"/); +assert.doesNotMatch(app, /providerTraceSummary\(message\.providerTrace\)/); +assert.doesNotMatch(app, /function messageEvidenceFields/); assert.match(app, /codeAgentSummaryRow\("sessionId\/status", summary\.sessionIdStatus/); assert.match(app, /codeAgentSummaryRow\("workspace", summary\.workspace/); assert.match(app, /codeAgentSummaryRow\("sandbox", summary\.sandbox/); @@ -796,13 +784,10 @@ assert.match(app, /codeAgentSummaryRow\("conversation facts", conversationFactsS assert.match(app, /codeAgentSummaryRow\("fact traces", conversationFactTracesSummary\(summary\.latestMessage\?\.conversationFacts\)/); assert.match(app, /codeAgentSummaryRow\("runnerTrace", summary\.runnerTrace/); assert.match(app, /function sessionSummary/); -assert.match(app, /function conversationFactRows/); assert.match(app, /function conversationFactsSummary/); -assert.match(app, /function conversationFactSkillsSummary/); -assert.match(app, /function conversationFactToolsSummary/); -assert.match(app, /function longLivedSessionGateSummary/); -assert.match(app, /read-only-session-tools/); -assert.match(app, /controlled-readonly-session-registry/); +assert.match(app, /function codeAgentAvailabilitySummary/); +assert.doesNotMatch(app, /function longLivedSessionGateSummary/); +assert.doesNotMatch(app, /function conversationFactRows/); assert.match(app, /not-codex-stdio/); assert.match(app, /not-write-capable/); assert.match(app, /process-local-session-registry/); @@ -815,15 +800,19 @@ assert.match(app, /function isSourceFixtureCompletion/); assert.match(app, /function isSourceFixtureCompletedChatMessage/); assert.match(app, /Code Agent SOURCE 回复/); assert.match(app, /SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE/); -assert.match(app, /function messageEvidencePanel/); -assert.doesNotMatch(app, /details\.open\s*=\s*message\.status === "running"/); -assert.match(app, /function messageAttributionPanel/); -assert.match(app, /function messageEvidenceSummary/); -assert.match(app, /function boundedEvidenceField/); -assert.match(app, /codeAgentAttributionFromMessage/); -assert.match(app, /codeAgentFactsFromMessage/); -assert.match(app, /function codeAgentCapabilityFactsPanel/); -assert.match(app, /compactHwlabApiFact/); +assert.match(app, /function messageTracePanel/); +assert.match(app, /function subscribeRunnerTrace/); +assert.match(app, /new EventSource\(`\/v1\/agent\/chat\/trace\/\$\{encodeURIComponent\(traceId\)\}\/stream`\)/); +assert.match(app, /source\.addEventListener\("runnerTrace"/); +assert.match(app, /function pollRunnerTrace/); +assert.match(app, /function updateMessageTrace/); +assert.match(app, /function runnerTraceFromSnapshot/); +assert.doesNotMatch(app, /function messageEvidencePanel/); +assert.doesNotMatch(app, /function messageAttributionPanel/); +assert.doesNotMatch(app, /function codeAgentCapabilityFactsPanel/); +assert.doesNotMatch(app, /codeAgentAttributionFromMessage/); +assert.doesNotMatch(app, /codeAgentFactsFromMessage/); +assert.doesNotMatch(app, /compactHwlabApiFact/); assert.match(app, /function renderCodeAgentSummary/); assert.match(app, /currentCodeAgentStatusSummary/); assert.match(app, /classifyCodeAgentStatusSummary/); @@ -862,17 +851,16 @@ assert.match(styles, /\.message-card\.status-source\s*{/); assert.match(styles, /\.message-user\s*{/); assert.match(styles, /\.message-agent,\s*\n\.message-system\s*{/); assert.match(styles, /\.message-pending-context\s*{/); -assert.match(styles, /\.message-attribution\s*{/); -assert.match(styles, /\.message-attribution-chip,\s*\n\.message-evidence-chip\s*{/); -assert.match(styles, /\.message-attribution-chip\.is-missing\s*{/); assert.match(styles, /\.message-m3-evidence\s*{/); assert.match(styles, /\.message-m3-rows\s*{/); assert.match(styles, /\.message-m3-row\s*{/); assert.match(styles, /\.copy-chip\s*{/); -assert.match(styles, /\.message-evidence\s*{/); -assert.match(styles, /\.message-evidence-chip\s*{/); -assert.match(styles, /\.code-agent-facts\s*{/); -assert.match(styles, /\.code-agent-fact-grid\s*{/); +assert.match(styles, /\.message-trace\s*{/); +assert.match(styles, /\.message-trace-events\s*{/); +assert.match(styles, /\.message-trace-label\s*{/); +assert.doesNotMatch(styles, /\.message-attribution\s*{/); +assert.doesNotMatch(styles, /\.message-evidence\s*{/); +assert.doesNotMatch(styles, /\.code-agent-facts\s*{/); assert.match(styles, /\.code-agent-summary\s*{/); assert.match(styles, /\.tone-border-warn/); assert.match(styles, /\.tone-border-blocked/); @@ -885,9 +873,7 @@ assert.match(styles, /\.(?:status-dot|state-tag|badge)[^{]*{[^}]*max-width:\s*10 assert.match(styles, /\.probe-card\s*{[^}]*min-width:\s*0;/s); assert.match(styles, /\.probe-card strong\s*{[^}]*line-height:\s*1\.2;[^}]*overflow-wrap:\s*anywhere;/s); assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-m3-rows\s*{[\s\S]*?grid-template-columns:\s*1fr;/); -assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-attribution,\s*\n\s*\.code-agent-facts,\s*\n\s*\.message-m3-evidence\s*{[\s\S]*?grid-column:\s*1;/); -assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-evidence\s*{[\s\S]*?grid-column:\s*1;/); -assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.code-agent-fact-grid\s*{[\s\S]*?grid-template-columns:\s*1fr;/); +assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.message-trace,\s*\n\s*\.message-m3-evidence\s*{[\s\S]*?grid-column:\s*1;/); for (const userFacingFunctionName of [ "codeAgentStatusMessage", "codeAgentPromptText", diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 0ec3cff1..9c27c3bd 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -882,14 +882,13 @@ h3 { overflow-wrap: anywhere; } -.message-evidence { +.message-trace { grid-column: 2; min-width: 0; } .message-pending-context, -.message-attribution, -.code-agent-facts, +.message-trace, .message-m3-evidence { grid-column: 2; min-width: 0; @@ -903,23 +902,17 @@ h3 { background: rgba(55, 45, 24, 0.54); } -.message-attribution { +.message-trace { background: rgba(20, 24, 23, 0.76); border-left-width: 3px; } -.code-agent-facts { - background: rgba(15, 17, 16, 0.72); - border-left-width: 3px; -} - .message-m3-evidence { background: rgba(15, 17, 16, 0.44); } .message-pending-head, -.message-attribution-head, -.code-agent-facts-head { +.message-trace-head { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -934,8 +927,7 @@ h3 { } .message-pending-summary, -.message-attribution-summary, -.code-agent-facts-summary { +.message-trace-summary { min-width: 0; color: var(--muted); font-size: 11px; @@ -1115,40 +1107,34 @@ h3 { outline: 0; } -.message-evidence summary { +.message-trace summary { cursor: pointer; list-style-position: inside; } -.message-evidence-chips { - display: flex; - flex-wrap: wrap; +.message-trace-events { + display: grid; gap: 5px; - margin-top: 6px; -} - -.message-attribution-chips { - min-width: 0; - display: flex; - flex-wrap: wrap; - gap: 5px; -} - -.message-attribution-chip, -.message-evidence-chip { - max-width: 100%; - padding: 2px 5px; - background: var(--surface); - border: 1px solid var(--line); + margin: 6px 0 0; + padding-left: 18px; color: var(--muted); font-family: var(--mono); font-size: 10px; +} + +.message-trace-events li { + min-width: 0; overflow-wrap: anywhere; } -.message-attribution-chip.is-missing { - border-color: rgba(223, 170, 85, 0.55); - color: var(--accent); +.message-trace-seq, +.message-trace-label, +.message-trace-meta { + margin-right: 6px; +} + +.message-trace-label { + color: var(--text); } .message-blocker { @@ -2089,8 +2075,7 @@ tbody tr:last-child td { } .message-pending-context, - .message-attribution, - .code-agent-facts, + .message-trace, .message-m3-evidence { grid-column: 1; } @@ -2108,23 +2093,17 @@ tbody tr:last-child td { grid-template-columns: minmax(0, 0.36fr) minmax(0, 1fr) auto; } - .message-evidence { + .message-trace { grid-column: 1; } - .message-attribution-head, - .code-agent-facts-head, - .code-agent-fact-grid, + .message-trace-head, .code-agent-summary summary, .code-agent-summary-detail, .code-agent-summary-row { grid-template-columns: 1fr; } - .code-agent-fact-grid { - grid-template-columns: 1fr; - } - .code-agent-summary-trace { justify-self: start; text-align: left;