#!/usr/bin/env node import http from "node:http"; import https from "node:https"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; 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"; const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000; const MAX_GATEWAY_SHELL_TIMEOUT_MS = 600000; const REQUEST_TIMEOUT_GRACE_MS = 10000; const POWERSHELL_PROLOGUE = [ "$ErrorActionPreference = 'Stop';", "$utf8NoBom = [System.Text.UTF8Encoding]::new($false);", "[Console]::InputEncoding = $utf8NoBom;", "[Console]::OutputEncoding = $utf8NoBom;", "$OutputEncoding = $utf8NoBom;", "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8';", "$PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8';", "function Read-HwlabText { param([Parameter(Mandatory=$true)][string]$LiteralPath) [System.IO.File]::ReadAllText($LiteralPath, $utf8NoBom) }", "function Select-HwlabText { param([Parameter(Mandatory=$true)][string]$LiteralPath,[Parameter(Mandatory=$true)][string]$Pattern,[int]$First=20) Select-String -LiteralPath $LiteralPath -Pattern $Pattern -Encoding UTF8 | Select-Object -First $First @{Name='path';Expression={$_.Path}}, @{Name='lineNumber';Expression={$_.LineNumber}}, @{Name='line';Expression={$_.Line}} }", "function ConvertTo-HwlabJson { param([Parameter(ValueFromPipeline=$true)][object]$InputObject,[int]$Depth=8) begin { $items = @() } process { $items += $InputObject } end { if ($items.Count -eq 1) { $items[0] | ConvertTo-Json -Compress -Depth $Depth } else { $items | ConvertTo-Json -Compress -Depth $Depth } } }" ].join("\n"); async function main() { const args = parseArgs(process.argv.slice(2)); const command = await resolveCommand(args); if (args.help || !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 operationId = args.operationId || `op_gateway_shell_cli_${randomUUID()}`; const timeoutMs = boundedPositiveInteger(args.timeoutMs ?? process.env.HWLAB_GATEWAY_SHELL_TIMEOUT_MS, DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 1000, max: MAX_GATEWAY_SHELL_TIMEOUT_MS }); const requestTimeoutMs = boundedPositiveInteger(args.requestTimeoutMs, timeoutMs + REQUEST_TIMEOUT_GRACE_MS, { min: timeoutMs, max: timeoutMs + Math.max(REQUEST_TIMEOUT_GRACE_MS, Math.floor(timeoutMs / 2)) }); const response = await requestJson(`${apiBaseUrl}/v1/rpc/hardware.invoke.shell`, { method: "POST", timeoutMs: requestTimeoutMs, 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, operationId, gatewaySessionId: args.gatewaySessionId || DEFAULT_GATEWAY_SESSION_ID, resourceId: args.resourceId || DEFAULT_RESOURCE_ID, capabilityId: args.capabilityId || DEFAULT_CAPABILITY_ID, input: { 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 = {}; const booleanFlags = new Set(["json", "help", "h", "powershellStdin", "psStdin"]); for (let index = 0; index < argv.length; index += 1) { const item = argv[index]; if ((item === "--help") || (item === "-h")) { args.help = 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)); if (booleanFlags.has(key)) { args[key] = true; } else { 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]", " node tools/hwlab-gateway-shell.mjs --powershell-command \"Get-ChildItem ...\" [--json]", " node tools/hwlab-gateway-shell.mjs --powershell-stdin [--json] < script.ps1", "", "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", " --powershell-command PS1 Encode PS1 as UTF-16LE and run powershell.exe -EncodedCommand", " --powershell-file PATH Read a local PS1 file, encode it, and run through the gateway", " --powershell-stdin Read PS1 from stdin, encode it, and run through the gateway", " --powershell-exe NAME Default powershell.exe", " --cwd WINDOWS_PATH Gateway-side working directory; prefer this over shell-level cd &&", " --timeout-ms N Gateway command timeout, default 120000ms; can also use HWLAB_GATEWAY_SHELL_TIMEOUT_MS", " --request-timeout-ms N HTTP wait timeout; defaults to command timeout plus a small response grace", " --operation-id ID Stable operationId to preserve correlation if dispatch times out", " --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"); } async function resolveCommand(args) { if (args.powershellStdin || args.psStdin) { return powershellEncodedCommand(await readStdin(), args); } if (args.powershellFile || args.psFile) { return powershellEncodedCommand(readFileSync(args.powershellFile || args.psFile, "utf8"), args); } if (args.powershellCommand || args.psCommand || args.powershell) { return powershellEncodedCommand(args.powershellCommand || args.psCommand || args.powershell, args); } return args.command || ""; } function powershellEncodedCommand(script, args = {}) { const source = String(script || "").trim(); if (!source) return ""; const shell = args.powershellExe || "powershell.exe"; const prologue = args.noPowershellPrologue ? "" : `${POWERSHELL_PROLOGUE}\n`; const encoded = Buffer.from(`${prologue}${source}`, "utf16le").toString("base64"); return `${shell} -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encoded}`; } function readStdin() { return new Promise((resolve, reject) => { let text = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { text += chunk; }); process.stdin.on("end", () => resolve(text)); process.stdin.on("error", reject); process.stdin.resume(); }); } 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 boundedPositiveInteger(value, fallback, { min, max } = {}) { const parsed = positiveInteger(value, fallback); const lower = Number.isInteger(min) ? min : 1; const upper = Number.isInteger(max) ? max : parsed; return Math.min(Math.max(parsed, lower), upper); } function stripTrailingSlash(value) { return String(value).replace(/\/+$/u, ""); } main().catch((error) => { process.stderr.write(`${error.message}\n`); process.exit(1); });