diff --git a/deploy/deploy.json b/deploy/deploy.json index d4325561..f0d2cb7a 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -211,7 +211,7 @@ "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR": "repo-owned", "HWLAB_CODE_AGENT_WORKSPACE": "/workspace/hwlab", "HWLAB_CODE_AGENT_CODEX_WORKSPACE": "/workspace/hwlab", - "HWLAB_CODE_AGENT_CODEX_SANDBOX": "workspace-write", + "HWLAB_CODE_AGENT_CODEX_SANDBOX": "danger-full-access", "HWLAB_CODE_AGENT_CODEX_COMMAND": "/app/node_modules/.bin/codex", "CODEX_HOME": "/codex-home", "NO_PROXY": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 1c0042a9..08bf54f1 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -440,7 +440,7 @@ }, "HWLAB_CODE_AGENT_CODEX_SANDBOX": { "type": "string", - "const": "workspace-write" + "const": "danger-full-access" }, "HWLAB_CODE_AGENT_CODEX_COMMAND": { "type": "string", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 0c225d35..3988bdeb 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -143,7 +143,7 @@ }, { "name": "HWLAB_CODE_AGENT_CODEX_SANDBOX", - "value": "workspace-write" + "value": "danger-full-access" }, { "name": "HWLAB_CODE_AGENT_CODEX_COMMAND", diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index e9f21354..2133cd7b 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -31,13 +31,6 @@ import { codeAgentSessionLifecycleSummary, decorateCodeAgentSession } from "./code-agent-session-lifecycle.mjs"; -import { - CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED, - CODE_AGENT_HARDWARE_PROVIDER, - HARDWARE_INVOKE_SHELL_METHOD, - callCodeAgentHardwareRunner, - detectHardwareCapabilityIntent -} from "./code-agent-hardware-runner.mjs"; import { HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, HWLAB_M3_IO_CAPABILITY_LEVELS, @@ -81,6 +74,9 @@ const M3_IO_TOPOLOGY = Object.freeze({ targetResourceId: "res_boxsimu_2", targetPort: "DI1" }); +const HARDWARE_INVOKE_SHELL_METHOD = "hardware.invoke.shell"; +const LEGACY_CODE_AGENT_HARDWARE_PROVIDER = "hwlab-hardware-capability"; +const LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED = "blocked"; const OPENAI_FALLBACK_RUNNER_KIND = "openai-responses-fallback"; 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; @@ -151,8 +147,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { const message = normalizeUserMessage(params.message); const runnerIntent = detectReadOnlyRunnerIntent(message, { params, - env: options.env ?? process.env, - hardwareTopology: options.hardwareCapabilityTopology + env: options.env ?? process.env }); traceRecorder.append({ type: "request", @@ -161,9 +156,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message, waitingFor: runnerIntent.kind === "m3_io" ? HWLAB_M3_IO_API_ROUTE - : runnerIntent.kind === "hardware_shell" - ? HARDWARE_INVOKE_SHELL_METHOD - : "codex-stdio-readiness" + : "codex-stdio-readiness" }); const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; @@ -228,22 +221,6 @@ export async function handleCodeAgentChat(params = {}, options = {}) { return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); } - if (runnerIntent.kind === "hardware_shell") { - const runnerResult = await callCodeAgentHardwareRunner({ - intent: runnerIntent, - conversationId, - sessionId: requestedSessionId, - traceId, - now: options.now, - workspace: options.workspace, - sessionRegistry, - gatewayRegistry: options.gatewayRegistry, - invokeHardwareShellRpc: options.invokeHardwareShellRpc, - traceRecorder - }); - return completedRunnerPayload({ base, runnerResult, messageId, now: options.now, sessionRegistry }); - } - if (runnerIntent.kind === "session_context") { traceRecorder.append({ type: "session", @@ -2225,12 +2202,6 @@ function detectReadOnlyRunnerIntent(message, options = {}) { reason: "Code Agent 安全边界不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" }; } - const hardwareIntent = detectHardwareCapabilityIntent(text, { - params: options.params, - env: options.env, - topology: options.hardwareTopology - }); - if (hardwareIntent) return hardwareIntent; const m3IoIntent = detectM3IoIntent(text); if (m3IoIntent) { return m3IoIntent; @@ -2764,7 +2735,7 @@ function structuredCompletionBlocker(result, context = {}) { const hardwareTool = Array.isArray(result.toolCalls) ? result.toolCalls.find((toolCall) => toolCall?.name === HARDWARE_INVOKE_SHELL_METHOD) : null; - if ((result.provider === CODE_AGENT_HARDWARE_PROVIDER || hardwareTool || result.hardware?.type === "hardware_capability_blocker") && result.capabilityLevel === CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED) { + if ((result.provider === LEGACY_CODE_AGENT_HARDWARE_PROVIDER || hardwareTool || result.hardware?.type === "hardware_capability_blocker") && result.capabilityLevel === LEGACY_CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED) { const primary = result.hardware?.blocker ?? hardwareTool?.blocker ?? result.blocker ?? result.blockers?.[0] ?? result.runnerTrace?.blocker ?? null; return structuredBlocker({ code: primary?.code ?? "hardware_capability_blocked", diff --git a/internal/cloud/code-agent-hardware-runner.mjs b/internal/cloud/code-agent-hardware-runner.mjs deleted file mode 100644 index 8f2bac82..00000000 --- a/internal/cloud/code-agent-hardware-runner.mjs +++ /dev/null @@ -1,1182 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; - -export const HARDWARE_INVOKE_SHELL_METHOD = "hardware.invoke.shell"; -export const CODE_AGENT_HARDWARE_PROVIDER = "hwlab-hardware-capability"; -export const CODE_AGENT_HARDWARE_MODEL = "controlled-hardware-capability"; -export const CODE_AGENT_HARDWARE_BACKEND = "hwlab-cloud-api/hardware.invoke.shell"; -export const CODE_AGENT_HARDWARE_RUNNER_KIND = "hwlab-hardware-capability-runner"; -export const CODE_AGENT_HARDWARE_SANDBOX = "cloud-api-capability-route-only"; -export const CODE_AGENT_HARDWARE_SESSION_MODE = "controlled-hardware-capability-route"; -export const CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE = "cloud-api-hardware-capability-runner"; -export const CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED = "pc-gateway-shell-executed"; -export const CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED = "blocked"; - -const OUTPUT_LIMIT = 4000; -const COMMAND_SUMMARY_LIMIT = 320; -const DEFAULT_DEV_MVP_PC_GATEWAY_TOPOLOGY = Object.freeze([Object.freeze({ - projectId: "prj_mvp_topology", - gatewaySessionId: "gws_DESKTOP-1MHOD9I", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - capabilityName: "shell.exec", - gatewayLabel: "DESKTOP-1MHOD9I", - topologySource: "dev-mvp-pc-gateway-topology" -})]); - -const HARDWARE_LIMITATION_FLAGS = Object.freeze([ - "cloud-api-hardware-capability-only", - "no-direct-gateway-link", - "no-direct-box-link", - "no-direct-patch-panel-link", - "no-openai-text-fallback", - "registered-capability-required" -]); - -export function detectHardwareCapabilityIntent(message, { params = {}, env = process.env, topology } = {}) { - const text = String(message ?? "").trim(); - if (!mentionsPcGatewayShellCapability(text, params)) return null; - - const command = normalizeWindowsCommand( - params.input?.command ?? - params.command ?? - extractWindowsCommand(text) - ); - const explicit = { - projectId: cleanProtocolId(params.projectId, "prj") ?? extractProtocolField(text, "projectId", "prj"), - gatewaySessionId: cleanProtocolId(params.gatewaySessionId, "gws") ?? extractProtocolField(text, "gatewaySessionId", "gws") ?? extractProtocolField(text, "session", "gws"), - resourceId: cleanProtocolId(params.resourceId, "res") ?? extractProtocolField(text, "resourceId", "res"), - capabilityId: cleanProtocolId(params.capabilityId, "cap") ?? extractProtocolField(text, "capabilityId", "cap") - }; - const resolution = resolveHardwareTopology(explicit, { topology, env }); - - if (resolution.blocker) { - return { - kind: "hardware_shell", - toolName: HARDWARE_INVOKE_SHELL_METHOD, - action: "shell.exec", - command, - request: null, - topology: resolution, - blocker: resolution.blocker - }; - } - - if (!command) { - return { - kind: "hardware_shell", - toolName: HARDWARE_INVOKE_SHELL_METHOD, - action: "shell.exec", - request: resolution.request, - topology: resolution, - blocker: hardwareBlocker({ - code: "hardware_command_missing", - layer: "intent", - category: "needs_command", - summary: "PC gateway shell.exec request did not include a Windows cmd command.", - userMessage: "PC gateway shell.exec 请求缺少 Windows cmd 命令;请明确给出 cmd /c ...。", - retryable: false - }) - }; - } - - if (!isAllowedWindowsCmd(command)) { - return { - kind: "hardware_shell", - toolName: HARDWARE_INVOKE_SHELL_METHOD, - action: "shell.exec", - command, - request: resolution.request, - topology: resolution, - blocker: hardwareBlocker({ - code: "hardware_command_unsupported", - layer: "security", - category: "security_boundary", - summary: "Only explicit Windows cmd /c commands are allowed for the registered PC gateway shell.exec capability.", - userMessage: "该能力只允许已登记 PC gateway 的 Windows cmd /c 命令;不会执行 bash、sh、kubectl、PowerShell 或任意 shell。", - retryable: false - }) - }; - } - - return { - kind: "hardware_shell", - toolName: HARDWARE_INVOKE_SHELL_METHOD, - action: "shell.exec", - command, - request: resolution.request, - topology: resolution - }; -} - -export async function callCodeAgentHardwareRunner({ - intent, - conversationId, - sessionId, - traceId, - now, - workspace, - sessionRegistry, - gatewayRegistry, - invokeHardwareShellRpc, - traceRecorder -} = {}) { - const startedAt = nowIso(now); - const resolvedWorkspace = workspace ?? process.cwd(); - const request = intent?.request ?? null; - const requestId = `req_code_agent_hw_shell_${randomUUID()}`; - const actorId = "usr_code_agent"; - const acquire = acquireHardwareSession(sessionRegistry, { - conversationId, - sessionId, - traceId, - workspace: resolvedWorkspace, - now - }); - let session = acquire.session; - const preflightBlocker = intent?.blocker ?? acquire.blocker ?? hardwareRegistryBlocker({ request, gatewayRegistry, traceId }); - - traceRecorder?.append({ - type: "tool_call", - status: "started", - label: "tool:hardware.invoke.shell:started", - toolName: HARDWARE_INVOKE_SHELL_METHOD, - waitingFor: HARDWARE_INVOKE_SHELL_METHOD - }); - - if (preflightBlocker) { - session = releaseHardwareSession(sessionRegistry, session, { now, traceId, conversationId }); - const finishedAt = nowIso(now); - const runnerResult = hardwareBlockedRunnerResult({ - request, - command: intent?.command, - blocker: preflightBlocker, - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace: resolvedWorkspace, - session, - topology: intent?.topology - }); - appendHardwareTraceCompletion(traceRecorder, runnerResult); - return runnerResult; - } - - if (typeof invokeHardwareShellRpc !== "function") { - session = releaseHardwareSession(sessionRegistry, session, { now, traceId, conversationId }); - const finishedAt = nowIso(now); - const runnerResult = hardwareBlockedRunnerResult({ - request, - command: intent?.command, - blocker: hardwareBlocker({ - code: "hardware_dispatch_unavailable", - layer: "dispatch", - category: "needs_config", - summary: "Code Agent hardware runner has no cloud-api JSON-RPC dispatcher.", - userMessage: "Code Agent 硬件能力 runner 缺少 cloud-api 内部 dispatch 入口;本次未执行。", - retryable: false, - traceId - }), - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace: resolvedWorkspace, - session, - topology: intent?.topology - }); - appendHardwareTraceCompletion(traceRecorder, runnerResult); - return runnerResult; - } - - let rpcResponse; - try { - rpcResponse = await invokeHardwareShellRpc({ - method: HARDWARE_INVOKE_SHELL_METHOD, - params: { - projectId: request.projectId, - gatewaySessionId: request.gatewaySessionId, - resourceId: request.resourceId, - capabilityId: request.capabilityId, - input: { - command: intent.command - } - }, - traceId, - requestId, - actorId, - serviceId: CLOUD_API_SERVICE_ID - }); - } catch (error) { - rpcResponse = { - error: { - code: "hardware_dispatch_exception", - message: "hardware.invoke.shell dispatch threw before returning JSON-RPC", - data: { - reason: error?.message ?? "dispatch exception" - } - } - }; - } - - session = releaseHardwareSession(sessionRegistry, session, { now, traceId, conversationId }); - const finishedAt = nowIso(now); - const runnerResult = hardwareRunnerResultFromRpc({ - request, - command: intent.command, - rpcResponse, - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace: resolvedWorkspace, - session, - topology: intent?.topology - }); - appendHardwareTraceCompletion(traceRecorder, runnerResult); - return runnerResult; -} - -export function createCodeAgentHardwareInvokeShellRpc({ handleJsonRpcRequest, runtimeStore, env, dbProbe, gatewayRegistry } = {}) { - if (typeof handleJsonRpcRequest !== "function") return null; - return async ({ method = HARDWARE_INVOKE_SHELL_METHOD, params = {}, traceId, requestId, actorId, serviceId = CLOUD_API_SERVICE_ID } = {}) => handleJsonRpcRequest( - { - jsonrpc: "2.0", - id: requestId || `req_code_agent_hw_shell_${randomUUID()}`, - method, - params, - meta: { - traceId: traceId || `trc_code_agent_hw_shell_${randomUUID()}`, - actorId, - serviceId, - environment: "dev" - } - }, - { - adapter: "code-agent-internal", - runtimeStore, - env, - dbProbe, - gatewayRegistry - } - ); -} - -export function codeAgentHardwareRunnerDescriptor({ workspace, session = null, capabilityLevel = CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED } = {}) { - return { - kind: CODE_AGENT_HARDWARE_RUNNER_KIND, - provider: CODE_AGENT_HARDWARE_PROVIDER, - backend: CODE_AGENT_HARDWARE_BACKEND, - workspace: workspace ?? process.cwd(), - sandbox: CODE_AGENT_HARDWARE_SANDBOX, - session: CODE_AGENT_HARDWARE_SESSION_MODE, - sessionMode: CODE_AGENT_HARDWARE_SESSION_MODE, - sessionId: session?.sessionId ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE, - codexStdio: false, - longLivedSession: false, - durableSession: false, - writeCapable: true, - readOnly: false, - capabilityLevel, - toolPolicy: { - allowed: [`internal-json-rpc ${HARDWARE_INVOKE_SHELL_METHOD}`], - blocked: ["direct-gateway-link", "direct-box-link", "direct-patch-panel-link", "openai-text-fallback", "unregistered-shell"] - }, - runnerLimitations: [...HARDWARE_LIMITATION_FLAGS], - safety: { - secretsRead: false, - secretValuesPrinted: false, - kubeconfigRead: false, - cloudApiRouteOnly: true, - sourceServiceId: CLOUD_API_SERVICE_ID, - directGatewayCallsAllowed: false, - directBoxCallsAllowed: false, - directPatchPanelCallsAllowed: false, - openAiFallbackAllowed: false, - outputLimitBytes: OUTPUT_LIMIT - } - }; -} - -function hardwareRunnerResultFromRpc({ - request, - command, - rpcResponse, - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace, - session, - topology -}) { - const rpcError = rpcResponse?.error ?? null; - if (rpcError) { - const reason = rpcError.data?.reason ?? rpcError.message ?? "JSON-RPC dispatch failed"; - return hardwareBlockedRunnerResult({ - request, - command, - blocker: hardwareBlocker({ - code: String(reason).includes("unknown serviceId") ? "hardware_rpc_service_id_invalid" : "hardware_json_rpc_invalid", - layer: "json-rpc", - category: "json_rpc_meta", - summary: reason, - userMessage: String(reason).includes("unknown serviceId") - ? "cloud-api 内部 JSON-RPC serviceId 不在冻结列表中,本次未执行硬件能力。" - : "cloud-api 内部 JSON-RPC 请求未通过协议校验,本次未执行硬件能力。", - retryable: false, - traceId, - reason - }), - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace, - session, - topology, - rpcError - }); - } - - const result = rpcResponse?.result ?? {}; - const dispatch = result.dispatch ?? {}; - const dispatchStatus = dispatch.dispatchStatus ?? result.status ?? "unknown"; - const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null; - const stdout = safeOutput(dispatch.stdout ?? dispatch.output?.stdout ?? ""); - const stderr = safeOutput(dispatch.stderr ?? dispatch.output?.stderr ?? ""); - const shellExecuted = dispatch.shellExecuted === true; - const succeeded = shellExecuted && exitCode === 0 && ["succeeded", "completed"].includes(String(dispatchStatus).toLowerCase()); - const operationId = result.operationId ?? dispatch.operationId ?? null; - - if (!succeeded) { - return hardwareBlockedRunnerResult({ - request, - command, - blocker: hardwareFailureBlocker({ dispatchStatus, exitCode, result, dispatch, traceId }), - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace, - session, - topology, - result, - dispatch, - operationId, - dispatchStatus, - exitCode, - stdout, - stderr - }); - } - - const stdoutSummary = summarizeOutput(stdout); - const stderrSummary = summarizeOutput(stderr); - const toolCall = hardwareToolCall({ - request, - command, - status: "completed", - traceId, - requestId, - actorId, - operationId, - dispatchStatus, - exitCode, - stdout, - stderr, - stdoutSummary, - stderrSummary, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED, - startedAt, - finishedAt - }); - const runnerTrace = hardwareRunnerTrace({ - traceId, - startedAt, - finishedAt, - workspace, - session, - request, - operationId, - dispatchStatus, - exitCode, - status: "completed", - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED, - events: ["request:accepted", "runner:hardware-capability:selected", "tool:hardware.invoke.shell:started", "tool:hardware.invoke.shell:completed"] - }); - - return { - provider: CODE_AGENT_HARDWARE_PROVIDER, - model: CODE_AGENT_HARDWARE_MODEL, - backend: CODE_AGENT_HARDWARE_BACKEND, - content: hardwareReply({ request, operationId, dispatchStatus, exitCode, stdoutSummary, stderrSummary, blocked: false }), - workspace, - sandbox: CODE_AGENT_HARDWARE_SANDBOX, - session, - sessionMode: CODE_AGENT_HARDWARE_SESSION_MODE, - sessionReuse: session ? sessionReuseEvidence(session) : null, - implementationType: CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE, - runnerLimitations: [...HARDWARE_LIMITATION_FLAGS], - codexStdioFeasibility: skippedCodexStdioFeasibility({ workspace, reason: "hardware_capability_deterministic_route" }), - longLivedSessionGate: hardwareLongLivedGate(session), - toolCalls: [toolCall], - skills: hardwareSkills({ status: "used", blockers: [] }), - runner: codeAgentHardwareRunnerDescriptor({ workspace, session, capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED }), - runnerTrace, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED, - blockers: [], - route: HARDWARE_INVOKE_SHELL_METHOD, - toolName: HARDWARE_INVOKE_SHELL_METHOD, - operationId, - dispatchStatus, - exitCode, - stdoutSummary, - stderrSummary, - hardware: hardwareStructuredResult({ request, command, result, dispatch, operationId, dispatchStatus, exitCode, stdoutSummary, stderrSummary, topology }), - providerTrace: { - runnerKind: CODE_AGENT_HARDWARE_RUNNER_KIND, - codexStdio: false, - transport: "internal-json-rpc", - serviceId: CLOUD_API_SERVICE_ID, - method: HARDWARE_INVOKE_SHELL_METHOD, - traceId, - operationId, - dispatchStatus, - exitCode, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED, - fallbackUsed: false - } - }; -} - -function hardwareBlockedRunnerResult({ - request, - command, - blocker, - traceId, - requestId, - actorId, - startedAt, - finishedAt, - workspace, - session, - topology, - result = null, - dispatch = {}, - operationId = null, - dispatchStatus = null, - exitCode = null, - stdout = "", - stderr = "", - rpcError = null -}) { - const normalizedBlocker = { - ...blocker, - traceId, - route: HARDWARE_INVOKE_SHELL_METHOD, - toolName: HARDWARE_INVOKE_SHELL_METHOD - }; - const stdoutSummary = summarizeOutput(stdout); - const stderrSummary = summarizeOutput(stderr || normalizedBlocker.summary || normalizedBlocker.code); - const toolCall = hardwareToolCall({ - request, - command, - status: "blocked", - traceId, - requestId, - actorId, - operationId, - dispatchStatus, - exitCode, - stdout, - stderr, - stdoutSummary, - stderrSummary, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED, - blocker: normalizedBlocker, - startedAt, - finishedAt - }); - const runnerTrace = hardwareRunnerTrace({ - traceId, - startedAt, - finishedAt, - workspace, - session, - request, - operationId, - dispatchStatus, - exitCode, - status: "blocked", - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED, - blocker: normalizedBlocker, - events: ["request:accepted", "runner:hardware-capability:selected", "tool:hardware.invoke.shell:blocked", `blocker:${normalizedBlocker.code}`] - }); - - return { - provider: CODE_AGENT_HARDWARE_PROVIDER, - model: CODE_AGENT_HARDWARE_MODEL, - backend: CODE_AGENT_HARDWARE_BACKEND, - content: hardwareReply({ request, operationId, dispatchStatus, exitCode, stdoutSummary, stderrSummary, blocker: normalizedBlocker, blocked: true }), - workspace, - sandbox: CODE_AGENT_HARDWARE_SANDBOX, - session, - sessionMode: CODE_AGENT_HARDWARE_SESSION_MODE, - sessionReuse: session ? sessionReuseEvidence(session) : null, - implementationType: CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE, - runnerLimitations: [...HARDWARE_LIMITATION_FLAGS], - codexStdioFeasibility: skippedCodexStdioFeasibility({ workspace, reason: "hardware_capability_deterministic_route" }), - longLivedSessionGate: hardwareLongLivedGate(session), - toolCalls: [toolCall], - skills: hardwareSkills({ status: "blocked", blockers: [normalizedBlocker] }), - runner: codeAgentHardwareRunnerDescriptor({ workspace, session, capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED }), - runnerTrace, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED, - blockers: [normalizedBlocker], - blocker: normalizedBlocker, - route: HARDWARE_INVOKE_SHELL_METHOD, - toolName: HARDWARE_INVOKE_SHELL_METHOD, - operationId, - dispatchStatus, - exitCode, - stdoutSummary, - stderrSummary, - hardware: hardwareStructuredResult({ request, command, result, dispatch, operationId, dispatchStatus, exitCode, stdoutSummary, stderrSummary, topology, blocker: normalizedBlocker, rpcError }), - providerTrace: { - runnerKind: CODE_AGENT_HARDWARE_RUNNER_KIND, - codexStdio: false, - transport: "internal-json-rpc", - serviceId: CLOUD_API_SERVICE_ID, - method: HARDWARE_INVOKE_SHELL_METHOD, - traceId, - operationId, - dispatchStatus, - exitCode, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED, - fallbackUsed: false, - blocker: normalizedBlocker - } - }; -} - -function hardwareToolCall({ - request, - command, - status, - traceId, - requestId, - actorId, - operationId, - dispatchStatus, - exitCode, - stdout, - stderr, - stdoutSummary, - stderrSummary, - capabilityLevel, - blocker = null, - startedAt, - finishedAt -}) { - return { - id: `tool_${randomUUID()}`, - type: "cloud-api-rpc", - name: HARDWARE_INVOKE_SHELL_METHOD, - status, - method: HARDWARE_INVOKE_SHELL_METHOD, - route: HARDWARE_INVOKE_SHELL_METHOD, - serviceId: CLOUD_API_SERVICE_ID, - sourceServiceId: CLOUD_API_SERVICE_ID, - requestId, - actorId, - traceId, - projectId: request?.projectId ?? null, - gatewaySessionId: request?.gatewaySessionId ?? null, - resourceId: request?.resourceId ?? null, - capabilityId: request?.capabilityId ?? null, - capabilityName: "shell.exec", - command: command ? redactCommand(command) : null, - operationId, - dispatchStatus, - exitCode, - stdout: boundOutput(stdout).text, - stdoutSummary, - stderrSummary, - outputTruncated: boundOutput(stdout).truncated || boundOutput(stderr).truncated, - capabilityLevel, - blocker, - blockers: blocker ? [blocker] : [], - startedAt, - finishedAt, - safety: { - cloudApiRouteOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - openAiFallbackUsed: false, - serviceIdFrozen: true - } - }; -} - -function hardwareRunnerTrace({ - traceId, - startedAt, - finishedAt, - workspace, - session, - request, - operationId, - dispatchStatus, - exitCode, - status, - capabilityLevel, - events, - blocker = null -}) { - return { - traceId, - runnerKind: CODE_AGENT_HARDWARE_RUNNER_KIND, - workspace, - sandbox: CODE_AGENT_HARDWARE_SANDBOX, - sessionMode: CODE_AGENT_HARDWARE_SESSION_MODE, - sessionId: session?.sessionId ?? null, - sessionStatus: session?.status ?? null, - turn: session?.turn ?? null, - sessionReused: session?.reused ?? false, - implementationType: CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE, - limitations: [...HARDWARE_LIMITATION_FLAGS], - startedAt, - finishedAt, - events, - eventLabels: events, - method: HARDWARE_INVOKE_SHELL_METHOD, - serviceId: CLOUD_API_SERVICE_ID, - projectId: request?.projectId ?? null, - gatewaySessionId: request?.gatewaySessionId ?? null, - resourceId: request?.resourceId ?? null, - capabilityId: request?.capabilityId ?? null, - operationId, - dispatchStatus, - exitCode, - accepted: status === "completed", - status, - capabilityLevel, - blocker, - blockers: blocker ? [blocker] : [], - outputTruncated: false, - valuesPrinted: false, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - fallbackUsed: false, - note: "Controlled hardware capability route uses cloud-api internal JSON-RPC hardware.invoke.shell with frozen serviceId; Code Agent does not call gateway directly." - }; -} - -function hardwareStructuredResult({ request, command, result, dispatch = {}, operationId, dispatchStatus, exitCode, stdoutSummary, stderrSummary, topology, blocker = null, rpcError = null }) { - return { - type: blocker ? "hardware_capability_blocker" : "hardware_capability_result", - status: blocker ? "blocked" : "succeeded", - method: HARDWARE_INVOKE_SHELL_METHOD, - serviceId: CLOUD_API_SERVICE_ID, - projectId: request?.projectId ?? null, - gatewaySessionId: request?.gatewaySessionId ?? null, - resourceId: request?.resourceId ?? null, - capabilityId: request?.capabilityId ?? null, - capabilityName: "shell.exec", - capabilityLevel: blocker ? CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED : CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED, - operationId, - dispatchStatus, - exitCode, - stdoutSummary, - stderrSummary, - command: command ? redactCommand(command) : null, - topology: { - source: topology?.source ?? null, - fallbackUsed: topology?.fallbackUsed === true, - candidates: topology?.candidateCount ?? null - }, - dispatch, - result, - rpcError: rpcError ? { - code: rpcError.code, - message: rpcError.message, - reason: rpcError.data?.reason ?? null - } : null, - blocker, - path: { - summary: "Code Agent -> cloud-api internal JSON-RPC -> hardware.invoke.shell -> registered PC gateway", - segments: ["Code Agent", "cloud-api", "hardware.invoke.shell", "PC gateway"], - cloudApiOnly: true, - directGatewayCalls: false, - directBoxCalls: false, - directPatchPanelCalls: false, - openAiFallbackUsed: false - } - }; -} - -function hardwareReply({ request, operationId, dispatchStatus, exitCode, stdoutSummary, stderrSummary, blocker = null, blocked }) { - const lines = []; - if (blocked) { - lines.push(`PC gateway shell.exec 阻塞:${blocker.userMessage ?? blocker.summary ?? blocker.code}。`); - lines.push(`Blocker: ${blocker.code};layer=${blocker.layer ?? "unknown"};reason=${blocker.reason ?? blocker.summary ?? blocker.code}。`); - } else { - lines.push("PC gateway shell.exec 已通过 cloud-api 受控能力路由返回。"); - } - lines.push(`路径:Code Agent -> cloud-api internal JSON-RPC -> hardware.invoke.shell;未直连 gateway/box/patch-panel,未使用 OpenAI text fallback。`); - lines.push(`目标:projectId=${request?.projectId ?? "null"};gatewaySessionId=${request?.gatewaySessionId ?? "null"};resourceId=${request?.resourceId ?? "null"};capabilityId=${request?.capabilityId ?? "null"}。`); - lines.push(`结果:operationId=${operationId ?? "null"};dispatchStatus=${dispatchStatus ?? "null"};exitCode=${exitCode ?? "null"}。`); - if (stdoutSummary) lines.push(`stdout 摘要:${stdoutSummary}`); - if (stderrSummary) lines.push(`stderr 摘要:${stderrSummary}`); - return boundOutput(lines.join("\n")).text; -} - -function hardwareFailureBlocker({ dispatchStatus, exitCode, result, dispatch, traceId }) { - const status = String(dispatchStatus ?? "").toLowerCase(); - if (status === "not_connected") { - return hardwareBlocker({ - code: "hardware_gateway_offline", - layer: "gateway", - category: "gateway_offline", - summary: dispatch.message ?? "gateway is not connected", - userMessage: "目标 PC gateway 当前不在线或已过期,本次未执行。", - retryable: true, - traceId - }); - } - if (status === "timed_out") { - return hardwareBlocker({ - code: "hardware_dispatch_failed", - layer: "dispatch", - category: "timeout", - summary: dispatch.message ?? "gateway dispatch timed out", - userMessage: "cloud-api 已派发到 gateway,但等待回传超时。", - retryable: true, - traceId - }); - } - if (Number.isInteger(exitCode) && exitCode !== 0) { - return hardwareBlocker({ - code: "hardware_shell_failed", - layer: "gateway-shell", - category: "command_failed", - summary: `Windows cmd exited with ${exitCode}`, - userMessage: `Windows cmd 已执行但退出码为 ${exitCode}。`, - retryable: true, - traceId - }); - } - return hardwareBlocker({ - code: "hardware_dispatch_failed", - layer: "dispatch", - category: "dispatch_failed", - summary: dispatch.message ?? result?.status ?? "hardware.invoke.shell dispatch did not report a successful shell execution", - userMessage: "hardware.invoke.shell 未返回成功执行结果,本次按 dispatch failed 处理。", - retryable: true, - traceId - }); -} - -function hardwareRegistryBlocker({ request, gatewayRegistry, traceId }) { - if (!request || !gatewayRegistry || typeof gatewayRegistry.getSession !== "function") return null; - const session = gatewayRegistry.getSession(request.gatewaySessionId); - if (!session) { - return hardwareBlocker({ - code: "hardware_session_missing", - layer: "gateway-registry", - category: "session_missing", - summary: `gateway session ${request.gatewaySessionId} is not registered in the cloud-api gateway registry`, - userMessage: "目标 gateway session 未在 cloud-api gateway registry 中登记。", - retryable: true, - traceId - }); - } - if (typeof gatewayRegistry.isOnline === "function" && !gatewayRegistry.isOnline(request.gatewaySessionId)) { - return hardwareBlocker({ - code: "hardware_gateway_offline", - layer: "gateway-registry", - category: "gateway_offline", - summary: `gateway session ${request.gatewaySessionId} is registered but offline or stale`, - userMessage: "目标 gateway 已登记但当前离线或心跳过期。", - retryable: true, - traceId - }); - } - if (session.resourceId && session.resourceId !== request.resourceId) { - return hardwareBlocker({ - code: "hardware_resource_missing", - layer: "gateway-registry", - category: "resource_missing", - summary: `resource ${request.resourceId} is not registered under gateway session ${request.gatewaySessionId}`, - userMessage: "目标 resourceId 不属于当前 gateway session。", - retryable: false, - traceId - }); - } - if (Array.isArray(session.capabilities) && session.capabilities.length > 0) { - const found = session.capabilities.some((capability) => { - if (typeof capability === "string") return capability === request.capabilityId || capability === "shell.exec"; - return capability?.capabilityId === request.capabilityId || capability?.id === request.capabilityId || capability?.name === "shell.exec"; - }); - if (!found) { - return hardwareBlocker({ - code: "hardware_capability_missing", - layer: "gateway-registry", - category: "capability_missing", - summary: `capability ${request.capabilityId} is not registered on gateway session ${request.gatewaySessionId}`, - userMessage: "目标 shell.exec capability 未在该 gateway session 上登记。", - retryable: false, - traceId - }); - } - } - return null; -} - -function resolveHardwareTopology(explicit, { topology, env = process.env } = {}) { - const explicitComplete = ["projectId", "gatewaySessionId", "resourceId", "capabilityId"].every((field) => explicit[field]); - const candidates = hardwareTopologyCandidates({ topology, env }); - const matches = candidates.filter((candidate) => topologyMatchesExplicit(candidate, explicit)); - - if (explicitComplete && (candidates.length === 0 || matches.length <= 1)) { - const matched = matches[0] ?? {}; - return { - ok: true, - source: Object.keys(explicit).every((key) => explicit[key] === matched[key]) ? matched.topologySource ?? "configured-topology" : "explicit-fields", - fallbackUsed: false, - candidateCount: matches.length, - request: { - projectId: explicit.projectId, - gatewaySessionId: explicit.gatewaySessionId, - resourceId: explicit.resourceId, - capabilityId: explicit.capabilityId - } - }; - } - - if (matches.length === 1) { - const match = matches[0]; - return { - ok: true, - source: match.topologySource ?? "configured-topology", - fallbackUsed: !explicitComplete, - candidateCount: 1, - request: { - projectId: explicit.projectId ?? match.projectId, - gatewaySessionId: explicit.gatewaySessionId ?? match.gatewaySessionId, - resourceId: explicit.resourceId ?? match.resourceId, - capabilityId: explicit.capabilityId ?? match.capabilityId - } - }; - } - - const missing = Object.entries(explicit).filter(([, value]) => !value).map(([key]) => key); - if (matches.length > 1) { - return { - ok: false, - source: "ambiguous-topology", - fallbackUsed: false, - candidateCount: matches.length, - blocker: hardwareBlocker({ - code: "hardware_topology_ambiguous", - layer: "topology", - category: "topology_ambiguous", - summary: `PC gateway shell.exec topology is not unique; ${matches.length} candidates match the request.`, - userMessage: "PC gateway shell.exec 拓扑不唯一,请显式提供 projectId/gatewaySessionId/resourceId/capabilityId。", - retryable: false, - missing - }) - }; - } - - const explicitAny = Object.values(explicit).some(Boolean); - return { - ok: false, - source: "missing-topology", - fallbackUsed: false, - candidateCount: 0, - blocker: hardwareBlocker({ - code: explicitAny ? "hardware_capability_missing" : "hardware_topology_missing", - layer: "topology", - category: explicitAny ? "capability_missing" : "topology_missing", - summary: explicitAny - ? "No registered PC gateway shell.exec topology matches the explicit request." - : "PC gateway shell.exec request does not include enough identifiers and no unique topology is available.", - userMessage: explicitAny - ? "显式请求的 PC gateway shell.exec capability 未登记或不匹配。" - : "缺少 PC gateway shell.exec 拓扑字段,且当前没有唯一可解析拓扑。", - retryable: false, - missing - }) - }; -} - -function hardwareTopologyCandidates({ topology, env = process.env } = {}) { - if (Array.isArray(topology)) return topology.map(normalizeTopologyCandidate).filter(Boolean); - if (Array.isArray(topology?.candidates)) return topology.candidates.map(normalizeTopologyCandidate).filter(Boolean); - if (Array.isArray(topology?.capabilities)) return topology.capabilities.map(normalizeTopologyCandidate).filter(Boolean); - if (topology?.pcGatewayShell) return [normalizeTopologyCandidate(topology.pcGatewayShell)].filter(Boolean); - - const envCandidate = normalizeTopologyCandidate({ - projectId: env.HWLAB_CODE_AGENT_PC_GATEWAY_PROJECT_ID, - gatewaySessionId: env.HWLAB_CODE_AGENT_PC_GATEWAY_SESSION_ID, - resourceId: env.HWLAB_CODE_AGENT_PC_GATEWAY_RESOURCE_ID, - capabilityId: env.HWLAB_CODE_AGENT_PC_GATEWAY_CAPABILITY_ID, - topologySource: "env:HWLAB_CODE_AGENT_PC_GATEWAY_*" - }); - if (envCandidate) return [envCandidate]; - - return DEFAULT_DEV_MVP_PC_GATEWAY_TOPOLOGY.map(normalizeTopologyCandidate).filter(Boolean); -} - -function normalizeTopologyCandidate(candidate = {}) { - const normalized = { - projectId: cleanProtocolId(candidate.projectId, "prj"), - gatewaySessionId: cleanProtocolId(candidate.gatewaySessionId, "gws"), - resourceId: cleanProtocolId(candidate.resourceId, "res"), - capabilityId: cleanProtocolId(candidate.capabilityId, "cap"), - capabilityName: candidate.capabilityName ?? candidate.name ?? "shell.exec", - topologySource: candidate.topologySource ?? candidate.source ?? "configured-topology" - }; - if (!normalized.projectId || !normalized.gatewaySessionId || !normalized.resourceId || !normalized.capabilityId) return null; - if (normalized.capabilityName !== "shell.exec" && normalized.capabilityId !== "cap_windows_cmd_exec") return null; - return normalized; -} - -function topologyMatchesExplicit(candidate, explicit) { - return Object.entries(explicit).every(([field, value]) => !value || candidate[field] === value); -} - -function mentionsPcGatewayShellCapability(text, params = {}) { - if (params.capabilityId || params.gatewaySessionId || params.resourceId || params.input?.command || params.command) { - return true; - } - return /PC\s*gateway|Windows\s+cmd|cmd(?:\.exe)?\s*\/c|shell\.exec|hardware\.invoke\.shell|cap_windows_cmd_exec|res_windows_host|gws_DESKTOP/iu.test(text); -} - -function extractProtocolField(text, field, prefix) { - const aliases = field === "gatewaySessionId" - ? "(?:gatewaySessionId|gatewaySession|session)" - : field; - const match = String(text ?? "").match(new RegExp(`${aliases}\\s*[:=]\\s*([A-Za-z][A-Za-z0-9._:-]+)`, "iu")); - return cleanProtocolId(match?.[1], prefix); -} - -function extractWindowsCommand(text) { - const match = String(text ?? "").match(/\bcmd(?:\.exe)?\s*\/c\s+[\s\S]+$/iu); - if (!match) return null; - return trimCommandTail(match[0]); -} - -function trimCommandTail(command) { - return String(command ?? "") - .replace(/(?:。|;)\s*(?:返回|请返回|输出|并返回|$)[\s\S]*$/u, "") - .replace(/\s+(?:返回|请返回|输出|并返回)\s+(?:stdout|stderr|exitCode|退出码)[\s\S]*$/iu, "") - .replace(/[。;]\s*$/u, "") - .trim(); -} - -function normalizeWindowsCommand(command) { - if (typeof command !== "string") return null; - const trimmed = trimCommandTail(command); - return trimmed || null; -} - -function isAllowedWindowsCmd(command) { - const value = String(command ?? "").trim(); - if (!/^cmd(?:\.exe)?\s+\/c\s+\S/iu.test(value)) return false; - return !/\b(?:bash|sh|kubectl|powershell|pwsh)\b|sh\s+-c/iu.test(value); -} - -function acquireHardwareSession(sessionRegistry, { conversationId, sessionId, traceId, workspace, now } = {}) { - if (!sessionRegistry || typeof sessionRegistry.acquire !== "function") return { session: null, blocker: null }; - const acquired = sessionRegistry.acquire({ - conversationId, - sessionId, - workspace, - sandbox: CODE_AGENT_HARDWARE_SANDBOX, - runnerKind: CODE_AGENT_HARDWARE_RUNNER_KIND, - sessionMode: CODE_AGENT_HARDWARE_SESSION_MODE, - capabilityLevel: CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED, - implementationType: CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE, - traceId, - now - }); - if (acquired.ok) return { session: acquired.session, blocker: null }; - return { - session: acquired.session ?? null, - blocker: hardwareBlocker({ - code: acquired.code ?? "hardware_session_blocked", - layer: "session", - category: "session_blocked", - summary: acquired.message ?? "Code Agent hardware capability session could not be acquired.", - userMessage: "Code Agent 硬件能力 session 当前不可用。", - retryable: true, - traceId - }) - }; -} - -function releaseHardwareSession(sessionRegistry, session, { now, traceId, conversationId } = {}) { - if (!session || !sessionRegistry || typeof sessionRegistry.release !== "function") return session; - return sessionRegistry.release(session.sessionId, { - now, - traceId, - conversationId, - reused: session.reused, - status: "idle" - }) ?? session; -} - -function appendHardwareTraceCompletion(traceRecorder, runnerResult) { - traceRecorder?.append({ - type: "tool_call", - status: runnerResult.capabilityLevel === CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED ? "completed" : "blocked", - label: runnerResult.capabilityLevel === CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED - ? "tool:hardware.invoke.shell:completed" - : "tool:hardware.invoke.shell:blocked", - toolName: HARDWARE_INVOKE_SHELL_METHOD, - outputSummary: `dispatchStatus=${runnerResult.dispatchStatus ?? "null"}; exitCode=${runnerResult.exitCode ?? "null"}; operationId=${runnerResult.operationId ?? "null"}`, - terminal: false - }); -} - -function skippedCodexStdioFeasibility({ workspace, reason }) { - return { - checked: true, - skipped: true, - reason, - sourceIssue: "pikasTech/HWLAB#460", - status: "skipped", - ready: false, - canStartLongLivedCodexStdio: false, - commandProbe: { - ready: false, - skipped: true, - reason - }, - workspace: workspace ?? process.cwd(), - sandbox: CODE_AGENT_HARDWARE_SANDBOX, - blockers: [], - blockerCodes: [], - summary: "Registered hardware capability requests use deterministic cloud-api JSON-RPC route; Codex stdio and text fallback are not part of this control decision." - }; -} - -function hardwareLongLivedGate(session) { - return { - status: "skipped", - ready: false, - provider: CODE_AGENT_HARDWARE_PROVIDER, - runnerKind: CODE_AGENT_HARDWARE_RUNNER_KIND, - sessionId: session?.sessionId ?? null, - sessionMode: CODE_AGENT_HARDWARE_SESSION_MODE, - implementationType: CODE_AGENT_HARDWARE_IMPLEMENTATION_TYPE, - blockers: [], - reason: "hardware_capability_deterministic_route" - }; -} - -function hardwareSkills({ status, blockers }) { - return { - status, - items: [{ - name: HARDWARE_INVOKE_SHELL_METHOD, - summary: "Controlled cloud-api hardware capability route for registered PC gateway shell.exec.", - capabilityLevel: status === "used" ? CODE_AGENT_HARDWARE_CAPABILITY_EXECUTED : CODE_AGENT_HARDWARE_CAPABILITY_BLOCKED - }], - count: 1, - blockers - }; -} - -function hardwareBlocker({ code, layer, category, summary, userMessage, retryable, traceId = null, reason = null, missing = [] }) { - return { - code, - layer, - category, - retryable: Boolean(retryable), - summary: redactText(summary), - message: redactText(summary), - userMessage, - reason: reason ? redactText(reason) : undefined, - traceId, - route: HARDWARE_INVOKE_SHELL_METHOD, - toolName: HARDWARE_INVOKE_SHELL_METHOD, - missing - }; -} - -function sessionReuseEvidence(session) { - return { - conversationId: session.conversationId, - sessionId: session.sessionId, - mapped: true, - reused: session.reused, - turn: session.turn, - previousTurns: Math.max(0, session.turn - 1), - workspace: session.workspace, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - idleTimeoutMs: session.idleTimeoutMs, - expiresAt: session.expiresAt, - lastTraceId: session.lastTraceId, - status: session.status - }; -} - -function safeOutput(value) { - return redactText(value); -} - -function summarizeOutput(value) { - const text = String(value ?? "").trim().replace(/\s+/gu, " "); - return text.length > COMMAND_SUMMARY_LIMIT ? `${text.slice(0, COMMAND_SUMMARY_LIMIT)}...` : text; -} - -function boundOutput(value, maxLength = OUTPUT_LIMIT) { - const text = String(value ?? ""); - if (text.length <= maxLength) return { text, truncated: false }; - return { - text: `${text.slice(0, maxLength)}\n[output truncated at ${maxLength} bytes]`, - truncated: true - }; -} - -function redactCommand(value) { - return summarizeOutput(redactText(value)); -} - -function redactText(value) { - return String(value ?? "") - .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***") - .replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***") - .replace(/([A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*=)[^\s]+/giu, "$1***") - .replace(/([A-Za-z0-9_]*KEY[A-Za-z0-9_]*=)[^\s]+/giu, "$1***"); -} - -function cleanProtocolId(value, prefix) { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (!trimmed) return null; - if (/^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/u.test(trimmed)) return trimmed; - return `${prefix}_${trimmed.replace(/[^A-Za-z0-9._:-]/gu, "-").slice(0, 80)}`; -} - -function nowIso(now) { - return typeof now === "function" ? now() : new Date().toISOString(); -} diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 893a8513..5a41ea9e 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -16,11 +16,6 @@ import { classifyCodeAgentChatReadiness, classifyCodexRunnerCapability } from "../../scripts/src/code-agent-response-contract.mjs"; -import { - createCodeAgentHardwareInvokeShellRpc -} from "./code-agent-hardware-runner.mjs"; -import { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs"; -import { handleJsonRpcRequest } from "./json-rpc.mjs"; import { HWLAB_M3_IO_API_BASE_URL_ENV, HWLAB_M3_IO_CAPABILITY_LEVELS, @@ -1825,296 +1820,79 @@ test("Code Agent blocks direct gateway or patch-panel requests instead of using assert.match(direct.error.message, /Skill CLI -> HWLAB API \/v1\/m3\/io/u); }); -test("Code Agent routes explicit registered PC gateway shell.exec through controlled hardware runner", async () => { +test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardware shortcut", async () => { + const calls = []; let providerCalled = false; - let dispatchCall = null; - const registry = createCodeAgentSessionRegistry({ - idFactory: () => "ses_pc_gateway_shell" + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const registry = createCodeAgentSessionRegistry(); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_pc_gateway_stdio", + createRpcClient: async () => createFakeAppServerClient({ + calls, + responses: ["已通过受控 wrapper 执行 PC gateway 命令。"] + }) }); - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_pc_gateway_shell", - traceId: "trc_pc_gateway_shell", - projectId: "prj_mvp_topology", - gatewaySessionId: "gws_DESKTOP-1MHOD9I", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - message: "请通过已登记 PC gateway shell.exec 执行 Windows cmd:cmd /c echo HWLAB-PC-GW && hostname" - }, - { - now: () => "2026-05-23T00:08:00.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() - }, - sessionRegistry: registry, - callProvider: async () => { - providerCalled = true; - throw new Error("hardware route must not use text fallback"); - }, - codexStdioManager: { - describe() { - throw new Error("hardware route must not inspect Codex stdio fallback"); - }, - async probe() { - throw new Error("hardware route must not probe Codex stdio fallback"); - }, - async chat() { - throw new Error("hardware route must not call Codex stdio fallback"); - }, - cancel() {}, - reapIdle() {} - }, - invokeHardwareShellRpc: async (call) => { - dispatchCall = call; - assert.equal(call.serviceId, "hwlab-cloud-api"); - assert.equal(call.method, "hardware.invoke.shell"); - assert.equal(call.params.projectId, "prj_mvp_topology"); - assert.equal(call.params.gatewaySessionId, "gws_DESKTOP-1MHOD9I"); - assert.equal(call.params.resourceId, "res_windows_host"); - assert.equal(call.params.capabilityId, "cap_windows_cmd_exec"); - assert.equal(call.params.input.command, "cmd /c echo HWLAB-PC-GW && hostname"); - return { - jsonrpc: "2.0", - id: call.requestId, - result: { - accepted: true, - status: "succeeded", - operationId: "op_pc_gateway_shell", - gatewaySessionId: "gws_DESKTOP-1MHOD9I", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - dispatch: { - shellExecuted: true, - dispatchStatus: "succeeded", - exitCode: 0, - stdout: "HWLAB-PC-GW\r\nDESKTOP-1MHOD9I\r\n", - stderr: "" - } - } - }; - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-hardware-capability"); - assert.equal(payload.backend, "hwlab-cloud-api/hardware.invoke.shell"); - assert.equal(payload.capabilityLevel, "pc-gateway-shell-executed"); - assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell"); - assert.equal(payload.toolCalls[0].status, "completed"); - assert.equal(payload.toolCalls[0].dispatchStatus, "succeeded"); - assert.equal(payload.toolCalls[0].exitCode, 0); - assert.equal(payload.toolCalls[0].operationId, "op_pc_gateway_shell"); - assert.match(payload.toolCalls[0].stdoutSummary, /HWLAB-PC-GW/u); - assert.match(payload.toolCalls[0].stdoutSummary, /DESKTOP-1MHOD9I/u); - assert.equal(payload.operationId, "op_pc_gateway_shell"); - assert.equal(payload.dispatchStatus, "succeeded"); - assert.equal(payload.exitCode, 0); - assert.match(payload.stdoutSummary, /DESKTOP-1MHOD9I/u); - assert.equal(payload.runnerTrace.eventLabels.includes("tool:hardware.invoke.shell:completed"), true); - assert.equal(payload.runnerTrace.directGatewayCalls, false); - assert.equal(payload.providerTrace.serviceId, "hwlab-cloud-api"); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(providerCalled, false); - assert.equal(dispatchCall !== null, true); -}); - -test("Code Agent returns structured hardware blocker for ambiguous PC gateway topology without fallback", async () => { - let dispatchCalled = false; - const payload = await handleCodeAgentChat( - { - conversationId: "cnv_pc_gateway_ambiguous", - traceId: "trc_pc_gateway_ambiguous", - message: "请通过 PC gateway shell.exec 执行 Windows cmd:cmd /c echo SHOULD_NOT_RUN" - }, - { - now: () => "2026-05-23T00:08:30.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() - }, - hardwareCapabilityTopology: [ - { - projectId: "prj_mvp_topology", - gatewaySessionId: "gws_DESKTOP-1MHOD9I", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - capabilityName: "shell.exec" - }, - { - projectId: "prj_mvp_topology", - gatewaySessionId: "gws_DESKTOP-SECOND", - resourceId: "res_windows_host_2", - capabilityId: "cap_windows_cmd_exec", - capabilityName: "shell.exec" - } - ], - callProvider: async () => { - throw new Error("ambiguous hardware route must not use text fallback"); - }, - codexStdioManager: { - describe() { - throw new Error("ambiguous hardware route must not inspect Codex stdio fallback"); - }, - async probe() { - throw new Error("ambiguous hardware route must not probe Codex stdio fallback"); - }, - async chat() { - throw new Error("ambiguous hardware route must not call Codex stdio fallback"); - }, - cancel() {}, - reapIdle() {} - }, - invokeHardwareShellRpc: async () => { - dispatchCalled = true; - throw new Error("ambiguous hardware route must not dispatch"); - } - } - ); - - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-hardware-capability"); - assert.equal(payload.capabilityLevel, "blocked"); - assert.equal(payload.blocker.code, "hardware_topology_ambiguous"); - assert.equal(payload.blocker.route, "hardware.invoke.shell"); - assert.equal(payload.blocker.toolName, "hardware.invoke.shell"); - assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell"); - assert.equal(payload.toolCalls[0].status, "blocked"); - assert.equal(payload.toolCalls[0].blocker.code, "hardware_topology_ambiguous"); - assert.equal(payload.dispatchStatus, null); - assert.equal(payload.exitCode, null); - assert.match(payload.reply.content, /拓扑不唯一/u); - assert.equal(payload.providerTrace.fallbackUsed, false); - assert.equal(dispatchCalled, false); -}); - -test("Code Agent PC gateway route uses cloud-api internal JSON-RPC dispatch and gateway result", async () => { - const gatewayRegistry = createGatewayDemoRegistry({ - staleMs: 30000, - dispatchTimeoutMs: 1000 - }); - gatewayRegistry.updateSession({ - projectId: "prj_mvp_topology", - gatewayId: "gtw_DESKTOP-1MHOD9I", - gatewaySessionId: "gws_DESKTOP-1MHOD9I", - resourceId: "res_windows_host", - capabilities: [{ - projectId: "prj_mvp_topology", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - name: "shell.exec" - }] - }); - const registry = createCodeAgentSessionRegistry({ - idFactory: () => "ses_pc_gateway_internal_rpc" - }); - const chat = handleCodeAgentChat( - { - conversationId: "cnv_pc_gateway_internal_rpc", - traceId: "trc_pc_gateway_internal_rpc", - projectId: "prj_mvp_topology", - gatewaySessionId: "gws_DESKTOP-1MHOD9I", - resourceId: "res_windows_host", - capabilityId: "cap_windows_cmd_exec", - message: "请通过 PC gateway shell.exec 执行 Windows cmd:cmd /c echo HWLAB-INTERNAL-RPC && hostname" - }, - { - now: () => "2026-05-23T00:08:45.000Z", - env: { - PATH: process.env.PATH, - HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", - HWLAB_CODE_AGENT_WORKSPACE: process.cwd() - }, - sessionRegistry: registry, - gatewayRegistry, - invokeHardwareShellRpc: createCodeAgentHardwareInvokeShellRpc({ - handleJsonRpcRequest, - gatewayRegistry - }), - callProvider: async () => { - throw new Error("internal hardware route must not use text fallback"); - }, - codexStdioManager: { - describe() { - throw new Error("internal hardware route must not inspect Codex stdio fallback"); - }, - async probe() { - throw new Error("internal hardware route must not probe Codex stdio fallback"); - }, - async chat() { - throw new Error("internal hardware route must not call Codex stdio fallback"); - }, - cancel() {}, - reapIdle() {} - } - } - ); - - const outbound = await waitForGatewayRequest(gatewayRegistry, "gws_DESKTOP-1MHOD9I"); - assert.equal(outbound.method, "hardware.invoke.shell"); - assert.equal(outbound.meta.serviceId, "hwlab-cloud-api"); - assert.equal(outbound.params.input.command, "cmd /c echo HWLAB-INTERNAL-RPC && hostname"); - const completion = gatewayRegistry.complete({ - response: { - jsonrpc: "2.0", - id: outbound.id, - result: { - accepted: true, - status: "succeeded", - operationId: "op_pc_gateway_internal_rpc", + try { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_pc_gateway_stdio", + traceId: "trc_pc_gateway_stdio", + projectId: "prj_mvp_topology", gatewaySessionId: "gws_DESKTOP-1MHOD9I", - shellExecuted: true, - dispatchStatus: "succeeded", - exitCode: 0, - stdout: "HWLAB-INTERNAL-RPC\r\nDESKTOP-1MHOD9I\r\n", - stderr: "", - auditId: "aud_pc_gateway_internal_rpc", - evidenceId: "evd_pc_gateway_internal_rpc", - gateway: { - gatewayId: "gtw_DESKTOP-1MHOD9I", - gatewaySessionId: "gws_DESKTOP-1MHOD9I" + resourceId: "res_windows_host", + capabilityId: "cap_windows_cmd_exec", + message: "请通过 PC gateway shell.exec 执行 Windows cmd:cmd /c echo HWLAB-PC-GW && hostname" + }, + { + now: () => "2026-05-23T00:08:00.000Z", + 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_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses" + }, + sessionRegistry: registry, + codexStdioManager: manager, + callProvider: async () => { + providerCalled = true; + throw new Error("text fallback must not be used"); } } - } - }); - assert.equal(completion.accepted, true); + ); - const payload = await chat; - validateCodeAgentChatSchema(payload); - assert.equal(payload.status, "completed"); - assert.equal(payload.provider, "hwlab-hardware-capability"); - assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell"); - assert.equal(payload.toolCalls[0].status, "completed"); - assert.equal(payload.toolCalls[0].dispatchStatus, "succeeded"); - assert.equal(payload.toolCalls[0].exitCode, 0); - assert.match(payload.toolCalls[0].stdoutSummary, /HWLAB-INTERNAL-RPC/u); - assert.match(payload.toolCalls[0].stdoutSummary, /DESKTOP-1MHOD9I/u); - assert.equal(payload.operationId, "op_pc_gateway_internal_rpc"); - assert.equal(payload.hardware.path.cloudApiOnly, true); - assert.equal(payload.runnerTrace.eventLabels.includes("tool:hardware.invoke.shell:completed"), true); - assert.equal(payload.providerTrace.transport, "internal-json-rpc"); - assert.equal(payload.providerTrace.serviceId, "hwlab-cloud-api"); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "codex-stdio"); + assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio"); + assert.equal(payload.sandbox, "danger-full-access"); + assert.equal(payload.runner.kind, "codex-app-server-stdio-runner"); + assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session"); + assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "hardware.invoke.shell"), false); + assert.equal(providerCalled, false); + const turn = calls.find((call) => call.method === "turn/start"); + assert.ok(turn, "Codex stdio turn/start should be called"); + assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-shell\.mjs/u); + assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills/u); + assert.match(turn.args.prompt, /cmd \/c echo HWLAB-PC-GW && hostname/u); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } }); function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -async function waitForGatewayRequest(gatewayRegistry, gatewaySessionId) { - for (let attempt = 0; attempt < 40; attempt += 1) { - const outbound = gatewayRegistry.nextRequest(gatewaySessionId); - if (outbound) return outbound; - await delay(5); - } - throw new Error(`gateway request was not queued for ${gatewaySessionId}`); -} - function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } diff --git a/internal/cloud/codex-stdio-session.mjs b/internal/cloud/codex-stdio-session.mjs index 0e7093c8..aa36507e 100644 --- a/internal/cloud/codex-stdio-session.mjs +++ b/internal/cloud/codex-stdio-session.mjs @@ -94,6 +94,9 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [ "Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.", "Do not call gateway, box-simu, or patch-panel directly.", "Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.", + "For registered PC gateway Windows cmd or Keil requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms --command \"cmd /c ...\".", + "Use the Windows-side skill CLIs under C:\\Users\\liang\\.agents\\skills\\ from that cmd command when they exist; do not reimplement those tools in the prompt.", + "Do not satisfy PC gateway requests by string matching, assistant-only claims, internal shortcut dispatch, or direct gateway URLs; the Codex turn must run the wrapper and report command evidence.", "Do not claim M3, M4, or M5 acceptance unless the response includes live HWLAB evidence." ].join("\n"); @@ -3374,6 +3377,8 @@ function buildCodexUserPrompt(message, { conversationId, traceId, conversationFa return [ `conversationId: ${conversationId}`, `traceId: ${traceId}`, + "", + CODEX_STDIO_BOUNDARY_INSTRUCTIONS, codexConversationFactsPrompt(conversationFacts), codexSidecarSkillsPrompt(sidecar), "", @@ -3468,7 +3473,7 @@ function resolveCodexCommand(env = process.env, params = {}, options = {}) { function resolveCodexSandbox(env = process.env, options = {}) { const value = firstNonEmpty(options.sandbox, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, CODEX_STDIO_SANDBOX); - return ["read-only", "workspace-write"].includes(value) ? value : CODEX_STDIO_SANDBOX; + return ["read-only", "workspace-write", "danger-full-access"].includes(value) ? value : CODEX_STDIO_SANDBOX; } function resolveCodexHome(env = process.env) { diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 11bd021e..b6bbe945 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -26,7 +26,6 @@ import { describeCodeAgentAvailability, handleCodeAgentChat } from "./code-agent-chat.mjs"; -import { createCodeAgentHardwareInvokeShellRpc } from "./code-agent-hardware-runner.mjs"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs"; import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.mjs"; @@ -87,6 +86,9 @@ export function createCloudApiServer(options = {}) { const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry(); const traceStore = options.traceStore || defaultCodeAgentTraceStore; const codexStdioManager = options.codexStdioManager || createCodexStdioSessionManager({ traceStore }); + const codeAgentChatResults = options.codeAgentChatResults || createCodeAgentChatResultStore({ + maxResults: parsePositiveInteger(env.HWLAB_CODE_AGENT_CHAT_RESULT_CACHE_SIZE, 256) + }); const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env }); const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), @@ -94,7 +96,7 @@ export function createCloudApiServer(options = {}) { }); return createServer(async (request, response) => { try { - await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore, codexStdioManager }); + await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults }); } catch (error) { sendJson(response, 500, { error: { @@ -119,7 +121,7 @@ export function ensureCodeAgentRuntimeBase(env = process.env) { : appCodexCommand; env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace; env.HWLAB_CODE_AGENT_CODEX_WORKSPACE ||= workspace; - env.HWLAB_CODE_AGENT_CODEX_SANDBOX ||= "workspace-write"; + env.HWLAB_CODE_AGENT_CODEX_SANDBOX ||= "danger-full-access"; env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED ||= "1"; env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned"; env.CODEX_HOME ||= codexHome; @@ -334,6 +336,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/result/")) { + await handleCodeAgentChatResultHttp(request, response, url, options); + return; + } + if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") { await handleCodeAgentCancelHttp(request, response, options); return; @@ -1240,39 +1247,188 @@ async function handleCodeAgentChatHttp(request, response, options) { return; } - const payload = await handleCodeAgentChat( - { - ...params, - traceId: getHeader(request, "x-trace-id") || params.traceId - }, - { - callProvider: options.callCodeAgentProvider, - timeoutMs: options.codeAgentTimeoutMs, - hardTimeoutMs: options.codeAgentHardTimeoutMs, - env: options.env, - workspace: options.workspace, - skillsDirs: options.skillsDirs, - skillsDirsExact: options.skillsDirsExact, - skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, - sessionRegistry: options.sessionRegistry, - m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options), - codexStdioManager: options.codexStdioManager, - traceStore: options.traceStore, - gatewayRegistry: options.gatewayRegistry, - hardwareCapabilityTopology: options.hardwareCapabilityTopology, - invokeHardwareShellRpc: options.invokeHardwareShellRpc ?? createCodeAgentHardwareInvokeShellRpc({ - handleJsonRpcRequest, - runtimeStore: options.runtimeStore, - env: options.env, - dbProbe: options.dbProbe, - gatewayRegistry: options.gatewayRegistry - }) - } - ); + const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`; + const chatParams = { + ...params, + traceId + }; + + if (codeAgentChatShortConnectionRequested(request, params, options)) { + submitCodeAgentChatTurn({ + params: chatParams, + options, + traceId + }); + const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`; + sendJson(response, 202, { + accepted: true, + status: "running", + shortConnection: true, + controlSemantics: "submit-and-poll", + traceId, + conversationId: safeConversationId(params.conversationId) || null, + sessionId: safeSessionId(params.sessionId) || null, + traceUrl, + resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`, + streamUrl: `${traceUrl}/stream`, + cancelUrl: "/v1/agent/chat/cancel", + polling: { + resultIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_RESULT_POLL_INTERVAL_MS, 1000), + traceIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_POLL_INTERVAL_MS, 1000) + }, + runnerTrace: (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId) + }); + return; + } + + const payload = await runCodeAgentChat(chatParams, options); sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload); } +function runCodeAgentChat(params, options) { + return handleCodeAgentChat(params, codeAgentChatExecutionOptions(options)); +} + +function codeAgentChatExecutionOptions(options = {}) { + return { + callProvider: options.callCodeAgentProvider, + timeoutMs: options.codeAgentTimeoutMs, + hardTimeoutMs: options.codeAgentHardTimeoutMs, + env: options.env, + workspace: options.workspace, + skillsDirs: options.skillsDirs, + skillsDirsExact: options.skillsDirsExact, + skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, + sessionRegistry: options.sessionRegistry, + m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options), + codexStdioManager: options.codexStdioManager, + traceStore: options.traceStore, + gatewayRegistry: options.gatewayRegistry + }; +} + +function submitCodeAgentChatTurn({ params, options, traceId }) { + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + const results = options.codeAgentChatResults ?? createCodeAgentChatResultStore(); + results.set(traceId, { + accepted: true, + status: "running", + traceId, + conversationId: safeConversationId(params.conversationId) || null, + sessionId: safeSessionId(params.sessionId) || null, + updatedAt: new Date().toISOString() + }); + traceStore.ensure(traceId, { + runnerKind: "codex-app-server-stdio-runner", + workspace: options.workspace ?? options.env?.HWLAB_CODE_AGENT_CODEX_WORKSPACE ?? options.env?.HWLAB_CODE_AGENT_WORKSPACE ?? null, + sandbox: options.env?.HWLAB_CODE_AGENT_CODEX_SANDBOX ?? null, + sessionMode: "codex-app-server-stdio-long-lived", + implementationType: "repo-owned-codex-app-server-stdio-session" + }); + traceStore.append(traceId, { + type: "request", + status: "accepted", + label: "request:accepted-short-connection", + message: "Code Agent request accepted; client must poll trace/result with short HTTP requests.", + waitingFor: "codex-stdio" + }); + + const run = async () => { + try { + const payload = await runCodeAgentChat(params, options); + results.set(traceId, payload); + traceStore.append(traceId, { + type: "result", + status: payload.status === "completed" ? "completed" : "failed", + label: `result:${payload.status}`, + message: payload.status === "completed" + ? "Code Agent result is ready for short-connection polling." + : payload.error?.message ?? "Code Agent completed without a successful reply.", + sessionId: payload.sessionId ?? payload.session?.sessionId, + sessionStatus: payload.session?.status, + terminal: true + }); + } catch (error) { + const payload = { + status: "failed", + traceId, + conversationId: safeConversationId(params.conversationId) || null, + sessionId: safeSessionId(params.sessionId) || null, + error: { + code: "code_agent_background_failed", + layer: "api", + retryable: true, + message: error?.message ?? "Code Agent background turn failed", + userMessage: "Code Agent 后台任务失败;trace/result 轮询已保留错误。" + }, + updatedAt: new Date().toISOString() + }; + results.set(traceId, payload); + traceStore.append(traceId, { + type: "result", + status: "failed", + label: "result:failed", + errorCode: payload.error.code, + message: payload.error.message, + terminal: true + }); + } + }; + setImmediate(() => { + run(); + }); +} + +function codeAgentChatShortConnectionRequested(request, params, options = {}) { + const header = firstHeaderValue(request, "prefer"); + const explicit = firstHeaderValue(request, "x-hwlab-short-connection"); + const envValue = options.env?.HWLAB_CODE_AGENT_CHAT_SHORT_CONNECTION; + return /(?:^|,)\s*respond-async\s*(?:,|$)/iu.test(String(header ?? "")) || + truthyFlag(explicit) || + params?.shortConnection === true || + truthyFlag(envValue); +} + +async function handleCodeAgentChatResultHttp(request, response, url, options) { + const parts = url.pathname.split("/").filter(Boolean); + const traceId = decodeURIComponent(parts[4] ?? ""); + if (!safeTraceId(traceId)) { + sendJson(response, 400, { + error: { + code: "invalid_trace_id", + message: "traceId must start with trc_ and contain only safe identifier characters" + } + }); + return; + } + const results = options.codeAgentChatResults; + const result = results?.get(traceId) ?? null; + if (result && result.status !== "running") { + sendJson(response, 200, result); + return; + } + const traceStore = options.traceStore ?? defaultCodeAgentTraceStore; + const runnerTrace = traceStore.snapshot(traceId); + if (result || runnerTrace.status !== "missing") { + sendJson(response, 202, { + accepted: true, + status: "running", + shortConnection: true, + traceId, + runnerTrace, + waitingFor: runnerTrace.waitingFor ?? "codex-stdio" + }); + return; + } + sendJson(response, 404, { + error: { + code: "code_agent_result_not_found", + message: `No Code Agent result is registered for ${traceId}` + } + }); +} + async function handleCodeAgentCancelHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {}; @@ -1544,6 +1700,30 @@ function sendTraceSse(response, traceStore, traceId, options = {}) { }); } +function createCodeAgentChatResultStore({ maxResults = 256 } = {}) { + const limit = positiveInteger(maxResults, 256); + const results = new Map(); + function set(traceId, payload) { + const id = safeTraceId(traceId); + if (!id) return; + results.set(id, { + ...payload, + traceId: id, + cachedAt: new Date().toISOString() + }); + while (results.size > limit) { + const oldest = results.keys().next().value; + if (!oldest) break; + results.delete(oldest); + } + } + return { + set, + get: (traceId) => results.get(safeTraceId(traceId)) ?? null, + clear: () => results.clear() + }; +} + function createCodeAgentM3HwlabApiRequestJson(options = {}) { return async (targetUrl, request = {}) => { const url = new URL(targetUrl); @@ -1779,6 +1959,14 @@ function getHeader(request, name) { return value; } +function firstHeaderValue(request, name) { + return getHeader(request, name); +} + +function truthyFlag(value) { + return /^(?:1|true|yes|on|enabled)$/iu.test(String(value ?? "").trim()); +} + function safeTraceId(value) { const text = String(value ?? "").trim(); return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; @@ -1799,6 +1987,11 @@ function parsePositiveInteger(value, fallback) { return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + async function runtimeReadiness(runtimeStore) { if (typeof runtimeStore?.readiness === "function") { return runtimeStore.readiness(); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 3edbfb90..cd5397db 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -2201,6 +2201,87 @@ test("cloud api /v1/agent/chat runs Codex stdio pwd with session and workspace e } }); +test("cloud api /v1/agent/chat supports short submit and result polling", async () => { + const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-")); + const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-codex-home-")); + const server = createCloudApiServer({ + env: { + PATH: process.env.PATH, + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace, + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses" + }, + codexStdioManager: { + describe() { + return { + ...codexStdioReadyFixture({ workspace, codexHome }), + sandbox: "danger-full-access" + }; + }, + async probe() { + return { + ...codexStdioReadyFixture({ workspace, codexHome }), + sandbox: "danger-full-access" + }; + }, + async chat(params = {}) { + return { + ...codexStdioChatFixture({ workspace, codexHome, params }), + sandbox: "danger-full-access", + session: { + ...codexStdioChatFixture({ workspace, codexHome, params }).session, + sandbox: "danger-full-access" + } + }; + }, + cancel() {}, + reapIdle() {} + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const traceId = "trc_server-test-short-submit"; + const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-trace-id": traceId, + "prefer": "respond-async", + "x-hwlab-short-connection": "1" + }, + body: JSON.stringify({ + conversationId: "cnv_server-test-short-submit", + message: "用pwd列出你当前的工作目录" + }) + }); + assert.equal(submit.status, 202); + const accepted = await submit.json(); + assert.equal(accepted.accepted, true); + assert.equal(accepted.shortConnection, true); + assert.equal(accepted.traceId, traceId); + assert.equal(accepted.resultUrl, `/v1/agent/chat/result/${traceId}`); + + const payload = await pollAgentResult(port, traceId); + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.traceId, traceId); + assert.equal(payload.sandbox, "danger-full-access"); + assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"))); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await rm(workspace, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("cloud api health reports Codex stdio runner facts without readonly limitations", async () => { const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-stdio-")); const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-codex-home-")); @@ -4799,6 +4880,20 @@ async function postAgent(port, body) { return response.json(); } +async function pollAgentResult(port, traceId) { + let last = null; + for (let attempt = 0; attempt < 20; attempt += 1) { + const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${encodeURIComponent(traceId)}`); + last = { + status: response.status, + body: await response.json() + }; + if (response.status === 200) return last.body; + await delay(10); + } + throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`); +} + async function createFakeCodexCommand() { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-")); const packageRoot = path.join(root, "node_modules", "@openai", "codex"); diff --git a/package.json b/package.json index 70050adc..5605b88d 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/dev-runtime-hotfix-audit.mjs && 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 --check scripts/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --test scripts/artifact-runtime-readiness-guard.test.mjs scripts/src/dev-runtime-hotfix-audit.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/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.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-hardware-runner.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-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.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/refresh-artifact-catalog.test.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.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/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.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/cicd-jobs.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 cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.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/refresh-artifact-catalog.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/cloud-web-proxy.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/cloud-web-routes.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.mjs && node --check internal/dev-entrypoint/cloud-web-proxy.test.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-cloud-api/provision.mjs && node --check cmd/hwlab-cloud-api/migrate.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 tools/hwlab-gateway-shell.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/refresh-artifact-catalog.test.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/dev-runtime-base-image.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/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.mjs && node --check scripts/src/dev-runtime-hotfix-audit.test.mjs && node --test scripts/src/dev-runtime-hotfix-audit.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/cicd-jobs.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 cmd/hwlab-cloud-api/provision.mjs --check && node cmd/hwlab-cloud-api/migrate.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/refresh-artifact-catalog.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/cloud-web-proxy.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", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 3370721b..2fa7fb06 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -358,7 +358,7 @@ function ensureCodeAgentRuntimeBase() { const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home"; process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace; process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace; - process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "workspace-write"; + process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "danger-full-access"; process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1"; process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned"; process.env.HWLAB_CODE_AGENT_PROVIDER = process.env.HWLAB_CODE_AGENT_PROVIDER || "codex-stdio"; @@ -780,7 +780,7 @@ function dockerfile(baseImage, port) { "ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home", "ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab", "ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab", - "ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write", + "ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=danger-full-access", "ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1", "ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned", "ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex", diff --git a/scripts/src/code-agent-response-contract.mjs b/scripts/src/code-agent-response-contract.mjs index 424c58a6..4c8a0f4d 100644 --- a/scripts/src/code-agent-response-contract.mjs +++ b/scripts/src/code-agent-response-contract.mjs @@ -1,6 +1,7 @@ const trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner", "codex-stdio"]); const trustedRunnerProviders = new Set(["codex-stdio"]); const partialRunnerProviders = new Set(["codex-readonly-runner"]); +const trustedCodexSandboxes = new Set(["workspace-write", "danger-full-access"]); const readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]); const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const blockedCodeAgentUiLabels = new Set([ @@ -123,7 +124,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { if (runnerKind !== "codex-app-server-stdio-runner") missing.push("runner.kind=codex-app-server-stdio-runner"); if (capabilityLevel !== "long-lived-codex-stdio-session") missing.push("capabilityLevel=long-lived-codex-stdio-session"); if (!workspace) missing.push("workspace"); - if (sandbox !== "workspace-write") missing.push("sandbox=workspace-write"); + if (!trustedCodexSandboxes.has(sandbox)) missing.push("sandbox=workspace-write|danger-full-access"); if (sessionMode !== "codex-app-server-stdio-long-lived") missing.push("sessionMode=codex-app-server-stdio-long-lived"); if (implementationType !== "repo-owned-codex-app-server-stdio-session") missing.push("implementationType=repo-owned-codex-app-server-stdio-session"); if (!session) missing.push("session"); @@ -149,7 +150,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { level: "BLOCKED/controlled-readonly-long-lived-session", blocker: "controlled-readonly-not-long-lived", capabilityPass: false, - reason: "controlled-readonly-session-registry exposes a reusable read-only session, but it is not Codex stdio, workspace-write, or a full Code Agent capability pass." + reason: "controlled-readonly-session-registry exposes a reusable read-only session, but it is not Codex stdio, a write-capable sandbox, or a full Code Agent capability pass." }; } return { @@ -177,7 +178,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = { blocker: null, capabilityPass: true, longLivedSession: true, - reason: "completed repo-owned Codex stdio response includes reusable session, workspace-write sandbox, toolCalls, skills, runnerTrace, and a passed long-lived session gate" + reason: "completed repo-owned Codex stdio response includes reusable session, write-capable sandbox, toolCalls, skills, runnerTrace, and a passed long-lived session gate" }; } diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 00741291..fe2c6b43 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -398,7 +398,7 @@ function validateCloudApiCodeAgentSource(ctx, env) { expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API Codex stdio supervisor mode"); expectEqual(ctx, env.HWLAB_CODE_AGENT_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_WORKSPACE", "cloud API Code Agent workspace contract"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API Codex stdio workspace mount contract"); - expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API Codex stdio sandbox contract"); + expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API Codex stdio sandbox contract"); expectEqual(ctx, env.HWLAB_CODE_AGENT_CODEX_COMMAND, "/app/node_modules/.bin/codex", "$.services.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_COMMAND", "cloud API Codex command contract"); expectEqual(ctx, env.CODEX_HOME, "/codex-home", "$.services.hwlab-cloud-api.env.CODEX_HOME", "cloud API CODEX_HOME contract"); expectNoProxyIncludes(ctx, env.NO_PROXY, contract.noProxyRequired, "$.services.hwlab-cloud-api.env.NO_PROXY"); @@ -594,7 +594,7 @@ function validateCloudApiCodeAgentArtifacts(ctx, workloads) { expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR")?.value, "repo-owned", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR", "cloud API workload Codex stdio supervisor mode"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_WORKSPACE", "cloud API workload Code Agent workspace contract"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_WORKSPACE")?.value, "/workspace/hwlab", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE", "cloud API workload Codex stdio workspace mount contract"); - expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_SANDBOX")?.value, "workspace-write", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API workload Codex stdio sandbox contract"); + expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_SANDBOX")?.value, "danger-full-access", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_SANDBOX", "cloud API workload Codex stdio sandbox contract"); expectEqual(ctx, env.get("HWLAB_CODE_AGENT_CODEX_COMMAND")?.value, "/app/node_modules/.bin/codex", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CODE_AGENT_CODEX_COMMAND", "cloud API workload Codex command contract"); expectEqual(ctx, env.get("CODEX_HOME")?.value, "/codex-home", "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.CODEX_HOME", "cloud API workload CODEX_HOME contract"); expectNoProxyIncludes(ctx, env.get("NO_PROXY")?.value, contract.noProxyRequired, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.NO_PROXY"); diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index dec3e976..8586e162 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -220,7 +220,7 @@ assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1", "cloud-api assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned", "cloud-api Codex stdio supervisor mode"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_WORKSPACE, "/workspace/hwlab", "cloud-api Code Agent workspace contract"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab", "cloud-api Codex stdio workspace mount contract"); -assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "workspace-write", "cloud-api Codex stdio sandbox contract"); +assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_SANDBOX, "danger-full-access", "cloud-api Codex stdio sandbox contract"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_CODEX_COMMAND, "/app/node_modules/.bin/codex", "cloud-api Codex command contract"); assert.equal(cloudApi.env.CODEX_HOME, "/codex-home", "cloud-api CODEX_HOME contract"); assertNoProxyIncludes(cloudApi.env.NO_PROXY, DEV_CODE_AGENT_PROVIDER_CONTRACT.noProxyRequired, "cloud-api NO_PROXY contract"); @@ -292,7 +292,7 @@ function assertCodeAgentProviderWorkloadContract(env) { assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR?.value, "repo-owned", "cloud-api workload Codex stdio supervisor mode"); assert.equal(env.HWLAB_CODE_AGENT_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Code Agent workspace contract"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE?.value, "/workspace/hwlab", "cloud-api workload Codex stdio workspace mount contract"); - assert.equal(env.HWLAB_CODE_AGENT_CODEX_SANDBOX?.value, "workspace-write", "cloud-api workload Codex stdio sandbox contract"); + assert.equal(env.HWLAB_CODE_AGENT_CODEX_SANDBOX?.value, "danger-full-access", "cloud-api workload Codex stdio sandbox contract"); assert.equal(env.HWLAB_CODE_AGENT_CODEX_COMMAND?.value, "/app/node_modules/.bin/codex", "cloud-api workload Codex command contract"); assert.equal(env.CODEX_HOME?.value, "/codex-home", "cloud-api workload CODEX_HOME contract"); assertNoProxyIncludes(env.NO_PROXY?.value, contract.noProxyRequired, "cloud-api workload NO_PROXY contract"); diff --git a/tools/hwlab-gateway-shell.mjs b/tools/hwlab-gateway-shell.mjs new file mode 100644 index 00000000..197001a2 --- /dev/null +++ b/tools/hwlab-gateway-shell.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +import http from "node:http"; +import https from "node:https"; +import { randomUUID } from "node:crypto"; + +const DEFAULT_API_BASE_URL = "http://127.0.0.1:6667"; +const DEFAULT_PROJECT_ID = "prj_mvp_topology"; +const DEFAULT_GATEWAY_SESSION_ID = "gws_DESKTOP-1MHOD9I"; +const DEFAULT_RESOURCE_ID = "res_windows_host"; +const DEFAULT_CAPABILITY_ID = "cap_windows_cmd_exec"; + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.command) { + printHelp(); + process.exit(args.help ? 0 : 2); + } + + const apiBaseUrl = stripTrailingSlash(args.apiBaseUrl || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || DEFAULT_API_BASE_URL); + const traceId = args.traceId || `trc_gateway_shell_cli_${Date.now()}`; + const requestId = args.requestId || `req_gateway_shell_cli_${randomUUID()}`; + const timeoutMs = positiveInteger(args.timeoutMs, 30000); + const response = await requestJson(`${apiBaseUrl}/v1/rpc/hardware.invoke.shell`, { + method: "POST", + timeoutMs, + headers: { + "content-type": "application/json", + "x-trace-id": traceId, + "x-request-id": requestId, + "x-actor-id": args.actorId || "usr_code_agent", + "x-source-service-id": "hwlab-cloud-api" + }, + body: { + projectId: args.projectId || DEFAULT_PROJECT_ID, + gatewaySessionId: args.gatewaySessionId || DEFAULT_GATEWAY_SESSION_ID, + resourceId: args.resourceId || DEFAULT_RESOURCE_ID, + capabilityId: args.capabilityId || DEFAULT_CAPABILITY_ID, + input: { + command: args.command, + cwd: args.cwd || undefined, + timeoutMs + } + } + }); + + if (args.json) { + process.stdout.write(`${JSON.stringify(response.body, null, 2)}\n`); + } else { + process.stdout.write(formatResponse(response.body, { httpStatus: response.statusCode, traceId, requestId })); + } + + const result = response.body?.result ?? {}; + const dispatch = result.dispatch ?? {}; + const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : Number.isInteger(result.exitCode) ? result.exitCode : null; + const failed = response.statusCode < 200 || response.statusCode >= 300 || response.body?.error || result.status === "failed" || result.status === "rejected" || (exitCode !== null && exitCode !== 0); + process.exit(failed ? 1 : 0); +} + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + if (item === "--help" || item === "-h") { + args.help = true; + } else if (item === "--json") { + args.json = true; + } else if (item.startsWith("--") && item.includes("=")) { + const [key, ...rest] = item.slice(2).split("="); + args[toCamel(key)] = rest.join("="); + } else if (item.startsWith("--")) { + const key = toCamel(item.slice(2)); + args[key] = argv[index + 1] ?? ""; + index += 1; + } + } + return args; +} + +function toCamel(value) { + return String(value).replace(/-([a-z])/gu, (_, char) => char.toUpperCase()); +} + +function printHelp() { + process.stdout.write([ + "Usage: node tools/hwlab-gateway-shell.mjs --command \"cmd /c ...\" [--json]", + "", + "Options:", + " --api-base-url URL Cloud API/edge base URL, default http://127.0.0.1:6667", + " --command CMD Windows cmd command to execute through the registered PC gateway", + " --timeout-ms N Short dispatch timeout in milliseconds", + " --project-id ID Default prj_mvp_topology", + " --gateway-session-id ID Default gws_DESKTOP-1MHOD9I", + " --resource-id ID Default res_windows_host", + " --capability-id ID Default cap_windows_cmd_exec", + " --json Print full JSON-RPC response" + ].join("\n") + "\n"); +} + +function requestJson(url, { method, headers, body, timeoutMs }) { + return new Promise((resolve, reject) => { + const target = new URL(url); + const client = target.protocol === "https:" ? https : http; + const payload = JSON.stringify(body ?? {}); + const request = client.request(target, { + method, + headers: { + ...headers, + "content-length": Buffer.byteLength(payload) + }, + timeout: timeoutMs + }, (response) => { + let text = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + text += chunk; + }); + response.on("end", () => { + try { + resolve({ + statusCode: response.statusCode ?? 0, + body: text ? JSON.parse(text) : null + }); + } catch (error) { + reject(new Error(`Cloud API returned non-JSON response: ${error.message}`)); + } + }); + }); + request.on("timeout", () => { + request.destroy(new Error(`Gateway shell request timed out after ${timeoutMs}ms`)); + }); + request.on("error", reject); + request.end(payload); + }); +} + +function formatResponse(body, { httpStatus, traceId, requestId }) { + if (body?.error) { + return [ + `http=${httpStatus} trace=${traceId} request=${requestId}`, + `error=${body.error.message ?? body.error.code}`, + body.error.data?.reason ? `reason=${body.error.data.reason}` : null + ].filter(Boolean).join("\n") + "\n"; + } + const result = body?.result ?? {}; + const dispatch = result.dispatch ?? {}; + const lines = [ + `http=${httpStatus} trace=${traceId} request=${requestId}`, + `status=${result.status ?? "unknown"} operation=${result.operationId ?? "null"} dispatch=${dispatch.dispatchStatus ?? "unknown"} exit=${dispatch.exitCode ?? "null"}` + ]; + if (dispatch.stdout) lines.push(String(dispatch.stdout).trimEnd()); + if (dispatch.stderr) lines.push(String(dispatch.stderr).trimEnd()); + return `${lines.join("\n")}\n`; +} + +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(value ?? "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function stripTrailingSlash(value) { + return String(value).replace(/\/+$/u, ""); +} + +main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exit(1); +}); diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 5faa0e2f..48faee47 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -1331,11 +1331,12 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI method: "POST", headers: { "Content-Type": "application/json", - "X-Trace-Id": traceId + "X-Trace-Id": traceId, + "Prefer": "respond-async", + "X-HWLAB-Short-Connection": "1" }, - timeoutMs: CODE_AGENT_TIMEOUT_MS, + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), timeoutName: "Code Agent", - activityRef: () => state.currentRequest?.traceId === traceId ? state.currentRequest : null, body: JSON.stringify({ message, conversationId, @@ -1353,12 +1354,53 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI const error = agentErrorFromHttpResponse(response, traceId); throw error; } + if (response.status === 202 || response.data?.shortConnection === true) { + return waitForAgentMessageResult(traceId, response.data); + } return { ...response.data, traceId: response.data?.traceId || traceId }; } +async function waitForAgentMessageResult(traceId, accepted = {}) { + const startedAt = Date.now(); + const resultPath = accepted?.resultUrl || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + while (true) { + const activity = readActivityRef(() => state.currentRequest?.traceId === traceId ? state.currentRequest : null, startedAt); + const idleMs = Math.max(0, Date.now() - activity.lastActivityAt); + if (idleMs >= CODE_AGENT_TIMEOUT_MS) { + const error = new Error(`Code Agent 超过 ${CODE_AGENT_TIMEOUT_MS}ms 无新事件`); + error.code = "client_trace_idle_timeout"; + error.timeoutMs = CODE_AGENT_TIMEOUT_MS; + error.idleMs = idleMs; + error.lastActivityAt = activity.lastActivityIso; + error.waitingFor = activity.waitingFor; + error.traceId = traceId; + throw error; + } + const response = await fetchJson(resultPath, { + timeoutMs: Math.min(API_TIMEOUT_MS, 3000), + timeoutName: "Code Agent result" + }); + if (response.ok && response.status === 200 && response.data?.status && response.data.status !== "running") { + return { + ...response.data, + traceId: response.data.traceId || traceId + }; + } + if (!response.ok && response.status && response.status !== 404) { + const error = agentErrorFromHttpResponse(response, traceId); + throw error; + } + await wait(Math.min(TRACE_POLL_INTERVAL_MS, 1000)); + } +} + +function wait(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + async function loadLiveSurface() { const projectId = gateSummary.topology.projectId; const [healthLive, liveBuilds, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ @@ -3877,14 +3919,15 @@ function messageTracePanel(message) { const list = document.createElement("ol"); list.className = "message-trace-events"; const events = Array.isArray(trace?.events) ? trace.events : []; + const rows = traceDisplayRows(trace, events); if (events.length === 0) { const item = document.createElement("li"); item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`; list.append(item); } else { - const toolbar = messageTraceToolbar(message, trace, events, list); - list.dataset.traceMode = events.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? "tail" : "all"; - renderTraceEventList(list, tracePreviewEvents(events)); + const toolbar = messageTraceToolbar(message, trace, events, rows, list); + list.dataset.traceMode = rows.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? "tail" : "all"; + renderTraceEventList(list, tracePreviewRows(rows)); details.append(summary, toolbar, list); return details; } @@ -3892,19 +3935,19 @@ function messageTracePanel(message) { return details; } -function messageTraceToolbar(message, trace, events, list) { +function messageTraceToolbar(message, trace, events, rows, list) { const toolbar = document.createElement("div"); toolbar.className = "message-trace-toolbar"; - const count = textSpan(messageTraceCountText(events.length, false), "message-trace-count"); + const count = textSpan(messageTraceCountText(events.length, rows.length, false), "message-trace-count"); toolbar.append(count); - if (events.length > CODE_AGENT_TRACE_PREVIEW_LIMIT) { + if (rows.length > CODE_AGENT_TRACE_PREVIEW_LIMIT) { let expanded = false; const toggle = traceActionButton("展开全部", () => { expanded = !expanded; - renderTraceEventList(list, expanded ? events : tracePreviewEvents(events)); + renderTraceEventList(list, expanded ? rows : tracePreviewRows(rows)); list.classList.toggle("message-trace-events-full", expanded); list.dataset.traceMode = expanded ? "all" : "tail"; - count.textContent = messageTraceCountText(events.length, expanded); + count.textContent = messageTraceCountText(events.length, rows.length, expanded); toggle.textContent = expanded ? "收起为最近 14" : "展开全部"; }, "在面板内查看完整 trace 时间线"); toolbar.append(toggle); @@ -3914,28 +3957,160 @@ function messageTraceToolbar(message, trace, events, list) { return toolbar; } -function tracePreviewEvents(events) { - return events.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? events.slice(-CODE_AGENT_TRACE_PREVIEW_LIMIT) : events; +function tracePreviewRows(rows) { + return rows.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? rows.slice(-CODE_AGENT_TRACE_PREVIEW_LIMIT) : rows; } -function messageTraceCountText(total, expanded) { - if (total <= CODE_AGENT_TRACE_PREVIEW_LIMIT || expanded) return `显示全部 ${total} / 总计 ${total}`; - return `显示最近 ${CODE_AGENT_TRACE_PREVIEW_LIMIT} / 总计 ${total}`; +function messageTraceCountText(rawTotal, displayTotal, expanded) { + if (displayTotal <= CODE_AGENT_TRACE_PREVIEW_LIMIT || expanded) return `显示全部 ${displayTotal} / 原始 ${rawTotal}`; + return `显示最近 ${CODE_AGENT_TRACE_PREVIEW_LIMIT} / 可读 ${displayTotal} / 原始 ${rawTotal}`; } -function renderTraceEventList(list, events) { +function renderTraceEventList(list, rows) { list.replaceChildren(); - for (const event of events) { + for (const row of rows) { 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") - ); + item.className = `message-trace-row tone-border-${toneClass(row.tone)}`; + const header = document.createElement("div"); + header.className = "message-trace-line"; + header.textContent = row.header; + item.append(header); + if (row.body) { + const body = document.createElement("pre"); + body.className = "message-trace-body"; + body.textContent = row.body; + item.append(body); + } list.append(item); } } +function traceDisplayRows(trace, events) { + const rows = []; + for (const event of events) { + const row = traceDisplayRow(trace, event); + if (!row) continue; + rows.push(row); + } + return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean); +} + +function traceDisplayRow(trace, event, options = {}) { + if (!options.includeNoise && isNoisyTraceEvent(event)) return null; + const clock = traceClock(event.createdAt); + const total = formatTraceDuration(traceRelativeMs(trace, event)); + const status = traceStatusToken(event); + const label = readableTraceLabel(event); + const meta = [ + status, + event.errorCode ? `error=${event.errorCode}` : null, + typeof event.timeoutMs === "number" ? `timeout=${formatTraceDuration(event.timeoutMs)}` : null, + typeof event.idleMs === "number" ? `idle=${formatTraceDuration(event.idleMs)}` : null, + event.outputSummary ? `out=${compactTraceSize(event.outputSummary)}` : null + ].filter(Boolean).join(" "); + const body = traceDisplayBody(event); + return { + seq: event.seq ?? null, + tone: traceEventTone(event), + header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`, + body + }; +} + +function isNoisyTraceEvent(event) { + const label = String(event?.label ?? ""); + if (/assistant:chunk|token_count|outputDelta:chunk/iu.test(label)) return true; + if (event?.type === "assistant_message" && event?.status === "chunk") return true; + if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true; + if (event?.label === "request:accepted" && !event.message) return true; + return false; +} + +function readableTraceLabel(event) { + const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`); + if (label === "request:accepted-short-connection") return "submit short-connection"; + if (label === "request:accepted") return "request accepted"; + if (label.startsWith("tool:codex-app-server.thread/start+turn/start")) return "codex turn start"; + if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume"; + if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output"; + if (label.startsWith("turn:completed")) return "turn completed"; + if (label.startsWith("result:completed")) return "result ready"; + if (label.startsWith("result:failed")) return "result failed"; + if (label.startsWith("timeout:")) return "timeout"; + if (label.startsWith("cancel:")) return "cancel"; + return label + .replace(/^tool:/u, "") + .replace(/^assistant:/u, "assistant ") + .replace(/:completed:completed$/u, " completed") + .replace(/:completed$/u, " completed") + .replace(/:started$/u, " started"); +} + +function traceDisplayBody(event) { + const lines = [ + event.promptSummary, + event.outputSummary, + event.message, + event.chunk && !isNoisyTraceEvent(event) ? event.chunk : null, + event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null + ] + .map((value) => cleanTraceText(value)) + .filter(Boolean); + return lines.join("\n").slice(0, 1400); +} + +function traceStatusToken(event) { + const status = String(event?.status ?? ""); + if (status === "completed" || status === "succeeded") return "ok"; + if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event?.errorCode) return "fail"; + if (["started", "running", "accepted"].includes(status)) return "run"; + return status || "event"; +} + +function traceEventTone(event) { + const status = traceStatusToken(event); + if (status === "ok") return "ok"; + if (status === "fail") return "blocked"; + if (status === "run") return "warn"; + return "source"; +} + +function traceClock(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19); +} + +function traceRelativeMs(trace, event) { + if (typeof event?.elapsedMs === "number") return event.elapsedMs; + const start = Date.parse(trace?.startedAt ?? trace?.createdAt ?? ""); + const current = Date.parse(event?.createdAt ?? ""); + return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start); +} + +function formatTraceDuration(ms) { + const total = Math.max(0, Math.floor(Number(ms) || 0)); + const seconds = Math.floor(total / 1000); + const hh = String(Math.floor(seconds / 3600)).padStart(2, "0"); + const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0"); + const ss = String(seconds % 60).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +function compactTraceSize(value) { + const length = String(value ?? "").length; + if (length >= 1000) return `${(length / 1000).toFixed(length >= 10000 ? 0 : 1)}k`; + return String(length); +} + +function cleanTraceText(value) { + const text = String(value ?? "").trim(); + if (!text) return ""; + return text + .replace(/\s*\/\s*/gu, " / ") + .replace(/[ \t]{2,}/gu, " ") + .replace(/\n{3,}/gu, "\n\n"); +} + function traceActionButton(label, onClick, title) { const button = document.createElement("button"); button.type = "button"; @@ -4480,7 +4655,7 @@ function isCodexRunnerCapableEvidence(value) { value?.runner?.writeCapable === true && value?.runner?.durableSession === true && Boolean(value?.workspace || value?.runner?.workspace) && - (value?.sandbox || value?.runner?.sandbox) === "workspace-write" && + ["workspace-write", "danger-full-access"].includes(value?.sandbox || value?.runner?.sandbox) && Array.isArray(value?.toolCalls) && value.toolCalls.some((tool) => tool?.status === "completed") && value?.longLivedSessionGate?.status === "pass" && diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 818233fe..01cc26f6 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -780,15 +780,20 @@ 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, /"Prefer":\s*"respond-async"/); +assert.match(app, /function waitForAgentMessageResult/); +assert.match(app, /\/v1\/agent\/chat\/result\/\$\{encodeURIComponent\(traceId\)\}/); assert.match(app, /function messageTracePanel/); assert.match(app, /CODE_AGENT_TRACE_PREVIEW_LIMIT\s*=\s*14/); assert.match(app, /function messageTraceToolbar/); assert.match(app, /function messageTraceCountText/); -assert.match(app, /显示最近\s+\$\{CODE_AGENT_TRACE_PREVIEW_LIMIT\}\s+\/\s+总计\s+\$\{total\}/); +assert.match(app, /显示最近\s+\$\{CODE_AGENT_TRACE_PREVIEW_LIMIT\}\s+\/\s+可读\s+\$\{displayTotal\}\s+\/\s+原始\s+\$\{rawTotal\}/); assert.match(app, /展开全部/); assert.match(app, /复制 JSON/); assert.match(app, /下载 trace/); assert.match(app, /function renderTraceEventList/); +assert.match(app, /function traceDisplayRows/); +assert.match(app, /function traceDisplayRow/); assert.match(app, /function downloadTraceJson/); assert.match(styles, /\.message-trace-toolbar\s*\{/); assert.match(styles, /\.message-trace-count\s*\{/); @@ -899,7 +904,8 @@ assert.match(styles, /\.message-m3-row\s*{/); assert.match(styles, /\.copy-chip\s*{/); assert.match(styles, /\.message-trace\s*{/); assert.match(styles, /\.message-trace-events\s*{/); -assert.match(styles, /\.message-trace-label\s*{/); +assert.match(styles, /\.message-trace-line\s*{/); +assert.match(styles, /\.message-trace-body\s*{/); assert.doesNotMatch(styles, /\.message-attribution\s*{/); assert.doesNotMatch(styles, /\.message-evidence\s*{/); assert.doesNotMatch(styles, /\.code-agent-facts\s*{/); diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index 66906a13..fc27e947 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -1348,12 +1348,13 @@ h3 { .message-trace-events { display: grid; - gap: 5px; - margin: 6px 0 0; - padding-left: 18px; + gap: 8px; + margin: 8px 0 0; + padding-left: 0; color: var(--muted); font-family: var(--mono); font-size: 10px; + list-style: none; } .message-trace-events-full { @@ -1363,19 +1364,32 @@ h3 { padding-right: 4px; } -.message-trace-events li { +.message-trace-row { min-width: 0; + display: grid; + gap: 4px; + padding: 7px 8px; + border: 1px solid var(--line-soft); + background: rgba(11, 13, 12, 0.34); overflow-wrap: anywhere; } -.message-trace-seq, -.message-trace-label, -.message-trace-meta { - margin-right: 6px; +.message-trace-line { + color: var(--text); + line-height: 1.45; } -.message-trace-label { - color: var(--text); +.message-trace-body { + max-height: 180px; + margin: 0; + padding: 0; + overflow: auto; + color: var(--muted); + font-family: var(--mono); + font-size: 10px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; } .message-blocker {