feat: preload gateway tran helper
This commit is contained in:
@@ -790,14 +790,16 @@ test("Code Agent PC gateway prompt reaches Codex stdio instead of internal hardw
|
||||
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, /\/app\/tools\/hwlab-gateway-tran\.mjs/u);
|
||||
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u);
|
||||
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u);
|
||||
assert.match(turn.args.prompt, /improve \/app\/tools\/hwlab-gateway-tran\.mjs first/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);
|
||||
assert.match(turn.args.prompt, /--powershell-stdin/u);
|
||||
assert.match(turn.args.prompt, /-EncodedCommand/u);
|
||||
assert.match(turn.args.prompt, /Windows filesystem inventory/u);
|
||||
assert.match(turn.args.prompt, /Select-Object -First/u);
|
||||
assert.match(turn.args.prompt, /ConvertTo-HwlabJson/u);
|
||||
assert.match(turn.args.prompt, /cmd -- <command>/u);
|
||||
assert.match(turn.args.prompt, /ps -- <script>/u);
|
||||
assert.match(turn.args.prompt, /tran upload\/download/u);
|
||||
assert.doesNotMatch(turn.args.prompt, /hwlab-gateway-shell\.mjs|--powershell-stdin|ConvertTo-HwlabJson/u);
|
||||
} finally {
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
@@ -884,7 +886,7 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn
|
||||
conversationId: "cnv_keil_gateway_stdio",
|
||||
traceId: "trc_keil_gateway_stdio",
|
||||
projectId: "prj_mvp_topology",
|
||||
message: String.raw`请用 /app/tools/hwlab-gateway-shell.mjs 通过 PC gateway 调用 Windows cmd,进入 C:\Users\liang\.agents\skills\keil,然后运行 py -3 keil-cli.py build -p "F:\Work\constart\projects\71-00075-11\FirmWare\MDK-ARM\FREQ_Controller_FW.uvprojx" -t FREQ_Controller_FW;如果返回 job id,请用 py -3 keil-cli.py job-status <job_id> 轮询到终态。`
|
||||
message: String.raw`请通过 PC gateway 调用 Windows cmd,进入 C:\Users\liang\.agents\skills\keil,然后运行 py -3 keil-cli.py build -p "F:\Work\constart\projects\71-00075-11\FirmWare\MDK-ARM\FREQ_Controller_FW.uvprojx" -t FREQ_Controller_FW;如果返回 job id,请用 py -3 keil-cli.py job-status <job_id> 轮询到终态。`
|
||||
},
|
||||
{
|
||||
now: () => "2026-05-24T14:35:00.000Z",
|
||||
@@ -921,8 +923,10 @@ test("Code Agent Keil gateway prompt is not blocked by M3 IO intent guard", asyn
|
||||
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, /--powershell-stdin/u);
|
||||
assert.match(turn.args.prompt, /\/app\/tools\/hwlab-gateway-tran\.mjs/u);
|
||||
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:\/f\/work/u);
|
||||
assert.match(turn.args.prompt, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/u);
|
||||
assert.doesNotMatch(turn.args.prompt, /hwlab-gateway-shell\.mjs|--powershell-stdin/u);
|
||||
assert.match(turn.args.prompt, /C:\\Users\\liang\\\.agents\\skills\\keil/u);
|
||||
assert.match(turn.args.prompt, /keil-cli\.py build/u);
|
||||
assert.match(turn.args.prompt, /job-status/u);
|
||||
|
||||
@@ -87,14 +87,14 @@ const CODEX_STDIO_BOUNDARY_INSTRUCTIONS = [
|
||||
"You are the HWLAB Cloud Workbench Code Agent.",
|
||||
"Use the provided workspace and repo-owned Codex stdio session only.",
|
||||
"Do not read or print secrets, tokens, kubeconfig files, DB URLs, private keys, or raw environment values.",
|
||||
"For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available wrapper, skill CLI, or project tool that actually reaches the target.",
|
||||
"For registered PC gateway Windows command or skill requests, invoke the repo-owned wrapper with the Codex exec tool: node /app/tools/hwlab-gateway-shell.mjs --json --timeout-ms <gatewayShellTimeoutMs> --powershell-stdin. Put scripts on stdin and set working directory with wrapper --cwd or PowerShell Set-Location -LiteralPath; do not hand-build compound cmd /c, cd &&, pipes, or nested quotes.",
|
||||
"For PowerShell through the PC gateway, do not hand-escape cmd pipes or quotes; use a single-quoted heredoc with --powershell-stdin. The wrapper sends powershell.exe -EncodedCommand and injects UTF-8 helper functions for Unicode-safe execution.",
|
||||
"For Windows text files and skill manifests, avoid raw Get-Content or Select-String objects in JSON. Use Read-HwlabText, Select-HwlabText, and ConvertTo-HwlabJson from the wrapper prologue, or explicitly project plain scalar fields before ConvertTo-Json.",
|
||||
"For Windows filesystem inventory, start with one small bounded PowerShell stdin script: use -LiteralPath, Select-Object -First, ConvertTo-HwlabJson, and keep stdout under about 12 KB. Do not dump full directory JSON and then retry.",
|
||||
"For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI from a PowerShell stdin script using explicit paths or argument arrays. Do not reimplement skill logic, and do not use shell working-directory tricks like cd &&.",
|
||||
"For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py from the generic PowerShell stdin wrapper path; do not reimplement Keil build logic.",
|
||||
"For boot logs and UART capture, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\serial-monitor with npm run cli -- server status/start, monitor start, and fetch from the generic PowerShell stdin wrapper path; for 71-FREQ prefer the skill-documented baud rate unless live evidence says otherwise.",
|
||||
"For hardware, gateway, box-simu, patch-panel, DAP, PWM, Keil, serial, and Windows skill requests, execute the requested work through the repo-owned Codex stdio session with the available tran wrapper, skill CLI, or project tool that actually reaches the target.",
|
||||
"For registered PC gateway Windows command or skill requests, use the preloaded tran wrapper: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work <cmd|ps|upload|download> [options] -- <args>. The locator before ':' is the gateway session and the path after ':' is the Windows workspace, with /f/work and f:/work both mapping to F:\\work.",
|
||||
"For Windows cmd, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- <command>. For PowerShell, call tran as: node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps -- <script>. The wrapper owns UTF-8, quoting, cwd, EncodedCommand, and stdout/stderr passthrough; do not hand-build nested cmd /c, cd &&, pipes, or quoting workarounds.",
|
||||
"For reliable Windows file transfer, use tran upload/download instead of pasting large base64 through chat or shell output. Prefer the canonical tool workspace gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp when updating PC-side helper files.",
|
||||
"If /app/tools/hwlab-gateway-tran.mjs itself is awkward, unreliable, or causes quoting, Chinese text, encoding, cwd, stdout/stderr, upload, or download friction, improve /app/tools/hwlab-gateway-tran.mjs first, then update the gateway-side copy at gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp before continuing the user task.",
|
||||
"For Windows-side skills under C:\\Users\\liang\\.agents\\skills\\, read the skill manifest when needed and call its CLI through hwlab-gateway-tran.mjs using explicit paths or argument arrays. Do not reimplement skill logic.",
|
||||
"For F:\\work or F:\\work\\ConStart discovery, first list top-level project markers and *.uvprojx candidates with bounded output. For Keil build/program requests, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\keil with py -3 keil-cli.py through hwlab-gateway-tran.mjs; do not reimplement Keil build logic.",
|
||||
"For boot logs and UART capture, use the Windows skill CLI at C:\\Users\\liang\\.agents\\skills\\serial-monitor with npm run cli -- server status/start, monitor start, and fetch through hwlab-gateway-tran.mjs; for 71-FREQ prefer the skill-documented baud rate unless live evidence says otherwise.",
|
||||
"For long Keil build/program/download jobs, prefer the skill CLI async job flow: start the job with a short bounded wrapper call, then poll job-status, state files, or logs with short wrapper calls. Do not use gateway --wait long polling unless the user explicitly asks for synchronous waiting and sets a sufficient gateway timeout.",
|
||||
"If a Windows gateway command reaches the gateway but fails due script syntax or output size, simplify once and report the failed operationId plus the corrected bounded command evidence. Do not spend multiple turns on exploratory rewrites.",
|
||||
"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.",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -65,7 +65,7 @@ test("component model uses built-in service paths", () => {
|
||||
"internal/cloud/",
|
||||
"internal/db/",
|
||||
"skills/",
|
||||
"tools/hwlab-gateway-shell.mjs"
|
||||
"tools/"
|
||||
]);
|
||||
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.mjs"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.mjs"]);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ const serviceSpecificPaths = Object.freeze({
|
||||
"internal/cloud/",
|
||||
"internal/db/",
|
||||
"internal/audit/",
|
||||
"tools/hwlab-gateway-shell.mjs",
|
||||
"tools/",
|
||||
"skills/"
|
||||
],
|
||||
"hwlab-cloud-web": ["web/hwlab-cloud-web/"],
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env node
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_API_BASE_URL = "http://127.0.0.1:6667";
|
||||
const DEFAULT_PROJECT_ID = "prj_mvp_topology";
|
||||
const DEFAULT_RESOURCE_ID = "res_windows_host";
|
||||
const DEFAULT_CAPABILITY_ID = "cap_windows_cmd_exec";
|
||||
const DEFAULT_TIMEOUT_MS = 120000;
|
||||
const MAX_TIMEOUT_MS = 600000;
|
||||
const DEFAULT_CHUNK_SIZE = 900;
|
||||
const MAX_CHUNK_SIZE = 3000;
|
||||
const REQUEST_TIMEOUT_GRACE_MS = 10000;
|
||||
const POWERSHELL_PROLOGUE = [
|
||||
"$ErrorActionPreference = 'Stop';",
|
||||
"$utf8NoBom = [System.Text.UTF8Encoding]::new($false);",
|
||||
"$ProgressPreference = 'SilentlyContinue';",
|
||||
"[Console]::InputEncoding = $utf8NoBom;",
|
||||
"[Console]::OutputEncoding = $utf8NoBom;",
|
||||
"$OutputEncoding = $utf8NoBom;",
|
||||
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8';",
|
||||
"$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8';"
|
||||
].join("\n");
|
||||
|
||||
async function main() {
|
||||
const cli = parseCli(process.argv.slice(2));
|
||||
if (cli.help) {
|
||||
printHelp(cli.exitCode ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBaseUrl = stripTrailingSlash(cli.options.apiBaseUrl || process.env.HWLAB_GATEWAY_TRAN_API_BASE_URL || process.env.HWLAB_GATEWAY_SHELL_API_BASE_URL || DEFAULT_API_BASE_URL);
|
||||
const timeoutMs = boundedPositiveInteger(cli.options.timeoutMs ?? process.env.HWLAB_GATEWAY_TRAN_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, { min: 1000, max: MAX_TIMEOUT_MS });
|
||||
const requestTimeoutMs = boundedPositiveInteger(cli.options.requestTimeoutMs, timeoutMs + REQUEST_TIMEOUT_GRACE_MS, {
|
||||
min: timeoutMs,
|
||||
max: timeoutMs + Math.max(REQUEST_TIMEOUT_GRACE_MS, Math.floor(timeoutMs / 2))
|
||||
});
|
||||
const chunkSize = boundedPositiveInteger(cli.options.chunkSize ?? process.env.HWLAB_GATEWAY_TRAN_CHUNK_SIZE, DEFAULT_CHUNK_SIZE, { min: 128, max: MAX_CHUNK_SIZE });
|
||||
const target = parseLocator(cli.locator);
|
||||
const context = { apiBaseUrl, timeoutMs, requestTimeoutMs, chunkSize, json: cli.options.json, target };
|
||||
|
||||
if (cli.command === "cmd") {
|
||||
await runCmd(context, cli.args);
|
||||
} else if (cli.command === "ps") {
|
||||
await runPowerShell(context, cli.args);
|
||||
} else if (cli.command === "upload") {
|
||||
await uploadFile(context, cli.args);
|
||||
} else if (cli.command === "download") {
|
||||
await downloadFile(context, cli.args);
|
||||
} else {
|
||||
fail(`unknown command: ${cli.command}. Use cmd, ps, upload, or download.`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCli(argv) {
|
||||
if (argv.length === 0) return { help: true, exitCode: 2 };
|
||||
if (argv[0] === "--help" || argv[0] === "-h") return { help: true, exitCode: 0 };
|
||||
const [locator, command, ...rest] = argv;
|
||||
if (!locator || !command) return { help: true, exitCode: 2 };
|
||||
const options = {};
|
||||
const args = [];
|
||||
for (let index = 0; index < rest.length; index += 1) {
|
||||
const item = rest[index];
|
||||
if (item === "--") {
|
||||
args.push(...rest.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (item === "--json") {
|
||||
options.json = true;
|
||||
} else if (item === "--api-base-url") {
|
||||
options.apiBaseUrl = requireOptionValue(rest, index, item);
|
||||
index += 1;
|
||||
} else if (item.startsWith("--api-base-url=")) {
|
||||
options.apiBaseUrl = item.slice("--api-base-url=".length);
|
||||
} else if (item === "--timeout-ms") {
|
||||
options.timeoutMs = requireOptionValue(rest, index, item);
|
||||
index += 1;
|
||||
} else if (item.startsWith("--timeout-ms=")) {
|
||||
options.timeoutMs = item.slice("--timeout-ms=".length);
|
||||
} else if (item === "--request-timeout-ms") {
|
||||
options.requestTimeoutMs = requireOptionValue(rest, index, item);
|
||||
index += 1;
|
||||
} else if (item.startsWith("--request-timeout-ms=")) {
|
||||
options.requestTimeoutMs = item.slice("--request-timeout-ms=".length);
|
||||
} else if (item === "--chunk-size") {
|
||||
options.chunkSize = requireOptionValue(rest, index, item);
|
||||
index += 1;
|
||||
} else if (item.startsWith("--chunk-size=")) {
|
||||
options.chunkSize = item.slice("--chunk-size=".length);
|
||||
} else {
|
||||
args.push(item);
|
||||
}
|
||||
}
|
||||
return { locator, command, options, args };
|
||||
}
|
||||
|
||||
function requireOptionValue(argv, index, flag) {
|
||||
const value = argv[index + 1];
|
||||
if (!value) fail(`${flag} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLocator(locator) {
|
||||
const colonIndex = String(locator).indexOf(":");
|
||||
if (colonIndex < 1) {
|
||||
fail(`invalid locator: ${locator}. Expected gatewaySessionId:/f/work or gatewaySessionId:f:/work.`);
|
||||
}
|
||||
const gatewaySessionId = locator.slice(0, colonIndex);
|
||||
const rawWorkspace = locator.slice(colonIndex + 1);
|
||||
if (!gatewaySessionId || !rawWorkspace) {
|
||||
fail(`invalid locator: ${locator}. Gateway id and workspace path are required.`);
|
||||
}
|
||||
return {
|
||||
gatewaySessionId,
|
||||
rawWorkspace,
|
||||
workspace: normalizeGatewayPath(rawWorkspace)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGatewayPath(value) {
|
||||
const input = String(value ?? "").trim();
|
||||
if (!input) fail("empty gateway workspace path");
|
||||
if (/^[a-zA-Z]:[\\/]/u.test(input)) {
|
||||
return `${input[0].toUpperCase()}:${input.slice(2).replace(/[\\/]+/gu, "\\")}`;
|
||||
}
|
||||
const msysMatch = input.match(/^\/([a-zA-Z])(?:\/|$)(.*)$/u);
|
||||
if (msysMatch) {
|
||||
const drive = msysMatch[1].toUpperCase();
|
||||
const rest = msysMatch[2] ? `\\${msysMatch[2].replace(/[\\/]+/gu, "\\")}` : "\\";
|
||||
return `${drive}:${rest}`;
|
||||
}
|
||||
if (/^\\\\/u.test(input)) return input.replace(/\//gu, "\\");
|
||||
return input.replace(/[\\/]+/gu, "\\");
|
||||
}
|
||||
|
||||
async function runCmd(context, args) {
|
||||
const command = shellText(args, "cmd requires a command after --");
|
||||
const response = await invokeShell(context, {
|
||||
command: buildPowerShellCommand(buildCmdPassthroughScript(command), context.target.workspace),
|
||||
cwd: context.target.workspace
|
||||
});
|
||||
if (context.json) printJson(response);
|
||||
else printDispatch(response);
|
||||
process.exit(isFailed(response) ? 1 : 0);
|
||||
}
|
||||
|
||||
function buildCmdPassthroughScript(command) {
|
||||
return [
|
||||
`$cmd = '${psQuote(command)}'`,
|
||||
"$env:PYTHONUTF8 = '1'",
|
||||
"$env:PYTHONIOENCODING = 'utf-8'",
|
||||
"$env:PYTHONLEGACYWINDOWSSTDIO = '0'",
|
||||
"& cmd.exe /d /s /c \"chcp 65001 >NUL & $cmd\"",
|
||||
"exit $LASTEXITCODE"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function runPowerShell(context, args) {
|
||||
const script = shellText(args, "ps requires a PowerShell script after --");
|
||||
const command = buildPowerShellCommand(script, context.target.workspace);
|
||||
const response = await invokeShell(context, { command, cwd: context.target.workspace });
|
||||
if (context.json) printJson(response);
|
||||
else printDispatch(response);
|
||||
process.exit(isFailed(response) ? 1 : 0);
|
||||
}
|
||||
|
||||
async function uploadFile(context, args) {
|
||||
if (args.length < 1) fail("upload requires <local_source_path> [remote_target_path]");
|
||||
const localPath = args[0];
|
||||
if (!existsSync(localPath)) fail(`local file not found: ${localPath}`);
|
||||
const data = readFileSync(localPath);
|
||||
const stat = statSync(localPath);
|
||||
const localHash = sha256(data);
|
||||
const remotePath = resolveRemotePath(context.target.workspace, args[1] || path.basename(localPath));
|
||||
const tempPath = `${remotePath}.hwlab-upload-${randomUUID()}.b64`;
|
||||
const dataTempPath = `${remotePath}.hwlab-upload-${randomUUID()}.tmp`;
|
||||
const chunks = splitText(data.toString("base64"), context.chunkSize);
|
||||
|
||||
process.stderr.write(`upload ${localPath} -> ${remotePath} (${stat.size} bytes, ${chunks.length} chunks)\n`);
|
||||
await expectOk(await invokePowerShell(context, [
|
||||
`$remotePath = '${psQuote(remotePath)}'`,
|
||||
`$tempPath = '${psQuote(tempPath)}'`,
|
||||
"$dir = [System.IO.Path]::GetDirectoryName($remotePath)",
|
||||
"if (-not [string]::IsNullOrWhiteSpace($dir)) { [System.IO.Directory]::CreateDirectory($dir) | Out-Null }",
|
||||
"[System.IO.File]::WriteAllText($tempPath, '', [System.Text.Encoding]::ASCII)",
|
||||
`Write-Output 'upload-init:${chunks.length}'`
|
||||
].join("\n")), "upload init");
|
||||
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
await expectOk(await invokePowerShell(context, [
|
||||
`$tempPath = '${psQuote(tempPath)}'`,
|
||||
`[System.IO.File]::AppendAllText($tempPath, '${chunks[index]}', [System.Text.Encoding]::ASCII)`,
|
||||
`Write-Output 'upload-chunk:${index + 1}/${chunks.length}'`
|
||||
].join("\n")), `upload chunk ${index + 1}/${chunks.length}`);
|
||||
}
|
||||
|
||||
const finalResponse = await invokePowerShell(context, [
|
||||
`$remotePath = '${psQuote(remotePath)}'`,
|
||||
`$tempPath = '${psQuote(tempPath)}'`,
|
||||
`$dataTempPath = '${psQuote(dataTempPath)}'`,
|
||||
`$expectedBytes = ${stat.size}`,
|
||||
`$expectedHash = '${localHash}'`,
|
||||
"$b64 = [System.IO.File]::ReadAllText($tempPath, [System.Text.Encoding]::ASCII)",
|
||||
"$bytes = [System.Convert]::FromBase64String($b64)",
|
||||
"[System.IO.File]::WriteAllBytes($dataTempPath, $bytes)",
|
||||
"$item = Get-Item -LiteralPath $dataTempPath",
|
||||
"$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $dataTempPath).Hash.ToLowerInvariant()",
|
||||
"if ($item.Length -ne $expectedBytes) { throw \"upload byte mismatch: $($item.Length) != $expectedBytes\" }",
|
||||
"if ($hash -ne $expectedHash) { throw \"upload sha256 mismatch: $hash != $expectedHash\" }",
|
||||
"Move-Item -LiteralPath $dataTempPath -Destination $remotePath -Force",
|
||||
"Remove-Item -LiteralPath $tempPath -Force",
|
||||
"$item = Get-Item -LiteralPath $remotePath",
|
||||
"$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $remotePath).Hash.ToLowerInvariant()",
|
||||
"if ($item.Length -ne $expectedBytes) { throw \"upload final byte mismatch: $($item.Length) != $expectedBytes\" }",
|
||||
"if ($hash -ne $expectedHash) { throw \"upload final sha256 mismatch: $hash != $expectedHash\" }",
|
||||
"Write-Output (\"upload-ok path={0} bytes={1} sha256={2}\" -f $remotePath, $item.Length, $hash)"
|
||||
].join("\n"));
|
||||
|
||||
if (context.json) printJson(finalResponse);
|
||||
else printDispatch(finalResponse);
|
||||
process.exit(isFailed(finalResponse) ? 1 : 0);
|
||||
}
|
||||
|
||||
async function downloadFile(context, args) {
|
||||
if (args.length < 1) fail("download requires <remote_source_path> [local_target_path]");
|
||||
const remotePath = resolveRemotePath(context.target.workspace, args[0]);
|
||||
const localPath = args[1] || path.basename(remotePath.replace(/\\/gu, "/"));
|
||||
const statResponse = await invokePowerShell(context, [
|
||||
`$remotePath = '${psQuote(remotePath)}'`,
|
||||
"if (-not (Test-Path -LiteralPath $remotePath -PathType Leaf)) { throw \"remote file not found: $remotePath\" }",
|
||||
"$item = Get-Item -LiteralPath $remotePath",
|
||||
"$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $remotePath).Hash.ToLowerInvariant()",
|
||||
"$b64Length = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($remotePath)).Length",
|
||||
"[pscustomobject]@{ path = $remotePath; bytes = $item.Length; sha256 = $hash; base64Length = $b64Length } | ConvertTo-Json -Compress"
|
||||
].join("\n"));
|
||||
expectOk(statResponse, "download stat");
|
||||
const info = parseJsonLine(dispatchOf(statResponse).stdout, "download stat JSON");
|
||||
const chunks = [];
|
||||
process.stderr.write(`download ${remotePath} -> ${localPath} (${info.bytes} bytes)\n`);
|
||||
for (let offset = 0; offset < info.base64Length; offset += context.chunkSize) {
|
||||
const length = Math.min(context.chunkSize, info.base64Length - offset);
|
||||
const response = await invokePowerShell(context, [
|
||||
`$remotePath = '${psQuote(remotePath)}'`,
|
||||
`$offset = ${offset}`,
|
||||
`$length = ${length}`,
|
||||
"$b64 = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($remotePath))",
|
||||
"Write-Output $b64.Substring($offset, $length)"
|
||||
].join("\n"));
|
||||
expectOk(response, `download chunk ${Math.floor(offset / context.chunkSize) + 1}`);
|
||||
chunks.push(String(dispatchOf(response).stdout ?? "").replace(/\s+/gu, ""));
|
||||
}
|
||||
const data = Buffer.from(chunks.join(""), "base64");
|
||||
const actualHash = sha256(data);
|
||||
if (data.length !== info.bytes) fail(`download byte mismatch: ${data.length} != ${info.bytes}`);
|
||||
if (actualHash !== String(info.sha256).toLowerCase()) fail(`download sha256 mismatch: ${actualHash} != ${info.sha256}`);
|
||||
mkdirSync(path.dirname(localPath), { recursive: true });
|
||||
writeFileSync(localPath, data);
|
||||
if (context.json) printJson({ status: "downloaded", remotePath, localPath, bytes: data.length, sha256: actualHash });
|
||||
else process.stderr.write(`download-ok path=${localPath} bytes=${data.length} sha256=${actualHash}\n`);
|
||||
}
|
||||
|
||||
function resolveRemotePath(workspace, candidate) {
|
||||
const text = String(candidate ?? "").trim();
|
||||
if (!text) fail("remote path is empty");
|
||||
if (/^[a-zA-Z]:[\\/]/u.test(text) || /^\/([a-zA-Z])(?:\/|$)/u.test(text) || /^\\\\/u.test(text)) {
|
||||
return normalizeGatewayPath(text);
|
||||
}
|
||||
return `${workspace.replace(/\\+$/u, "")}\\${text.replace(/[\\/]+/gu, "\\")}`;
|
||||
}
|
||||
|
||||
function shellText(args, message) {
|
||||
if (!Array.isArray(args) || args.length === 0) fail(message);
|
||||
return args.join(" ");
|
||||
}
|
||||
|
||||
function buildPowerShellCommand(script, cwd) {
|
||||
const source = [
|
||||
POWERSHELL_PROLOGUE,
|
||||
cwd ? `Set-Location -LiteralPath '${psQuote(cwd)}';` : "",
|
||||
script
|
||||
].filter(Boolean).join("\n");
|
||||
const encoded = Buffer.from(source, "utf16le").toString("base64");
|
||||
return `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
|
||||
}
|
||||
|
||||
function invokePowerShell(context, script) {
|
||||
return invokeShell(context, {
|
||||
command: buildPowerShellCommand(script, context.target.workspace),
|
||||
cwd: context.target.workspace
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeShell(context, { command, cwd }) {
|
||||
const requestId = `req_gateway_tran_${randomUUID()}`;
|
||||
const operationId = `op_gateway_tran_${randomUUID()}`;
|
||||
const response = await requestJson(`${context.apiBaseUrl}/v1/rpc/hardware.invoke.shell`, {
|
||||
method: "POST",
|
||||
timeoutMs: context.requestTimeoutMs,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": `trc_gateway_tran_${Date.now()}`,
|
||||
"x-request-id": requestId,
|
||||
"x-actor-id": "usr_code_agent",
|
||||
"x-source-service-id": "hwlab-cloud-api"
|
||||
},
|
||||
body: {
|
||||
projectId: DEFAULT_PROJECT_ID,
|
||||
operationId,
|
||||
gatewaySessionId: context.target.gatewaySessionId,
|
||||
resourceId: DEFAULT_RESOURCE_ID,
|
||||
capabilityId: DEFAULT_CAPABILITY_ID,
|
||||
input: {
|
||||
command,
|
||||
cwd,
|
||||
timeoutMs: context.timeoutMs
|
||||
}
|
||||
}
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
function dispatchOf(response) {
|
||||
const result = response?.result ?? {};
|
||||
return result.dispatch ?? result;
|
||||
}
|
||||
|
||||
function printDispatch(response) {
|
||||
const dispatch = dispatchOf(response);
|
||||
if (dispatch.stdout) process.stdout.write(String(dispatch.stdout));
|
||||
if (dispatch.stderr) process.stderr.write(String(dispatch.stderr));
|
||||
if (dispatch.stdout && !String(dispatch.stdout).endsWith("\n")) process.stdout.write("\n");
|
||||
if (dispatch.stderr && !String(dispatch.stderr).endsWith("\n")) process.stderr.write("\n");
|
||||
if (response?.error) process.stderr.write(`error=${response.error.message ?? response.error.code}\n`);
|
||||
if (dispatch.dispatchStatus && dispatch.dispatchStatus !== "succeeded") process.stderr.write(`dispatch=${dispatch.dispatchStatus}\n`);
|
||||
if (Number.isInteger(dispatch.exitCode) && dispatch.exitCode !== 0) process.stderr.write(`exit=${dispatch.exitCode}\n`);
|
||||
}
|
||||
|
||||
function isFailed(response) {
|
||||
if (!response || response.error) return true;
|
||||
const result = response.result ?? {};
|
||||
const dispatch = dispatchOf(response);
|
||||
if (["failed", "rejected", "timed_out"].includes(result.status) || ["failed", "rejected", "timed_out", "not_connected"].includes(dispatch.dispatchStatus)) return true;
|
||||
if (Number.isInteger(dispatch.exitCode) && dispatch.exitCode !== 0) return true;
|
||||
if (result.accepted === true && dispatch.shellExecuted === false) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function expectOk(response, label) {
|
||||
if (!isFailed(response)) return;
|
||||
printDispatch(response);
|
||||
fail(`${label} failed`);
|
||||
}
|
||||
|
||||
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 tran request timed out after ${timeoutMs}ms`)));
|
||||
request.on("error", reject);
|
||||
request.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
function parseJsonLine(text, label) {
|
||||
const line = String(text ?? "").split(/\r?\n/u).find((item) => item.trim().startsWith("{") || item.trim().startsWith("["));
|
||||
if (!line) fail(`${label} was not present`);
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch (error) {
|
||||
fail(`${label} parse failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function splitText(text, size) {
|
||||
const chunks = [];
|
||||
for (let index = 0; index < text.length; index += size) chunks.push(text.slice(index, index + size));
|
||||
return chunks.length ? chunks : [""];
|
||||
}
|
||||
|
||||
function psQuote(value) {
|
||||
return String(value).replace(/'/gu, "''");
|
||||
}
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function printJson(value) {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function boundedPositiveInteger(value, fallback, { min, max } = {}) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
const selected = Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
return Math.min(Math.max(selected, min ?? 1), max ?? selected);
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
return String(value).replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function printHelp(exitCode = 0) {
|
||||
const stream = exitCode === 0 ? process.stdout : process.stderr;
|
||||
stream.write([
|
||||
"Usage: node /app/tools/hwlab-gateway-tran.mjs <gateway:path> <cmd|ps|upload|download> [options] -- <args>",
|
||||
"",
|
||||
"Locator examples:",
|
||||
" gws_DESKTOP-1MHOD9I:/f/work",
|
||||
" gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp",
|
||||
"",
|
||||
"Commands:",
|
||||
" cmd -- <command> Run a Windows cmd command in the gateway workspace.",
|
||||
" ps -- <script> Run a UTF-8 PowerShell script in the gateway workspace.",
|
||||
" upload <local> [remote] Upload a local file using verified base64 chunks.",
|
||||
" download <remote> [local] Download a remote file using verified base64 chunks.",
|
||||
"",
|
||||
"Options:",
|
||||
" --api-base-url URL Default http://127.0.0.1:6667 or HWLAB_GATEWAY_TRAN_API_BASE_URL.",
|
||||
" --timeout-ms N Gateway command timeout, default 120000ms.",
|
||||
" --request-timeout-ms N HTTP wait timeout; defaults to command timeout plus grace.",
|
||||
" --chunk-size N Upload/download base64 chunk size, default 900, max 3000.",
|
||||
" --json Print the full JSON response for cmd/ps or transfer summary.",
|
||||
"",
|
||||
"Examples:",
|
||||
" node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd",
|
||||
" node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work ps -- 'Get-ChildItem'",
|
||||
" node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp upload ./hwlab-gateway-tran.mjs",
|
||||
" node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work download hwlab/.tmp/hwlab-gateway-tran.mjs ./hwlab-gateway-tran.mjs"
|
||||
].join("\n") + "\n");
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`error: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`fatal: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -7,14 +7,14 @@ test("final response markdown supports common blocks and escapes raw html", () =
|
||||
const html = renderMessageMarkdown([
|
||||
"# Gateway 控制 Windows CMD 的方法",
|
||||
"",
|
||||
"核心工具是 `/app/tools/hwlab-gateway-shell.mjs`。",
|
||||
"核心工具是 `/app/tools/hwlab-gateway-tran.mjs`。",
|
||||
"",
|
||||
"| 参数 | 作用 |",
|
||||
"|---|---|",
|
||||
"| `--json` | 输出 JSON |",
|
||||
"| `cmd` | 透传 Windows cmd |",
|
||||
"",
|
||||
"```bash",
|
||||
"node /app/tools/hwlab-gateway-shell.mjs --json",
|
||||
"node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd",
|
||||
"```",
|
||||
"",
|
||||
"<span class=\"message-copy\">raw html should stay inert</span>",
|
||||
|
||||
@@ -69,7 +69,7 @@ const gatewayDemoSmoke = fs.readFileSync(path.resolve(repoRoot, "scripts/gateway
|
||||
const gatewayNonblockingProbe = fs.readFileSync(path.resolve(repoRoot, "scripts/gateway-outbound-nonblocking-probe.mjs"), "utf8");
|
||||
const simHttp = fs.readFileSync(path.resolve(repoRoot, "internal/sim/http.mjs"), "utf8");
|
||||
const codexStdioSession = fs.readFileSync(path.resolve(repoRoot, "internal/cloud/codex-stdio-session.mjs"), "utf8");
|
||||
const gatewayShellTool = fs.readFileSync(path.resolve(repoRoot, "tools/hwlab-gateway-shell.mjs"), "utf8");
|
||||
const gatewayTranTool = fs.readFileSync(path.resolve(repoRoot, "tools/hwlab-gateway-tran.mjs"), "utf8");
|
||||
const frontendSource = `${html}\n${auth}\n${app}\n${artifactPublisher}`;
|
||||
const requiredTrustedRecordTerms = Object.freeze([
|
||||
"Code Agent 对话记录",
|
||||
@@ -1189,31 +1189,25 @@ assert.match(codexStdioSession, /turn:waiting:first_assistant_token/);
|
||||
assert.match(codexStdioSession, /progressOnly:\s*true/);
|
||||
assert.match(codexStdioSession, /\["assistant-message", "turn\/completed"\]\.includes\(activity\.waitingFor\)/);
|
||||
assert.match(codexStdioSession, /does not reset the backend idle timer/);
|
||||
assert.match(codexStdioSession, /--powershell-stdin/);
|
||||
assert.match(codexStdioSession, /gatewayShellTimeoutMs/);
|
||||
assert.match(codexStdioSession, /Read-HwlabText/);
|
||||
assert.match(codexStdioSession, /ConvertTo-HwlabJson/);
|
||||
assert.match(codexStdioSession, /-EncodedCommand/);
|
||||
assert.match(codexStdioSession, /do not hand-build compound cmd \/c, cd &&, pipes, or nested quotes/);
|
||||
assert.match(codexStdioSession, /Set-Location -LiteralPath/);
|
||||
assert.match(codexStdioSession, /Windows filesystem inventory/);
|
||||
assert.match(codexStdioSession, /Select-Object -First/);
|
||||
assert.match(codexStdioSession, /ConvertTo-Json -Compress/);
|
||||
assert.match(codexStdioSession, /\/app\/tools\/hwlab-gateway-tran\.mjs/);
|
||||
assert.match(codexStdioSession, /gws_DESKTOP-1MHOD9I:\/f\/work/);
|
||||
assert.match(codexStdioSession, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/);
|
||||
assert.match(codexStdioSession, /cmd -- <command>/);
|
||||
assert.match(codexStdioSession, /ps -- <script>/);
|
||||
assert.match(codexStdioSession, /tran upload\/download/);
|
||||
assert.match(codexStdioSession, /improve \/app\/tools\/hwlab-gateway-tran\.mjs first/);
|
||||
assert.match(codexStdioSession, /C:\\\\Users\\\\liang\\\\\.agents\\\\skills\\\\keil/);
|
||||
assert.match(codexStdioSession, /C:\\\\Users\\\\liang\\\\\.agents\\\\skills\\\\serial-monitor/);
|
||||
assert.match(codexStdioSession, /monitor start/);
|
||||
assert.match(codexStdioSession, /async job flow/);
|
||||
assert.match(gatewayShellTool, /function powershellEncodedCommand/);
|
||||
assert.match(gatewayShellTool, /DEFAULT_GATEWAY_SHELL_TIMEOUT_MS\s*=\s*120000/);
|
||||
assert.match(gatewayShellTool, /REQUEST_TIMEOUT_GRACE_MS/);
|
||||
assert.match(gatewayShellTool, /op_gateway_shell_cli_\$\{randomUUID\(\)\}/);
|
||||
assert.match(gatewayShellTool, /operationId,/);
|
||||
assert.match(gatewayShellTool, /Read-HwlabText/);
|
||||
assert.match(gatewayShellTool, /Buffer\.from\(`\$\{prologue\}\$\{source\}`,\s*"utf16le"\)\.toString\("base64"\)/);
|
||||
assert.match(gatewayShellTool, /--powershell-stdin/);
|
||||
assert.match(gatewayShellTool, /--cwd WINDOWS_PATH/);
|
||||
assert.match(gatewayShellTool, /powershell\.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand/);
|
||||
assert.doesNotMatch(gatewayShellTool, /keil-find|keil-build|keil-job-status/);
|
||||
assert.doesNotMatch(codexStdioSession, /hwlab-gateway-shell\.mjs|--powershell-stdin|Read-HwlabText|ConvertTo-HwlabJson/);
|
||||
assert.match(gatewayTranTool, /function normalizeGatewayPath/);
|
||||
assert.match(gatewayTranTool, /gws_DESKTOP-1MHOD9I:f:\/work\/hwlab\/\.tmp/);
|
||||
assert.match(gatewayTranTool, /DEFAULT_CHUNK_SIZE\s*=\s*900/);
|
||||
assert.match(gatewayTranTool, /function uploadFile/);
|
||||
assert.match(gatewayTranTool, /function downloadFile/);
|
||||
assert.match(gatewayTranTool, /sha256 mismatch/);
|
||||
assert.match(gatewayTranTool, /powershell\.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand/);
|
||||
assert.match(cloudJsonRpc, /Math\.max\(configured \?\? 0,\s*requested \?\? 0,\s*120000\)/);
|
||||
assert.match(cloudJsonRpc, /op_gateway_shell_\$\{randomUUID\(\)\}/);
|
||||
assert.match(gatewayDemoRegistry, /DEFAULT_DISPATCH_TIMEOUT_MS\s*=\s*120000/);
|
||||
|
||||
@@ -29,16 +29,16 @@ test("final assistant response renders basic markdown without raw html injection
|
||||
text: [
|
||||
"# Gateway 控制 Windows CMD 的方法",
|
||||
"",
|
||||
"核心工具是 `/app/tools/hwlab-gateway-shell.mjs`。",
|
||||
"核心工具是 `/app/tools/hwlab-gateway-tran.mjs`。",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"| 参数 | 作用 |",
|
||||
"|---|---|",
|
||||
"| `--json` | 输出 JSON |",
|
||||
"| `cmd` | 透传 Windows cmd |",
|
||||
"",
|
||||
"```bash",
|
||||
"node /app/tools/hwlab-gateway-shell.mjs --json",
|
||||
"node /app/tools/hwlab-gateway-tran.mjs gws_DESKTOP-1MHOD9I:/f/work cmd -- pwd",
|
||||
"```",
|
||||
"",
|
||||
"<span class=\"message-copy\">raw html should stay inert</span>",
|
||||
@@ -56,7 +56,7 @@ test("final assistant response renders basic markdown without raw html injection
|
||||
|
||||
const renderedText = await markdown.innerText();
|
||||
assert.match(renderedText, /Gateway 控制 Windows CMD 的方法/);
|
||||
assert.match(renderedText, /--json/);
|
||||
assert.match(renderedText, /cmd/);
|
||||
assert.match(renderedText, /raw html should stay inert/);
|
||||
|
||||
await page.close();
|
||||
|
||||
Reference in New Issue
Block a user