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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_CODEX_SANDBOX",
|
||||
"value": "workspace-write"
|
||||
"value": "danger-full-access"
|
||||
},
|
||||
{
|
||||
"name": "HWLAB_CODE_AGENT_CODEX_COMMAND",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 cmd:cmd /c echo HWLAB-PC-GW && hostname"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:08:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
},
|
||||
sessionRegistry: registry,
|
||||
callProvider: async () => {
|
||||
providerCalled = true;
|
||||
throw new Error("hardware route must not use text fallback");
|
||||
},
|
||||
codexStdioManager: {
|
||||
describe() {
|
||||
throw new Error("hardware route must not inspect Codex stdio fallback");
|
||||
},
|
||||
async probe() {
|
||||
throw new Error("hardware route must not probe Codex stdio fallback");
|
||||
},
|
||||
async chat() {
|
||||
throw new Error("hardware route must not call Codex stdio fallback");
|
||||
},
|
||||
cancel() {},
|
||||
reapIdle() {}
|
||||
},
|
||||
invokeHardwareShellRpc: async (call) => {
|
||||
dispatchCall = call;
|
||||
assert.equal(call.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(call.method, "hardware.invoke.shell");
|
||||
assert.equal(call.params.projectId, "prj_mvp_topology");
|
||||
assert.equal(call.params.gatewaySessionId, "gws_DESKTOP-1MHOD9I");
|
||||
assert.equal(call.params.resourceId, "res_windows_host");
|
||||
assert.equal(call.params.capabilityId, "cap_windows_cmd_exec");
|
||||
assert.equal(call.params.input.command, "cmd /c echo HWLAB-PC-GW && hostname");
|
||||
return {
|
||||
jsonrpc: "2.0",
|
||||
id: call.requestId,
|
||||
result: {
|
||||
accepted: true,
|
||||
status: "succeeded",
|
||||
operationId: "op_pc_gateway_shell",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
dispatch: {
|
||||
shellExecuted: true,
|
||||
dispatchStatus: "succeeded",
|
||||
exitCode: 0,
|
||||
stdout: "HWLAB-PC-GW\r\nDESKTOP-1MHOD9I\r\n",
|
||||
stderr: ""
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "hwlab-hardware-capability");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/hardware.invoke.shell");
|
||||
assert.equal(payload.capabilityLevel, "pc-gateway-shell-executed");
|
||||
assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell");
|
||||
assert.equal(payload.toolCalls[0].status, "completed");
|
||||
assert.equal(payload.toolCalls[0].dispatchStatus, "succeeded");
|
||||
assert.equal(payload.toolCalls[0].exitCode, 0);
|
||||
assert.equal(payload.toolCalls[0].operationId, "op_pc_gateway_shell");
|
||||
assert.match(payload.toolCalls[0].stdoutSummary, /HWLAB-PC-GW/u);
|
||||
assert.match(payload.toolCalls[0].stdoutSummary, /DESKTOP-1MHOD9I/u);
|
||||
assert.equal(payload.operationId, "op_pc_gateway_shell");
|
||||
assert.equal(payload.dispatchStatus, "succeeded");
|
||||
assert.equal(payload.exitCode, 0);
|
||||
assert.match(payload.stdoutSummary, /DESKTOP-1MHOD9I/u);
|
||||
assert.equal(payload.runnerTrace.eventLabels.includes("tool:hardware.invoke.shell:completed"), true);
|
||||
assert.equal(payload.runnerTrace.directGatewayCalls, false);
|
||||
assert.equal(payload.providerTrace.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(providerCalled, false);
|
||||
assert.equal(dispatchCall !== null, true);
|
||||
});
|
||||
|
||||
test("Code Agent returns structured hardware blocker for ambiguous PC gateway topology without fallback", async () => {
|
||||
let dispatchCalled = false;
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_pc_gateway_ambiguous",
|
||||
traceId: "trc_pc_gateway_ambiguous",
|
||||
message: "请通过 PC gateway shell.exec 执行 Windows cmd:cmd /c echo SHOULD_NOT_RUN"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:08:30.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
},
|
||||
hardwareCapabilityTopology: [
|
||||
{
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
capabilityName: "shell.exec"
|
||||
},
|
||||
{
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewaySessionId: "gws_DESKTOP-SECOND",
|
||||
resourceId: "res_windows_host_2",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
capabilityName: "shell.exec"
|
||||
}
|
||||
],
|
||||
callProvider: async () => {
|
||||
throw new Error("ambiguous hardware route must not use text fallback");
|
||||
},
|
||||
codexStdioManager: {
|
||||
describe() {
|
||||
throw new Error("ambiguous hardware route must not inspect Codex stdio fallback");
|
||||
},
|
||||
async probe() {
|
||||
throw new Error("ambiguous hardware route must not probe Codex stdio fallback");
|
||||
},
|
||||
async chat() {
|
||||
throw new Error("ambiguous hardware route must not call Codex stdio fallback");
|
||||
},
|
||||
cancel() {},
|
||||
reapIdle() {}
|
||||
},
|
||||
invokeHardwareShellRpc: async () => {
|
||||
dispatchCalled = true;
|
||||
throw new Error("ambiguous hardware route must not dispatch");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "hwlab-hardware-capability");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.equal(payload.blocker.code, "hardware_topology_ambiguous");
|
||||
assert.equal(payload.blocker.route, "hardware.invoke.shell");
|
||||
assert.equal(payload.blocker.toolName, "hardware.invoke.shell");
|
||||
assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell");
|
||||
assert.equal(payload.toolCalls[0].status, "blocked");
|
||||
assert.equal(payload.toolCalls[0].blocker.code, "hardware_topology_ambiguous");
|
||||
assert.equal(payload.dispatchStatus, null);
|
||||
assert.equal(payload.exitCode, null);
|
||||
assert.match(payload.reply.content, /拓扑不唯一/u);
|
||||
assert.equal(payload.providerTrace.fallbackUsed, false);
|
||||
assert.equal(dispatchCalled, false);
|
||||
});
|
||||
|
||||
test("Code Agent PC gateway route uses cloud-api internal JSON-RPC dispatch and gateway result", async () => {
|
||||
const gatewayRegistry = createGatewayDemoRegistry({
|
||||
staleMs: 30000,
|
||||
dispatchTimeoutMs: 1000
|
||||
});
|
||||
gatewayRegistry.updateSession({
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewayId: "gtw_DESKTOP-1MHOD9I",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
|
||||
resourceId: "res_windows_host",
|
||||
capabilities: [{
|
||||
projectId: "prj_mvp_topology",
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
name: "shell.exec"
|
||||
}]
|
||||
});
|
||||
const registry = createCodeAgentSessionRegistry({
|
||||
idFactory: () => "ses_pc_gateway_internal_rpc"
|
||||
});
|
||||
const chat = handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_pc_gateway_internal_rpc",
|
||||
traceId: "trc_pc_gateway_internal_rpc",
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
message: "请通过 PC gateway shell.exec 执行 Windows cmd:cmd /c echo HWLAB-INTERNAL-RPC && hostname"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:08:45.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd()
|
||||
},
|
||||
sessionRegistry: registry,
|
||||
gatewayRegistry,
|
||||
invokeHardwareShellRpc: createCodeAgentHardwareInvokeShellRpc({
|
||||
handleJsonRpcRequest,
|
||||
gatewayRegistry
|
||||
}),
|
||||
callProvider: async () => {
|
||||
throw new Error("internal hardware route must not use text fallback");
|
||||
},
|
||||
codexStdioManager: {
|
||||
describe() {
|
||||
throw new Error("internal hardware route must not inspect Codex stdio fallback");
|
||||
},
|
||||
async probe() {
|
||||
throw new Error("internal hardware route must not probe Codex stdio fallback");
|
||||
},
|
||||
async chat() {
|
||||
throw new Error("internal hardware route must not call Codex stdio fallback");
|
||||
},
|
||||
cancel() {},
|
||||
reapIdle() {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const outbound = await waitForGatewayRequest(gatewayRegistry, "gws_DESKTOP-1MHOD9I");
|
||||
assert.equal(outbound.method, "hardware.invoke.shell");
|
||||
assert.equal(outbound.meta.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(outbound.params.input.command, "cmd /c echo HWLAB-INTERNAL-RPC && hostname");
|
||||
const completion = gatewayRegistry.complete({
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: outbound.id,
|
||||
result: {
|
||||
accepted: true,
|
||||
status: "succeeded",
|
||||
operationId: "op_pc_gateway_internal_rpc",
|
||||
try {
|
||||
const payload = await handleCodeAgentChat(
|
||||
{
|
||||
conversationId: "cnv_pc_gateway_stdio",
|
||||
traceId: "trc_pc_gateway_stdio",
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
|
||||
shellExecuted: true,
|
||||
dispatchStatus: "succeeded",
|
||||
exitCode: 0,
|
||||
stdout: "HWLAB-INTERNAL-RPC\r\nDESKTOP-1MHOD9I\r\n",
|
||||
stderr: "",
|
||||
auditId: "aud_pc_gateway_internal_rpc",
|
||||
evidenceId: "evd_pc_gateway_internal_rpc",
|
||||
gateway: {
|
||||
gatewayId: "gtw_DESKTOP-1MHOD9I",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I"
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
message: "请通过 PC gateway shell.exec 执行 Windows cmd:cmd /c echo HWLAB-PC-GW && hostname"
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-23T00:08:00.000Z",
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
||||
HWLAB_CODE_AGENT_WORKSPACE: process.cwd(),
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://172.26.26.227:17680/v1/responses"
|
||||
},
|
||||
sessionRegistry: registry,
|
||||
codexStdioManager: manager,
|
||||
callProvider: async () => {
|
||||
providerCalled = true;
|
||||
throw new Error("text fallback must not be used");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.equal(completion.accepted, true);
|
||||
);
|
||||
|
||||
const payload = await chat;
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "hwlab-hardware-capability");
|
||||
assert.equal(payload.toolCalls[0].name, "hardware.invoke.shell");
|
||||
assert.equal(payload.toolCalls[0].status, "completed");
|
||||
assert.equal(payload.toolCalls[0].dispatchStatus, "succeeded");
|
||||
assert.equal(payload.toolCalls[0].exitCode, 0);
|
||||
assert.match(payload.toolCalls[0].stdoutSummary, /HWLAB-INTERNAL-RPC/u);
|
||||
assert.match(payload.toolCalls[0].stdoutSummary, /DESKTOP-1MHOD9I/u);
|
||||
assert.equal(payload.operationId, "op_pc_gateway_internal_rpc");
|
||||
assert.equal(payload.hardware.path.cloudApiOnly, true);
|
||||
assert.equal(payload.runnerTrace.eventLabels.includes("tool:hardware.invoke.shell:completed"), true);
|
||||
assert.equal(payload.providerTrace.transport, "internal-json-rpc");
|
||||
assert.equal(payload.providerTrace.serviceId, "hwlab-cloud-api");
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
||||
assert.equal(payload.sandbox, "danger-full-access");
|
||||
assert.equal(payload.runner.kind, "codex-app-server-stdio-runner");
|
||||
assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session");
|
||||
assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "hardware.invoke.shell"), false);
|
||||
assert.equal(providerCalled, false);
|
||||
const turn = calls.find((call) => call.method === "turn/start");
|
||||
assert.ok(turn, "Codex stdio turn/start should be called");
|
||||
assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-shell\.mjs/u);
|
||||
assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills/u);
|
||||
assert.match(turn.args.prompt, /cmd \/c echo HWLAB-PC-GW && hostname/u);
|
||||
} finally {
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForGatewayRequest(gatewayRegistry, gatewaySessionId) {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
const outbound = gatewayRegistry.nextRequest(gatewaySessionId);
|
||||
if (outbound) return outbound;
|
||||
await delay(5);
|
||||
}
|
||||
throw new Error(`gateway request was not queued for ${gatewaySessionId}`);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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" &&
|
||||
|
||||
@@ -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*{/);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user