Merge PR #469: 修复 Code Agent 短连接 trace 与 PC gateway 路由

Direct merge authorized by user for HWLAB development攻坚模式; D601/CI/CD validation follows.
This commit is contained in:
Lyon
2026-05-24 22:00:55 +08:00
committed by GitHub
20 changed files with 826 additions and 1581 deletions
+1
View File
@@ -17,6 +17,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- Runner 和指挥常用工作区是 `/workspace/hwlab`;进入仓库先检查分支与工作树状态,详见 [docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md)。
- D601 发布/构建工作区是 `/home/ubuntu/workspace/hwlab`;不要把 runner 临时目录当作发布真相,详见 [docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)。
- Master server 只做控制、Git、issue/PR 和短轮询;HWLAB `check`、Playwright、本地构建、发布预检和 CI/CD 验证必须放到 D601/runner/CI/CD,详见 [docs/reference/deployment-publish.md](docs/reference/deployment-publish.md)。
- 当前一律走 PR 工作流;不要直推 `main`、不要合并自己的 PR、不要改 PROD、不要重启服务。
- `DC-DCSN-P0-2026-003` / [pikasTech/HWLAB#78](https://github.com/pikasTech/HWLAB/issues/78) 是当前 M3 虚拟硬件可信闭环的上位约束;其他任务不得把 SOURCE、LOCAL、DRY-RUN、fixture 或前端状态误报为 M3 DEV-LIVE。
- 仓库禁止创建或提交 repo report 目录;验收、进展和结论只承载在 #7、专题 issue、每日简报或 PR/issue 评论。临时 JSON 只能写入 `/tmp``.state` 或 CI artifact,不能进入源码仓库。
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -143,7 +143,7 @@
},
{
"name": "HWLAB_CODE_AGENT_CODEX_SANDBOX",
"value": "workspace-write"
"value": "danger-full-access"
},
{
"name": "HWLAB_CODE_AGENT_CODEX_COMMAND",
+21
View File
@@ -38,6 +38,27 @@ Use `/home/ubuntu/workspace/hwlab` for D601 build and rollout operations.
Do not treat `/home/ubuntu/hwlab` or other runner worktrees as the publishing
truth.
## Master Server Check Boundary
Master server is only a control, coordination, Git, issue/PR, and short polling
entrypoint for HWLAB. It must not run HWLAB validation workloads locally:
`npm run check`, `npm run web:check`, Playwright/browser smokes, full test
suites, build/preflight jobs, image publishing, or DEV CD checks belong on
D601, Code Queue runners, or the formal CI/CD job plane.
The master-side HWLAB clone may be used for lightweight source reading,
`git status`, `git diff`, patch preparation, commits, PR creation, and polling
D601/CI/CD status. If a validation command would consume significant CPU,
memory, browser resources, Docker, k3s, registry, or long-lived process time,
skip it on master and run the equivalent command from a clean D601 worktree or
submit it through `hwlab-cli cicd submit`.
Master wrappers may start short-lived D601 commands and poll their status, but
the validation work itself must execute on D601 or CI/CD. A master-local
timeout, browser hang, or resource shortage is not DEV-LIVE evidence and must
not be turned into a product blocker; stop the local check and move the
verification to D601/CI/CD.
## Regularization Stages
| Stage | Current HWLAB state | Required next behavior |
+6 -35
View File
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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 cmdcmd /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 cmdcmd /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 cmdcmd /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 cmdcmd /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, "\\$&");
}
+6 -1
View File
@@ -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 <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) {
+225 -32
View File
@@ -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();
+95
View File
@@ -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");
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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",
+4 -3
View File
@@ -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"
};
}
+2 -2
View File
@@ -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");
+2 -2
View File
@@ -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");
+167
View File
@@ -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);
});
+199 -24
View File
@@ -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" &&
+8 -2
View File
@@ -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*{/);
+24 -10
View File
@@ -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 {