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)。 - 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)。 - 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、不要重启服务。 - 当前一律走 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。 - `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,不能进入源码仓库。 - 仓库禁止创建或提交 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_CODEX_STDIO_SUPERVISOR": "repo-owned",
"HWLAB_CODE_AGENT_WORKSPACE": "/workspace/hwlab", "HWLAB_CODE_AGENT_WORKSPACE": "/workspace/hwlab",
"HWLAB_CODE_AGENT_CODEX_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", "HWLAB_CODE_AGENT_CODEX_COMMAND": "/app/node_modules/.bin/codex",
"CODEX_HOME": "/codex-home", "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", "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": { "HWLAB_CODE_AGENT_CODEX_SANDBOX": {
"type": "string", "type": "string",
"const": "workspace-write" "const": "danger-full-access"
}, },
"HWLAB_CODE_AGENT_CODEX_COMMAND": { "HWLAB_CODE_AGENT_CODEX_COMMAND": {
"type": "string", "type": "string",
+1 -1
View File
@@ -143,7 +143,7 @@
}, },
{ {
"name": "HWLAB_CODE_AGENT_CODEX_SANDBOX", "name": "HWLAB_CODE_AGENT_CODEX_SANDBOX",
"value": "workspace-write" "value": "danger-full-access"
}, },
{ {
"name": "HWLAB_CODE_AGENT_CODEX_COMMAND", "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 Do not treat `/home/ubuntu/hwlab` or other runner worktrees as the publishing
truth. 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 ## Regularization Stages
| Stage | Current HWLAB state | Required next behavior | | Stage | Current HWLAB state | Required next behavior |
+6 -35
View File
@@ -31,13 +31,6 @@ import {
codeAgentSessionLifecycleSummary, codeAgentSessionLifecycleSummary,
decorateCodeAgentSession decorateCodeAgentSession
} from "./code-agent-session-lifecycle.mjs"; } 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 { import {
HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION,
HWLAB_M3_IO_CAPABILITY_LEVELS, HWLAB_M3_IO_CAPABILITY_LEVELS,
@@ -81,6 +74,9 @@ const M3_IO_TOPOLOGY = Object.freeze({
targetResourceId: "res_boxsimu_2", targetResourceId: "res_boxsimu_2",
targetPort: "DI1" 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 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 DIRECT_HARDWARE_TARGET_PATTERN = /https?:\/\/[^\s"']*(?:gateway(?:-simu)?|box(?:-simu)?|patch-panel|hwlab-patch-panel)[^\s"']*|\/invoke\b|\/sync\/tick\b|:7101\b|:7201\b|:7301\b/iu;
const READONLY_TOOL_OUTPUT_LIMIT = 4000; const READONLY_TOOL_OUTPUT_LIMIT = 4000;
@@ -151,8 +147,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
const message = normalizeUserMessage(params.message); const message = normalizeUserMessage(params.message);
const runnerIntent = detectReadOnlyRunnerIntent(message, { const runnerIntent = detectReadOnlyRunnerIntent(message, {
params, params,
env: options.env ?? process.env, env: options.env ?? process.env
hardwareTopology: options.hardwareCapabilityTopology
}); });
traceRecorder.append({ traceRecorder.append({
type: "request", type: "request",
@@ -161,9 +156,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) {
promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message, promptSummary: message.length > 160 ? `${message.slice(0, 157)}...` : message,
waitingFor: runnerIntent.kind === "m3_io" waitingFor: runnerIntent.kind === "m3_io"
? HWLAB_M3_IO_API_ROUTE ? HWLAB_M3_IO_API_ROUTE
: runnerIntent.kind === "hardware_shell" : "codex-stdio-readiness"
? HARDWARE_INVOKE_SHELL_METHOD
: "codex-stdio-readiness"
}); });
const securityIntent = runnerIntent.kind === "security" ? runnerIntent : null; 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 }); 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") { if (runnerIntent.kind === "session_context") {
traceRecorder.append({ traceRecorder.append({
type: "session", type: "session",
@@ -2225,12 +2202,6 @@ function detectReadOnlyRunnerIntent(message, options = {}) {
reason: "Code Agent 安全边界不读取或输出 secret、token、kubeconfig、密码、私钥或环境变量原文。" 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); const m3IoIntent = detectM3IoIntent(text);
if (m3IoIntent) { if (m3IoIntent) {
return m3IoIntent; return m3IoIntent;
@@ -2764,7 +2735,7 @@ function structuredCompletionBlocker(result, context = {}) {
const hardwareTool = Array.isArray(result.toolCalls) const hardwareTool = Array.isArray(result.toolCalls)
? result.toolCalls.find((toolCall) => toolCall?.name === HARDWARE_INVOKE_SHELL_METHOD) ? result.toolCalls.find((toolCall) => toolCall?.name === HARDWARE_INVOKE_SHELL_METHOD)
: null; : 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; const primary = result.hardware?.blocker ?? hardwareTool?.blocker ?? result.blocker ?? result.blockers?.[0] ?? result.runnerTrace?.blocker ?? null;
return structuredBlocker({ return structuredBlocker({
code: primary?.code ?? "hardware_capability_blocked", code: primary?.code ?? "hardware_capability_blocked",
File diff suppressed because it is too large Load Diff
@@ -16,11 +16,6 @@ import {
classifyCodeAgentChatReadiness, classifyCodeAgentChatReadiness,
classifyCodexRunnerCapability classifyCodexRunnerCapability
} from "../../scripts/src/code-agent-response-contract.mjs"; } 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 { import {
HWLAB_M3_IO_API_BASE_URL_ENV, HWLAB_M3_IO_API_BASE_URL_ENV,
HWLAB_M3_IO_CAPABILITY_LEVELS, 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); 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 providerCalled = false;
let dispatchCall = null; const fakeCodex = await createFakeCodexCommand();
const registry = createCodeAgentSessionRegistry({ const codexHome = await prepareFakeCodexHome();
idFactory: () => "ses_pc_gateway_shell" const registry = createCodeAgentSessionRegistry();
const manager = createCodexStdioSessionManager({
idFactory: () => "ses_pc_gateway_stdio",
createRpcClient: async () => createFakeAppServerClient({
calls,
responses: ["已通过受控 wrapper 执行 PC gateway 命令。"]
})
}); });
const payload = await handleCodeAgentChat( try {
{ const payload = await handleCodeAgentChat(
conversationId: "cnv_pc_gateway_shell", {
traceId: "trc_pc_gateway_shell", conversationId: "cnv_pc_gateway_stdio",
projectId: "prj_mvp_topology", traceId: "trc_pc_gateway_stdio",
gatewaySessionId: "gws_DESKTOP-1MHOD9I", projectId: "prj_mvp_topology",
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",
gatewaySessionId: "gws_DESKTOP-1MHOD9I", gatewaySessionId: "gws_DESKTOP-1MHOD9I",
shellExecuted: true, resourceId: "res_windows_host",
dispatchStatus: "succeeded", capabilityId: "cap_windows_cmd_exec",
exitCode: 0, message: "请通过 PC gateway shell.exec 执行 Windows cmdcmd /c echo HWLAB-PC-GW && hostname"
stdout: "HWLAB-INTERNAL-RPC\r\nDESKTOP-1MHOD9I\r\n", },
stderr: "", {
auditId: "aud_pc_gateway_internal_rpc", now: () => "2026-05-23T00:08:00.000Z",
evidenceId: "evd_pc_gateway_internal_rpc", env: {
gateway: { PATH: process.env.PATH,
gatewayId: "gtw_DESKTOP-1MHOD9I", OPENAI_API_KEY: "test-openai-key-material",
gatewaySessionId: "gws_DESKTOP-1MHOD9I" 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);
validateCodeAgentChatSchema(payload); assert.equal(payload.status, "completed");
assert.equal(payload.status, "completed"); assert.equal(payload.provider, "codex-stdio");
assert.equal(payload.provider, "hwlab-hardware-capability"); assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell"); assert.equal(payload.sandbox, "danger-full-access");
assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.runner.kind, "codex-app-server-stdio-runner");
assert.equal(payload.toolCalls[0].dispatchStatus, "succeeded"); assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session");
assert.equal(payload.toolCalls[0].exitCode, 0); assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "hardware.invoke.shell"), false);
assert.match(payload.toolCalls[0].stdoutSummary, /HWLAB-INTERNAL-RPC/u); assert.equal(providerCalled, false);
assert.match(payload.toolCalls[0].stdoutSummary, /DESKTOP-1MHOD9I/u); const turn = calls.find((call) => call.method === "turn/start");
assert.equal(payload.operationId, "op_pc_gateway_internal_rpc"); assert.ok(turn, "Codex stdio turn/start should be called");
assert.equal(payload.hardware.path.cloudApiOnly, true); assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-shell\.mjs/u);
assert.equal(payload.runnerTrace.eventLabels.includes("tool:hardware.invoke.shell:completed"), true); assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills/u);
assert.equal(payload.providerTrace.transport, "internal-json-rpc"); assert.match(turn.args.prompt, /cmd \/c echo HWLAB-PC-GW && hostname/u);
assert.equal(payload.providerTrace.serviceId, "hwlab-cloud-api"); } finally {
await rm(fakeCodex.root, { recursive: true, force: true });
await rm(codexHome, { recursive: true, force: true });
}
}); });
function delay(ms) { function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, 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) { function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); 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 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.", "Do not call gateway, box-simu, or patch-panel directly.",
"Hardware control requests must go through cloud-api/HWLAB API/skill CLI controlled paths.", "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." "Do not claim M3, M4, or M5 acceptance unless the response includes live HWLAB evidence."
].join("\n"); ].join("\n");
@@ -3374,6 +3377,8 @@ function buildCodexUserPrompt(message, { conversationId, traceId, conversationFa
return [ return [
`conversationId: ${conversationId}`, `conversationId: ${conversationId}`,
`traceId: ${traceId}`, `traceId: ${traceId}`,
"",
CODEX_STDIO_BOUNDARY_INSTRUCTIONS,
codexConversationFactsPrompt(conversationFacts), codexConversationFactsPrompt(conversationFacts),
codexSidecarSkillsPrompt(sidecar), codexSidecarSkillsPrompt(sidecar),
"", "",
@@ -3468,7 +3473,7 @@ function resolveCodexCommand(env = process.env, params = {}, options = {}) {
function resolveCodexSandbox(env = process.env, options = {}) { function resolveCodexSandbox(env = process.env, options = {}) {
const value = firstNonEmpty(options.sandbox, env.HWLAB_CODE_AGENT_CODEX_SANDBOX, CODEX_STDIO_SANDBOX); 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) { function resolveCodexHome(env = process.env) {
+225 -32
View File
@@ -26,7 +26,6 @@ import {
describeCodeAgentAvailability, describeCodeAgentAvailability,
handleCodeAgentChat handleCodeAgentChat
} from "./code-agent-chat.mjs"; } from "./code-agent-chat.mjs";
import { createCodeAgentHardwareInvokeShellRpc } from "./code-agent-hardware-runner.mjs";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs"; import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.mjs";
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs"; import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.mjs"; import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.mjs";
@@ -87,6 +86,9 @@ export function createCloudApiServer(options = {}) {
const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry(); const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry();
const traceStore = options.traceStore || defaultCodeAgentTraceStore; const traceStore = options.traceStore || defaultCodeAgentTraceStore;
const codexStdioManager = options.codexStdioManager || createCodexStdioSessionManager({ traceStore }); 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 runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({ const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
@@ -94,7 +96,7 @@ export function createCloudApiServer(options = {}) {
}); });
return createServer(async (request, response) => { return createServer(async (request, response) => {
try { 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) { } catch (error) {
sendJson(response, 500, { sendJson(response, 500, {
error: { error: {
@@ -119,7 +121,7 @@ export function ensureCodeAgentRuntimeBase(env = process.env) {
: appCodexCommand; : appCodexCommand;
env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace; env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace;
env.HWLAB_CODE_AGENT_CODEX_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_ENABLED ||= "1";
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned"; env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned";
env.CODEX_HOME ||= codexHome; env.CODEX_HOME ||= codexHome;
@@ -334,6 +336,11 @@ async function handleRestAdapter(request, response, url, options) {
return; 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") { if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") {
await handleCodeAgentCancelHttp(request, response, options); await handleCodeAgentCancelHttp(request, response, options);
return; return;
@@ -1240,39 +1247,188 @@ async function handleCodeAgentChatHttp(request, response, options) {
return; return;
} }
const payload = await handleCodeAgentChat( const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`;
{ const chatParams = {
...params, ...params,
traceId: getHeader(request, "x-trace-id") || params.traceId traceId
}, };
{
callProvider: options.callCodeAgentProvider, if (codeAgentChatShortConnectionRequested(request, params, options)) {
timeoutMs: options.codeAgentTimeoutMs, submitCodeAgentChatTurn({
hardTimeoutMs: options.codeAgentHardTimeoutMs, params: chatParams,
env: options.env, options,
workspace: options.workspace, traceId
skillsDirs: options.skillsDirs, });
skillsDirsExact: options.skillsDirsExact, const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs, sendJson(response, 202, {
sessionRegistry: options.sessionRegistry, accepted: true,
m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options), status: "running",
codexStdioManager: options.codexStdioManager, shortConnection: true,
traceStore: options.traceStore, controlSemantics: "submit-and-poll",
gatewayRegistry: options.gatewayRegistry, traceId,
hardwareCapabilityTopology: options.hardwareCapabilityTopology, conversationId: safeConversationId(params.conversationId) || null,
invokeHardwareShellRpc: options.invokeHardwareShellRpc ?? createCodeAgentHardwareInvokeShellRpc({ sessionId: safeSessionId(params.sessionId) || null,
handleJsonRpcRequest, traceUrl,
runtimeStore: options.runtimeStore, resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
env: options.env, streamUrl: `${traceUrl}/stream`,
dbProbe: options.dbProbe, cancelUrl: "/v1/agent/chat/cancel",
gatewayRegistry: options.gatewayRegistry 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); 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) { async function handleCodeAgentCancelHttp(request, response, options) {
const body = await readBody(request, options.bodyLimitBytes); const body = await readBody(request, options.bodyLimitBytes);
let params = {}; 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 = {}) { function createCodeAgentM3HwlabApiRequestJson(options = {}) {
return async (targetUrl, request = {}) => { return async (targetUrl, request = {}) => {
const url = new URL(targetUrl); const url = new URL(targetUrl);
@@ -1779,6 +1959,14 @@ function getHeader(request, name) {
return value; 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) { function safeTraceId(value) {
const text = String(value ?? "").trim(); const text = String(value ?? "").trim();
return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null; 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; 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) { async function runtimeReadiness(runtimeStore) {
if (typeof runtimeStore?.readiness === "function") { if (typeof runtimeStore?.readiness === "function") {
return runtimeStore.readiness(); 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 () => { 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 workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-stdio-"));
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-health-codex-home-")); 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(); 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() { async function createFakeCodexCommand() {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-")); const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
const packageRoot = path.join(root, "node_modules", "@openai", "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"; 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_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_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_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_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"; 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_CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab", "ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_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_ENABLED=1",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned", "ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned",
"ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex", "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 trustedLiveProviders = new Set(["openai-responses", "codex-cli", "codex-readonly-runner", "codex-stdio"]);
const trustedRunnerProviders = new Set(["codex-stdio"]); const trustedRunnerProviders = new Set(["codex-stdio"]);
const partialRunnerProviders = new Set(["codex-readonly-runner"]); 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 readonlySessionCapabilityLevels = new Set(["read-only-tools", "read-only-session-tools"]);
const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu; const untrustedProviderPattern = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
const blockedCodeAgentUiLabels = new Set([ 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 (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 (capabilityLevel !== "long-lived-codex-stdio-session") missing.push("capabilityLevel=long-lived-codex-stdio-session");
if (!workspace) missing.push("workspace"); 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 (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 (implementationType !== "repo-owned-codex-app-server-stdio-session") missing.push("implementationType=repo-owned-codex-app-server-stdio-session");
if (!session) missing.push("session"); if (!session) missing.push("session");
@@ -149,7 +150,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
level: "BLOCKED/controlled-readonly-long-lived-session", level: "BLOCKED/controlled-readonly-long-lived-session",
blocker: "controlled-readonly-not-long-lived", blocker: "controlled-readonly-not-long-lived",
capabilityPass: false, 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 { return {
@@ -177,7 +178,7 @@ export function classifyCodexRunnerCapability(payload, { httpStatus = null } = {
blocker: null, blocker: null,
capabilityPass: true, capabilityPass: true,
longLivedSession: 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_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_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_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.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"); 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"); 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_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_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_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("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"); 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"); 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_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_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_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.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"); 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"); 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_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_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_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.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"); 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"); 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", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "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", timeoutName: "Code Agent",
activityRef: () => state.currentRequest?.traceId === traceId ? state.currentRequest : null,
body: JSON.stringify({ body: JSON.stringify({
message, message,
conversationId, conversationId,
@@ -1353,12 +1354,53 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
const error = agentErrorFromHttpResponse(response, traceId); const error = agentErrorFromHttpResponse(response, traceId);
throw error; throw error;
} }
if (response.status === 202 || response.data?.shortConnection === true) {
return waitForAgentMessageResult(traceId, response.data);
}
return { return {
...response.data, ...response.data,
traceId: response.data?.traceId || traceId 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() { async function loadLiveSurface() {
const projectId = gateSummary.topology.projectId; const projectId = gateSummary.topology.projectId;
const [healthLive, liveBuilds, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([ 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"); const list = document.createElement("ol");
list.className = "message-trace-events"; list.className = "message-trace-events";
const events = Array.isArray(trace?.events) ? trace.events : []; const events = Array.isArray(trace?.events) ? trace.events : [];
const rows = traceDisplayRows(trace, events);
if (events.length === 0) { if (events.length === 0) {
const item = document.createElement("li"); const item = document.createElement("li");
item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`; item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`;
list.append(item); list.append(item);
} else { } else {
const toolbar = messageTraceToolbar(message, trace, events, list); const toolbar = messageTraceToolbar(message, trace, events, rows, list);
list.dataset.traceMode = events.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? "tail" : "all"; list.dataset.traceMode = rows.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? "tail" : "all";
renderTraceEventList(list, tracePreviewEvents(events)); renderTraceEventList(list, tracePreviewRows(rows));
details.append(summary, toolbar, list); details.append(summary, toolbar, list);
return details; return details;
} }
@@ -3892,19 +3935,19 @@ function messageTracePanel(message) {
return details; return details;
} }
function messageTraceToolbar(message, trace, events, list) { function messageTraceToolbar(message, trace, events, rows, list) {
const toolbar = document.createElement("div"); const toolbar = document.createElement("div");
toolbar.className = "message-trace-toolbar"; 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); toolbar.append(count);
if (events.length > CODE_AGENT_TRACE_PREVIEW_LIMIT) { if (rows.length > CODE_AGENT_TRACE_PREVIEW_LIMIT) {
let expanded = false; let expanded = false;
const toggle = traceActionButton("展开全部", () => { const toggle = traceActionButton("展开全部", () => {
expanded = !expanded; expanded = !expanded;
renderTraceEventList(list, expanded ? events : tracePreviewEvents(events)); renderTraceEventList(list, expanded ? rows : tracePreviewRows(rows));
list.classList.toggle("message-trace-events-full", expanded); list.classList.toggle("message-trace-events-full", expanded);
list.dataset.traceMode = expanded ? "all" : "tail"; list.dataset.traceMode = expanded ? "all" : "tail";
count.textContent = messageTraceCountText(events.length, expanded); count.textContent = messageTraceCountText(events.length, rows.length, expanded);
toggle.textContent = expanded ? "收起为最近 14" : "展开全部"; toggle.textContent = expanded ? "收起为最近 14" : "展开全部";
}, "在面板内查看完整 trace 时间线"); }, "在面板内查看完整 trace 时间线");
toolbar.append(toggle); toolbar.append(toggle);
@@ -3914,28 +3957,160 @@ function messageTraceToolbar(message, trace, events, list) {
return toolbar; return toolbar;
} }
function tracePreviewEvents(events) { function tracePreviewRows(rows) {
return events.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? events.slice(-CODE_AGENT_TRACE_PREVIEW_LIMIT) : events; return rows.length > CODE_AGENT_TRACE_PREVIEW_LIMIT ? rows.slice(-CODE_AGENT_TRACE_PREVIEW_LIMIT) : rows;
} }
function messageTraceCountText(total, expanded) { function messageTraceCountText(rawTotal, displayTotal, expanded) {
if (total <= CODE_AGENT_TRACE_PREVIEW_LIMIT || expanded) return `显示全部 ${total} / 总计 ${total}`; if (displayTotal <= CODE_AGENT_TRACE_PREVIEW_LIMIT || expanded) return `显示全部 ${displayTotal} / 原始 ${rawTotal}`;
return `显示最近 ${CODE_AGENT_TRACE_PREVIEW_LIMIT} / 总计 ${total}`; return `显示最近 ${CODE_AGENT_TRACE_PREVIEW_LIMIT} / 可读 ${displayTotal} / 原始 ${rawTotal}`;
} }
function renderTraceEventList(list, events) { function renderTraceEventList(list, rows) {
list.replaceChildren(); list.replaceChildren();
for (const event of events) { for (const row of rows) {
const item = document.createElement("li"); const item = document.createElement("li");
item.append( item.className = `message-trace-row tone-border-${toneClass(row.tone)}`;
textSpan(`#${event.seq ?? "?"}`, "message-trace-seq"), const header = document.createElement("div");
textSpan(event.label ?? `${event.type}:${event.status}`, "message-trace-label"), header.className = "message-trace-line";
textSpan(traceEventMeta(event), "message-trace-meta") 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); 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) { function traceActionButton(label, onClick, title) {
const button = document.createElement("button"); const button = document.createElement("button");
button.type = "button"; button.type = "button";
@@ -4480,7 +4655,7 @@ function isCodexRunnerCapableEvidence(value) {
value?.runner?.writeCapable === true && value?.runner?.writeCapable === true &&
value?.runner?.durableSession === true && value?.runner?.durableSession === true &&
Boolean(value?.workspace || value?.runner?.workspace) && 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) && Array.isArray(value?.toolCalls) &&
value.toolCalls.some((tool) => tool?.status === "completed") && value.toolCalls.some((tool) => tool?.status === "completed") &&
value?.longLivedSessionGate?.status === "pass" && 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, /codexStdioFeasibility:\s*result\.codexStdioFeasibility/);
assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/); assert.match(app, /longLivedSessionGate:\s*result\.longLivedSessionGate/);
assert.match(app, /conversationFacts:\s*result\.conversationFacts/); 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, /function messageTracePanel/);
assert.match(app, /CODE_AGENT_TRACE_PREVIEW_LIMIT\s*=\s*14/); assert.match(app, /CODE_AGENT_TRACE_PREVIEW_LIMIT\s*=\s*14/);
assert.match(app, /function messageTraceToolbar/); assert.match(app, /function messageTraceToolbar/);
assert.match(app, /function messageTraceCountText/); 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, /展开全部/);
assert.match(app, /复制 JSON/); assert.match(app, /复制 JSON/);
assert.match(app, /下载 trace/); assert.match(app, /下载 trace/);
assert.match(app, /function renderTraceEventList/); assert.match(app, /function renderTraceEventList/);
assert.match(app, /function traceDisplayRows/);
assert.match(app, /function traceDisplayRow/);
assert.match(app, /function downloadTraceJson/); assert.match(app, /function downloadTraceJson/);
assert.match(styles, /\.message-trace-toolbar\s*\{/); assert.match(styles, /\.message-trace-toolbar\s*\{/);
assert.match(styles, /\.message-trace-count\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, /\.copy-chip\s*{/);
assert.match(styles, /\.message-trace\s*{/); assert.match(styles, /\.message-trace\s*{/);
assert.match(styles, /\.message-trace-events\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-attribution\s*{/);
assert.doesNotMatch(styles, /\.message-evidence\s*{/); assert.doesNotMatch(styles, /\.message-evidence\s*{/);
assert.doesNotMatch(styles, /\.code-agent-facts\s*{/); assert.doesNotMatch(styles, /\.code-agent-facts\s*{/);
+24 -10
View File
@@ -1348,12 +1348,13 @@ h3 {
.message-trace-events { .message-trace-events {
display: grid; display: grid;
gap: 5px; gap: 8px;
margin: 6px 0 0; margin: 8px 0 0;
padding-left: 18px; padding-left: 0;
color: var(--muted); color: var(--muted);
font-family: var(--mono); font-family: var(--mono);
font-size: 10px; font-size: 10px;
list-style: none;
} }
.message-trace-events-full { .message-trace-events-full {
@@ -1363,19 +1364,32 @@ h3 {
padding-right: 4px; padding-right: 4px;
} }
.message-trace-events li { .message-trace-row {
min-width: 0; 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; overflow-wrap: anywhere;
} }
.message-trace-seq, .message-trace-line {
.message-trace-label, color: var(--text);
.message-trace-meta { line-height: 1.45;
margin-right: 6px;
} }
.message-trace-label { .message-trace-body {
color: var(--text); 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 { .message-blocker {