#!/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 LOCAL_API_BASE_URL = "http://127.0.0.1:6667"; const DEFAULT_API_PORT = "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 = 2000; const MAX_CHUNK_SIZE = 2000; 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 || defaultApiBaseUrl()); 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 defaultApiBaseUrl() { const namespace = readKubernetesNamespace(); if (process.env.KUBERNETES_SERVICE_HOST && namespace) { return `http://hwlab-cloud-api.${namespace}.svc.cluster.local:${DEFAULT_API_PORT}`; } return LOCAL_API_BASE_URL; } function readKubernetesNamespace() { try { const value = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8").trim(); if (/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(value)) return value; } catch { // Non-Kubernetes hosts keep the local cloud-api default. } return ""; } 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 [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 [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 [options] -- ", "", "Locator examples:", " gws_DESKTOP-1MHOD9I:/f/work", " gws_DESKTOP-1MHOD9I:f:/work/hwlab/.tmp", "", "Commands:", " cmd -- Run a Windows cmd command in the gateway workspace.", " ps --